diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml index 511e290621..e0b3e158eb 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml @@ -9,8 +9,8 @@ en: edefff7cd986a509 zh: a270839fa86976a2 /agent-note-guided-plugin-installation/decision: - en: 5448b9f8aaa9a569 - zh: 9024c5328c5da3bd + en: ca0224ae08a4b383 + zh: 780603ca24487949 /agent-note-guided-plugin-installation/alternatives-considered: en: be64b541f4d81e7c zh: 8600531c08ab4288 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md index 40cbf6179b..aa62340f84 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md @@ -14,7 +14,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina **A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store. -**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../../../../packages/boot/plugin-manager/README.md)). The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. +**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../../../../packages/boot/plugin-manager/README.md)). The client shows the kind as one line and folds pnpm's output behind the details, which also name the complete log and a failure that stopped before pnpm ran ([details](../bug-fix/2026-10-28-plugin-install-failure-diagnostics.md)). Parsing the log is confined to this one function with a fixture-driven test. **GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling. diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md index c53c138059..09c2c2e572 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md @@ -14,7 +14,7 @@ Status: implemented **失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。 -**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../../../../packages/boot/plugin-manager/README.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 +**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../../../../packages/boot/plugin-manager/README.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情;详情还会指出完整日志,以及尚未运行 pnpm 就停下的一次失败([详情](../bug-fix/2026-10-28-plugin-install-failure-diagnostics.zh.md))。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 **GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml index 04a53081d6..dc596b4fae 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml @@ -9,8 +9,8 @@ en: 386864a282e5ad58 zh: a4ea75ec70eb7778 /agent-note-shared-native-cua-process-with-agent-scoped-state/decision: - en: ebf231a6aad109bb - zh: 53b553e679569da1 + en: 11bab6870c3d802f + zh: a22114f2217a3a41 /agent-note-shared-native-cua-process-with-agent-scoped-state/alternatives-considered: en: 883f71387f599160 zh: 4b5f1450be799397 diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md index 1bc795dd52..8e986aaa2f 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md @@ -12,6 +12,8 @@ On macOS 26.6.2, replayd derives capture-client identity from the executable pat One user shares a native service per executable content digest across Agents and Hosts. A version lock elects the service; an additional canonical-executable-path lock prevents different builds published at one path from running together. Locks remain held until process exit. The managed startup command hands service lifetime to its client connections, and the last connection's completed cleanup ends the service. The diagnostic stdin/stdout entry proxies to this same service. +The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Installed packages retain their distributed signatures. + Each Agent connection owns its application handles, AX snapshots, limits and cancellation state. An app handle grants no exclusive use of the application. A FIFO orders complete desktop operations, including clipboard transactions, while response writes are independent. Agent disposal retires only its connection. The [object API decision](2026-09-17-experimental-cua-object-api.md) retains evaluator independence and coordinate semantics; the [consent decision](2026-09-26-cua-application-consent.md) retains application authorization. Cancellation and timeout first stop one request and wait for acknowledgement. A timed-out screenshot retains the single system-RPC slot until the real callback completes; new screenshots fail with `CAPTURE_BUSY` instead of accumulating requests. Late pixels are discarded, while other AX and input operations remain available. Ending an await or creating a wrapper does not prove that the system connection has recovered. An operation or cleanup that cannot stop can invalidate the process, making every old handle unusable. Uncertain input is never replayed automatically. diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md index c40d277837..371ae5cc3a 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md @@ -12,6 +12,8 @@ Status: implemented 同一用户的多个 Agent 和 Host 按可执行文件内容摘要共享原生服务。版本锁选出服务实例,规范化可执行路径上的另一把锁阻止发布在同一路径的不同构建同时运行。进程退出才释放锁。受管启动命令将服务生命期交给客户端连接,最后一条连接清理完成后服务退出。诊断用 stdin/stdout 入口也代理到同一个服务。 +服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。安装版保留分发时的签名。 + 每条 Agent 连接独立持有应用句柄、AX 快照、资源限制和取消状态。持有 app 句柄不代表独占应用。FIFO 队列串行执行包含剪贴板事务的完整桌面操作,响应写入独立进行。Agent 销毁只释放自己的连接。[对象接口决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责执行器独立性及坐标语义,[授权决策](2026-09-26-cua-application-consent.zh.md)继续负责应用许可。 取消或超时先停止单次请求并等待确认。截图超时后,唯一的系统 RPC 槽位保留到真实回调结束;新截图返回 `CAPTURE_BUSY`,不继续堆积请求。迟到图片被丢弃,其他 AX 与输入操作仍可执行。结束 await 或创建包装对象不能证明系统连接已恢复。操作或清理无法停止时,才可能使进程失效,届时所有旧句柄均不可用。结果不确定的输入不会自动重放。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml new file mode 100644 index 0000000000..458cbcc733 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-21-context-menu-contributions.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-context-menu-contributions.md +/agent-note-context-menu-contributions: + en: 380bed76b1989558 + zh: ecbb5c15933431f6 +/agent-note-context-menu-contributions/problem: + en: baf7f6e331fd9f03 + zh: 8a45faaad34e6b0c +/agent-note-context-menu-contributions/decision: + en: f04341778de57140 + zh: 12147060ea28feb1 +/agent-note-context-menu-contributions/alternatives-considered: + en: 1fc419cf3a0642d3 + zh: f774e64679bed2ab +/agent-note-context-menu-contributions/consequences: + en: 7a55e405714d598f + zh: d7ff74ace672bf22 diff --git a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md new file mode 100644 index 0000000000..da02632cf2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md @@ -0,0 +1,29 @@ +# Agent Note: Context menu contributions + +Status: implemented + +English | [中文](2026-10-21-context-menu-contributions.zh.md) + +## Problem + +Session and tab menus already accept React Slot entries. Reusing only those entries would require external plugins to supply React components and would leave action discovery, target capture, and cancellation coupled to each trigger. + +## Decision + +[Context menus](../../../../packages/client/ui-context-menu/README.md) own typed target families, contribution resolution, and callback lifetime. Feature plugins register actions through the same service available to external Client plugins. Plain presentation fields let a renderer consume resolved actions without loading contributed React components. Existing Session and tab Slots remain in feature-owned adapters, preserving those extension positions. + +[File actions](../../../../packages/client/ui-file-actions/README.md) own original-content transfer separately from menus and image components. Saving retains original encoding; clipboard interoperability may require PNG conversion. A browser download supplies no local path, so reveal requires a verified Host file or the destination returned by a native save. + +The menu plugin contributes `useContextMenu` through the existing Slot root binding. Its ordinary `getTriggerProps` factory centralizes DOM gestures while business owners still provide captured targets. Plain children receive props or existing delegates; a separate React Context or utility package would duplicate the established lifetime and injection mechanism. + +## Alternatives considered + +**React Slots alone.** They preserve arbitrary visual content but cannot supply a presentation-independent action model. They remain available for existing component contributions. + +**A central switch over built-in target kinds.** It would make each new plugin-owned target require changes to the menu implementation. Declaration merging and registered providers instead keep target-specific behavior with its owner. + +**Native menus first.** It would duplicate the existing keyboard, placement, and dismissal implementation while excluding component-only legacy entries. The shared Web renderer serves Desktop too; another renderer can consume the plain action view independently. + +## Consequences + +An opening captures its target and contribution set. Dismissal cancels metadata work; unloading withdraws that provider's actions. Selected actions own execution and must recheck mutable resources. Legacy React entries are absent from plain-data resolution, so native rendering cannot assume complete parity. These lifetimes are covered by registry tests; browser scenarios cover unchanged selection, original bytes, and nested-overlay dismissal. diff --git a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md new file mode 100644 index 0000000000..cb0b3c5f3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 右键菜单贡献 + +Status: implemented + +[English](2026-10-21-context-menu-contributions.md) | 中文 + +## 问题 + +Session 和标签页菜单已经支持 React Slot 条目。仅复用这些条目会要求外部插件提供 React 组件,并使动作发现、目标捕获和取消逻辑继续耦合到各个触发位置。 + +## 决策 + +[右键菜单](../../../../packages/client/ui-context-menu/README.zh.md)负责类型化目标类别、贡献解析和回调生命周期。功能插件通过与外部 Client 插件相同的服务注册动作。纯数据展示字段让渲染器无需加载贡献方的 React 组件即可消费已解析动作。现有 Session 和标签页 Slot 保留在各功能所属的适配器中,从而保留这些扩展位置。 + +[文件动作](../../../../packages/client/ui-file-actions/README.zh.md)独立于菜单和图片组件负责原始内容传输。保存保留原始编码;剪贴板互操作可能需要转换为 PNG。浏览器下载不提供本机路径,因此在文件夹显示要求已验证的 Host 文件,或原生保存返回的目标路径。 + +菜单插件通过现有 Slot root 绑定贡献 `useContextMenu`。普通函数 `getTriggerProps` 统一处理 DOM 手势,业务所有者继续提供捕获的目标。普通子组件通过 props 或现有 delegate 接收能力;独立 React Context 或工具包会重复已有生命周期与注入机制。 + +## 考虑过的替代方案 + +**仅使用 React Slot。** 它们支持任意视觉内容,但无法提供独立于展示方式的动作模型。现有组件贡献仍可使用它们。 + +**在中心位置按内置目标类型分支。** 这种方式会使每个新增的插件目标都要求修改菜单实现。声明合并与注册式提供方让目标特有行为保留在其所属插件。 + +**优先实现原生菜单。** 这会重复已有的键盘、定位和关闭实现,同时无法容纳仅提供组件的旧条目。Desktop 同样使用共享 Web 渲染器;其他渲染器可以独立消费纯数据动作视图。 + +## 影响 + +打开菜单时捕获目标和贡献集合。关闭取消元数据任务;插件卸载撤回该提供方的动作。已选动作自行负责执行,并须重新检查可变资源。旧 React 条目不参与纯数据解析,因此原生渲染不能假定功能完全一致。注册表测试覆盖这些生命周期;浏览器场景覆盖选择不变、原始字节以及嵌套浮层关闭。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.i18n.yaml new file mode 100644 index 0000000000..99a0eb8afa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-28-forge-git-session-permissions.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.md +/agent-note-session-permissions-for-forge-git-queries: + en: a06c42342d1f0bad + zh: 88b063883336cb0b +/agent-note-session-permissions-for-forge-git-queries/problem: + en: ee1e5ba8190a9419 + zh: 88d6c35a3a400eb0 +/agent-note-session-permissions-for-forge-git-queries/decision: + en: 77d7706b2577e552 + zh: a4538a19656d5945 +/agent-note-session-permissions-for-forge-git-queries/alternatives-considered: + en: 69f0bbbc412a546f + zh: 9527e87b5adfc7c3 +/agent-note-session-permissions-for-forge-git-queries/consequences: + en: acf5249a959ac14c + zh: 268fc51ec3278dcb diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.md b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.md new file mode 100644 index 0000000000..fd753156d0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.md @@ -0,0 +1,31 @@ +# Agent Note: Session permissions for Forge Git queries + +Status: implemented + +English | [中文](2026-10-28-forge-git-session-permissions.zh.md) + +## Problem + +The [Forge panel](../feature/2026-10-09-forge-panel.md) forces read-only confinement even when a Session grants full access. On hosts without a usable sandbox, its checkout and local changes remain unavailable while the Session's shell works. Background queries also reach repository-configured executable helpers through working-tree comparisons. + +## Decision + +Git inspection uses the Session's resolved sandbox mode and subprocess provider. Full access requires no sandbox backend; confined modes retain their enforcement and never retry unconfined. The Session remains the authority for filesystem permissions, including workspace-write access. Query commands do not request repository mutations. + +Local patches and statistics apply command-local Git configuration to disable clean/process filters without changing user configuration. Environment entries preserve subsection names containing equals signs. Existing hook, fsmonitor, external-diff, textconv, optional-lock, and lazy-fetch restrictions remain; submodule queries show commit changes without scanning internal dirty files. Configuration read failure stops the comparison; the changes view reports the filter configuration failure separately from oversized diffs and offers a retry. These restrictions follow the informational-diff approach in Codex while retaining DSH's execution-world and cancellation ownership. + +## Alternatives considered + +**Force read-only for every query.** This makes an explicitly unconfined Session depend on an unavailable backend. Using the Session mode keeps tool and panel permission behavior consistent. + +**Retry without confinement after failure.** This would silently grant authority that a restricted Session does not have. + +**Run configured content converters.** They can execute programs and write files during automatic refresh. Disabling them trades converted-content equivalence for predictable query behavior. + +## Consequences + +Operating-system write protection follows the Session mode rather than a separate panel policy. With clean/process filters disabled, comparisons still apply Git's built-in newline, ident, and encoding conversions: materialized LFS and other filtered files can appear changed even when ordinary Git considers them clean. Pointer-only checkouts compare normally. Uncommitted submodule contents are omitted. The original Forge note remains active for panel ownership and worktree lifecycle; this note supersedes only its unconditional read-only policy. + +Filter overrides require Git 2.31 or newer; older Git can ignore them and execute configured helpers under Session permissions. Enumeration and diff run in separate processes, so a concurrently added driver can escape the overrides. These restrictions limit incidental helper execution; the Session sandbox determines the permissions of every process that runs. + +Real-repository tests cover permission modes, unavailable confinement, unchanged index/configuration, configured helper markers, submodule commits, filtered bytes, and failed filter enumeration. The Web replay covers permission changes through the picker and the resulting checkout and local-change view. diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.zh.md b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.zh.md new file mode 100644 index 0000000000..49ad8f7be5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Forge Git 查询的 Session 权限 + +Status: implemented + +[English](2026-10-28-forge-git-session-permissions.md) | 中文 + +## 问题 + +[Forge 面板](../feature/2026-10-09-forge-panel.zh.md)即使在 Session 授予完全权限时也强制使用只读沙箱。在没有可用沙箱的主机上,Session 的 shell 可以工作,检出信息与本地变更却持续不可用。后台查询还会通过工作区比较执行仓库配置的辅助程序。 + +## 决策 + +Git 检查使用 Session 解析出的沙箱模式与子进程提供方。完全权限无需沙箱后端;受限模式保留强制约束,绝不以无沙箱方式重试。Session 统一决定文件系统权限,包括工作区写入权限。查询命令不请求修改仓库。 + +本地补丁与统计应用命令级 Git 配置以禁用 clean/process filter,不修改用户配置。环境项保留包含等号的子节名称。现有钩子、fsmonitor、外部 diff、textconv、可选锁与延迟获取限制继续生效;子模块查询展示提交变化,不扫描内部脏文件。配置读取失败时停止比较;变更视图区分过滤器配置失败与 diff 过大,并提供重试。这些限制参考 Codex 的信息展示型 diff,同时保留 DSH 对执行环境与取消过程的管理。 + +## 考虑过的替代方案 + +**所有查询强制只读。** 这让已明确取消限制的 Session 依赖不可用的后端。使用 Session 模式使工具与面板的权限行为一致。 + +**失败后无沙箱重试。** 这会静默授予受限 Session 原本没有的权限。 + +**运行已配置的内容转换器。** 它们可能在自动刷新时执行程序并写入文件。禁用它们以放弃转换后内容等价性为代价,换取可预测的查询行为。 + +## 后果 + +操作系统的写入保护跟随 Session 模式,而非独立的面板策略。禁用 clean/process filter 后,比较仍应用 Git 内置的换行、ident 与编码转换:已物化的 LFS 文件及其他过滤文件可能显示为变更,即使普通 Git 认为它们未变。仅保留指针的检出可正常比较。子模块内部未提交内容不显示。原 Forge 记录仍保留面板所有权与工作树生命周期的决策;本记录仅替代其无条件只读策略。 + +过滤器覆盖需要 Git 2.31 或更新版本;旧 Git 可能忽略覆盖,按 Session 权限执行已配置的辅助程序。枚举与 diff 在独立进程中执行,因此并发新增的驱动可能绕过覆盖。这些限制减少附带的辅助程序执行;所有实际运行进程的权限由 Session 沙箱决定。 + +真实仓库测试覆盖权限模式、沙箱不可用、索引与配置不变、辅助程序标记、子模块提交、过滤文件字节和过滤器枚举失败。Web 回放覆盖通过选择器切换权限及相应的检出信息与本地变更视图。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.i18n.yaml new file mode 100644 index 0000000000..aaf821df79 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-28-plugin-install-failure-diagnostics.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.md +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog: + en: dc13db49685fee98 + zh: 65896465ceaba16a +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/problem: + en: 34c85dbc0fdaeeaf + zh: b11042edb1eb8e3b +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/decision: + en: fa3f10f9d7502077 + zh: c6b83153a0f5e324 +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/alternatives-considered: + en: 2616d060183a7a74 + zh: 15179fd42ea2b6dc +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/consequences: + en: 246d2a60f856c631 + zh: d7a552af3ad32b6f +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/related: + en: e0709383c16148e9 + zh: 00765408d0ad25f1 +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/testing: + en: 336df2ffde183519 + zh: 86e62a8a052c1f91 diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.md new file mode 100644 index 0000000000..89712bf9f7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.md @@ -0,0 +1,47 @@ +# Agent Note: Install failures keep their diagnostics in the dialog + +Status: implemented + +English | [中文](2026-10-28-plugin-install-failure-diagnostics.zh.md) + +## Problem + +A failed plugin installation could show the generic sentence "Something went wrong during the install; the details say what" while its expanded details showed only "No output". The dialog rendered the live install-log stream and the failure kind, and the two Host diagnostics that explain such a failure never entered that stream. A pnpm run that printed nothing had its diagnostic (`result.shortMessage`, or `pnpm failed` as the fallback) written to the complete log and the returned result but never streamed, so the dialog's terminal stayed empty. The GitHub connection check that stops an installation before pnpm runs kept its captured output only in `packageResult.output`. The Client also ignored the settled result's `packageResult.logPath` and `stage`, so a failure with recorded diagnostics offered no path to the complete log and could not say that a finished installation failed while the bundle was being activated. + +## Decision + +**The install-log stream carries the Host diagnostic of every run the dialog shows.** A pnpm run that fails without printing anything streams the diagnostic the Host records for it, so the complete log and the dialog show the same text. The GitHub connection check reports the `git … ls-remote` command line it ran, with the user information of an http(s) repository URL removed; when its network failure or timeout stops the installation, `installBundle` emits that command, the captured output, and the failure's exit status as the installation's only install-log run under the caller's request id. A check failure the installation survives streams nothing. A timed-out check's Git returned no exit status, so that run settles without one; the returned result records the coerced status 1, because `PackageResult.exitCode` is a number, and `PackageResult.timedOut` states that the status is the one the failure is read as. The command line itself stays module-local: `installBundle` strips it off the plain `PackageResult` it returns, so no second wire field appears. + +**The failed screen keeps what the settled result reports, not only the live stream.** `InstallState` holds the settled `packageResult.logPath` — the run that settled the dialog, on the Host's filesystem — and the details name it as the complete log. `InstallState['failure'].stage` and `ChangeResult.enabled` add the sentence that the installation finished and enabling the bundle failed, only when that change did ask to enable it; an install that left activation to **Enable now** never claims it failed enabling. Details on a settled screen without a streamed run say the dialog received no terminal output, never that the run printed none, and a running install makes no such claim. The generic sentence names the failed install command, except when the failed step is applying an enabled bundle, where the enabling sentence stands alone. The answer and the log travel over separate transports, so a run whose first chunk arrives after the answer is kept in the settled dialog as a settled run rather than dropped or shown as running. + +No wire type, public export, or config key is added: `ChangeResult.packageResult.logPath`, `ChangeResult.stage`, `ChangeResult.enabled`, and `packageResult.timedOut` already carry these facts to every consumer. The check's command line is a module-local `GithubConnectionFailure` in `github-connection.ts`. + +## Alternatives considered + +**Have the Client synthesize a terminal run from `packageResult.output`.** Rejected: the install-log stream would stay incomplete for every other reader, including the agent tool and the complete Host log, and the dialog would carry two notions of a run — one streamed, one rebuilt from the result. + +**Render `packageResult.output` as a separate Host-diagnostic block.** Rejected: it duplicates the terminal surface with a second styling and copy path, and the failing check's command line would still be missing. + +**Add a failure-stage field to `ChangeResult`.** Rejected: `stage` already names installation and activation, and only activation needed its own sentence; a second stage vocabulary would make every consumer learn two fields for one fact. + +**Keep the generic sentence and rely on the details.** Rejected: the empty details panel this fixes was exactly what the sentence pointed at, so the sentence must not promise diagnostics the dialog does not show. + +**Report the check's coerced exit code in the streamed run too.** Rejected: the stream describes what the process did, and a Git the deadline killed returned no status; the result keeps the coerced status only because its exit code is a number, which the JSDoc states. + +## Consequences + +- A failed installation always offers something to act on: a pnpm diagnostic for a silent failure, the failing Git command and its output for a GitHub connection failure, the complete log of the settled run, and a sentence naming the failed step when an enabled installation could not be applied. +- The generic sentence becomes "The install command failed; the details say why" in every dictionary; the details' no-run note says the dialog received no terminal output. +- A GitHub connection failure reports `timedOut` and returns the coerced exit status 1, while its streamed run settles without an exit code; the displayed command line omits URL user information, and no `argv` field joins the Remote answer. +- The details' log path covers the run that settled the dialog and is a Host path, so earlier registry attempts and a remote browser may not reach it. +- No new configuration, wire field, or public package export. + +## Related + +[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.md) owns the install dialog's screens, the pre-install spec check, and the failure classification; this note extends its failed screen and install-log stream. + +## Testing + +- `packages/boot/plugin-manager/tests/operations.spec.ts` pins the no-output diagnostic stream; `github-connection.spec.ts` pins the recorded command line without URL user information and the timeout state, including the coerced status in the result; `manager.spec.ts` pins the single streamed check run under the request id, its absent exit code on timeout, that the returned `packageResult` carries no command line, and that a check failure pnpm may resolve streams nothing. +- `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` pins the settled log path, stage and enablement fact, the run kept when its first chunk arrives after the answer, and the reset on retry; `components.client.spec.tsx` pins the enabling sentence (only for an install that asked to enable), the complete-log line, and the settled no-output sentence. +- `apps/web/tests/plugin-install-github.e2e.ts` expands the details of a real GitHub connection failure and finds the Git command, its output, and the log path; `apps/web/tests/plugin-install-silent-failure.e2e.ts` fails a real installation through a package manager that prints nothing and finds the run's command line, its diagnostic, and the complete log in the details. diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.zh.md new file mode 100644 index 0000000000..5c8220ed13 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Install failures keep their diagnostics in the dialog + +Status: implemented + +[English](2026-10-28-plugin-install-failure-diagnostics.md) | 中文 + +## Problem + +一次插件安装失败时,界面可能显示通用的“安装过程中出错,原因见安装详情”,而展开后的详情只有“无输出”。对话框渲染的是实时的 install-log 流和失败 kind,而解释这类失败的 Host 诊断从未进入该流。打印了空输出的 pnpm 运行,其诊断(`result.shortMessage`,缺省为 `pnpm failed`)只写进完整日志和返回结果,从未被流式发送,因此对话框的终端一直是空的。在 pnpm 运行前就中止安装的 GitHub 连接检查,其捕获的输出只留在 `packageResult.output` 里。客户端同时也忽略了已结束结果中的 `packageResult.logPath` 和 `stage`,所以即使诊断已被记录,失败界面也没有指向完整日志的入口,也无法说明安装其实已经完成、失败发生在启用 bundle 时。 + +## Decision + +**install-log 流携带对话框所展示的每一次运行的 Host 诊断。** 没有打印任何内容就失败的 pnpm 运行,会把 Host 为其记录的那条诊断流式发送出来,于是完整日志和对话框显示同样的文本。GitHub 连接检查会报告它实际执行的 `git … ls-remote` 命令行,并去掉 http(s) 仓库 URL 中的用户信息;当它的网络失败或超时中止安装时,`installBundle` 把该命令、捕获的输出和这次失败的退出状态作为该次安装唯一的 install-log 运行、以调用方的 request id 发出。安装仍能继续进行的检查失败不发送任何内容。超时的检查里 Git 没有返回退出状态,所以该运行不带退出码结束;返回结果记录强制填入的状态 1,因为 `PackageResult.exitCode` 是数字,而 `PackageResult.timedOut` 说明该状态只是失败被读取成的状态。命令行本身仍只在模块内:`installBundle` 在返回的普通 `PackageResult` 上剥掉它,因此不会多出 wire 字段。 + +**失败界面保留已结束结果所报告的内容,而不只是实时流。** `InstallState` 持有已结束的 `packageResult.logPath`——结束对话框的那次运行,位于 Host 文件系统——详情把它标为完整日志。`InstallState['failure'].stage` 与 `ChangeResult.enabled` 补充一句话:安装已完成,启用 bundle 时失败;只有当这次变更确实要求启用时才出现,把启用留给**立即启用**的安装不会声称启用失败。已结束界面里没有任何流式运行的详情会说明对话框没有收到终端输出,而不会断言运行本身没有输出;安装进行中也不会这样说。通用提示句点明失败的是安装命令,但失败步骤是应用已启用的 bundle 时,只使用启用阶段的那句话。安装应答与日志走不同传输,因此首个 chunk 晚于应答到达的运行会作为已结束的运行保留在已结束的对话框里,既不会被丢弃,也不会显示成运行中。 + +不新增 wire 类型、公开导出或配置键:`ChangeResult.packageResult.logPath`、`ChangeResult.stage`、`ChangeResult.enabled` 和 `packageResult.timedOut` 本来就把这些事实带给每个消费方。检查的命令行是 `github-connection.ts` 内模块级的 `GithubConnectionFailure`。 + +## Alternatives considered + +**让客户端从 `packageResult.output` 合成一次终端运行。** 否决:对其他读取方(agent 工具和完整的 Host 日志)来说 install-log 流仍然不完整,而且对话框会出现两种“运行”的概念——一种来自流,一种从结果重建。 + +**把 `packageResult.output` 渲染成单独的 Host 诊断块。** 否决:它会以第二套样式和文案路径重复终端界面,而且失败检查的命令行仍然缺失。 + +**给 `ChangeResult` 新增失败阶段字段。** 否决:`stage` 已经区分安装和启用,而且只有启用需要自己的提示句;第二套阶段词汇会让每个消费方为一个事实学两个字段。 + +**保留通用提示句、只依赖详情。** 否决:这次要修的正是被该提示句指向的空详情面板,因此提示句不能承诺对话框并不显示的诊断。 + +**在流式运行里也报告检查被强制的退出码。** 否决:流描述的是进程实际做了什么,而被期限终止的 Git 没有返回任何状态;结果保留强制状态只是因为它的退出码字段是数字,JSDoc 已写明这一点。 + +## Consequences + +- 安装失败后总有可行动的信息:静默失败时的 pnpm 诊断、GitHub 连接失败时出错的 Git 命令及其输出、结束对话框那次运行的完整日志,以及已启用的安装在应用阶段失败时点名失败步骤的一句话。 +- 通用提示句在所有词典中变为“安装命令失败,原因见安装详情”;详情里的无运行提示改为说明对话框没有收到终端输出。 +- GitHub 连接失败会报告 `timedOut` 并返回强制状态 1,而它的流式运行不带退出码结束;展示的命令行去掉 URL 用户信息,Remote 应答里也不会多出 `argv` 字段。 +- 详情里的日志路径只覆盖结束对话框的那次运行,且是 Host 路径,因此更早的换源尝试与远端浏览器可能都够不到它。 +- 不新增配置、wire 字段或公开包导出。 + +## Related + +[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.zh.md) 拥有安装对话框的各个界面、安装前的 spec 检查和失败分类;本记录扩展了它的失败界面和 install-log 流。 + +## Testing + +- `packages/boot/plugin-manager/tests/operations.spec.ts` 钉住无输出诊断的流式发送;`github-connection.spec.ts` 钉住去掉 URL 用户信息的记录命令行与超时状态(含结果里的强制状态);`manager.spec.ts` 钉住以 request id 发送的唯一检查运行、超时下缺失的退出码、返回的 `packageResult` 不含命令行,以及 pnpm 可能自行解决的检查失败不发送任何运行。 +- `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` 钉住已结束的日志路径、stage 与启用事实、首个 chunk 晚于应答到达时保留的运行,以及重试时的重置;`components.client.spec.tsx` 钉住启用阶段的提示句(只对要求启用的安装)、完整日志行,以及已结束界面的无输出提示句。 +- `apps/web/tests/plugin-install-github.e2e.ts` 展开一次真实 GitHub 连接失败的详情,找到 Git 命令、它的输出和日志路径;`apps/web/tests/plugin-install-silent-failure.e2e.ts` 让一次真实安装通过不打印任何内容的包管理器失败,并在详情中找到该运行的命令行、诊断和完整日志。 diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml index f50ce14bc9..96e5042c3e 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml @@ -15,8 +15,8 @@ en: db0a2deb9193a00d zh: dab5a65b88957464 /agent-note-experimental-auto-review-before-each-tool-call/decision/one-complete-reviewer-request: - en: 7bd9fef90e8639b9 - zh: e2a096885c9c1e83 + en: 45ac7b4e759b29ad + zh: 5374cc89e587f076 /agent-note-experimental-auto-review-before-each-tool-call/decision/result-and-cancellation: en: 647fe68c52b907e0 zh: 267a739bad44ad65 diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.md index 9434c92f41..547dd3a2cf 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.md +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.md @@ -41,6 +41,8 @@ The integration uses only the latest `request/header.config` provider/model and Each item is its own message and keeps the same text in later reviews: the pending call becomes a historical call, and the integration remembers, for each live Session, the schema each reviewed call showed so that it reappears at the same position. A review's messages therefore begin with all messages of the previous review. Providers that cache only at message ends, such as OpenAI implicit breakpoints on GPT-5.6 and later, or at a final marker with lookback, such as Anthropic `cache_control`, reuse the previous review; one growing user message let them reuse only the policy. The schema memory is process-local, so the first review after a restart omits earlier schemas and misses the cache once. +The request also carries the reviewed Session's `sessionId` and `purpose: 'auto-review'`. Routes that require a per-conversation identity or key prompt caching on one, such as the `x-opencode-session` header OpenCode requires and OpenAI `prompt_cache_key`, derive it from that id; without it OpenCode rejects every review, and Auto fails every call. The [DeepSeek Session-log upload](../../../../packages/session/session-log-deepseek/README.md#request-field) applies the same eligibility and registered exclusions to reviews as to other requests. The uploaded data is the canonical Session log; review prompts and decisions remain outside that log. + The reviewer builds the history and the pending call from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver. The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only as the final message; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget. diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md index 5ba7a8b39c..bc833d3fc3 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md @@ -41,6 +41,8 @@ Integration 只使用最新 `request/header.config` 的 provider/model 与 shi 每个条目各占一条消息,并在后续评审中保持相同文本:待审调用会变成历史调用,integration 为每个存活 Session 记住每次已审调用展示的 schema,使其在同一位置再次出现。因此一次评审的消息以上一次评审的全部消息开头。只在消息结尾缓存的 provider(如 GPT-5.6 及以后的 OpenAI 隐式断点),或在末尾标记处向前回溯的 provider(如 Anthropic `cache_control`),都能复用上一次评审;单条不断增长的 user 消息只能让它们复用策略。Schema 记忆只在进程内,因此重启后的第一次评审会缺少先前的 schema,并有一次缓存未命中。 +请求还携带被评审 Session 的 `sessionId` 与 `purpose: 'auto-review'`。需要按对话标识路由或以其为 prompt 缓存键的路由(例如 OpenCode 要求的 `x-opencode-session` header 与 OpenAI 的 `prompt_cache_key`)由该 id 派生;缺少它时 OpenCode 会拒绝每次评审,Auto 会让每个调用失败。[DeepSeek session-log 上传](../../../../packages/session/session-log-deepseek/README.zh.md#request-field)对评审和其他请求应用相同的上传资格与注册排除条件。上传的是权威 Session 日志;评审提示词和决策仍不进入该日志。 + reviewer 从 Session 的完整动作历史构建历史与待审调用:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。 主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只作为最后一条消息出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。 diff --git a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml new file mode 100644 index 0000000000..fc9f88a600 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml @@ -0,0 +1,55 @@ +# Bilingual-pair consistency record for 2026-09-13-liveshare-protocol-and-owner-surfaces.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.md +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague: + en: e73b246e19c9b412 + zh: c87594ad5eccbfb5 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/problem: + en: 17bc15d382139909 + zh: 3eff34b5ddea951e +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision: + en: a15c7c13f167e5ac + zh: a15c7c13f167e5ac +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-a-share-is: + en: ff1dbb0f7c2355e4 + zh: 3de92b2e6c5e9e15 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-a-reader-can-do: + en: 5652ed7f515a2a5d + zh: 993378f78f5a4120 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/two-driving-tiers: + en: 0b9303333bf482ae + zh: 6dfc68ad9e2924c3 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/default-visibility: + en: 754705367c350b31 + zh: e10b6541458c4a45 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/how-shared-work-stays-inside-its-grant: + en: fa3ab42895a16ce1 + zh: 429be63cfb8e55a6 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/keeping-one-branch-across-retries: + en: 6b0369dafe34a5aa + zh: aea9465216204122 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/who-the-owner-is: + en: 7e6d1305782d2822 + zh: 66fb2cbef0f49d47 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-the-reader-sees-at-the-top: + en: 44d02a91cf0c5715 + zh: dabbf6c59ff08e4a +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/when-the-connection-breaks: + en: 36ee6e090797806b + zh: 21809a0cf990d905 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/how-the-served-page-is-exercised: + en: d9cbb8277862587c + zh: 867fa6c4db95d09c +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-the-package-exposes: + en: d13a0df787f212d4 + zh: 24794e8bf4d92492 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/alternatives-considered: + en: 2daeba6e763383e4 + zh: fe0fe3f7f4d546e7 +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/verification: + en: 76cc786336c5daf5 + zh: 98bd2b9cc7d1791c +/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/consequences: + en: 8a6e06a9fba917e2 + zh: 68a9d19b15119345 diff --git a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.md b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.md new file mode 100644 index 0000000000..a4d1c6af81 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.md @@ -0,0 +1,156 @@ +# Agent Note: Liveshare — sharing a DSH session with a colleague + +Status: implemented + +English | [中文](2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md) + +## Problem + +A DSH session runs on your own computer. A colleague sometimes needs to watch one while you work — to follow a debugging session, or to see how a long task is going. Today there is no way to do that. Sending the session log is not the same thing: the colleague would need your plugins, configuration, and sandbox to render it, and the session only makes sense while it is running on your machine. + +So a share needs three things at once. A **page the colleague opens in a browser** that shows the session running on your machine, without connecting them to it. A **limit on what that page can do**, starting with "read without writing" — anything the page offers beyond what you agreed is a security hole, not a convenience. And an **answer to "who shared this?"**, because a page that cannot say who its owner is cannot be trusted by whoever opens it. + +This note covers the half that runs inside the owner's DSH — the share dialog, the owner-side service behind it, the package's browser face, and the page a reader opens — plus the protocol package both sides speak. The Liveshare server, the shared service in the middle, is its own package (`@deepseek-ai/dsh-experimental-liveshare-relay`) and is reviewed and deployed separately. + +## Decision + +### What a share is + +An owner creates a share for one session from DSH itself. The share is published to the Liveshare server, a service both sides can reach, and DSH returns a link. Opening that link shows the session rendered by the owner's own DSH with its own plugins, so the reader never needs the owner's configuration. The reader's list contains the source Session and any children that reader created through the share. Workspace names, membership, and ordering are trimmed to those Sessions; unrelated Sessions and workspaces stay hidden. + +A share created from the DSH Share dialog is an **organization link** share: it carries `accessMode: 'open'`, so anyone in the organization who has the link is admitted. The deployment's `LIVESHARE_ALLOWED_SUBJECTS` is unioned into every share's ACL: it narrows an `open` share to the subjects it lists, and on a stored `selected` record it adds those subjects beside the record's own selection. + +**Specific colleagues (`selected`) and the older `approval` spelling are legacy stored records.** The Host still reads them with their recorded ACL and shows their access summary, and the Liveshare server still admits their viewers by that ACL, but the shipped Share dialog offers no way to create one. `liveshareShares.create` keeps its `accessMode` and `allowedSubjectIds` fields for those records and for callers that invoke the Remote directly; the shipped dialog sends neither. + +### What a reader can do + +The reader's page is the normal DSH client, not a cut-down copy. The sidebar, the session list, expanding, collapsing, and selecting are all there, because browsing is part of reading. What is missing is every action the reader is not allowed to perform: creating an unrelated session, renaming, archiving, deleting, and managing a workspace. Branching follows the share's own fork switch, prompting is available in both tiers and runs inside that tier's sandbox, and attachment upload follows the Sessions the reader can see; management permissions remain independent of the tier and the fork switch. + +These actions are not hidden with CSS. One access snapshot decides what the page may do: the remote-access client follows the reader's own `remoteAccess/follow`, and each button and menu entry reads its endpoint's answer on the global resource, a Session, or a Workspace through `useRemoteAccess`, `useAccess` or `useSessionAccess`, or `useWorkspaceAccess`, rendering again whenever the answer changes. The owner's DSH computes the snapshot from this share's `share-viewer` policy, so an ability the owner withdraws closes without reloading the page — and the server refuses those actions anyway; the page only avoids offering what would be refused. The served index carries the reader's opening snapshot, so no entry appears before the page knows it is allowed, and after the snapshot stream fails every answer is refused. The owner's own page opens with the owner baseline and behaves exactly like an ordinary DSH. Per-Session refusal reasons are not part of the snapshot: the page states the page-level reason (share mode, stop, expiry, lost access) and generic read-only copy. A page that cannot read the Session at all — offline owner, stopped or expired share, lapsed sign-in — states that condition at the top and gives the reader an action, instead of leaving an empty shell or a bare code. + +### Two driving tiers + +Every share carries a `ViewerSessionMode` with two values, and both drive the shared Session: + +- **view** — the reader drives the shared Session's loop through the normal prompt path, inside a read-only sandbox in the granted workspace, so the Session's tools never write a file. The read and search surface (`read`, `read_image`, `glob`, `grep`, `present`), the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`). Every declared path is checked before the tool runs, and a tool whose call cannot be checked is refused. +- **write** — the reader drives the same loop in a bounded writable sandbox that also modifies files inside the granted workspace, under the Host's write ceiling. + +The sandbox is installed on the Session, but it confines only the turns a share viewer starts: `agent/pre-step` records whether an admitted turn's entering input names a share viewer, and the guard and the replacements read that answer for each execution. A turn the owner starts keeps the Session's tools; an owner's steering input inside a viewer's turn stays confined with it. The marker lives in Host memory, is recomputed for each turn, and never enters the Session log. Stopping every share, or letting them all expire, disposes the layer and gives the Session its own tools back. + +The earlier vocabulary had three tiers — `view` watched, `fork-readonly` branched, and `write` could write. `view` and `fork-readonly` differed only in whether the reader could branch and drive a child, which the share's own `allowsFork` switch already decides; a third mode duplicated that switch and let the two answers disagree, so the two driving tiers replaced it. + +Forking is not a tier. The share carries its own `allowsFork` switch, independent of the tier: a `view` share may allow it and a `write` share may refuse it. A fork creates a child the viewer owns with its own read-only or writable ceiling and leaves the source untouched. + +Attachment upload is not a tier or a switch. `fileUploads/upload` rides the read role, so every Session a share serves the reader accepts files; visibility alone decides it, and the owner has no separate attachment switch. + +A mode this version does not recognize, and a persisted record whose mode the host cannot establish, are not driven: the share stays readable, and the page reports what it could not establish. The withdrawn `fork-readonly` name is not read back as `view`; a persisted owner record that names it is rejected when the owner state loads. + +The sandbox is a property of the Session, but it applies per turn: the Host records, in memory, whether the admitted input of the running turn names a share viewer, and confines that turn while leaving an owner-started turn on the Session's native tools. The earlier shipped design confined every turn, including the owner's; it needed no turn read, but a share changed how the owner's own work ran. Persisting a turn's requester was rejected: a committed-message projection had to be folded, re-read for every execution, and kept in step with the share that admitted it, and it still had to fail closed on mixed or unestablished requesters. The in-memory marker keeps the guard and the replacements reading one answer per execution without adding a Session event; a restart recomputes it from the next admitted step, and a rejected or never-admitted step leaves no marker. + +### Default visibility + +The package ships as a runtime dependency of every dsh installation, but a default installation must show it nowhere in the plugin manager: neither under **Built-in plugin settings**, nor under **Experimental plugins**, nor on the **More** page. A person enables it deliberately — by inserting the bundle's rows in the active profile's `cordis.patch.yml`, or by selecting the bundle in `dsh.profile.bundles`; the [package README](../../../../packages/experimental/liveshare/README.md) owns both routes. The package must still ship with the release and stay installable, and users who enabled it before this rule keep seeing it. + +The Host's bundle `enabled` flag states whether the profile selected the bundle, which is not the same as whether the bundle's plugins run: inserting the bundle's rows through `cordis.patch.yml` starts its plugins while leaving the bundle unselected, so a visibility rule built on `enabled` would hide a bundle the person had just enabled and would strand the documented enable path. + +The package declares the hiding intent in its own bundle manifest as `dsh.bundle.listedByDefault: false`; the manager keeps one generic visibility rule rather than a client-side name set. A bundle whose manifest does not declare `listedByDefault: false` is listed; one that declares it is listed only when the profile enables it, the installation carries it selected, or one of the rows its bundle patch declares is running (`pkg.rows.some(row => row.enabled)`). The filter applies before both lists are derived, so the name is absent from the main Experimental plugins group, the More page, Built-in plugin settings, and the Installed plugins group until one of those holds. The Host's `listBundles` contract, the profile format, and the installed package set are unchanged. `BundleInfo.rows` already joins each declared row with the Loader entry currently carrying it, and the client's `PackageView` carries whether that entry runs, so row state is the effective answer for both enable routes: removing the rows, disabling them, or deselecting the bundle stops them and the card leaves both lists again. + +A default-hidden bundle is not addressable by ID while its card is hidden: `openPkg` resolves only the listed inventory, so `pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-liveshare')` renders no details until the same enablement conditions list it. + +### How shared work stays inside its grant + +The Owner, not the viewer page or the Liveshare server, authorizes each Session operation. A child receives its real scoped tool restrictions before it can accept a prompt, including after a cold resume: the host installs the limit from the base `agent/created` event. That listener runs after the agent entered the registry, but creation has not resolved, AgentLoop holds the agent's queued input, and a throw fails creation, so a refused limit still prevents any prompt. A missing host service or failed installation refuses the child; a configuration flag, a tool name, or a promise to install later is not enforcement. + +A shared source Session can still serve its owner's own work. Every reader call is dispatched through the owner's own Connection and Gateway as that share-and-reader pair's own Peer, whose `share-viewer` principal (the share id and the server-asserted identity triple) is fixed by in-process owner code when the Peer is created; no wire field can change it. The Gateway decides the Peer's calls, and the Session notifications it forwards to the Peer, through remote access: this package registers the `share-viewer` policy, which binds a served reader to read roles on the shared Session, its own forks, and the Workspaces holding them, and to fork and write roles whose conditions apply the share mode; it binds nothing else, so every other endpoint and notification is refused, and it binds nothing once the share stops serving the reader. The owner invalidates the reader's access on every grant, revocation, mode change, and expiry, and disposes the reader's Peer when the grant ends or the share stops or expires. A reader's prompt enters the Session as a `user-rpc` message whose `source.principal` names that reader; owner messages carry no principal. While any share over that Session is active, the owner host mounts its own confined replacements for the read and search surface and the no-effect tools — plus bounded `edit` and `write` when some share may write — in the Session's own scope, and a viewer-started turn may run nothing else. An owner-started turn keeps the Session's native tools, so a share never narrows the owner's own work. The pre-step records which kind of turn is running, in memory only. Each operation re-reads the shares' current Relay grants and checks the resolved target against the granted root; the pre-step re-checks every entering message's viewer against the Relay's admission before a model step, so reader input queued before a revocation never drives the model. A reader's stop of the shared Session is dispatched through the Gateway like a prompt: the policy admits it while the share's tier is established and the Session is confined, and the owner's own Session controller stops whatever turn that Session is running, the owner's included. The general Peer, principal, and remote-access design is in [Peer principals and remote access](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md). + +A confined file tool resolves every path its own arguments declare, checks that the granted root contains each result, and hands the deployment's tool that canonical path; a pathless search uses the Session's working directory, which the replacement checks like a named path. A bounded edit or write performs the same containment check and then edits or writes through the filesystem backend with the Session's current sandbox policy and the normal observation policy. + +### Keeping one branch across retries + +One fork request binds its viewer, share, source, operation identity, and completed prefix. The Owner reserves that operation durably before creating a child, and records the child the owner's fork answer returned; a retry of a recorded operation replays that answer instead of forking again. A different prefix under the same identity is refused. The owner's `SessionForkRequest` mints the child id, so the Host sends no child identity: a host that dies after the child was created but before the operation was recorded cannot name that child on retry and creates a new one. Closing that window needs a Session Controller request that accepts a caller-supplied child identity. The inherited prefix stops before unrelated queued input; only immediately following request metadata is retained. Revoked children keep their logs but cannot resume with broader access, and removing a share does not expose the source Session's unrestricted tools. + +### Who the owner is + +A share can only be created by a verified owner. The dialog shows the identity the owner's DSH read back — name, expiry, when it will renew — and uses a label derived from the identity when there is no display name. The code that creates the share refuses an unverified owner as well, so the question is not settled by whether a button was clickable. + +Signing in happens once, in a window the page opens while the owner clicks. The DSH creates a short-lived transaction tied to the Liveshare server address, to the Liveshare server instance, and to the page allowed to finish it; the page opens the sign-in page the DSH names, accepts one one-time code only from that exact origin, window, and transaction, and hands it back to its own DSH. + +The Owner redeems the code with the verifier it retained, stores the credential with owner-only permissions, and sends it only to the Liveshare server address that issued it. New registration requires that verified credential; existing-share reconnect uses the share's management token, and explicit identity claim preserves the share's ACL, mode, expiry, and token. A share's named colleagues are the owner's stored selection plus the deployment's own allowlist; nothing is derived from a display identity. + +### What the reader sees at the top + +The served page reuses the sidebar's existing brand row instead of adding a bar on top of the app: the usual Harness mark, plus a LiveShare entry that names who shared the session and which DSH version they run, with the Liveshare server's own release labeled separately. Clicking it returns to the page listing the shares you can open. + +### When the connection breaks + +A reader's feed can fail: a stream disappears, the Liveshare server cancels a request, a read times out. The page retries only the failures that are defined as repeatable, and stops for good on authentication, permission, and missing-share errors, showing the reason instead of reconnecting forever. + +### How the served page is exercised + +The page a viewer opens is the owner's own Web page. The Liveshare server asks the owner for its rendered index document, places a short carrier script at its top, and passes the page's shell assets and plugin bundles through from the owner, so the page runs every owner plugin at the owner's version. The carrier only turns the page's calls and streams into reads over the Liveshare server's channel; the Liveshare plugin itself recognizes a viewer page and mounts the viewer surfaces instead of the Share action. The owner renders that index with the reader's own access snapshot in place of the owner baseline, and the page then follows the reader's `remoteAccess/follow` snapshot; no other read carries a reader's grants. + +The viewer page is tested in-process: the test assembles the page from the carrier, the shell, and the client plugins including Liveshare's, stands in for the Liveshare server's read channel, and answers the page's reads from a scripted owner, so the assertions see the real shell, carrier, and presentation plugins. + +### What the package exposes + +The package ships one Host program and one browser face. Its public entries are the Host export (`lib/index.js`), `./client` (`lib/client.js`, a closure-factory bundle), `./viewer-declarations`, the share-management Remote's `./typert` and `./remote` faces, `./types`, the bundle patch `./cordis.patch.yml`, and the locale JSON files. The manifest declares the browser face as `dsh.client` with `platform: web` and the client services it needs; that face reads `__DSH_LIVESHARE_VIEWER__`, mounts the viewer session plugin and its brand, model, permission, session-row, and share-path surfaces on a served viewer page, and otherwise mounts the owner's generated Remote before the Session-header Share action. + +Share management is one `liveshareShares` Typert Remote with twelve methods — `create`, `list`, `stop`, `updatePermissions`, `setCapabilities`, `capabilities`, `relay`, `setRelay`, `identity`, `audit`, `testRelay`, and `testPublish` — over the operations the owner host runs. The only same-origin HTTP routes left are the four owner sign-in and credential routes: `/api/liveshare.ownerLoginStart`, `/api/liveshare.ownerLoginRedeem`, `/api/liveshare.ownerLoginCancel`, and `/api/liveshare.ownerCredential`. + +The outgoing gateway is a supervised child process whose source entry is `src/owner-gateway/cli.ts` and whose published entry is `lib/owner-gateway/cli.js`; the directory name keeps it distinct from the DSH API gateway. + +## Alternatives considered + +- **Build a separate read-only page instead of serving the normal client.** Rejected: reading needs browsing, grouping, and selecting, so a second page would have to rebuild all of it and would quietly lose the navigation the product keeps. +- **Decide each button's availability where the button is.** Rejected: every entry reads one access snapshot the owner computes, so a screen added later cannot quietly reintroduce an action the reader may not perform. +- **Let the reader's page decide its own access.** Rejected: only the owner and the service decide what a share grants; the page states what it was told. +- **Treat an older share with no mode as writable.** Rejected: that would hand a reader access the owner never configured, so a share without a mode is read-only. +- **Put the sign-in code in the page address, or read the credential from the popup's cookies.** Rejected: addresses leak through history, referrers, and logs, and a page should not hold credentials. +- **Show the service's own error text when the page cannot explain a refusal.** Rejected: that text may contain a file path, a credential, or another reader's session, so the page shows the stable reason it can vouch for. +- **Put execution restrictions only in editable configuration or a sandbox mode.** Rejected: a child can change configuration, and per-call execution choices can outrank a mode. The real operation needs a monotonic guard and a verified implementation. +- **Check a model-supplied path only before dispatch, or globally constrain every filesystem read.** Rejected: a pre-dispatch check is separated from the tool call by other listeners, while a global read rule would also constrain unrelated Owner work. The containment check runs inside the authorized Session's own read and write. +- **Reuse the deployment's default root or copy the entire source tail into a child.** Rejected: both can include files or pending input the viewer was never granted. +- **Use a deployment-wide secret or self-reported display fields as owner identity.** Rejected: neither identifies the logged-in person who approved this publication. +- **Keep three tiers.** Rejected: `view` and `fork-readonly` differ only in whether the reader can branch and drive a child, which is already the independent fork switch. A third mode duplicates that switch and lets the two answers disagree. +- **Keep a per-share attachment-upload switch.** Rejected: an attachment is part of a prompt on a Session the reader can already see. The separate switch duplicated visibility and could only subtract from it, while the upload role already rides the read role. +- **Attribute every turn to a requester and exempt the owner's own turns.** Rejected after it shipped, then narrowed: persisting a turn's requester needed a Host-only projection over message sources, a turn-boundary read per execution, and a re-check of the share that admitted each turn, and it still had to fail closed on mixed or unestablished requesters. The shipped design keeps only an in-memory marker for the running turn — `agent/pre-step` reads the entering principals and the guard reads that answer — so an owner-started turn keeps the Session's native tools without adding a Session event. The accepted cost is that an owner's steering input inside a viewer's turn stays confined; a viewer message that reaches a later step narrows that turn. +- **Let each tool name its own tier or sandbox.** Rejected: the tier belongs to the share. A per-call choice would let one execution outrank the grant that admitted it. +- **Test the bundle's `enabled` flag directly.** Rejected: it would keep the code smallest, but the patch route starts the bundle's plugins while leaving the bundle unselected, so the card would stay hidden exactly when its plugins started, contradicting the product rule. +- **Derive effective enablement in the Host.** Rejected: `listBundles` could report a new or redefined effective state, but that would change a cross-package contract for one client-only presentation rule and invite every bundle consumer to reinterpret `enabled`, which must keep meaning "selected in the profile". +- **Ship LiveShare outside the installation's optional bundles.** Rejected: that would make it visible nowhere by construction, but the package must still ship as an npm package with the release and be installable, and it would also remove the bundle from the Host's list entirely, taking the details entry with it. +- **Keep the colleague picker, or replace it with a directory-backed selector.** Deferred: naming a colleague needs an identity directory the Liveshare server does not provide, so the dialog cannot offer a choice it can verify. Stored `selected` and `approval` records keep working through their ACL, and the creation entry point stays retired until a directory contract exists; `liveshareShares.create` keeps its fields for those records and direct Remote callers, so this defers the API rather than deleting it. + +## Verification + +- A reader's page keeps the normal navigation, and no action the reader may not perform appears on the shared session or on any branch. +- A DSH without the Liveshare bundle, and the owner's own page, behave exactly as before. +- A share uses one of two tiers and that tier's sandbox; unknown modes, unreadable answers, and shares without a mode stay readable but are never driven. +- A share cannot be created without a verified owner identity. +- A share created from the dialog carries `open`; a stored `selected` or `approval` record keeps its recorded ACL and access summary, and `create` still stores a requested `selected` mode with its subjects. +- Sign-in accepts one one-time code from the transaction's origin, window, and state, and nothing else; a failed or abandoned attempt discards the transaction, and no credential, cookie, or code reaches a URL, storage, or a log. +- Real Controller, ToolRuntime, filesystem, and profile tests cover fork identity, cold enforcement, bounded execution, revocation, and normal cancellation. The built viewer must use its real POST carrier and Owner authorization handler; deterministic display fixtures do not replace execution or persistence tests. +- Generated API documentation and both SDK projections remain aligned with the `user-rpc` source principal, which only the `agent/pre-step` admission check consumes. +- `ViewerSessionMode` has two members, and a persisted `fork-readonly` record is rejected when the owner state loads. +- A `view` share drives the source Session, refuses file writes and out-of-grant reads, and can fork when `allowsFork` is set. +- `fileUploads/upload` is allowed on every Session the reader can see, for every tier and fork combination. +- Source tool decisions read only the Session's mounted layer: the guard permits this installation's confined replacements for the read and search surface and the no-effect tools, plus bounded `edit` and `write` on a writable Session, and refuses every other name, with no turn, requester, or origin consulted. +- Unit and real-runtime tests cover the tier policy, confined reads and writes, fork ceilings, the layer's installation and release, and the pre-step admission check over the durable message source. +- The plugin-manager component tests hold the bundle out of both lists while its declared rows are off, list it once a declared row runs, and still open it by bundle ID while it is hidden; the recorded goldens and the refresh card count move with that rule. + +## Consequences + +The reader's page can only be as accurate as what the service tells it. Capability answers, the owner's identity status, the session mode, the owner's DSH version, and the sign-in endpoints all come from the owner's DSH and the Liveshare server; until those are deployed together with this layer, the page stays at its most conservative setting — read-only, with a sign-in that cannot complete — rather than guessing. + +The page is not a security boundary. The owner's DSH and the Liveshare server refuse a reader's writes no matter what the page does; the access snapshot exists so the page does not offer actions that would only be refused. + +Existing ACLs are retained. A record without a mode is readable but not driven, and contradictory mode fields are refused. Unattributed shares are shown as unverified history. + +Owner reconnect state is atomically replaced and queued within one process, but multiple processes sharing one file can still lose updates or compete for a gateway. Cross-process execution ownership is unresolved; atomic replacement alone is not a transaction or a route claim. Containment is checked by path: a local process that replaces a directory inside the grant with a symlink between the check and the open can redirect a viewer's read, or a write elsewhere within the sandbox's writable roots. Operations anchored to a directory handle would close this window and are left to a separate change. Final viewer-artifact and SDK snapshot evidence remains required before release. + +A `view` share is not watch-only: it runs the reader's prompts and tool calls inside the read-only sandbox. A deployment that wants a passive reader has no separate mode and must rely on the reader not sending prompts rather than on a tier name. Fork reach is the only independently switchable action; attachment upload cannot be disabled separately. + +The sandbox reaches the owner's own turns too. While any share over a Session is active the owner can only read inside the granted workspace, and can write only when some share allows writing; a share that is merely published — even one nobody is using — therefore changes the owner's own tool surface until it is stopped or expires. Cancellation has no attribution either: a reader's stop ends whatever turn the shared Session is running. + +The default cards list, the More page, and their recorded goldens lose one card, and the manager's refresh card count drops by one. A person who only knows the plugin page cannot discover LiveShare or switch it on from that page; the package README owns the two enable routes instead. A bundle enabled through the patch route shows its card with the switch off while its rows run, because the switch reflects the profile's bundle selection and writes that selection; the documentation states the two routes and this difference. The rule covers one explicitly named bundle rather than a manifest flag, so adding another hidden-until-running bundle, or retiring this one, is a one-line set edit with a test and docs update. + +Shipped creation produces organization-link shares only: the Share dialog sends neither `accessMode` nor `allowedSubjectIds`, so every new share is `open`, and the deployment's `LIVESHARE_ALLOWED_SUBJECTS` is the only subject-level narrowing a new share can carry. Stored `selected` and `approval` records keep working and keep their access summaries, and the create fields stay on the Remote for those records and direct callers while no UI exposes them. diff --git a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md new file mode 100644 index 0000000000..871eb3fe91 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md @@ -0,0 +1,156 @@ +# Agent Note: Liveshare —— 把 DSH 会话分享给同事看 + +Status: implemented + +[English](2026-09-13-liveshare-protocol-and-owner-surfaces.md) | 中文 + +## Problem + +DSH 会话跑在你自己的电脑上。同事有时需要在你工作的同时看某个会话——跟着一起排查问题,或者看看一个长任务进展到哪了。现在没有这个能力。把会话日志发过去并不等价:对方要有你的插件、配置和沙箱才能渲染出来,而且会话只有在你机器上运行时才有意义。 + +所以一次分享要同时解决三件事:一个**同事用浏览器打开就能看的页面**,看到你机器上正在跑的会话,但不需要连到你的机器;一个**对页面能做什么的限制**,从"能读、不能写文件"开始——页面多给一个你没同意的操作就是安全漏洞,不是便利;以及一个**"这是谁分享的"的答案**,因为说不清归属的页面,打开它的人无从信任。 + +这条 Note 覆盖 owner 自己 DSH 里运行的那一半——分享对话框、背后的 owner 端服务、本包的浏览器端,以及读者打开的页面——以及双方共用的协议包。中间的 Liveshare 服务端是独立的包(`@deepseek-ai/dsh-experimental-liveshare-relay`),单独审查与部署。 + +## Decision + +### 一次分享是什么 + +owner 在 DSH 里为某一个会话创建分享。分享发布到 Liveshare 服务端(双方都能访问的服务)后,DSH 返回一个链接。打开链接看到的是 owner 自己的 DSH 用自己插件渲染出的会话,读者不需要 owner 的配置。读者列表包含源会话,以及该读者通过分享创建的子会话。工作区名称、成员和顺序裁剪为这些会话;无关会话和工作区保持不可见。 + +从 DSH 分享弹窗新建的分享是**组织内持链接**分享:它携带 `accessMode: 'open'`,组织内拿到链接的人都能看。部署级 `LIVESHARE_ALLOWED_SUBJECTS` 会并入每份分享的 ACL:对 `open` 分享,它把准入收窄到列出的主体;对已存储的 `selected` 记录,它把列出的主体加在记录自己的选择旁边。 + +**指定同事(`selected`)与更早的 `approval` 拼写是旧版持久记录。** Host 仍按记录里的 ACL 读取它们并展示访问摘要,Liveshare 服务端仍按该 ACL 接纳它们的查看者,但随版本发布的分享弹窗不提供新建入口。`liveshareShares.create` 的 `accessMode` 与 `allowedSubjectIds` 字段保留给这些记录以及直接调用该 Remote 的调用方;随版本发布的弹窗两个都不发送。 + +### 读者能做什么 + +读者页面就是正常的 DSH 客户端,不是精简版:侧栏、会话列表、展开折叠、选择都在,因为浏览本来就是"看"的一部分。缺的只是读者无权执行的动作——创建无关会话、重命名、归档、删除、管理工作区。分支遵循分享自己的 fork 开关;发送消息在两档里都可用,并在该档的沙箱内运行;附件上传跟随读者可见的会话。管理权限与档位和 fork 开关都保持独立。 + +这些动作不是用 CSS 藏起来的。页面能做什么由一份访问快照决定:remote-access 客户端跟随读者自己的 `remoteAccess/follow`,每个按钮和菜单项经 `useRemoteAccess`、`useAccess` 或 `useSessionAccess`、`useWorkspaceAccess` 读取其端点在全局资源、某个会话或某个工作区上的答案,答案变化时重新渲染。owner 的 DSH 依据该分享的 `share-viewer` 策略计算快照,因此 owner 收回某项能力时,对应入口不用重新加载页面就会关闭——而且服务端本来就会拒绝这些操作,页面只是不去提供注定被拒绝的按钮。读者收到的 index 已带上读者的首份快照,因此页面在确认允许之前不会显示任何入口;快照流失败后,所有答案都为拒绝。owner 自己的页面以 owner 基线打开,行为与普通 DSH 完全一致。快照不包含逐会话的拒绝原因:页面陈述页面级原因(分享模式、停止、过期、失去访问权)和通用的只读文案。拿不到分享正文时,页面顶部直接陈述该状态,并且每一档都给出读者自己能做的动作(重试、重新登录、回到分享列表,或明确指引联系分享者),不会停在空白外壳或错误码上。 + +### 两档驱动 + +每个分享带一个 `ViewerSessionMode`,只有两个值,且两档都会驱动共享会话: + +- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。 +- **write** —— 读者在受宿主写入上限约束的可写沙箱里驱动同一个 loop,沙箱也会修改授予工作区内的文件。 + +沙箱安装在会话上,但只约束分享查看者发起的轮次:`agent/pre-step` 记录一个已准入轮次的进入输入是否带有 share-viewer principal,守卫与替代定义对该轮次的每次执行读取这一结论。所有者自己发起的轮次保留会话自己的工具;所有者在查看者轮次中追加的输入仍留在该受限轮次内。该标记只存在于 Host 内存,按轮次重算,不写入会话日志。所有分享停止或全部过期后执行层被释放,会话恢复自己的工具。 + +较早的词汇有三档——`view` 只观看、`fork-readonly` 分支、`write` 可写。`view` 与 `fork-readonly` 只差在读者能否分支并驱动子会话,而这已经由分享自己的 `allowsFork` 开关决定;第三档重复了该开关,还会让两处答案互相矛盾,因此由两档驱动取代。 + +fork 不是一档。分享自带独立的 `allowsFork` 开关,与档位无关:`view` 分享可以允许 fork,`write` 分享也可以拒绝。fork 创建的子会话归读者所有,带有自己的只读或可写上限,源会话保持不变。 + +附件上传既不是一档,也不是一个开关。`fileUploads/upload` 随读角色授予,因此分享向读者提供的每条会话都接受文件;只由可见性决定,所有者没有单独的附件开关。 + +本版本不认识的模式、以及宿主无法确定模式的持久记录,都不会被驱动:分享仍可读取,页面会说明它无法确定什么。已退场的 `fork-readonly` 名称不会读回为 `view`;持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。 + +沙箱是会话的属性,但按轮次生效:Host 在内存中记录运行中轮次的已准入输入是否带有 share-viewer principal,只约束该轮次,把所有者发起的轮次留在会话原生工具上。此前上线的实现限制每个轮次(包括所有者的),虽不需要读取轮次,却让分享改变了所有者自己的工作方式。持久化轮次请求者被否决:那需要从已提交消息来源折叠的投影、每次执行读取 turn boundary、并与接纳该轮次的分享保持同步,而且在混合或无法确立请求者时仍必须默认拒绝。内存标记让守卫与替代定义对每次执行读取同一个结论,且不新增会话事件;重启后从下一个准入步骤重新计算,被拒绝或从未准入的步骤不留下标记。 + +### 默认可见性 + +本包作为运行时依赖随每个 dsh 安装一同交付,但默认安装必须在插件管理器里完全不显示它:**内置插件配置**下不显示,**实验性插件**下不显示,**更多**页也不显示。用户主动启用它——在当前 profile 的 `cordis.patch.yml` 中插入组合包的行,或在 `dsh.profile.bundles` 中选中该组合包;两条启用路径由[包 README](../../../../packages/experimental/liveshare/README.zh.md)负责。该包仍必须随版本发布、保持可安装,且在此规则之前已启用的用户继续能看到它。 + +Host 的组合包 `enabled` 标志表示 profile 是否选中了这个组合包,这与它的插件是否在运行并不等价:通过 `cordis.patch.yml` 插入组合包的行会让插件运行,而组合包本身仍未选中;因此以 `enabled` 为准的可见性规则会把用户刚刚启用的组合包隐藏起来,使写入文档的启用路径失效。 + +因此管理器维护一个 `HIDDEN_UNTIL_RUNNING` 组合包名集合——当前只有 `@deepseek-ai/dsh-experimental-liveshare`——集合中的组合包只有在它 patch 声明的行至少有一条在运行时才会被列出(`pkg.rows.some(row => row.enabled)`)。过滤发生在两个列表派生之前,因此在满足条件前,它不会出现在主"实验性插件"组、**更多**页、**内置插件配置**和"已安装插件"组中。该集合是 `PluginManagerPage.tsx` 中的客户端数据;Host 的 `listBundles` 契约、profile 格式和已安装包集合都保持不变。`BundleInfo.rows` 已把每条声明的行与当前承载它的 Loader 条目关联起来,客户端的 `PackageView` 携带该条目是否在运行,因此行状态就是两条启用路径的有效答案:删除这些行、把它们置为 disabled,或取消选中组合包,都会让它们停止运行,卡片随之从两个列表消失。 + +卡片隐藏期间组合包 ID 仍可按 ID 打开:`openPkg` 会从未过滤的 Host 组合包列表中解析隐藏的组合包,因此 `pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-liveshare')` 仍能渲染其详情。其它按 ID 链接组合包的界面因此继续可用,组合包页也像其它未选中组合包的页面一样列出各行及其关闭状态。 + +### 分享工作如何限制在授权范围内 + +Owner 而不是查看页或 Liveshare 服务端授权每次会话操作。子会话必须先安装真实作用域工具限制,才能接受消息,冷恢复后也一样:宿主机从基础 `agent/created` 事件安装该限制。该监听器运行时智能体已登记进注册表,但创建尚未完成,AgentLoop 会扣住其排队输入,且监听器抛错会使创建失败,因此被拒绝的限制仍能阻止任何消息。宿主机服务缺失或安装失败都会拒绝子会话;配置开关、工具名或稍后安装的承诺都不是执行限制。 + +共享源会话仍可服务所有者自己的工作。每次读者调用都以「该分享与该读者」专属的 Peer 经所有者自己的 Connection 与 Gateway 分派,该 Peer 的 `share-viewer` principal(分享 ID 与服务端断言的身份三元组)由所有者进程内代码在创建 Peer 时确定,任何线路字段都无法改变它。Gateway 经 remote access 判定该 Peer 的调用以及转发给它的会话通知:本包注册 `share-viewer` 策略,把受服务的读者绑定到共享会话、其自己的 fork 及包含它们的工作区上的读取角色,以及条件按分享模式判定的 fork 与写入角色;除此之外不做任何绑定,因此其它端点和通知一律被拒绝,分享不再服务该读者后也不再有任何绑定。所有者在每次授权、撤权、模式变化与过期时使读者的访问失效,并在授权结束、分享停止或过期时释放读者的 Peer。读者的提示词以 `user-rpc` 消息进入会话,其 `source.principal` 指明该读者;所有者的消息不带 principal。只要该会话还有分享活跃,所有者 Host 就在会话自身作用域内挂载读取与检索面与无副作用工具的受限替代,以及(某个分享允许写入时)有界 `edit` 与 `write`,查看者发起的轮次只能运行这些。所有者自己发起的轮次保留会话原生工具,因此分享不会收窄所有者自己的工作。pre-step 只在内存中记录当前轮次属于哪一类。每次操作都会重新读取分享当前的 Relay 授权,并按授予根目录检查解析后的目标;pre-step 在模型步骤前按 Relay 准入重新检查每条进入消息的查看者,因此撤权前排队的读者输入永远不会驱动模型。读者对共享会话的停止与提示词一样经 Gateway 分派:策略在分享档位确立且会话受沙箱限制时放行,随后由所有者自己的会话控制器停止该会话当前运行的轮次,包括所有者发起的轮次。通用的 Peer、principal 与 remote access 设计见 [Peer principal 与 remote access](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。 + +受限文件工具解析其自身参数声明的每个路径,检查获授根目录包含解析结果,再把规范路径交给部署的工具;无路径的搜索使用会话工作目录,由替代定义按命名路径一样检查。受限编辑或写入执行同样的包含检查,然后按会话当前沙箱策略和正常观察策略经文件系统后端编辑或写入。 + +### 重试时保留同一个分支 + +一次 fork 请求绑定查看者、分享、源会话、操作身份和已完成前缀。Owner 在创建子会话前持久化预留该操作,并记录 owner 的 fork 应答返回的子会话;重试已记录的操作会重放该应答,而不是再次 fork。同一身份指定不同前缀会被拒绝。owner 的 `SessionForkRequest` 自己生成子会话 id,因此 host 不发送子会话身份:若 host 在子会话已创建、但操作尚未记录时退出,重试无法指名该子会话,会再创建一个。要关闭这一窗口,需要 Session Controller 的请求接受调用方提供的子会话身份。继承前缀停在无关排队输入之前;只保留紧随其后的请求元数据。被撤销的子会话保留日志,但不能以更宽权限恢复;移除分享不会暴露源会话不受限制的工具。 + +### owner 是谁 + +只有已验证的 owner 才能创建分享。对话框显示 owner 的 DSH 回读到的身份——名字、有效期、何时续期——没有显示名时用身份派生出来的标签。创建分享的代码本身也会拒绝未验证的 owner,不是只看按钮能不能点。 + +登录只做一次,且发生在 owner 点击时页面预留的窗口里。DSH 生成一个短寿命事务,绑定 Liveshare 服务端地址、Liveshare 服务端实例,以及允许完成它的页面;页面打开 DSH 指定的登录页,只接受来自该 origin、该窗口、该事务的一次性 code,并把它交回自己的 DSH。 + +Owner 使用自己保留的校验值兑换代码,以仅所有者可读写的权限保存凭据,并只向签发它的 Liveshare 服务端地址发送。首次登记需要已验证凭据;已有分享重连使用分享管理 token,显式身份认领保留该分享的 ACL、模式、过期时间和 token。分享指定的同事是 owner 的存储选择加上部署自身的允许列表;不会从显示身份派生任何内容。 + +### 读者页面顶部显示什么 + +被服务的页面复用侧栏原有的品牌行,而不是在应用上再压一条横条:原本的 Harness 标记,加上一个 LiveShare 入口,写明是谁分享的、对方跑的是哪个 DSH 版本(Liveshare 服务端自己的 release 另行标注)。点击它回到"我能看的分享"列表页。 + +### 连接断开时 + +读者的数据流可能失败:流消失、Liveshare 服务端取消请求、读取超时。页面只重试定义上可重复的失败;遇到认证、权限与分享不存在的错误就直接停下并显示原因,不会无休止地重连。 + +### 服务出去的页面怎么测 + +读者打开的页面就是 owner 自己的 Web 页面。Liveshare 服务端向 owner 要渲染好的 index 文档,在最前面插入一段很短的 carrier 脚本,页面的 shell 资源和插件 bundle 也从 owner 透传过来,所以页面跑的是 owner 版本的全部插件。carrier 只把页面的调用和流转成 Liveshare 服务端通道上的读请求;由 Liveshare 插件自己识别 viewer 页,挂上 viewer 界面,而不是分享按钮。owner 渲染这份 index 时用读者自己的访问快照取代 owner 基线,页面随后跟随读者的 `remoteAccess/follow` 快照;其他读请求都不夹带读者的授权。 + +读者页面在进程内测试:测试用 carrier、shell 和包括 Liveshare 在内的 client 插件拼出页面,顶替 Liveshare 服务端的读通道,并用脚本化的 owner 回答页面的读请求,因此断言看到的是真实 shell、carrier 与呈现插件。 + +### 本包对外提供什么 + +本包交付一个 Host 程序和一个浏览器端。公开入口包括 Host 导出(`lib/index.js`)、`./client`(`lib/client.js`,closure-factory bundle)、`./viewer-declarations`、分享管理 Remote 的 `./typert` 与 `./remote` 两个面、`./types`、组合包 patch `./cordis.patch.yml`,以及 locale JSON 文件。manifest 以 `dsh.client` 声明浏览器端,`platform` 为 `web`,并列出它需要的客户端服务;该入口读取 `__DSH_LIVESHARE_VIEWER__`:在被服务的查看页上挂载查看页 session 插件及其品牌、模型、权限、会话行与分享路径界面,否则先挂载 owner 生成的 Remote,再挂载 Session 头部操作。 + +分享管理是一个 `liveshareShares` Typert Remote,共十二个方法——`create`、`list`、`stop`、`updatePermissions`、`setCapabilities`、`capabilities`、`relay`、`setRelay`、`identity`、`audit`、`testRelay` 与 `testPublish`——背后是 owner 宿主执行的操作。仅存的同源 HTTP 路由是四条 owner 登录与凭据路由:`/api/liveshare.ownerLoginStart`、`/api/liveshare.ownerLoginRedeem`、`/api/liveshare.ownerLoginCancel` 与 `/api/liveshare.ownerCredential`。 + +出站网关是受监督的子进程,源码入口为 `src/owner-gateway/cli.ts`,发布入口为 `lib/owner-gateway/cli.js`;目录名让它与 DSH API gateway 明确区分。 + +## Alternatives considered + +- **单独做一个只读页面,不服务正常客户端。** 否决:阅读需要浏览、分组与选择,另做一个页面就得把这些全部重建,还会悄悄丢掉产品要保留的导航。 +- **每个按钮各自判断能不能点。** 否决:每个入口都读取 owner 计算的同一份访问快照,后加的界面才不会悄悄重新引入读者无权执行的动作。 +- **让读者页面自己决定权限。** 否决:分享授予什么,只由 owner 与服务端决定;页面只陈述被告知的结果。 +- **把没有模式字段的旧分享当作可写。** 否决:那会把 owner 从未配置的权限交给读者,因此没有模式的分享一律只读。 +- **把登录 code 放进页面地址,或从弹窗的 cookie 读凭据。** 否决:地址会经浏览历史、referrer 和日志泄漏,页面也不应该持有凭据。 +- **页面解释不了某个拒绝时,直接展示服务端的错误原文。** 否决:那段文本可能包含文件路径、凭据或别的读者的会话,所以页面只显示它能担保的稳定原因。 +- **只在可编辑配置或沙箱模式中写执行限制。** 否决:子会话能够改变配置,而逐调用执行选择可能优先于模式。真实操作必须经过单调拒绝的守卫和已验证实现。 +- **只在分派前检查模型路径,或全局限制所有文件系统读取。** 否决:分派前检查与工具调用之间还隔着其他监听器,而全局读取规则还会限制无关 Owner 工作。包含检查在已授权会话自身的读写中执行。 +- **复用部署默认根目录,或把源会话整个尾部复制到子会话。** 否决:两者都可能包含查看者未获授的文件或待处理输入。 +- **以部署共享密钥或自报显示字段作为所有者身份。** 否决:两者都不能识别批准本次发布的已登录人员。 +- **保留三档。** 否决:`view` 与 `fork-readonly` 只差在读者能否分支并驱动子会话,而这已经是独立的 fork 开关。第三档重复了该开关,还会让两处答案互相矛盾。 +- **保留分享级的附件上传开关。** 否决:附件是读者已经能看到的会话上的一条 prompt 的一部分。单独的开关重复了可见性,且只能从可见性里做减法,而上传角色本来就随读角色授予。 +- **按轮次归属请求者并让所有者轮次豁免。** 上线后否决,之后缩小范围:持久化轮次请求者需要仅 Host 可见的消息来源投影、每次执行读取 turn boundary、重新检查接纳该轮次的分享,而且在混合或无法确立请求者时仍必须默认拒绝。现在的实现只为运行中轮次保留内存标记——`agent/pre-step` 读取进入消息的 principal,守卫读取该结论——因此所有者发起的轮次保留会话原生工具,且不新增会话事件。接受的代价是:所有者在查看者轮次中追加的输入仍留在受限轮次内;查看者消息若进入后续步骤,会立即收窄该轮次。 +- **让每个工具自行声明档位或沙箱。** 否决:档位属于分享。逐调用选择会让某次执行凌驾于批准它的授权之上。 +- **严格的 manifest 选中判断。** 否决:直接判断 `pkg.enabled` 代码最少,但默认安装启用 LiveShare 的唯一文档路径是编辑 `cordis.patch.yml`,那不会让组合包被选中。卡片会在插件恰好开始运行时仍然隐藏,与产品规则矛盾。 +- **在 Host 派生有效启用状态。** 否决:`listBundles` 可以以新字段或改写字段报告有效状态,但这会为一条纯客户端的展示规则改动跨包契约,并诱使每个组合包消费方重新解释 `enabled`——它必须继续表示"profile 已选中"。 +- **把 LiveShare 移出安装随附的可选组合包。** 否决:将它从可选集合移除即可从构造上让它处处不可见,但该包仍需随版本作为 npm 包发布并可安装,而且这会把组合包从 Host 列表中完全移除,详情入口也会随之消失。 +- **保留选人器,或换成由目录支撑的选择器。** 推迟:点名同事需要身份目录,而 Liveshare 服务端目前不提供;弹窗无法给出它能验证的选择。已存储的 `selected` 与 `approval` 记录仍按 ACL 工作,在有目录契约之前新建入口保持退役;`liveshareShares.create` 的字段保留给这些记录与直接调用方,所以这是推迟 API,而不是删除它。 + +## Verification + +- 读者页面保留正常导航,且在共享会话与任何分支上都不会出现读者无权执行的动作。 +- 没有 Liveshare bundle 的 DSH 以及 owner 自己的页面,行为与以前完全一致。 +- 分享使用两档之一及该档的沙箱;未知模式、读不懂的应答、没有模式的分享仍可读取,但绝不会被驱动。 +- 没有已验证的 owner 身份,就无法创建分享。 +- 弹窗创建的分享携带 `open`;已存储的 `selected` 或 `approval` 记录保留记录中的 ACL 与访问摘要,`create` 仍会存储调用方请求的 `selected` 模式及其主体。 +- 登录只接受来自该事务 origin、窗口与 state 的一次性 code,别的都不接受;失败或放弃都会丢弃事务,凭据、cookie 与 code 都不进入 URL、存储或日志。 +- 真实 Controller、ToolRuntime、文件系统和 profile 测试覆盖 fork 身份、冷恢复限制、有界执行、撤权和正常取消。已构建查看页必须使用真实 POST 载体和 Owner 授权处理器;确定性显示 fixture(测试前置数据)不替代执行或持久化测试。 +- 生成的 API 文档和两种 SDK 投影与 `user-rpc` 来源 principal 保持一致;该字段只由 `agent/pre-step` 准入校验消费。 +- `ViewerSessionMode` 只有两个成员;持久化的 `fork-readonly` 记录在 owner 状态加载时被拒绝。 +- `view` 分享驱动源会话,拒绝文件写入与越界读取,并在设置 `allowsFork` 时可以 fork。 +- 对读者可见的每条会话,无论档位与 fork 开关如何组合,`fileUploads/upload` 都被允许。 +- 源会话的工具决策只读取会话上挂载的执行层:守卫放行本次安装的读取与检索面与无副作用工具的受限替代,可写会话下另有有界 `edit` 与 `write`,拒绝其它所有工具名,不读取轮次、请求者或来源。 +- 单元测试与真实运行时测试覆盖档位策略、受限读写、fork 上限、执行层的安装与释放,以及基于持久消息来源的 pre-step 准入校验。 +- 插件管理器的组件测试在声明行全部关闭时把该组合包排除在两个列表之外,在声明行有一条运行后列出它,并在它隐藏时仍能按组合包 ID 打开;录制的 golden 与刷新卡片数随该规则变化。 + +## Consequences + +读者页面只能准确到服务端告诉它的程度。能力回答、owner 身份状态、会话模式、owner 的 DSH 版本、登录端点,都由 owner 的 DSH 与 Liveshare 服务端提供;在与本层一起部署之前,页面保持最保守的状态——只读,且登录无法完成——而不是去猜。 + +页面不是安全边界。无论页面做什么,owner 的 DSH 与 Liveshare 服务端都会拒绝读者的写入;这份访问快照的作用只是让页面不去提供注定被拒绝的按钮。 + +已有 ACL 保持不变。无模式记录仍可读取,但不会被驱动;互相矛盾的模式字段被拒绝。没有归属的分享显示为“未验证历史”。 + +Owner 重连状态以原子替换写入,并在单进程内排队,但多个进程共享一个文件仍可能丢失更新或争用网关。跨进程执行归属尚未解决;原子替换本身不是事务或路由认领。包含关系按路径检查:若本地进程在检查与打开之间把获授范围内的目录换成符号链接,可以让查看者的读取改指别处,或让写入落到沙箱可写根内的其他位置。基于目录句柄锚定的操作可以消除这一窗口,留待单独变更引入。发布前仍需完成查看页产物和 SDK 快照验证。 + +`view` 分享不是"只看":它会在只读沙箱里运行读者的 prompt 与工具调用。想要被动观看的部署没有单独模式,只能依赖读者不发送 prompt,而不能依赖档位名称。fork 是唯一可独立关闭的动作;附件上传无法单独关闭。 + +沙箱同样作用于所有者自己的轮次。只要某会话还有分享活跃,所有者也只能在授予工作区内读取,且只有在某个分享允许写入时才能写入;因此一条仅仅发布、并没有人在用的分享也会改变所有者自己的工具面,直到它停止或过期。取消同样没有归属:读者的一次停止会结束共享会话正在运行的任何轮次。 + +默认卡片列表、**更多**页及其录制 golden 各少一张卡片,管理器的刷新卡片数也减一。只认识插件页的人无法从该页发现 LiveShare 或直接开启它;启用路径由包 README 负责。通过 patch 路径启用的组合包,其卡片显示为关闭状态而行在运行,因为开关反映并写入 profile 的组合包选中状态;文档会说明两条路径及其差异。该规则覆盖一个显式命名的组合包而不是 manifest 标志,因此新增另一个"运行前隐藏"的组合包或退役这一个,都只是一行集合改动,并配一条测试和文档更新。 + +随版本发布的创建流程只产生组织链接分享:分享弹窗不发送 `accessMode` 与 `allowedSubjectIds`,因此新分享一律为 `open`,部署级 `LIVESHARE_ALLOWED_SUBJECTS` 是新分享唯一能获得的主体级收窄。已存储的 `selected` 与 `approval` 记录继续工作并保留访问摘要;`liveshareShares.create` 的字段仍留在 Remote 上供这些记录与直接调用方使用,而界面不暴露它们。 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml index 7d202e9987..53d2b74f3a 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml @@ -9,14 +9,14 @@ en: 344bcc7e9825bbc8 zh: c1d0f4b54f5b3786 /agent-note-change-request-panel-and-worktree-sessions/decision: - en: b18b287b701aef93 - zh: 462c0f561f1a0c18 + en: 6a0dc836205863ba + zh: 3d4feb0385953dda /agent-note-change-request-panel-and-worktree-sessions/deferred: en: 32ef8af85f4d2f66 zh: b82b67f94346399e /agent-note-change-request-panel-and-worktree-sessions/alternatives-considered: - en: 91b3338f958ad0e9 - zh: b64f30d333280084 + en: e471e16b6b6450ca + zh: 5fbc9242edc8d112 /agent-note-change-request-panel-and-worktree-sessions/consequences: - en: 97976f073b9f65e0 - zh: 95be3f7f375b0876 + en: 81790c448f0a7d3b + zh: ec63ccb2ad315ff8 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md index 20b9231fce..1285ba6cdf 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md @@ -12,7 +12,7 @@ English | [中文](2026-10-09-forge-panel.zh.md) [`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry. -The Remote inspects the checkout through the Session's subprocess provider under the Session's read-only sandbox policy, so it reads what the Session's commands see and cannot write. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun. +The Remote inspects the checkout through the Session's subprocess provider under its [resolved permissions and query restrictions](../bug-fix/2026-10-28-forge-git-session-permissions.md), so it reads what the Session's commands see. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun. Status polling follows panel subscriptions: a Session's bindings are watched only while one of its surfaces is mounted, and the registry stops polling change requests nobody watches. Each card keeps the last snapshot a poll returned, so a transient failure keeps the title and number visible. A Session's only open change request starts expanded, and with several every card starts collapsed; the user's first toggle replaces this default. @@ -24,7 +24,9 @@ The worktree a Session works in can be removed outside it. Each inspection check The panel does not move a Session between existing worktrees. Removing a change request appends a `dismiss` record, and its toast offers Undo through `track`. -The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from confined `git diff` in the Session's execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other. +The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from `git diff` using the Session's permissions and execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other. + +Review comments capture the file, side, range, complete hunk, and reviewed head when the user selects code. Agent references enter the existing composer quote mechanism, so the user controls batching and the sent message records exactly what the model receives. GitHub writes belong to the optional forge provider operation: preflight checks the current head, the write addresses that commit, and an ambiguous outcome blocks another write until the user reconciles it. Review drafts and inline records belong to a browser tab occurrence, not to the Session log or a GitHub pending review. Switching Sessions retains that occurrence; closing it discards the local state and rejects late write completions. Published GitHub comments remain external records. ## Deferred @@ -79,6 +81,12 @@ The changes view offers no scope for one commit or for the last turn yet; both w **Creating the change request from the card.** The panel would need to push the branch and compose the title and description; a message lets the Agent do both with its tools and the user's instructions, and keeps one way to bind change requests. +**Sending each line comment immediately to the Agent.** It starts a turn before the user can collect related comments or add a shared instruction; composer references preserve that choice. + +**Keeping review drafts after their tab closes.** Reopening the same PR would restore abandoned review state. A tab occurrence gives closing a clear discard meaning while preserving Session switching. + +**Automatically retrying a review write.** A lost response does not mean that GitHub rejected the request. Retaining the outgoing request and requiring reconciliation prevents a transport failure from silently duplicating comments. + ## Consequences -The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to eleven short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once. +The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to twelve short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once. diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md index 9ea88f5ec6..432f2b10a8 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md @@ -12,7 +12,7 @@ Status: implemented [`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。 -Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略下检查检出,因此它读到的与 Session 命令看到的相同,且无法写入。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。 +Remote 通过 Session 的子进程提供方,按其[解析出的权限与查询限制](../bug-fix/2026-10-28-forge-git-session-permissions.zh.md)检查检出,因此它读到的与 Session 命令看到的相同。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。 状态轮询跟随面板订阅:只有该 Session 的某个界面挂载时才监听其绑定,注册表停止轮询无人监听的变更请求。每张卡片保留最近一次轮询得到的快照,因此短暂的失败不会让标题与编号消失。Session 只有一个打开的变更请求时,它的卡片默认展开;有多个时全部折叠;用户第一次展开或收起卡片后,以用户的选择为准。 @@ -24,7 +24,9 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都 面板不在现有 worktree 之间移动 Session。移除变更请求会追加一条 `dismiss` 记录,它的 toast 通过 `track` 提供“撤销”。 -面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自 Session 执行环境中受限的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。 +面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自使用 Session 权限与执行环境的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。 + +评审评论在用户选择代码时固定文件、侧、行范围、完整 hunk 与所评审的 head。Agent 引用进入既有输入框引用机制,由用户决定批量发送,发送后的消息记录模型实际收到的内容。GitHub 写入属于 forge 提供方的可选操作:预检核对当前 head,写入明确指向该提交;结果不明时阻止再次写入,直到用户核对。评审草稿与行内记录归浏览器页签实例所有,不进入 Session 日志,也不创建 GitHub 待提交评审。切换 Session 保留该实例;关闭时丢弃本地状态并拒绝迟到的写入结果。已发布的 GitHub 评论仍保留在外部平台。 ## Deferred @@ -79,6 +81,12 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都 **直接在卡片上创建变更请求。** 面板需要推送分支并撰写标题与描述;发送消息让 Agent 用自己的工具和用户的指示完成这两件事,绑定变更请求的方式也只保留一种。 +**把每条行内评论立即发送给 Agent。** 用户还没来得及收集相关评论或补充共同指令,就会开始一轮;输入框引用保留用户的选择。 + +**关闭页签后仍保留评审草稿。** 重新打开同一个 PR 会恢复已放弃的评审状态。页签实例让关闭明确表示丢弃,同时保留切换 Session 的状态。 + +**自动重试评审写入。** 丢失响应不代表 GitHub 拒绝了请求。保留发出的请求并要求核对,可以避免传输失败悄悄产生重复评论。 + ## Consequences -面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十一个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。 +面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十二个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。 diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml index 4a98c2f555..c8b3188032 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml @@ -18,11 +18,11 @@ en: 1310a89c351e5bfe zh: e6386ae10b893c2d /agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints: - en: 11937dfa4a349a19 - zh: b91a136890fe92e8 + en: add81088ce51033f + zh: c7065dc3052648fa /agent-note-performance-baselines-for-large-session-corpora/calibration-evidence: - en: f934c95748dcbfe0 - zh: df6a68286c21510f + en: adb815c213373d8e + zh: 44d4c6752a4df51f /agent-note-performance-baselines-for-large-session-corpora/alternatives-considered: en: 4eaa4666262a1aee zh: 7e2eb44b94bad05d diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md index 2efe66ead2..7890b73070 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md @@ -47,6 +47,8 @@ The shared corpus and projection-list controller fixture awaits controller activ The file must finish within five minutes on standard hosted CI, including seeding, and its last case asserts that limit. Search over 1,000 Sessions and one fork of the longest Session take about 170 s of it, so each has one sample. The list corpus is the largest that fits the remainder. Budgets enforce medians of list boot, first, and repeat, and of the fork median over strata and rank 990; search and the longest fork enforce their single samples. Search and fork use separate 1,000-Session roots, so fork children never reach the search index. Rank 988 is the highest top-level rank at the measured p99 (7,229 events); forking a subagent child adds a lineage lookup, and rank 990 already falls in the 99.3% anchor. Peak RSS of the list, search, and fork processes has a budget with the shared 1.25 headroom and no time scale. Each child's deadline equals the file limit, and each test's deadline outlasts its sequential children, so a failed case reaps every child before the scratch root is removed. +Fork workers use a 512 MiB V8 old-space limit so every runner measures the same heap constraint. The corpus, timed endpoints, and whole-process peak RSS budget stay unchanged; native buffers still count toward RSS. The longest fork retains about 193 MiB after collection, so the limit leaves room for live objects while forcing collection before a larger host-selected heap delays it. This measures a bounded-heap workload, not the default-heap peak of every deployment. + ## Calibration evidence Budgets use standard two-CPU hosted CI expectations (Linux x64, Node 24.21, PR #5403), rounded above the higher recorded median across the AMD EPYC 7763 and EPYC 9V74 runners and multiplied by the shared 1.25 headroom. Samples varied by up to 15% within one run; the runner models differed by up to 25% per endpoint. The hosted-to-reference ratio is 2.4 to 3.4, above the shared reference scale of 2, so reference-machine scaling would reject ordinary hosted runs. @@ -67,6 +69,8 @@ With seeding in one process, the complete file took 294.5 s on the EPYC 7763 run On Apple M5 Pro with Node 26.5, isolated runs measured list 1,000 at 311–358 ms first, list 5,000 at 1,364–1,603 ms first, search at 57 s first, and fork of the longest Session at 6.0–6.4 s. A 10,000-Session list took 3.2–5.4 s for the first call with 1.9 GB peak RSS. +Node 24.22 hosted runs of the unchanged longest-fork workload recorded 696 MiB peak RSS on EPYC 9V74 and 1,077.7 MiB on EPYC 7763, against the same 938 MiB budget. On Apple M5 Pro with Node 24.19, two default-heap runs retained 192.8–193.4 MiB and peaked at 770.7–812.6 MiB in 2.416–2.447 s; two 512 MiB old-space runs retained the same range and peaked at 764.2–764.7 MiB in 2.487–2.528 s. The fixed limit controls the heap policy without raising the RSS or timing budgets. + ## Alternatives considered - **Sanitized copies of real Sessions.** Rejected: benchmarks never use recorded user material. diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md index f964d640b8..132619b19d 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md @@ -47,6 +47,8 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新 此文件(包括播种)必须在标准托管 CI 上五分钟内完成,其最后一个用例断言该上限。1,000 个 Session 的搜索和最长 Session 的一次 fork 约占其中 170 s,因此两者各取一个样本。列表语料是剩余时间内能容纳的最大规模。预算约束列表启动、首次和重复的中位数,以及 fork 分层中位数和排名 990 的中位数;搜索和最长 fork 约束各自的单个样本。搜索和 fork 使用各自独立的 1,000 个 Session 根目录,因此 fork 子会话不会进入搜索索引。排名 988 是实测 p99(7,229 个事件)上最高的顶层排名;fork subagent 子会话会增加一次谱系查找,而排名 990 已落入 99.3% 锚点。列表、搜索和 fork 进程的峰值 RSS 设有预算,使用共享的 1.25 余量,不做时间缩放。每个子进程的截止时间等于文件上限,每个测试的截止时间长于其顺序子进程之和,因此失败的用例会在删除临时根目录之前回收所有子进程。 +Fork worker 使用 512 MiB 的 V8 old-space 上限,让各运行器在相同的堆约束下测量。语料、计时终点和整个进程的峰值 RSS 预算保持不变,原生缓冲区仍计入 RSS。最长 fork 回收后保留约 193 MiB,因此该上限为存活对象保留空间,同时避免较大的宿主默认堆延迟回收。该用例测量受限堆下的工作负载,不代表每种部署默认堆下的峰值。 + ## 校准证据 预算使用标准双 CPU 托管 CI 的期望值(Linux x64、Node 24.21、PR #5403),取高于 AMD EPYC 7763 与 EPYC 9V74 运行器中较高记录中位数的整数,再乘以共享的 1.25 余量。同一次运行内的样本差异最多 15%;两种运行器型号在单个端点上最多相差 25%。托管与参考机器的比值为 2.4 到 3.4,高于共享的参考缩放系数 2,因此按参考机器缩放会拒绝正常的托管运行。 @@ -67,6 +69,8 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新 在 Apple M5 Pro 和 Node 26.5 上的隔离运行中,1,000 个 Session 的首次列表为 311–358 ms,5,000 个 Session 的首次列表为 1,364–1,603 ms,首次搜索为 57 s,fork 最长 Session 为 6.0–6.4 s。10,000 个 Session 的首次列表耗时 3.2–5.4 s,峰值 RSS 为 1.9 GB。 +未改动的最长 fork 工作负载在 Node 24.22 托管运行中,EPYC 9V74 的峰值 RSS 为 696 MiB,EPYC 7763 为 1,077.7 MiB,使用的预算均为 938 MiB。在 Apple M5 Pro、Node 24.19 上,两次默认堆运行回收后保留 192.8–193.4 MiB,峰值为 770.7–812.6 MiB,耗时 2.416–2.447 s;两次 512 MiB old-space 运行保留量相同,峰值为 764.2–764.7 MiB,耗时 2.487–2.528 s。固定上限用于控制堆策略,没有提高 RSS 或耗时预算。 + ## 考虑过的替代方案 - **脱敏复制真实 Session。** 已否决:基准从不使用录制的用户材料。 diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md index a16f8294e9..1fa60fcb4f 100644 --- a/.agents/skills/record-browser-gif/SKILL.md +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -1,6 +1,6 @@ --- name: record-browser-gif -description: Record browser or Web UI interaction demos as optimized GIFs using the available browser-control workflow, optional Playwright Videos for higher capture frame rates, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`, falling back to a dedicated assets branch where attach cannot apply. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow. +description: Record browser or Web UI interaction demos as optimized GIFs using the available browser-control workflow, optional Playwright Videos for higher capture frame rates, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`; publication requires gh v2.99.0 or later, and a GIF over 5 MiB is split into parts. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow. --- # Record Browser GIF @@ -17,8 +17,8 @@ The recording itself is part of the evidence: use a real server booted from that ## Keep recording separate from publication -- Recording produces local video or screenshots and one `.gif` artifact only; it never mutates remote state. -- Publication — attaching the GIF to a pull request body with `gh --attach`, or pushing it to an assets branch and embedding its URL where attach cannot apply — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch. +- Recording produces local video or screenshots; encoding produces one `.gif` artifact, or its [numbered parts](#attach-with-gh) when it exceeds the 5 MiB limit. Neither mutates remote state. +- Publication — attaching the GIF to a pull request body with `gh --attach` — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never commits to any branch. - Preserve the requested recording conditions. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture. - Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt. @@ -117,13 +117,15 @@ Encoder maintenance: run `python3 -m unittest discover -s "$GIF_SKILL_DIR/script Perform this step only when the task includes attaching the GIF to a pull request. -Never commit a GIF to the pull request's own branch or any branch that merges into a long-lived branch: binary media committed there bloats the repository history for every future clone. Prefer `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media. +Never commit a GIF to the pull request's own branch or any other branch: every default clone and full-history fetch of the repository downloads media committed to any branch. Publish only with `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media. ### Attach with gh -`gh --attach` requires `gh` v2.99.0 or later (`gh --version`), a repository on github.com — GitHub Enterprise Server is not supported — write access to the repository, and a GIF at or below 10 MB. Confirm the verified artifact fits that limit; when it does not, shrink it with `--max-width`, then `--colors` or `--fps`, before attaching. +Before attaching, check `gh --version`. `gh --attach` requires `gh` v2.99.0 or later, a repository on github.com — GitHub Enterprise Server is not supported — and write access to the repository. When `gh` is older than v2.99.0, stop and tell the developer to upgrade `gh` to v2.99.0 or later; keep the verified GIF for the retry, and do not install or upgrade `gh` without authorization. When any other requirement is unmet or the attach fails — authentication, write access, a repository outside github.com, or an upload error — stop and report it to the developer. Never publish the GIF any other way. -Write the GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites the reference in place to the uploaded URL, keeping its position and alt text: +Publish GIFs at or below 5 MiB, the encoder's default `--max-bytes`. When the encoder rejects the output for size, shrink it with `--max-width`, then `--colors` or `--fps`. When the GIF still exceeds 5 MiB at readable text quality, split it instead: encode consecutive, non-overlapping intervals of the same evidence run into numbered parts that each pass the encoder's size check — `--start` and `--end` over the one video, or consecutive screenshot subsets selected with `--pattern` — and verify every part as an artifact. Beside each embed, state its position (such as `2/3`) and its source interval or frames. + +Write each GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites each reference in place to its uploaded URL, keeping its position and alt text, so the order of references in the body sets the display order of split parts: ```markdown ![]() @@ -138,35 +140,8 @@ gh pr create --body-file --attach # new pull re gh pr edit --body-file --attach # existing pull request ``` -`--attach` is repeatable but refuses the same file twice. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '#'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the demonstrated live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected ``, and fetch the uploaded URL once to confirm `200` and `image/gif`. +`--attach` is repeatable but refuses the same file twice. Publish split parts in one `gh pr create` or `gh pr edit` with one `--attach` per part, so a single pair of live-head checks covers every part. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '#'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the demonstrated live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected ``, and fetch the uploaded URL once to confirm `200` and `image/gif`. -### Fall back to an assets branch +### Existing assets branches -Use the assets-branch workflow only when `gh --attach` cannot apply: the GIF still exceeds 10 MB, `gh` is older than v2.99.0, or the repository is not on github.com. GIFs then live on a dedicated orphan assets branch — a branch with no parent commit and nothing but media — and one assets branch serves a whole pull request series (named `-assets`; list existing ones with `git ls-remote --heads origin '*assets*'`). - -Before either workflow below pushes, verify that the assets branch contains media only and that the staged GIF's checksum matches the verified local artifact. - -For an existing assets branch, work in a shallow single-branch scratch clone so the publication cannot touch your working tree: - -```sh -git clone --branch --single-branch --depth 1 /tmp/assets-checkout -cp /absolute/path/to/demo.gif /tmp/assets-checkout/.gif -cd /tmp/assets-checkout -git add .gif -git commit -m "assets: gif (#)" -git push origin -``` - -For a new series, make a fresh shallow scratch clone (`git clone --depth 1 /tmp/assets-checkout`), create the orphan branch with `git switch --orphan `, then add the GIF, commit, and push the same way. - -After pushing, use authenticated GitHub API or raw requests to confirm the remote path, byte size, checksum, `200` response, and `image/gif` content type. An anonymous `404` does not disprove a private-repository asset; authenticate the verification instead. This proves the repository-member review path, not public availability. - -Immediately before editing the pull-request body, re-read the demonstrated pull request's live head and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. After the edit, re-read the demonstrated live head and require it to remain at that recorded commit. Separately, render the body through GitHub's Markdown API and confirm that the expected `` is present. - -Embed the GIF in the pull request body with the raw blob URL; the `?raw=true` suffix is required, because the plain blob URL renders GitHub's file page instead of the image: - -```markdown -![](https://github.com///blob//.gif?raw=true) -``` - -Never delete or rewrite an assets branch, and never force-push it: merged pull request bodies reference its URLs forever. Append new commits only. +Earlier pull request bodies embed GIFs from orphan `*-assets` branches in this repository. Never add to, delete, rewrite, or force-push them: merged pull request bodies reference their URLs. diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index 5620e6af63..d69a860098 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -14,6 +14,7 @@ on: - larger-runner-benchmark - consolidated-runner-benchmark - ssh-helper + - wine # New master pushes and manual runs replace obsolete checks in this workflow/ref. # Standby drills share cancellation; use completed runs as readiness evidence. @@ -49,9 +50,9 @@ jobs: secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} - # Wine runs once here, independently of the native serial standby aggregates. + # Wine runs on master pushes or an explicit suite=wine dispatch, independently of native standbys. windows: - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + if: (github.event_name == 'push' && github.ref == 'refs/heads/master') || (github.event_name == 'workflow_dispatch' && inputs.suite == 'wine') runs-on: ubuntu-latest name: windows node 24 / wine timeout-minutes: 15 diff --git a/.github/workflows/cua-helper-package.yml b/.github/workflows/cua-helper-package.yml new file mode 100644 index 0000000000..20ecb7526f --- /dev/null +++ b/.github/workflows/cua-helper-package.yml @@ -0,0 +1,83 @@ +name: Package CUA helper · macOS + +on: + workflow_dispatch: + inputs: + source_ref: + description: '同仓库可信源码分支或 dsh-v* tag' + type: string + default: master + required: true + preset: + description: '机器上的签名配置' + type: choice + options: [test, production] + default: test + required: true + workflow_call: + inputs: + source_ref: + type: string + required: true + preset: + type: string + default: production + outputs: + artifact: + description: '包含两个架构的已签名、公证 helper 归档' + value: ${{ jobs.package.outputs.artifact }} + +permissions: + contents: read + +# Desktop uses the same keychain search list and optional system proxy transaction. +concurrency: + group: macos-desktop-package + cancel-in-progress: false + +jobs: + package: + runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] + timeout-minutes: 60 + outputs: + artifact: ${{ steps.artifact.outputs.name }} + env: + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + DSH_TELEMETRY_DISABLED: '1' + PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ inputs.source_ref }} + persist-credentials: false + clean: true + - name: Validate source selection + run: | + node -e 'const cp=require("node:child_process"); const fs=require("node:fs"); const ref=process.env.PACKAGE_SOURCE_REF; + if(ref.startsWith("refs/pull/") || /^[a-f0-9]{40}$/i.test(ref)) throw Error("Select a trusted repository branch or release tag"); + const tag=ref.replace(/^refs\/tags\//, ""); + if(tag.startsWith("dsh-v") && tag!=="dsh-v"+JSON.parse(fs.readFileSync("package.json","utf8")).version) throw Error("Release tag must match product version"); + if(!["test","production"].includes(process.env.DSH_PACKAGING_PRESET)) throw Error("Invalid preset");' + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Build and verify notarized applications + run: | + mkdir -p dist + pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper + - name: Name retained artifact + id: artifact + run: echo "name=cua-helper-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v7.0.1 + with: + name: ${{ steps.artifact.outputs.name }} + path: dist/cua-helper/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index ec03a117de..eb7e3a5eef 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -1,12 +1,28 @@ # Publish the dsh release sequence to npm. This workflow is manual-only # (workflow_dispatch) and intentionally does not listen to pull_request or push: -# publication must always be an explicit, reviewed act from a dsh-v* tag, and it -# must never appear as a PR check. It repacks the current tree before publishing +# publication is an explicit, reviewed act; dsh-v* tag validation is enabled by +# default and may be skipped through the dispatch input. It never appears as a PR check. It repacks the current tree before publishing # so the bytes uploaded are exactly what this dispatch produced. name: Release publish (dsh) on: workflow_dispatch: + inputs: + cua_run_id: + description: '已有 CUA 打包 run ID:留空重新打包;填写时复用同一源码和版本的已验证产物' + type: string + required: false + default: '' + cua_run_attempt: + description: 'CUA 打包 attempt:仅与 run ID 一起使用;留空选择最新一次,不回退到更早的成功构建' + type: string + required: false + default: '' + skip-tag-check: + description: Skip release tag validation for the selected ref; other publication checks still apply. + type: boolean + required: false + default: false permissions: contents: read @@ -16,9 +32,23 @@ env: DSH_TELEMETRY_DISABLED: '1' jobs: + cua-helper: + if: (startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check) && inputs.cua_run_id == '' && inputs.cua_run_attempt == '' + uses: ./.github/workflows/cua-helper-package.yml + with: + source_ref: ${{ github.ref }} + preset: production + pack: name: Pack npm tarballs + needs: cua-helper + if: >- + ${{ !cancelled() && (needs.cua-helper.result == 'success' + || (needs.cua-helper.result == 'skipped' && (inputs.cua_run_id != '' || inputs.cua_run_attempt != ''))) }} runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read steps: # Complete history: the release scripts read tags. - uses: actions/checkout@v7.0.1 @@ -55,10 +85,50 @@ jobs: - name: Verify release version env: RELEASE_PUBLISH: 'true' - run: pnpm run release:verify --family dsh + run: pnpm run release:verify --family dsh ${{ inputs.skip-tag-check && '--skip-tag-check' || '' }} + + - name: Select retained CUA helper + if: inputs.cua_run_id != '' || inputs.cua_run_attempt != '' + id: retained-cua + env: + GH_TOKEN: ${{ github.token }} + run: pnpm exec tsx scripts/cua-helper/select-artifact.ts + + - name: Download retained CUA helper + if: inputs.cua_run_id != '' || inputs.cua_run_attempt != '' + uses: actions/download-artifact@v8.0.1 + with: + artifact-ids: ${{ steps.retained-cua.outputs.artifact-id }} + run-id: ${{ inputs.cua_run_id }} + github-token: ${{ github.token }} + path: dist/cua-helper - name: Build - run: pnpm run build:official + run: pnpm run build:official --serial + + - name: Download freshly built CUA helper + if: inputs.cua_run_id == '' && inputs.cua_run_attempt == '' + uses: actions/download-artifact@v8.0.1 + with: + name: ${{ needs.cua-helper.outputs.artifact }} + path: dist/cua-helper + + - name: Stage verified native helper applications + run: | + mkdir dist/cua-helper/extracted + tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted + pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted + + - name: Record CUA source and publication retry instructions + env: + CUA_RUN_ID: ${{ inputs.cua_run_id || github.run_id }} + CUA_RUN_ATTEMPT: ${{ steps.retained-cua.outputs.attempt || github.run_attempt }} + run: | + { + printf 'CUA helper: run `%s`, attempt `%s`.\n\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT" + printf '%s\n\n' 'npm 部分发布失败时,只重跑 Publish to npm job,复用原 dsh-npm-tarballs;不要重跑全部 job。' + printf '若需新建发布运行,填写 `cua_run_id=%s`、`cua_run_attempt=%s`,并使用相同源码 commit 和版本。\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT" + } >> "$GITHUB_STEP_SUMMARY" - name: Pack release tarballs run: pnpm run release:pack --family dsh --out dist/npm diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index cbb4fd2642..29229605de 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -53,11 +53,6 @@ on: type: string default: '' required: false - build_root: - description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区' - type: string - default: '' - required: false permissions: contents: read @@ -82,7 +77,6 @@ jobs: PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} PACKAGE_SIGNING: ${{ inputs.signing }} PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }} - PACKAGE_BUILD_ROOT: ${{ inputs.build_root }} PACKAGE_WINDOWS_COMPRESSION_LEVEL: ${{ inputs.compression_level }} steps: - name: Reset packaging checkout @@ -106,7 +100,7 @@ jobs: run: | $helpers = Join-Path $env:RUNNER_TEMP "packaging-helpers-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" New-Item -ItemType Directory -Path $helpers -ErrorAction Stop | Out-Null - foreach ($name in @('windows-packaging-priority.ps1', 'windows-build-workspace.mjs')) { + foreach ($name in @('windows-packaging-priority.ps1')) { Copy-Item -LiteralPath (Join-Path 'apps/desktop/scripts' $name) -Destination $helpers -ErrorAction Stop } "PACKAGE_CI_HELPERS=$helpers" >> $env:GITHUB_ENV @@ -140,17 +134,6 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' - - name: Select packaging workspace - id: build_workspace - env: - PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} - run: | - if (!$env:PACKAGE_BUILD_ROOT) { - "directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT - } else { - node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" - exit $LASTEXITCODE - } - name: Configure persistent pnpm store # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. env: @@ -167,7 +150,6 @@ jobs: "DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - name: Install locked dependencies - working-directory: ${{ steps.build_workspace.outputs.directory }} run: | & "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))" @@ -178,12 +160,10 @@ jobs: exit $LASTEXITCODE - name: Validate preset and resolve build version id: settings - working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} run: node apps/desktop/scripts/prepare-windows-ci.mjs - name: Check packaging configuration and toolchain - working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | @@ -192,23 +172,16 @@ jobs: pnpm @packageArgs exit $LASTEXITCODE - name: Build installer and verify packaged runtime - working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} - PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }} run: | & "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort - if ($env:PACKAGE_BUILD_TEMP) { - $env:TEMP = $env:PACKAGE_BUILD_TEMP - $env:TMP = $env:PACKAGE_BUILD_TEMP - } $packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION) if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } pnpm @packageArgs exit $LASTEXITCODE - name: Retain complete package outside the checkout id: retained - working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} @@ -219,7 +192,7 @@ jobs: with: name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true - path: ${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/ + path: ${{ github.workspace }}/apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 - name: Retain verified unsigned installer @@ -242,8 +215,3 @@ jobs: path: ${{ steps.retained.outputs.directory }}/* if-no-files-found: error retention-days: 14 - - name: Remove isolated packaging workspace - if: success() && inputs.build_root != '' - env: - PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} - run: node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove diff --git a/.gitignore b/.gitignore index 3cb86ce87d..4d52487a06 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ worktrees/ .agents/worktrees/ .typert-*/ native/system/test/bin/ + +# Generated company-signed CUA release record. +/packages/experimental/cua-macos/cua-helper.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 5d3533dab6..463f4f1b9e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, @@ -104,9 +104,9 @@ "@deepseek-ai/dsh-experimental-ralph-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-session-titles-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-terminal-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-liveshare": "workspace:*", "@deepseek-ai/dsh-experimental-session-search": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", - "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", "@deepseek-ai/dsh-personalized-recommendations": "workspace:*", "@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*" diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 6ec553654d..93469b15f2 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index e7fced6b8e..1c55529b1c 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -66,8 +66,8 @@ en: 182a9c6705213e13 zh: 681762f83490f4c6 /deepseek-harness-desktop/updates/local-updater-qualification: - en: 68ec2f8bd5763ea6 - zh: 23b932dfe8503602 + en: db5e898140a73d53 + zh: 80a9d58c74b4bb24 /deepseek-harness-desktop/low-level-development-overrides: en: 8264b135871e8978 zh: 0939aafb1b2961fc diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f961a72a92..89755436cb 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -353,6 +353,8 @@ The command builds the Desktop shell and runs its coordinator with real Electron The downloaded bytes are inert, and the installation call is recorded rather than executed. The test substitutes browser opening and clipboard writing to avoid external navigation and clipboard changes. It does not boot the full product workspace, qualify a real installer or restart, verify publisher signatures, or exercise differential updates or macOS. Stalled policy requests, feed requests, and payload transfers exercise real deadlines and recovery. The actual ordinary dialog verifies isolated preload loading, card geometry, an unfiltered parent page, cancellation, task-warning choices, and explicit installation approval; account-row component tests provide separate evidence. The [local qualification decision](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md) and [verification record](tests/README.md) preserve these limits; production release requirements remain unchanged. +Native original-file operations accept only the trusted application frame. Completion returns the destination path to that frame; revealing it uses the existing Host path-opening API without a separate reveal IPC or saved-file history. Downloads use Chromium’s URL download and native save-dialog APIs; `DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS` bounds startup until Chromium creates the download (300000 ms); the native save dialog has no application deadline. Clipboard-only remote image reads omit credentials, validate every redirect, reject the application Host and its loopback aliases, stop on main-frame navigation, and use `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS` (30000 ms) and `DSH_DESKTOP_IMAGE_READ_MAX_BYTES` (67108864 bytes). Image copying uses the renderer Clipboard API after PNG conversion. Other reachable HTTP(S) destinations follow deployment network isolation. [File actions](../../packages/client/ui-file-actions/README.md) defines saving and clipboard behavior. + ## Low-level development overrides diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a7500d5d28..3df2783476 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -353,6 +353,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 下载内容是不可执行的测试字节,安装调用仅记录而不执行。测试替换浏览器打开与剪贴板写入,避免外部导航和剪贴板修改。它不启动完整产品工作区,不验证真实安装器或重启,不验证发布者签名,也不覆盖差分更新或 macOS。停滞的策略请求、清单请求和负载传输会执行真实截止时间及恢复。真实常规弹窗验证隔离预加载、卡片尺寸、未施加模糊的父页面、取消、任务警告选项与显式安装批准;账户行组件测试另行提供证据。[本地验证决策](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md)和[验证记录](tests/README.zh.md)保留这些限制;生产发布要求保持不变。 +原生原始文件操作仅接受可信应用页面。完成后向该页面返回目标路径;显示位置复用现有 Host 路径打开 API,不提供独立 reveal IPC,也不保留已保存文件历史。下载使用 Chromium URL 下载和原生保存对话框 API;`DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS` 限定 Chromium 创建下载前的启动期限(300000 毫秒),不限制原生保存对话框的停留时间。仅剪贴板使用的远程图片读取不携带凭据,逐次校验重定向,拒绝应用 Host 及其回环别名,在主框架导航时终止,并使用 `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS`(30000 毫秒)与 `DSH_DESKTOP_IMAGE_READ_MAX_BYTES`(67108864 字节)。图片复制在转换为 PNG 后使用渲染进程的 Clipboard API。其他可达的 HTTP(S) 目标遵循部署侧网络隔离。[文件操作](../../packages/client/ui-file-actions/README.zh.md) 定义保存和剪贴板行为。 + ## 底层开发覆盖项 diff --git a/apps/desktop/config/publishing/macos/production.json.example b/apps/desktop/config/publishing/macos/production.json.example new file mode 100644 index 0000000000..cb7f67a0a4 --- /dev/null +++ b/apps/desktop/config/publishing/macos/production.json.example @@ -0,0 +1,13 @@ +{ + "bucket": "replace-with-bucket-1250000000", + "origin": "https://download.deepseek.com", + "credentialsFile": "/absolute/path/to/packaging/secrets/cos-production.json", + "macOS": { + "signingIdentity": "REPLACE_WITH_DEVELOPER_ID_APPLICATION_IDENTITY", + "teamId": "XXXXXXXXXX" + }, + "githubApp": { + "clientId": "REPLACE_WITH_GITHUB_APP_CLIENT_ID", + "privateKeyFile": "/absolute/path/to/packaging/publishing/keys/desktop-release-tagger.pem" + } +} diff --git a/apps/desktop/config/publishing/macos/test.json.example b/apps/desktop/config/publishing/macos/test.json.example new file mode 100644 index 0000000000..6981a88c6b --- /dev/null +++ b/apps/desktop/config/publishing/macos/test.json.example @@ -0,0 +1,9 @@ +{ + "bucket": "replace-with-bucket-1250000000", + "origin": "https://download-test.example.com", + "credentialsFile": "/absolute/path/to/packaging/secrets/cos-test.json", + "macOS": { + "signingIdentity": "REPLACE_WITH_DEVELOPER_ID_APPLICATION_IDENTITY", + "teamId": "XXXXXXXXXX" + } +} diff --git a/apps/desktop/config/publishing/windows/production.json.example b/apps/desktop/config/publishing/windows/production.json.example new file mode 100644 index 0000000000..9a2a743227 --- /dev/null +++ b/apps/desktop/config/publishing/windows/production.json.example @@ -0,0 +1,12 @@ +{ + "bucket": "replace-with-bucket-1250000000", + "origin": "https://download.deepseek.com", + "credentialsFile": "C:/absolute/path/to/packaging/secrets/cos-production.json", + "windows": { + "certificateThumbprint": "0000000000000000000000000000000000000000" + }, + "githubApp": { + "clientId": "REPLACE_WITH_GITHUB_APP_CLIENT_ID", + "privateKeyFile": "C:/absolute/path/to/packaging/publishing/keys/desktop-release-tagger.pem" + } +} diff --git a/apps/desktop/config/publishing/windows/test.json.example b/apps/desktop/config/publishing/windows/test.json.example new file mode 100644 index 0000000000..cf3e1a40a1 --- /dev/null +++ b/apps/desktop/config/publishing/windows/test.json.example @@ -0,0 +1,8 @@ +{ + "bucket": "replace-with-bucket-1250000000", + "origin": "https://download-test.example.com", + "credentialsFile": "C:/absolute/path/to/packaging/secrets/cos-test.json", + "windows": { + "certificateThumbprint": "0000000000000000000000000000000000000000" + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bf39dde903..40bdfefe27 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "private": true, "license": "MIT", "author": { @@ -88,6 +88,7 @@ "@deepseek-ai/dsh-plugin-manager": "workspace:*", "zod": "4.4.3", "@deepseek-ai/dsh-util-file-drag": "workspace:*", + "@deepseek-ai/dsh-client-ui-file-actions": "workspace:*", "tencentcloud-sdk-nodejs-teo": "4.1.330" } } diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index b98c3d075f..c39bb71bc3 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,11 +24,11 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: 9dd2227f2878519e - zh: cd95dd6efd48298f + en: 253e17998951931c + zh: b582b377391d2ec2 /desktop-packaging/macos-self-hosted-packaging: - en: ee628e830d849ae9 - zh: 44313f5fb842f042 + en: f4cc5209bdaeefba + zh: 1db959d1ed345fcc /desktop-packaging/unsigned-windows-test-installer: en: f429bc0c280e2dfa zh: 4adbcc9e906bdf80 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 83a0e5a77b..28aa9ff9c7 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -115,11 +115,9 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. -For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. - For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory. -The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats. +The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. The priority helper is copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. A missing helper stops CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats. Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression. @@ -129,13 +127,12 @@ Installer uploads store the already-compressed release files without ZIP recompr The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. -All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing, cache measurement and isolated-workspace options follow these common fields. +All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing and cache measurement options follow these common fields. Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). - ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. Install the user service from the checkout as the same account that registered the runner, without `sudo`: diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 3868508c9e..2e87933826 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -115,11 +115,9 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 -测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 - 测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。 -依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。 +依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。 安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。 @@ -129,13 +127,12 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s [Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 -三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量、独立构建目录选项位于公共字段之后。 +三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量选项位于公共字段之后。 运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 - 无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`: diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index d6940d4d14..14ef478ed7 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -21,14 +21,14 @@ en: 0478d8e35f9a3d55 zh: 0aa3fd828bc02691 /desktop-ci-publication/machine-setup: - en: 6e7b928af17f4377 - zh: 7c559078306df3fd + en: 0f43f2e193e95043 + zh: 1c1efb062c06f7b8 /desktop-ci-publication/package-and-publish: en: f7c12d343891e547 zh: da596ee1366c2046 /desktop-ci-publication/recovery-and-records: - en: 2e1e6b16d0d6bd41 - zh: 6ff4ad5a8f1f57b4 + en: b62a7484929f9941 + zh: c9dd06c77098e6ef /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 02f1e1b6af..5009bf87e7 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -54,7 +54,9 @@ For a queued job, compare the job page's requested labels with **Settings → Ac Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging directory. Completed attempts are copied to its sibling `releases///`; `DSH_DESKTOP_RELEASES_DIR` overrides that root with an absolute path. The inventory includes installers, update archives, blockmaps, completion metadata and a credential-free manifest. Debug packages are retained but cannot publish. Copies are checked before the attempt directory becomes visible, and an existing attempt cannot be replaced. GitHub retains the same file inventory for 14 days. Local files remain until an operator removes them; do not delete an attempt while it is being verified or published. -Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration. +Start from the [macOS test](../../config/publishing/macos/test.json.example), [macOS production](../../config/publishing/macos/production.json.example), [Windows test](../../config/publishing/windows/test.json.example), or [Windows production](../../config/publishing/windows/production.json.example) template. Create the `publishing/` directory and copy the selected file to `publishing/.json` below `DSH_PACKAGING_CONFIG_DIR`. Replace the example bucket, test origin, absolute paths, macOS signing identity and team ID or Windows certificate thumbprint, and production App client ID with the machine settings; keep the fixed production origin. Templates contain no credentials and do not grant COS, GitHub, or signing permissions. Store referenced secrets outside Git with access restricted to the publishing account. macOS uses the same configuration for arm64 and x64; the retained package determines the target. + +Each configuration declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration. Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`. Assign that label to exactly that runner. A target may be a single name or a nonempty list; the Mac entry above authorizes both packaging workflows on the same machine. Windows uses target `win-x64`. The retained target must match the selected packaging workflow. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. @@ -82,7 +84,7 @@ After resolving a failure, re-run the failed publishing job to keep its prepared The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. -If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. +If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. A rejected tag creation retains its HTTP status and validated GitHub request ID; a follow-up lookup does not replace that failure with a missing-tag error. A repository-rule rejection directs the operator to the matching rulesets without logging the raw response. Normal publication and manual restoration acquire the same local channel lock. A crash can leave a lock directory under the sibling `publish-locks/`; verify no writer is running before manually removing it. A runner label alone does not coordinate writers on other machines. Keep one publishing machine per channel and preserve its records when migrating. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 3a635b5116..151032eed9 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -54,7 +54,9 @@ job 排队时,先对照 job 页面请求的标签与 **Settings → Actions 保持 `DSH_PACKAGING_CONFIG_DIR` 指向现有机器打包目录。完成的构建复制到其同级 `releases///`;`DSH_DESKTOP_RELEASES_DIR` 可用绝对路径覆盖该根目录。文件清单包含安装包、更新归档、blockmap、完成记录和不含凭据的 manifest。调试包会留存,但不能发布。复制内容通过校验后才开放 attempt 目录,已有 attempt 不能被替换。GitHub 保留相同文件清单 14 天。本地文件保留到操作员删除;校验或发布期间不要删除对应 attempt。 -在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。 +从 [macOS test](../../config/publishing/macos/test.json.example)、[macOS production](../../config/publishing/macos/production.json.example)、[Windows test](../../config/publishing/windows/test.json.example) 或 [Windows production](../../config/publishing/windows/production.json.example) 模板开始,创建 `publishing/` 目录,将所选文件复制到 `DSH_PACKAGING_CONFIG_DIR` 下的 `publishing/.json`。用机器实际配置替换示例 bucket、test origin、绝对路径、macOS 签名身份和 Team ID 或 Windows 证书指纹,以及 production App client ID;保留固定的 production origin。模板不含凭据,也不授予 COS、GitHub 或签名权限。引用的秘密文件保存在 Git 之外,只允许发布账户访问。macOS 的 arm64 和 x64 共用配置,目标由留存包决定。 + +每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。 将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`。该标签只能分配给这台 runner。目标可为单个名称或非空列表;上述 Mac 条目允许同一台机器处理两种架构的打包工作流。Windows 使用目标 `win-x64`。留存包的目标必须与所选打包工作流一致。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 @@ -82,7 +84,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 -频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。 +频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。tag 创建被拒绝时保留其 HTTP 状态及经过校验的 GitHub request ID,后续查询不会用 tag 不存在的错误替换创建失败。仓库规则拒绝会提示操作人员检查匹配的 ruleset,不记录原始响应。 正常发布和手动恢复获取同一把本地频道锁。崩溃可能在同级 `publish-locks/` 下留下锁目录;手动删除前确认没有写入任务运行。runner 标签本身不能协调其他机器的写入。每个频道保持单一发布机器,迁移时保留记录。 diff --git a/apps/desktop/scripts/desktop-publish-tag.ts b/apps/desktop/scripts/desktop-publish-tag.ts index f47cc698a6..de60c03e84 100644 --- a/apps/desktop/scripts/desktop-publish-tag.ts +++ b/apps/desktop/scripts/desktop-publish-tag.ts @@ -40,8 +40,32 @@ export async function publishDesktopTag( body: JSON.stringify({ ref: `refs/tags/${tag}`, sha: commit }), redirect: 'error', signal: AbortSignal.timeout(30_000), }) if (response.status === 201) { await matches(response); return 'created' } - // A parallel platform release may have created the same version tag after our first read. - if (response.status === 422) { await response.body?.cancel(); await matches(await get()); return 'present' } + const requestId = response.headers.get('x-github-request-id') + const identity = requestId !== null && /^[A-Fa-f0-9:]{1,128}$/u.test(requestId) ? `; GitHub request ${requestId}` : '' + const failure = `desktop tag: creation failed (HTTP ${response.status}${identity})` + // A 422 can reject repository rules as well as report a concurrent tag creation. + if (response.status === 422) { + let detail: unknown + try { detail = await response.json() } catch (error) { + // Non-JSON error responses still retain the original HTTP status and request ID. + } + const rulesRejected = typeof detail === 'object' && detail !== null && 'message' in detail + && typeof detail.message === 'string' && detail.message.startsWith('Repository rule violations found') + const guidance = rulesRejected ? 'repository rules rejected tag creation; inspect the matching tag rulesets' + : 'inspect repository rule insights and App permissions before retrying' + let reconciled: Response + try { reconciled = await get() } catch (cause) { + throw new Error(`${failure}; follow-up tag lookup failed; ${guidance}`, { cause }) + } + if (!reconciled.ok) { + await reconciled.body?.cancel() + throw new Error(`${failure}; follow-up tag lookup returned HTTP ${reconciled.status}; ${guidance}`) + } + try { await matches(reconciled) } catch (cause) { + throw new Error(`${failure}; follow-up tag verification failed; inspect the existing tag before retrying`, { cause }) + } + return 'present' + } await response.body?.cancel() - throw new Error(`desktop tag: creation failed (HTTP ${response.status}); reconcile the tag before retrying`) + throw new Error(`${failure}; reconcile the tag before retrying`) } diff --git a/apps/desktop/scripts/windows-build-workspace.d.mts b/apps/desktop/scripts/windows-build-workspace.d.mts deleted file mode 100644 index 92f3a3c704..0000000000 --- a/apps/desktop/scripts/windows-build-workspace.d.mts +++ /dev/null @@ -1,19 +0,0 @@ -/** Identity of a Windows packaging run and its independent build root. */ -export interface WindowsBuildWorkspaceOptions { - sourceDirectory: string - buildRoot: string - runId: string - runAttempt: string - sourceSha: string -} -/** - * Clone the clean selected commit into a new run-owned directory. - * @param options - Original checkout and Actions run identity. - * @returns Checkout and temporary directory owned by this run. - */ -export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string; temporaryDirectory: string } -/** - * Remove the directory only when its ownership record matches the run. - * @param options - Original checkout and Actions run identity. - */ -export function removeWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): void diff --git a/apps/desktop/scripts/windows-build-workspace.mjs b/apps/desktop/scripts/windows-build-workspace.mjs deleted file mode 100644 index 977dda6618..0000000000 --- a/apps/desktop/scripts/windows-build-workspace.mjs +++ /dev/null @@ -1,96 +0,0 @@ -/** Create and remove a run-owned checkout for Windows packaging. */ -import { execFileSync } from 'node:child_process' -import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, isAbsolute, join, parse, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' - -function assertUnlinkedAncestors(directory) { - for (let current = directory; ; current = dirname(current)) { - if (existsSync(current) && lstatSync(current).isSymbolicLink()) { - throw new Error('Packaging workspace ancestors must not be links') - } - if (dirname(current) === current) break - } -} - -function locations(options) { - const { buildRoot, runId, runAttempt, sourceSha } = options - if (!buildRoot || !isAbsolute(buildRoot) || /[\r\n\0]/.test(buildRoot)) { - throw new Error('Packaging build root must be a nonempty absolute directory') - } - const root = resolve(buildRoot) - if (root === parse(root).root) throw new Error('Packaging build root must not be a volume root') - if (!/^[1-9]\d*$/.test(runId) || !/^[1-9]\d*$/.test(runAttempt) || !/^[a-f0-9]{40}$/.test(sourceSha)) { - throw new Error('Packaging workspace requires a run identity and exact commit') - } - assertUnlinkedAncestors(root) - for (let current = root; ; current = dirname(current)) { - if (existsSync(join(current, '.git'))) throw new Error('Packaging build root must be outside existing Git checkouts') - if (dirname(current) === current) break - } - const owner = join(root, `windows-package-${runId}-${runAttempt}`) - assertUnlinkedAncestors(owner) - return { root, owner, directory: join(owner, 'source'), temporaryDirectory: join(owner, 'temp') } -} - -function git(directory, args) { - return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() -} - -function ownership(options, paths) { - return JSON.stringify({ version: 1, root: paths.root, source: resolve(options.sourceDirectory), runId: options.runId, runAttempt: options.runAttempt, sourceSha: options.sourceSha }) -} - -/** - * Clone the validated commit into a new run-owned checkout; failures retain diagnostics. - * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity. - * @returns {{directory: string, temporaryDirectory: string}} Checkout and temporary directory owned by this run. - */ -export function prepareWindowsBuildWorkspace(options) { - const paths = locations(options) - if (git(options.sourceDirectory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(options.sourceDirectory, ['status', '--porcelain'])) { - throw new Error('Packaging source must be clean and match the selected commit') - } - mkdirSync(paths.root, { recursive: true }) - mkdirSync(paths.owner) - writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' }) - mkdirSync(paths.temporaryDirectory) - git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory]) - git(paths.directory, ['checkout', '--detach', options.sourceSha]) - if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) { - throw new Error('Isolated packaging checkout does not match the selected commit') - } - return { directory: paths.directory, temporaryDirectory: paths.temporaryDirectory } -} - -/** - * Remove only the matching run-owned directory after successful artifact upload. - * Transient filesystem failures retry up to three times; persistent failures propagate. - * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity. - * @returns {void} - */ -export function removeWindowsBuildWorkspace(options) { - const paths = locations(options) - const marker = join(paths.owner, 'owner.json') - const stat = lstatSync(marker) - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) { - throw new Error('Packaging workspace ownership does not match this run') - } - rmSync(paths.owner, { recursive: true, maxRetries: 3, retryDelay: 100 }) -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - const options = { - sourceDirectory: process.env.GITHUB_WORKSPACE ?? '', - buildRoot: process.env.PACKAGE_BUILD_ROOT ?? '', - runId: process.env.GITHUB_RUN_ID ?? '', - runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '', - sourceSha: process.env.PACKAGE_SOURCE_SHA ?? '', - } - if (process.argv[2] === '--remove') removeWindowsBuildWorkspace(options) - else { - if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments') - const result = prepareWindowsBuildWorkspace(options) - appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\ntemporaryDirectory=${result.temporaryDirectory}\n`) - } -} diff --git a/apps/desktop/src/file-actions.ts b/apps/desktop/src/file-actions.ts new file mode 100644 index 0000000000..45c4a25fc1 --- /dev/null +++ b/apps/desktop/src/file-actions.ts @@ -0,0 +1,192 @@ +/** Trusted-window Chromium downloads and clipboard fallback reads. */ +import { app, ipcMain, session } from 'electron' +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import { closeSync, openSync, rmSync } from 'node:fs' +import { extname, join } from 'node:path' +import { resolveDragFileName } from '@deepseek-ai/dsh-util-file-drag' +import { randomUUID } from 'node:crypto' +import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types' +import { DESKTOP_IPC } from './ipc.ts' +import { resolveDurationMs } from './duration-env.ts' + +/** @param value - untrusted IPC input. @returns captured URL and destination policy. */ +function saveRequest(value: unknown): { url: URL; name?: string; mediaType?: string; mode: 'download' | 'saveAs' } { + if (typeof value !== 'object' || value === null + || !('url' in value) || typeof value.url !== 'string' + || ('name' in value && typeof value.name !== 'string') + || ('mediaType' in value && typeof value.mediaType !== 'string') + || !('mode' in value) || (value.mode !== 'download' && value.mode !== 'saveAs')) throw new Error('Invalid file save request') + const url = new URL(value.url) + if (!['https:', 'http:', 'blob:', 'data:'].includes(url.protocol) || url.username || url.password) throw new Error('Unsupported file source') + return { url, mode: value.mode, + ...'name' in value ? { name: value.name as string } : {}, + ...'mediaType' in value ? { mediaType: value.mediaType as string } : {}, + } +} + +/** + * Install trusted-window original-file operations. + * @param assertOwner - rejects shell pages, subframes, and unowned windows. + * @param hostUrl - current application Host excluded from image reads. + */ +export function installFileActions( + assertOwner: (event: IpcMainInvokeEvent) => BrowserWindow, hostUrl: () => string | undefined = () => undefined, +): void { + const timeoutMs = resolveDurationMs(process.env, 'DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS', 30_000) + const maxBytes = Number(process.env.DSH_DESKTOP_IMAGE_READ_MAX_BYTES ?? 64 * 1024 * 1024) + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('DSH_DESKTOP_IMAGE_READ_MAX_BYTES must be a positive integer') + const pending = new Map + finish: (result: FileSaveResult | Error) => void + started: () => void + item?: Electron.DownloadItem + }>() + session.defaultSession.on('will-download', (_event, item, contents) => { + const url = item.getURLChain()[0] + if (url === undefined || !new URL(url).hash.startsWith('#dsh-download=')) return + const entry = pending.get(url) + if (entry === undefined || entry.owner.webContents !== contents) { item.cancel(); return } + entry.started() + entry.item = item + let reserved: string | undefined + try { + assertOwner(entry.event) + const name = resolveDragFileName({ + name: entry.request.name ?? item.getFilename(), + mediaType: item.getMimeType() || entry.request.mediaType || '', + }) + let destination = join(app.getPath('downloads'), name) + if (entry.request.mode === 'saveAs') { + item.setSaveDialogOptions({ defaultPath: destination, properties: ['showOverwriteConfirmation', 'createDirectory'] }) + } else { + const extension = extname(destination) + const stem = destination.slice(0, destination.length - extension.length) + for (let index = 0; ; index++) { + destination = index === 0 ? `${stem}${extension}` : `${stem} (${index})${extension}` + try { closeSync(openSync(destination, 'wx')); break } + catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') continue + throw error + } + } + reserved = destination + item.setSavePath(destination) + } + item.once('done', (_event, state) => { + try { + if (state !== 'completed') { + if (reserved !== undefined) rmSync(reserved, { force: true }) + entry.finish(state === 'cancelled' ? { status: 'cancelled' } : new Error('File download interrupted')) + return + } + if (!pending.has(url)) return + assertOwner(entry.event) + entry.finish({ status: 'saved', path: item.getSavePath() }) + } catch (error) { entry.finish(error instanceof Error ? error : new Error('File download unavailable')) } + }) + } catch (error) { + item.cancel() + try { if (reserved !== undefined) rmSync(reserved, { force: true }) } + catch (cleanupError) { + entry.finish(new AggregateError([error, cleanupError], 'File download failed and its partial file could not be removed')) + return + } + entry.finish(error instanceof Error ? error : new Error('File download unavailable')) + } + }) + const downloadTimeoutMs = resolveDurationMs(process.env, 'DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', 300_000) + ipcMain.handle(DESKTOP_IPC.fileSave, (event, value: unknown): Promise => { + const owner = assertOwner(event) + const request = saveRequest(value) + // Fragments do not change the fetched resource; each native request has its own completion identity. + request.url.hash = `dsh-download=${randomUUID()}` + const url = request.url.href + return new Promise((resolve, reject) => { + const contents = owner.webContents + const finish = (result: FileSaveResult | Error): void => { + if (!pending.delete(url)) return + clearTimeout(timer) + owner.removeListener('closed', cancel) + contents.removeListener('did-start-navigation', navigate) + if (result instanceof Error) reject(result) + else resolve(result) + } + const cancel = (): void => { + const item = pending.get(url)?.item + finish({ status: 'cancelled' }) + item?.cancel() + } + const navigate = (_event: Electron.Event, _url: string, inPlace: boolean, mainFrame: boolean): void => { + if (mainFrame && !inPlace) cancel() + } + const timer = setTimeout(() => { + const item = pending.get(url)?.item + finish(new Error('File download timed out')) + item?.cancel() + }, downloadTimeoutMs) + pending.set(url, { event, owner, request, finish, started: () => { clearTimeout(timer) } }) + owner.once('closed', cancel) + contents.on('did-start-navigation', navigate) + try { contents.downloadURL(url) } + catch (error) { finish(error instanceof Error ? error : new Error('File download unavailable')) } + }) + }) + ipcMain.handle(DESKTOP_IPC.clipboardImageRead, async (event, value: unknown) => { + const owner = assertOwner(event) + if (typeof value !== 'string') throw new Error('Invalid image URL') + const checkedUrl = (value: string): URL => { + const url = new URL(value) + const host = hostUrl() + const application = host === undefined ? undefined : new URL(host) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password + || application !== undefined && url.port === application.port + && (url.hostname === application.hostname || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) { + throw new Error('Invalid image URL') + } + return url + } + let url = checkedUrl(value) + const contents = owner.webContents + const lifetime = new AbortController() + const abort = (): void => { lifetime.abort() } + owner.once('closed', abort) + const navigate = (_event: Electron.Event, _url: string, inPlace: boolean, mainFrame: boolean): void => { + if (mainFrame && !inPlace) abort() + } + contents.on('did-start-navigation', navigate) + try { + const signal = AbortSignal.any([lifetime.signal, AbortSignal.timeout(timeoutMs)]) + let response: Response + for (let redirects = 0; ; redirects++) { + response = await fetch(url, { signal, redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer' }) + if (![301, 302, 303, 307, 308].includes(response.status)) break + await response.body?.cancel() + const location = response.headers.get('location') + if (redirects >= 20 || location === null) throw new Error('Invalid image redirect') + url = checkedUrl(new URL(location, url).href) + } + if (!response.ok) throw new Error('Original image unavailable') + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('Original image unavailable') + const chunks: Uint8Array[] = [] + let size = 0 + try { + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + size += chunk.value.byteLength + if (size > maxBytes) throw new Error('Original image exceeds the download limit') + chunks.push(chunk.value) + } + } finally { await reader.cancel(); reader.releaseLock() } + assertOwner(event) + lifetime.signal.throwIfAborted() + return { bytes: new Uint8Array(Buffer.concat(chunks)), mediaType: response.headers.get('content-type') ?? '' } + } finally { + owner.removeListener('closed', abort) + contents.removeListener('did-start-navigation', navigate) + } + }) +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index e62a84f0a1..a3c5f5d935 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,3 +1,4 @@ +import type { DesktopFileActions } from '@deepseek-ai/dsh-client-ui-file-actions/types' /** Typed preload operations exposed only by the Electron shell. */ import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol' @@ -7,6 +8,8 @@ import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { + fileSave: 'dsh-desktop:file-save', + clipboardImageRead: 'dsh-desktop:clipboard-image-read', shortcutsInput: 'dsh-desktop:shortcuts-input', shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window', shortcutsGet: 'dsh-desktop:shortcuts-get', @@ -76,6 +79,7 @@ export interface DesktopUpdatePresentation { /** Product documents cannot supply update versions, package URLs, or installation authorization. */ export interface DshDesktopProductApi { + readonly fileActions: DesktopFileActions readonly protocolVersion: 1 readonly browser: DesktopBrowserBridge readonly fileDrag: DesktopFileDrag diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4770fe83bf..674221b671 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,4 @@ +import { installFileActions } from './file-actions.ts' import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ @@ -826,6 +827,7 @@ async function main(): Promise { || !event.senderFrame.url.startsWith('dsh-app://app/')) throw new Error('Rejected Platform command') return owner } + installFileActions(assertMainApplication, () => hostUrl) ipcMain.on(PLATFORM_IPC.bootstrap, (event) => { try { event.returnValue = platformView.bootstrap(event) } catch { event.returnValue = null } diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index c9937a301e..26266b4f1c 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,3 +1,4 @@ +import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types' /** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' @@ -14,6 +15,11 @@ import type { FileDragHandle } from '@deepseek-ai/dsh-util-file-drag' function createProductApi(): DshDesktopProductApi { return { protocolVersion: 1, + fileActions: { + save: request => ipcRenderer.invoke(DESKTOP_IPC.fileSave, request) as Promise, + readClipboardImage: url => ipcRenderer.invoke(DESKTOP_IPC.clipboardImageRead, url) as + Promise<{ bytes: Uint8Array; mediaType: string }>, + }, browser: createDesktopBrowserBridge(), fileDrag: { prepare: (source, requestId) => ipcRenderer.invoke(DESKTOP_IPC.fileDragPrepare, source, requestId) as diff --git a/apps/desktop/tests/cua-optional-package.spec.ts b/apps/desktop/tests/cua-optional-package.spec.ts index 3f3f7ad572..85ef15f0ee 100644 --- a/apps/desktop/tests/cua-optional-package.spec.ts +++ b/apps/desktop/tests/cua-optional-package.spec.ts @@ -1,4 +1,4 @@ -/** Cold pnpm requests exercise shipped CUA dependency/platform metadata with inert package contents. */ +/** Cold pnpm requests exercise on-demand CUA dependency/platform metadata with inert package contents. */ import { createHash } from 'node:crypto' import { createServer } from 'node:http' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -37,18 +37,20 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for const cli = await readManifest('apps/cli') const bundle = await readManifest('packages/experimental/cua-bundle') const native = await readManifest('packages/experimental/cua-macos') - expect(cli.dependencies).toHaveProperty(bundleName) + expect(cli.dependencies).not.toHaveProperty(bundleName) expect(bundle.dependencies).not.toHaveProperty(providerName) expect(bundle.optionalDependencies).toEqual({ [providerName]: 'workspace:*' }) expect(native.os).toEqual(['darwin']) const version = '1.0.0' const bundleDependencies = Object.fromEntries(Object.keys(bundle.dependencies as Record).map(name => [name, version])) const manifests = [ - { name: cli.name, version, dependencies: { [bundleName]: version } }, + { name: cli.name, version }, { name: bundleName, version, dependencies: bundleDependencies, optionalDependencies: { [providerName]: version } }, ...Object.keys(bundleDependencies).map(name => ({ name, version })), { name: providerName, version, os: native.os, files: native.files }, ] + const generation = 'a'.repeat(64) + const nativePayload = `native/bin/darwin-arm64/${generation}/DSH Computer Use.app/Contents/MacOS/dsh-cua-native` const packages = new Map; bytes: Buffer }>() for (const manifest of manifests) { const name = String(manifest.name) @@ -56,18 +58,20 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for await mkdir(directory) await writeFile(join(directory, 'package.json'), JSON.stringify(manifest)) if (name === providerName) { - await mkdir(join(directory, 'native/bin/darwin-arm64'), { recursive: true }) + await mkdir(join(directory, nativePayload, '..'), { recursive: true }) for (const file of ['.gitignore', '.npmignore']) { await writeFile(join(directory, 'native', file), await readFile(join(repository, 'packages/experimental/cua-macos/native', file))) } - await writeFile(join(directory, 'native/bin/darwin-arm64/dsh-cua-native'), 'inert native payload') + await writeFile(join(directory, 'native/bin/darwin-arm64/current.json'), JSON.stringify({ bundle: generation })) + await writeFile(join(directory, nativePayload), 'inert native payload') } await command(['pack', '--out', 'package.tgz'], directory) const bytes = await readFile(join(directory, 'package.tgz')) packages.set(name, { manifest, bytes }) if (name === providerName) { const result = await execa('tar', ['-tzf', join(directory, 'package.tgz')]) - expect(result.stdout).toContain('package/native/bin/darwin-arm64/dsh-cua-native') + expect(result.stdout).toContain('package/native/bin/darwin-arm64/current.json') + expect(result.stdout).toContain(`package/${nativePayload}`) } } const requests: string[] = [] @@ -98,13 +102,13 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) const address = server.address() if (!address || typeof address === 'string') throw new Error('fixture registry has no TCP address') - for (const target of ['darwin', 'win32', 'linux', 'linux-unrestricted']) { + for (const target of ['darwin-base', 'darwin', 'win32', 'linux', 'linux-unrestricted']) { unrestricted = target === 'linux-unrestricted' const project = join(root, target) await mkdir(project) - await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [String(cli.name)]: version } })) + await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [target === 'darwin-base' ? String(cli.name) : bundleName]: version } })) await writeFile(join(project, 'pnpm-workspace.yaml'), [ - 'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target}]`, + 'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target === 'darwin-base' ? 'darwin' : target}]`, ' cpu: [arm64]', 'minimumReleaseAge: 0', '', ].join('\n')) requests.length = 0 @@ -113,8 +117,8 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for `--config.registry=http://127.0.0.1:${address.port}/`, `--config.store-dir=${join(project, 'store')}`, `--config.cache-dir=${join(project, 'cache')}`, `--config.state-dir=${join(project, 'state')}`, ], project) - expect(requests).toContain(`/${bundleName}.tgz`) - expect(requests).toContain('/@deepseek-ai/dsh-cua.tgz') + expect(requests.includes(`/${bundleName}.tgz`)).toBe(target !== 'darwin-base') + expect(requests.includes('/@deepseek-ai/dsh-cua.tgz')).toBe(target !== 'darwin-base') expect(requests.includes(`/${providerName}.tgz`), `${target}: ${JSON.stringify(requests)}\n${output}`) .toBe(target === 'darwin' || unrestricted) } diff --git a/apps/desktop/tests/desktop-publish-tag.spec.ts b/apps/desktop/tests/desktop-publish-tag.spec.ts index 7a13daba54..06894f7504 100644 --- a/apps/desktop/tests/desktop-publish-tag.spec.ts +++ b/apps/desktop/tests/desktop-publish-tag.spec.ts @@ -3,13 +3,13 @@ import { publishDesktopTag } from '../scripts/desktop-publish-tag.ts' const commit = 'a'.repeat(40) const tag = (sha = commit) => ({ object: { type: 'commit', sha } }) -function transport(replies: Array<{ status: number; body?: object }>) { +function transport(replies: Array<{ status: number; body?: object; rawBody?: string; headers?: Record }>) { const calls: Array<{ url: string; init: RequestInit | undefined }> = [] const request: typeof fetch = async (url, init) => { calls.push({ url: typeof url === 'string' ? url : url instanceof URL ? url.href : url.url, init }) const reply = replies.shift() if (!reply) throw new Error('unexpected HTTP request') - return new Response(JSON.stringify(reply.body ?? {}), { status: reply.status }) + return new Response(reply.rawBody ?? JSON.stringify(reply.body ?? {}), { status: reply.status, headers: reply.headers ?? {} }) } return { calls, request } } @@ -57,3 +57,36 @@ it('explains an existing annotated tag without replacing it', async () => { await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/not a lightweight commit tag/u) expect(http.calls).toHaveLength(1) }) + +it('keeps the creation status and safe rule diagnostic when no competing tag exists', async () => { + const http = transport([{ status: 404 }, { status: 422, + headers: { 'x-github-request-id': 'ABCD:1234' }, + body: { message: 'Repository rule violations found for refs/tags/desktop-v1.2.3', errors: ['secret-value'] } }, { status: 404 }]) + const failure = publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request) + await expect(failure).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234); follow-up tag lookup returned HTTP 404; repository rules rejected') + await expect(failure).rejects.not.toThrow('secret-value') + expect(http.calls.map(call => call.init?.method ?? 'GET')).toEqual(['GET', 'POST', 'GET']) +}) + +it('does not echo arbitrary GitHub response text or unrecognized request IDs', async () => { + const http = transport([{ status: 404 }, { status: 422, + headers: { 'x-github-request-id': 'fixture-token' }, body: { message: 'fixture-token' } }, { status: 403 }]) + const failure = publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request) + await expect(failure).rejects.toThrow('creation failed (HTTP 422); follow-up tag lookup returned HTTP 403') + await expect(failure).rejects.not.toThrow('fixture-token') +}) + +it('still rejects a competing tag on a different commit', async () => { + const http = transport([{ status: 404 }, { status: 422 }, { status: 200, body: tag('b'.repeat(40)) }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('creation failed (HTTP 422)') +}) + +it('retains creation context for a non-JSON rejection', async () => { + const http = transport([{ status: 404 }, { status: 422, rawBody: 'not JSON', headers: { 'x-github-request-id': 'ABCD:1234' } }, { status: 404 }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234)') +}) + +it('retains creation context when the follow-up request fails', async () => { + const http = transport([{ status: 404 }, { status: 422, headers: { 'x-github-request-id': 'ABCD:1234' } }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234); follow-up tag lookup failed') +}) diff --git a/apps/desktop/tests/desktop-publish-templates.spec.ts b/apps/desktop/tests/desktop-publish-templates.spec.ts new file mode 100644 index 0000000000..0ead9a11ab --- /dev/null +++ b/apps/desktop/tests/desktop-publish-templates.spec.ts @@ -0,0 +1,35 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, posix, win32 } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { readDesktopPublishConfig, type DesktopPublishConfig } from '../scripts/desktop-publish-config.ts' + +for (const platform of ['macos', 'windows'] as const) { + for (const preset of ['test', 'production'] as const) { + it(`accepts the ${platform} ${preset} template after machine paths are supplied`, async () => { + const path = fileURLToPath(new URL(`../config/publishing/${platform}/${preset}.json.example`, import.meta.url)) + const template = JSON.parse(await readFile(path, 'utf8')) as DesktopPublishConfig + const paths = platform === 'windows' ? win32 : posix + expect(paths.isAbsolute(template.credentialsFile)).toBe(true) + expect(template[platform === 'macos' ? 'macOS' : 'windows']).toBeDefined() + expect(Object.hasOwn(template, 'secretId')).toBe(false) + expect(Object.hasOwn(template, 'secretKey')).toBe(false) + if (preset === 'production') expect(paths.isAbsolute(template.githubApp?.privateKeyFile ?? '')).toBe(true) + const root = await mkdtemp(join(tmpdir(), 'publish-template-')) + try { + await mkdir(join(root, 'publishing')) + // The parser uses the current host's path syntax; examples retain their target platform's paths. + const config = { ...template, credentialsFile: join(root, 'absent-credentials.json'), + ...(template.githubApp ? { githubApp: { ...template.githubApp, privateKeyFile: join(root, 'absent-key.pem') } } : {}) } + const output = join(root, 'publishing', `${preset}.json`) + await writeFile(output, JSON.stringify(config)) + await expect(readDesktopPublishConfig(root, preset)).resolves.toEqual(config) + await writeFile(output, JSON.stringify({ ...config, bucket: undefined })) + const failure = readDesktopPublishConfig(root, preset) + await expect(failure).rejects.toHaveProperty('name', 'ZodError') + await expect(failure).rejects.toHaveProperty('issues.0.path', ['bucket']) + } finally { await rm(root, { recursive: true, force: true }) } + }) + } +} diff --git a/apps/desktop/tests/file-actions.spec.ts b/apps/desktop/tests/file-actions.spec.ts new file mode 100644 index 0000000000..972f64e7ee --- /dev/null +++ b/apps/desktop/tests/file-actions.spec.ts @@ -0,0 +1,213 @@ +/** Native original-byte saves and window-scoped IPC ownership. */ +import { EventEmitter } from 'node:events' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { IpcMainInvokeEvent } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +import { installFileActions } from '../src/file-actions.ts' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const electron = vi.hoisted(() => ({ + app: { getPath: vi.fn() }, ipcMain: { handle: vi.fn() }, session: { defaultSession: { on: vi.fn() } }, +})) +vi.mock('electron', () => electron) +const roots: string[] = [] +const owners: EventEmitter[] = [] +afterEach(async () => { + for (const owner of owners.splice(0)) owner.emit('closed') + vi.useRealTimers(); vi.unstubAllEnvs() + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); vi.clearAllMocks(); vi.unstubAllGlobals() +}) +async function directory() { const path = await mkdtemp(join(tmpdir(), 'dsh-file-actions-')); roots.push(path); return path } + +function harness(root: string) { + const owner = Object.assign(new EventEmitter(), { + webContents: Object.assign(new EventEmitter(), { downloadURL: vi.fn<(url: string) => void>() }), isDestroyed: () => false, + }) + owners.push(owner) + const assertOwner = vi.fn(() => owner as never) + electron.app.getPath.mockReturnValue(root) + installFileActions(assertOwner) + const event = {} as IpcMainInvokeEvent + const invoke = async (channel: string, value: unknown): Promise => { + const handler = electron.ipcMain.handle.mock.calls.find(([name]) => name === channel)?.[1] as + (event: IpcMainInvokeEvent, value: unknown) => Promise + return handler(event, value) + } + const item = (mime = 'image/gif', url = owner.webContents.downloadURL.mock.lastCall![0]) => { + let destination = '' + const download = Object.assign(new EventEmitter(), { + getURLChain: () => [url], + getFilename: () => 'original.gif', getMimeType: () => mime, getSavePath: () => destination, + setSavePath: vi.fn((path: string) => { destination = path }), + setSaveDialogOptions: vi.fn(), cancel: vi.fn(), + }) + const willDownload = electron.session.defaultSession.on.mock.calls[0]![1] as + (event: object, item: object, contents: object) => void + willDownload({}, download, owner.webContents) + return download + } + const save = async (mode: 'download' | 'saveAs' = 'download') => { + const pending = invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/original.gif', name: 'image.gif', mode }) + const download = item() + if (mode === 'download') await writeFile(download.getSavePath(), new Uint8Array([0, 128, 255])) + download.emit('done', {}, 'completed') + return pending + } + return { owner, assertOwner, invoke, item, save } +} + +it('returns the completed destination and releases download listeners', async () => { + const root = await directory(), f = harness(root) + await expect(f.save()).resolves.toEqual({ status: 'saved', path: join(root, 'image.gif') }) + expect(f.owner.listenerCount('closed')).toBe(0) + expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0) +}) + +it.each(['file:///private/data', 'dsh-app://app/api/private', 'javascript:alert(1)'])('rejects unsupported URL %s', async (url) => { + const root = await directory(), f = harness(root) + await expect(f.invoke(DESKTOP_IPC.fileSave, { url, mode: 'download' })).rejects.toThrow('Unsupported') + expect(await readdir(root)).toEqual([]) +}) + +it('retains original downloads and reserves distinct paths until Chromium finishes', async () => { + const root = await directory(), f = harness(root) + await writeFile(join(root, 'image.gif'), 'previous') + await f.save() + expect(await readFile(join(root, 'image.gif'), 'utf8')).toBe('previous') + expect(await readFile(join(root, 'image (1).gif'))).toEqual(Buffer.from([0, 128, 255])) +}) + +it.each(['cancelled', 'interrupted'] as const)('reports native %s without a completed path', async (state) => { + const root = await directory(), f = harness(root) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'wrong.png', mode: 'download' }) + const download = f.item('image/webp') + expect(download.setSavePath).toHaveBeenCalledWith(join(root, 'wrong.webp')) + download.emit('done', {}, state) + if (state === 'cancelled') await expect(pending).resolves.toEqual({ status: 'cancelled' }) + else await expect(pending).rejects.toThrow('interrupted') + expect(await readdir(root)).toEqual([]) +}) + +it('uses the native save dialog and waits for completion before reporting success', async () => { + const root = await directory(), f = harness(root) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'photo.png', mode: 'saveAs' }) + const download = f.item('image/jpeg') + expect(download.setSavePath).not.toHaveBeenCalled() + expect(download.setSaveDialogOptions).toHaveBeenCalledWith({ defaultPath: join(root, 'photo.jpg'), properties: ['showOverwriteConfirmation', 'createDirectory'] }) + download.emit('done', {}, 'cancelled') + await expect(pending).resolves.toEqual({ status: 'cancelled' }) + f.assertOwner.mockImplementation(() => { throw new Error('Rejected owner') }) + await expect(f.invoke(DESKTOP_IPC.fileSave, {})).rejects.toThrow('Rejected owner') +}) + +it('cancels pending downloads when the owning document changes', async () => { + const f = harness(await directory()) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' }) + const download = f.item() + f.owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/', false, true) + await expect(pending).resolves.toEqual({ status: 'cancelled' }) + expect(download.cancel).toHaveBeenCalledOnce() + download.emit('done', {}, 'cancelled') + expect(f.owner.listenerCount('closed')).toBe(0) +}) + +it('matches overlapping downloads of the same source to their captured requests', async () => { + const root = await directory(), f = harness(root) + const first = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'first.gif', mode: 'download' }) + const firstUrl = f.owner.webContents.downloadURL.mock.lastCall![0] + const second = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'second.gif', mode: 'saveAs' }) + const secondItem = f.item() + const firstItem = f.item('image/gif', firstUrl) + expect(firstItem.setSavePath).toHaveBeenCalledWith(join(root, 'first.gif')) + expect(secondItem.setSaveDialogOptions.mock.lastCall?.[0]).toMatchObject({ defaultPath: join(root, 'second.gif') }) + secondItem.emit('done', {}, 'cancelled') + firstItem.emit('done', {}, 'completed') + await expect(first).resolves.toMatchObject({ status: 'saved' }) + await expect(second).resolves.toEqual({ status: 'cancelled' }) +}) + +it('retires timed-out downloads and cancels items arriving after their window closes', async () => { + vi.useFakeTimers(); vi.stubEnv('DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', '1000') + const root = await directory(), f = harness(root) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' }) + const rejected = expect(pending).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(1000) + await rejected + const download = f.item() + expect(download.cancel).toHaveBeenCalledOnce() + download.emit('done', {}, 'cancelled') + expect(await readdir(root)).toEqual([]) + expect(f.owner.listenerCount('closed')).toBe(0) + expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0) + + const late = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' }) + f.owner.emit('closed') + await expect(late).resolves.toEqual({ status: 'cancelled' }) + const stale = f.item() + expect(stale.cancel).toHaveBeenCalledOnce() + expect(stale.setSavePath).not.toHaveBeenCalled() +}) + +it('allows the native save dialog to remain open beyond the download startup deadline', async () => { + vi.useFakeTimers(); vi.stubEnv('DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', '1000') + const f = harness(await directory()) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'saveAs' }) + const download = f.item() + await vi.advanceTimersByTimeAsync(2000) + expect(download.cancel).not.toHaveBeenCalled() + download.emit('done', {}, 'completed') + await expect(pending).resolves.toMatchObject({ status: 'saved' }) +}) + +it('reports a synchronous Chromium launch failure and releases pending listeners', async () => { + const f = harness(await directory()) + f.owner.webContents.downloadURL.mockImplementation(() => { throw new Error('Download unavailable') }) + await expect(f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' })).rejects.toThrow('Download unavailable') + expect(f.owner.listenerCount('closed')).toBe(0) + expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0) +}) + +it('aborts original-image reads on window navigation and releases lifecycle listeners', async () => { + const f = harness(await directory()) + let signal!: AbortSignal + vi.stubGlobal('fetch', vi.fn((_url: URL, options: RequestInit) => new Promise((_resolve, reject) => { + signal = options.signal! + signal.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')) }, { once: true }) + }))) + const reading = f.invoke(DESKTOP_IPC.clipboardImageRead, 'https://images.example/original.gif') + f.owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/', false, true) + await expect(reading).rejects.toThrow('Aborted') + expect(signal.aborted).toBe(true) + expect(f.owner.listenerCount('closed')).toBe(0) + expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0) +}) + +it('preserves pending downloads across iframe and same-document navigation', async () => { + const root = await directory(), f = harness(root) + const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'saveAs' }) + const download = f.item() + f.owner.webContents.emit('did-start-navigation', {}, 'https://example.org', false, false) + f.owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/#tab', true, true) + download.setSavePath(join(root, 'chosen.gif')) + download.emit('done', {}, 'completed') + await expect(pending).resolves.toEqual({ status: 'saved', path: join(root, 'chosen.gif') }) + expect(download.cancel).not.toHaveBeenCalled() +}) + +it('rejects redirects to the application Host', async () => { + const root = await directory() + const owner = Object.assign(new EventEmitter(), { + webContents: Object.assign(new EventEmitter(), { downloadURL: vi.fn<(url: string) => void>() }), isDestroyed: () => false, + }) + installFileActions(() => owner as never, () => 'http://127.0.0.1:19321') + const handler = (name: string) => electron.ipcMain.handle.mock.calls.find(([channel]) => channel === name)![1] as + (event: IpcMainInvokeEvent, value: unknown) => Promise + const fetchImage = vi.fn(async () => new Response(null, { status: 302, headers: { location: 'http://localhost:19321/api/private' } })) + vi.stubGlobal('fetch', fetchImage) + const event = {} as IpcMainInvokeEvent + await expect(handler(DESKTOP_IPC.clipboardImageRead)(event, 'https://images.example/photo')).rejects.toThrow('Invalid image URL') + expect(fetchImage).toHaveBeenCalledOnce() + expect(await readdir(root)).toEqual([]) +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 20325642a2..0cb7aa3e96 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -317,6 +317,7 @@ vi.mock('electron', () => ({ }, Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, session: { defaultSession: { + on: vi.fn(), setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders }, } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index af9a9f226a..8001ae6fc2 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -38,6 +38,19 @@ it('exposes onboarding size activation to the application document', async () => expect(electron.ipcRenderer.send.mock.calls).toEqual([[DESKTOP_IPC.onboardingActive, true], [DESKTOP_IPC.onboardingActive, false]]) }) +it('routes original-file requests through the trusted application bridge', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + const request = { url: 'https://images.example/original.gif', name: 'image.gif', mode: 'download' as const } + await api.fileActions.save(request) + await api.fileActions.readClipboardImage('https://images.example/original.gif') + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ + [DESKTOP_IPC.fileSave, request], + [DESKTOP_IPC.clipboardImageRead, 'https://images.example/original.gif'], + ]) +}) + it('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) await import('../src/preload-app.ts') diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 7915248243..4ae8743e2e 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -87,6 +87,7 @@ vi.mock('electron', () => ({ net: { fetch: vi.fn() }, nativeTheme: state.nativeTheme, session: { defaultSession: { + on: vi.fn(), setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() }, } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, diff --git a/apps/desktop/tsconfig.client.json b/apps/desktop/tsconfig.client.json index 3de33f06dd..eb17ebe087 100644 --- a/apps/desktop/tsconfig.client.json +++ b/apps/desktop/tsconfig.client.json @@ -18,6 +18,9 @@ }, { "path": "../../packages/client/product-analytics/tsconfig.client.json" + }, + { + "path": "../../packages/client/ui-file-actions" } ] } diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 44992641d5..7078ac8198 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -40,6 +40,9 @@ }, { "path": "../../packages/util/file-drag" + }, + { + "path": "../../packages/client/ui-file-actions" } ], "exclude": [ diff --git a/apps/plugin-market-generator/package.json b/apps/plugin-market-generator/package.json index d9e0d00dd0..a426b81646 100644 --- a/apps/plugin-market-generator/package.json +++ b/apps/plugin-market-generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-market-generator", "description": "Reference generator of plugin market index files from a list of npm packages and Git repositories", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 7e2d1ad835..51ecc68787 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/caption-overlays.e2e.ts b/apps/web/tests/caption-overlays.e2e.ts index 2a86277843..d336b64950 100644 --- a/apps/web/tests/caption-overlays.e2e.ts +++ b/apps/web/tests/caption-overlays.e2e.ts @@ -21,8 +21,9 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro document.documentElement.setAttribute('data-windows-titlebar', '') document.documentElement.style.setProperty('--dsh-windows-titlebar-height', '40px') }) - const reopenSidebar = page.getByRole('button', { name: '打开侧边栏', exact: true }) - if (await reopenSidebar.isVisible()) await reopenSidebar.click() + await page.setViewportSize({ width: 521, height: 600 }) + await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + const reopenSidebar = page.locator('[data-shell-leading]').getByRole('button', { name: '打开侧边栏', exact: true }) await openSettings(page, 'zh') const settings = page.getByRole('dialog', { name: '设置', exact: true }) const settingsMask = settings.locator('..').locator(':scope > [aria-hidden="true"]') @@ -51,7 +52,7 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro await menu.waitFor({ state: 'hidden' }) expect(await settings.isVisible()).toBe(true) await settings.getByRole('button', { name: '关闭', exact: true }).click() - if (await reopenSidebar.isVisible()) await reopenSidebar.click() + if (await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').count() > 0) await reopenSidebar.click() await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() await page.locator('[data-plugin-panel]').getByRole('button', { name: '添加插件', exact: true }).click() const plugin = page.getByRole('dialog', { name: '添加插件', exact: true }) @@ -88,7 +89,9 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro if (value === 'darwin') document.documentElement.setAttribute('data-platform', value) else document.documentElement.removeAttribute('data-platform') }, platform) - if (await reopenSidebar.isVisible()) await reopenSidebar.click() + // Publish the synthetic platform change through the frame's size observer. + await page.setViewportSize({ width: platform === 'darwin' ? 520 : 521, height: 600 }) + await page.locator('[data-sidebar-drawer]').waitFor() await openSettings(page, 'zh') await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 0) expect((await settingsMask.boundingBox())?.y).toBe(0) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 6092eddc02..fd5f778c1e 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -309,6 +309,9 @@ async function loadedFlowRows(page: Page): Promise { } async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise { + if (await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').count() > 0) { + await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click() + } // Search collapsed into a header action; expand it before filling. const searchButton = page.getByRole('button', { name: 'Search sessions' }) if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() @@ -1700,9 +1703,6 @@ describe('web e2e: long Chat scroll contract', () => { await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click() await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) await world.page.setViewportSize({ width: 700, height: 900 }) - // The narrow breakpoint auto-collapses the sidebar. Re-open it because - // this scenario switches sessions while pinning the narrow Chat scroll owner. - await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click() await world.page.getByRole('tab', { name: 'Chat', exact: true }).click() await nextPaint(world.page) await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE) diff --git a/apps/web/tests/client-plugin-live.e2e.ts b/apps/web/tests/client-plugin-live.e2e.ts index 21ad5a1182..72a389f9c8 100644 --- a/apps/web/tests/client-plugin-live.e2e.ts +++ b/apps/web/tests/client-plugin-live.e2e.ts @@ -26,7 +26,7 @@ async function openInventory(page: Page, url: string) { return dialog } -it('places dynamic Session menu rows by order among the shipped ones and removes them with their fiber', async () => { +it('keeps ordered legacy Session menu rows before Archive and removes them with their fiber', async () => { const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] }) onTestFinished(() => scaffold.close()) const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) @@ -46,12 +46,13 @@ it('places dynamic Session menu rows by order among the shipped ones and removes const trailing = row.locator('[data-session-trailing]') const observations: { stage: string; icons: string[]; time: boolean; pinned: boolean; actions: boolean }[] = [] - const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row): Promise => { + const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row, + timeVisible = iconsVisible): Promise => { const icons = target.locator('[data-session-trailing]') const actions = target.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` }) const time = target.locator('[class*="time"]') await expect.poll(() => icons.first().isVisible()).toBe(iconsVisible) - await expect.poll(() => time.isVisible()).toBe(iconsVisible) + await expect.poll(() => time.isVisible()).toBe(timeVisible) await expect.poll(() => actions.isVisible()).toBe(actionsVisible) observations.push({ stage, @@ -94,14 +95,16 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await observe('menu-open', false, true) const menu = page.getByRole('menu') await menu.waitFor() - expect(await menu.getByRole('separator').count()).toBe(1) + expect(await menu.getByRole('separator').count()).toBe(3) + const exportRow = menu.getByRole('menuitem', { name: 'Export session', exact: true }) + expect(await exportRow.locator('..').getByRole('separator').isVisible()).toBe(true) await compareOrRefreshGolden( SESSION_ACTION_EXPECTED, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode(), ) - await menu.getByRole('menuitem', { name: 'Export session' }).press('Enter') + await exportRow.press('Enter') await expect.poll(() => menu.count()).toBe(0) expect(await page.evaluate(() => ({ action: document.documentElement.dataset.sessionAction, @@ -113,7 +116,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await page.mouse.move(0, 0) await observe('blur', true, false) await page.setViewportSize({ width: 390, height: 844 }) - await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click() + await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click() await page.mouse.move(0, 0) await observe('narrow', true, false) const bounds = await row.evaluate((element) => { @@ -133,23 +136,23 @@ it('places dynamic Session menu rows by order among the shipped ones and removes try { const touchConsole = watchConsole(touchPage) await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click() + await touchPage.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click() const touchRow = touchPage.getByRole('treeitem').filter({ has: touchPage.getByText(SESSION_TITLE, { exact: true }) }) const touchTrigger = touchRow.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` }) await touchTrigger.waitFor() expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false) - await observe('touch', true, true, touchRow) + await observe('touch', true, true, touchRow, false) expect(await touchRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false) expect(await touchRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false) await touchTrigger.tap() await touchPage.getByRole('menu').waitFor() - await observe('touch-menu-open', true, true, touchRow) + await observe('touch-menu-open', true, true, touchRow, false) await touchPage.keyboard.press('Escape') - await observe('touch-menu-closed', true, true, touchRow) + await observe('touch-menu-closed', true, true, touchRow, false) await touchTrigger.tap() await touchPage.getByRole('menuitem', { name: 'Pin session', exact: true }).tap() - await touchRow.getByRole('img', { name: 'Pinned', exact: true }).waitFor() - await observe('touch-pinned', true, true, touchRow) + await touchRow.getByRole('button', { name: 'Unpin session', exact: true }).waitFor() + await observe('touch-pinned', true, true, touchRow, false) const touchBounds = await touchRow.evaluate((element) => { const title = element.querySelector('[class*="title"]')!.getBoundingClientRect() const resting = element.querySelector('[class*="rowResting"]')!.getBoundingClientRect() @@ -173,7 +176,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes expect(await row.evaluate(element => element === document.activeElement)).toBe(true) await observe('clicked-archived-row', true, false) await scaffold.ctx.workspaceRegistry.unarchiveSession(sessionId) - await observe('touch-unarchived', true, true, touchRow) + await observe('touch-unarchived', true, true, touchRow, false) expect(touchConsole.pageErrors).toEqual([]) } finally { await touchPage.close() @@ -193,13 +196,13 @@ it('places dynamic Session menu rows by order among the shipped ones and removes await row.hover() await trigger.click() await menu.waitFor() - const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session', 'Copy Session ID'] + const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Archive session'] const remainingItems = menu.getByRole('menuitem') expect(await remainingItems.count()).toBe(remainingNames.length) for (const [index, name] of remainingNames.entries()) { expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1) } - expect(await menu.getByRole('separator').count()).toBe(0) + expect(await menu.getByRole('separator').count()).toBe(2) await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) await menu.getByRole('menuitem', { name: 'Copy Session ID', exact: true }).click() await expect.poll(() => menu.count()).toBe(0) diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts new file mode 100644 index 0000000000..9e350ec6d4 --- /dev/null +++ b/apps/web/tests/context-menus.e2e.ts @@ -0,0 +1,386 @@ +/** Context gestures against built plugins, seeded Sessions, and original files. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { chromium, type Browser, type Locator, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const GIF = Buffer.from('R0lGODdhMAAgAIEAAEtu5gAAAAAAAAAAACwAAAAAMAAgAEAIQQABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmQEBADs=', 'base64') +async function menuLabels(menu: Locator): Promise { + return menu.getByRole('menuitem').evaluateAll(items => items.map((item) => { + const copy = item.cloneNode(true) as HTMLElement + for (const decoration of copy.querySelectorAll('[aria-hidden="true"]')) decoration.remove() + return copy.textContent ?? '' + })) +} +function fixture(title: string): string { + const session = Session.create(SessionId('context-menus-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Show the output.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + session.append('session/title', { title, messageSeqs: [user.seq], source: { kind: 'fallback' } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { stream: [], turn: 1, step: 1, message: createAssistantMessage({ + content: [{ type: 'text', text: '[First file](first.txt#L2) [Second file](second.txt) [Website](https://example.com/path?q=1#fragment)\n\n![Original](original.gif) ![Remote](https://images.example/original.gif)\n\nCONTEXT_MENU_READY' }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0 }), + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: 1_000 + event.seq * 1_000 })), ''].join('\n') +} + +describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', (platform) => { + let scaffold: WebScaffold, browser: Browser, page: Page + let tripwire: ReturnType + beforeAll(async () => { + scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]) }) + await mkdir(join(scaffold.workspaceCwd, 'folder')) + await scaffold.ctx.workspaceRegistry.create(join(scaffold.workspaceCwd, 'folder'), 'Menu workspace') + await writeFile(join(scaffold.workspaceCwd, 'first.txt'), 'FIRST\nSECOND\nTHIRD\n') + await writeFile(join(scaffold.workspaceCwd, 'second.txt'), 'SECOND FILE\n') + await writeFile(join(scaffold.workspaceCwd, 'original.gif'), GIF) + await seedSession(scaffold, fixture('Menu foreground'), 'context-menu-first') + await seedSession(scaffold, fixture('Menu background'), 'context-menu-other') + browser = await chromium.launch() + const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' }) + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + page = await context.newPage() + await page.setViewportSize({ width: 1291, height: 768 }) + tripwire = watchConsole(page) + await page.route('https://images.example/original.gif', route => route.fulfill({ body: GIF, contentType: 'image/gif', headers: { 'access-control-allow-origin': '*' } })) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto(scaffold.authenticatedUrl) + await page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' }).click() + await page.locator('[data-row-key="session:context-menu-other"]').click() + await page.locator('[data-row-key="session:context-menu-other"]').getByText('Menu background', { exact: true }).waitFor() + await page.locator('[data-row-key="session:context-menu-first"]').click() + await page.getByText('CONTEXT_MENU_READY', { exact: true }).waitFor() + }) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + it('shares Session entries without selecting the background Session, and dismisses at viewport edges', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-session')) + const background = page.locator('[data-row-key="session:context-menu-other"]') + await background.click({ button: 'right' }) + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor() + const names = await menuLabels(menu) + expect(names.slice(0, 5)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Show in folder']) + expect(names.at(-1)).toBe('Archive session') + expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true) + expect(await menu.getByRole('separator').count()).toBe(2) + expect(await menu.getByRole('menuitem', { name: 'Archive session', exact: true }).evaluate(row => + getComputedStyle(row.parentElement!.parentElement!).borderTopWidth)).toBe('0px') + const alignedSeparators = async () => { + const bounds = await menu.evaluate((node) => { + const row = node.querySelector('[role="menuitem"]')! + const rect = row.getBoundingClientRect() + const style = getComputedStyle(row) + return { + left: rect.left + Number.parseFloat(style.paddingLeft), + right: rect.right - Number.parseFloat(style.paddingRight), + lines: [...node.querySelectorAll('[role="separator"]')].map((line) => { + const rect = line.getBoundingClientRect() + return { left: rect.left, right: rect.right } + }), + } + }) + for (const line of bounds.lines) { + expect(line.left).toBeCloseTo(bounds.left, 1) + expect(line.right).toBeCloseTo(bounds.right, 1) + } + } + await alignedSeparators() + await expect(names.join('\n') + '\n').toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/session.txt')) + expect(names.some(name => name.includes('Rename'))).toBe(true) + expect(await background.getAttribute('aria-selected')).toBe('false') + await page.keyboard.press('Escape') + await background.getByRole('button', { name: /actions/i }).click() + await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor() + expect(await menuLabels(menu)).toEqual(names) + await alignedSeparators() + await background.getByRole('button', { name: /actions/i }).click() + expect(await menu.count()).toBe(0) + expect(await background.getAttribute('aria-selected')).toBe('false') + }) + + it('updates an open Session menu from another window’s shortcut preferences and still renames the captured row', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-live-shortcuts')) + const other = await page.context().newPage() + const key = 'dsh.keybindings.v1' + const original = await page.evaluate(key => localStorage.getItem(key), key) + try { + await other.goto(scaffold.authenticatedUrl) + await other.getByRole('button', { name: 'Search sessions', exact: true }).waitFor() + const background = page.locator('[data-row-key="session:context-menu-other"]') + await background.click({ button: 'right' }) + const item = page.getByRole('menuitem', { name: 'Rename', exact: true }) + await item.waitFor() + const popup = await page.getByRole('menu').elementHandle() + const initial = await item.getAttribute('aria-keyshortcuts') + await other.evaluate((key) => { + localStorage.setItem(key, JSON.stringify({ schemaVersion: 2, profiles: { + 'web:macos': { 'session.rename': { code: 'Comma', modifiers: ['primary', 'shift'] } }, + 'web:windows': { 'session.rename': { code: 'Comma', modifiers: ['primary', 'shift'] } }, + 'web:linux': { 'session.rename': { code: 'Comma', modifiers: ['primary', 'shift'] } }, + } })) + }, key) + await expect.poll(() => item.getAttribute('aria-keyshortcuts')).toBe(platform === 'MacIntel' ? 'Shift+Meta+,' : 'Control+Shift+,') + expect(await item.getAttribute('aria-keyshortcuts')).not.toBe(initial) + expect(await popup!.evaluate(element => element.isConnected)).toBe(true) + expect(await background.getAttribute('aria-selected')).toBe('false') + await item.click() + const rename = page.getByRole('dialog', { name: 'Rename session', exact: true }) + expect(await rename.getByLabel('Session name').inputValue()).toBe('Menu background') + await rename.getByRole('button', { name: 'Cancel', exact: true }).click() + expect(await background.getAttribute('aria-selected')).toBe('false') + await background.click({ button: 'right' }) + await item.waitFor() + await other.evaluate((key) => { + localStorage.setItem(key, JSON.stringify({ schemaVersion: 2, profiles: { + 'web:macos': { 'session.rename': null }, 'web:windows': { 'session.rename': null }, 'web:linux': { 'session.rename': null }, + } })) + }, key) + await expect.poll(() => item.getAttribute('aria-keyshortcuts')).toBeNull() + await other.evaluate(({ key, original }) => { + if (original === null) localStorage.removeItem(key) + else localStorage.setItem(key, original) + }, { key, original }) + await expect.poll(() => item.getAttribute('aria-keyshortcuts')).toBe(initial) + await page.keyboard.press('Escape') + } finally { + const cancel = page.getByRole('dialog', { name: 'Rename session', exact: true }).getByRole('button', { name: 'Cancel', exact: true }) + if (await cancel.isVisible()) await cancel.click() + await page.keyboard.press('Escape') + await other.evaluate(({ key, original }) => { + if (original === null) localStorage.removeItem(key) + else localStorage.setItem(key, original) + }, { key, original }) + await other.close() + } + }) + + it('shares Workspace menus and keeps folder gestures independent from expansion', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-workspace')) + const workspace = page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Menu workspace' }) + const expanded = await workspace.getAttribute('aria-expanded') + await workspace.click({ button: 'right' }) + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor() + const names = await menuLabels(menu) + expect(names).toEqual(['Rename', 'Show in folder', 'Delete workspace']) + expect(await menu.locator('svg[data-icon]').count()).toBe(3) + expect(await workspace.getAttribute('aria-expanded')).toBe(expanded) + await page.keyboard.press('Escape') + await workspace.getByRole('button', { name: /actions/i }).click() + await menu.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor() + expect(await menuLabels(menu)).toEqual(names) + await menu.getByRole('menuitem', { name: 'Rename', exact: true }).click() + await page.getByRole('dialog').waitFor() + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + await workspace.click({ button: 'right' }) + await menu.getByRole('menuitem', { name: 'Delete workspace', exact: true }).click() + await page.getByRole('dialog', { name: 'Delete workspace', exact: true }).waitFor() + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + await page.locator('[data-sidebar-right-expand]').click() + await page.locator('[data-sidebar-right-guide-entry="files"]').click() + await page.locator('[data-files-state="tree"]').waitFor() + const folder = page.locator('[data-files-entry="directory"]').filter({ hasText: 'folder' }).getByRole('button').first() + await folder.click({ button: 'right' }) + await menu.getByRole('menuitem', { name: 'Copy folder path', exact: true }).waitFor() + expect(await menuLabels(menu)).toEqual(['Show in folder', 'Copy folder path']) + expect(await folder.getAttribute('aria-expanded')).toBe('false') + await menu.getByRole('menuitem', { name: 'Copy folder path', exact: true }).click() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(join(scaffold.workspaceCwd, 'folder')) + await page.getByText('Copied', { exact: true }).waitFor() + await expect(names.join('\n') + '\n').toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/workspace.txt')) + }) + + it('prepares complete menus, anchors above the frame inset, and treats repeated gestures as separate occurrences', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-gestures')) + const file = page.getByRole('button', { name: 'First file', exact: true }) + await file.focus() + await page.keyboard.press('Shift+F10') + await page.getByRole('menuitem', { name: 'Copy file path', exact: true }).waitFor() + await page.keyboard.press('End') + expect(await page.getByRole('menuitem', { name: 'Save as…', exact: true }).count()).toBe(0) + expect(await page.getByRole('menuitem', { name: 'Download', exact: true }).evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('ArrowUp') + expect(await page.getByRole('menuitem', { name: 'Copy file path', exact: true }).evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Enter') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(join(scaffold.workspaceCwd, 'first.txt')) + expect(await file.evaluate(element => element === document.activeElement)).toBe(true) + await page.evaluate(() => { + document.documentElement.style.setProperty('--dsh-frame-overlay-top', '100px') + const frames: number[] = [] + const observer = new MutationObserver(() => { + for (const menu of document.querySelectorAll('[role="menu"]')) frames.push(menu.querySelectorAll('[role="menuitem"]').length) + }) + observer.observe(document.body, { subtree: true, childList: true }) + Object.assign(window, { menuObservation: { frames, observer } }) + }) + const header = page.locator('[data-session-title]').first() + const target = await header.count() > 0 ? header : page.getByText('Menu foreground', { exact: true }).last() + const box = (await target.boundingBox())! + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 } + await page.mouse.click(point.x, point.y, { button: 'right' }) + await page.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor() + expect((await page.getByRole('menu').boundingBox())!.y).toBeLessThan(100) + await page.mouse.click(point.x + 1, point.y, { button: 'right' }) + expect(await page.getByRole('menu').count()).toBe(0) + await file.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy file path', exact: true }).waitFor() + const previous = await page.getByRole('menu').elementHandle() + await page.getByRole('link', { name: 'Website', exact: true }).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy link address', exact: true }).waitFor() + expect(await previous!.evaluate(element => element.isConnected)).toBe(false) + await page.keyboard.press('Escape') + const counts = await page.evaluate(() => { + const observed = (window as Window & { menuObservation?: { frames: number[]; observer: MutationObserver } }).menuObservation + if (observed === undefined) throw new Error('Menu observer not installed') + observed.observer.disconnect() + document.documentElement.style.removeProperty('--dsh-frame-overlay-top') + return observed.frames + }) + expect(counts.length).toBeGreaterThan(0) + expect(counts.every(count => count > 0)).toBe(true) + }) + + it('preserves file line navigation, URL text, image format, and existing tab close actions', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-content')) + const file = page.getByRole('button', { name: 'First file', exact: true }) + await file.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Download', exact: true }).waitFor() + expect(await page.getByRole('menuitem', { name: 'Save as…', exact: true }).count()).toBe(0) + const fileDownloadPromise = page.waitForEvent('download') + await page.getByRole('menuitem', { name: 'Download', exact: true }).click() + const fileDownload = await fileDownloadPromise + expect(fileDownload.suggestedFilename()).toBe('first.txt') + expect(await readFile(await fileDownload.path(), 'utf8')).toBe('FIRST\nSECOND\nTHIRD\n') + const started = page.getByRole('alert').filter({ hasText: 'Download started' }) + await started.waitFor() + expect(await started.getByRole('button', { name: /Show in/ }).count()).toBe(0) + await file.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).click() + await page.locator('[data-document-preview]').getByText('SECOND', { exact: true }).waitFor() + await page.getByRole('link', { name: 'Website', exact: true }).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy link address', exact: true }).click() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('https://example.com/path?q=1#fragment') + await page.getByRole('img', { name: 'Original', exact: true }).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Download', exact: true }).waitFor() + await expect((await menuLabels(page.getByRole('menu'))).join('\n') + '\n') + .toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/image.txt')) + expect(await page.getByRole('menuitem', { name: 'Show in folder', exact: true }).count()).toBe(1) + const downloadPromise = page.waitForEvent('download') + await page.getByRole('menuitem', { name: 'Download', exact: true }).click() + const download = await downloadPromise + expect(download.suggestedFilename().endsWith('.gif')).toBe(true) + await page.getByRole('alert').filter({ hasText: 'Download started' }).waitFor() + const path = await download.path() + expect(path).not.toBeNull() + if (path === null) throw new Error('Browser download unavailable') + expect(await readFile(path)).toEqual(GIF) + await page.getByRole('img', { name: 'Original', exact: true }).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy image', exact: true }).click() + await page.getByText('Copied', { exact: true }).waitFor() + expect(await page.evaluate(async () => (await navigator.clipboard.read()).some(item => item.types.includes('image/png')))).toBe(true) + await page.getByRole('img', { name: 'Remote', exact: true }).click({ button: 'right' }) + expect(await page.getByRole('menuitem', { name: 'Show in folder', exact: true }).isDisabled()).toBe(true) + const remoteDownloadPromise = page.waitForEvent('download') + await page.getByRole('menuitem', { name: 'Download', exact: true }).click() + const remoteDownload = await remoteDownloadPromise + expect(await readFile((await remoteDownload.path()))).toEqual(GIF) + await page.getByRole('img', { name: 'Remote', exact: true }).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy image', exact: true }).click() + await page.getByText('Copied', { exact: true }).waitFor() + expect(await page.evaluate(async () => (await navigator.clipboard.read()).some(item => item.types.includes('image/png')))).toBe(true) + await page.getByRole('button', { name: 'Second file', exact: true }).click() + await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).waitFor() + const firstTab = page.locator('[role="tab"]').filter({ hasText: 'first.txt' }).first() + await firstTab.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Close', exact: true }).waitFor() + expect(await page.getByRole('menuitem', { name: 'Close', exact: true }).locator('svg[data-icon]').count()).toBe(1) + await page.getByRole('menuitem', { name: 'Close', exact: true }).click() + expect(await firstTab.count()).toBe(0) + expect(await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + }) + it('keeps original preview open when dismissing its menu and fits both themes at the viewport edge', async () => { + onTestFailed(() => saveFailureShot(page, 'context-menus-preview')) + await page.getByRole('button', { name: 'View full image: Original', exact: true }).click() + const preview = page.getByRole('dialog') + await preview.waitFor() + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await preview.getByRole('img').evaluate(element => element.dispatchEvent(new MouseEvent('contextmenu', { clientX: 1270, clientY: 735, bubbles: true }))) + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: 'Copy image', exact: true }).waitFor() + expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true) + const box = await menu.boundingBox() + expect(box).not.toBeNull() + expect(box!.x).toBeGreaterThan(1000) + expect(box!.y).toBeGreaterThan(500) + expect(box!.x + box!.width).toBeLessThanOrEqual(1291) + expect(box!.y + box!.height).toBeLessThanOrEqual(768) + await saveFailureShot(page, `context-menus-${colorScheme}`) + await page.keyboard.press('Escape') + expect(await menu.count()).toBe(0) + expect(await preview.count()).toBe(1) + } + await page.keyboard.press('Escape') + expect(await preview.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('offers completed Desktop destinations as toast text actions without enabling remote-image menu reveal', async () => { + const native = await browser.newPage({ viewport: { width: 1291, height: 768 }, locale: 'en-US' }) + const opened = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath').mockResolvedValue({ opened: true }) + try { + const destinations = { download: join(scaffold.workspaceCwd, 'downloaded.gif'), saveAs: join(scaffold.workspaceCwd, 'saved.txt') } + await native.addInitScript(({ platform, destinations }) => { + Object.defineProperty(navigator, 'platform', { value: platform }) + Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1, fileActions: { + save: async ({ mode }: { mode: 'download' | 'saveAs' }) => ({ status: 'saved', path: destinations[mode] }), + } } }) + }, { platform, destinations }) + await native.route('https://images.example/original.gif', route => route.fulfill({ body: GIF, contentType: 'image/gif' })) + await native.goto(scaffold.authenticatedUrl) + await native.evaluate((platform) => { + document.documentElement.dataset.platform = platform === 'MacIntel' ? 'darwin' : platform === 'Win32' ? 'win32' : 'linux' + }, platform) + const group = native.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await native.locator('[data-row-key="session:context-menu-first"]').click() + await native.getByText('CONTEXT_MENU_READY', { exact: true }).waitFor() + for (const [mode, label, text, theme] of [ + ['download', 'Download', 'Download complete', 'light'], + ['saveAs', 'Save as…', 'Saved', 'dark'], + ] as const) { + await native.emulateMedia({ colorScheme: theme }) + const trigger = mode === 'download' + ? native.getByRole('img', { name: 'Remote', exact: true }) + : native.getByRole('button', { name: 'First file', exact: true }) + await trigger.click({ button: 'right' }) + await native.getByRole('menuitem', { name: label, exact: true }).click() + const notice = native.getByRole('alert').filter({ hasText: text }) + const action = notice.getByRole('button', { name: platform === 'MacIntel' ? 'Show in Finder' : 'Show in folder', exact: true }) + await action.click() + await notice.waitFor({ state: 'hidden' }) + await expect.poll(() => opened.mock.calls.at(-1)?.[0]).toEqual({ path: destinations[mode], action: 'reveal' }) + } + expect(opened).toHaveBeenCalledTimes(2) + await native.getByRole('img', { name: 'Remote', exact: true }).click({ button: 'right' }) + const reveal = native.getByRole('menuitem', { name: 'Show in folder', exact: true }) + await reveal.waitFor() + expect(await reveal.isDisabled()).toBe(true) + } finally { + await native.close() + opened.mockRestore() + } + }) +}) diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts index a9ddcde869..c5900519c6 100644 --- a/apps/web/tests/cua-optin.e2e.ts +++ b/apps/web/tests/cua-optin.e2e.ts @@ -63,9 +63,9 @@ it.skipIf(process.platform !== 'darwin').each([ const dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill('@deepseek-ai/dsh-cua-bundle') await dialog.getByRole('button', { name: '安装', exact: true }).click() - const ready = page.getByRole('dialog', { name: '插件已随 DSH 提供', exact: true }) + const ready = page.getByRole('dialog', { name: '已安装', exact: true }) await ready.waitFor() - expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(1) + expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(0) return ready.getByRole('button', { name: '立即启用', exact: true }) } const enableNow = await offerCua() @@ -199,8 +199,7 @@ it.skipIf(process.platform !== 'darwin').each([ const program = 'globalThis.counter = (globalThis.counter ?? 0) + 1' expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(1) await toggle.click() - await toggle.waitFor({ state: 'hidden' }) - await card.waitFor({ state: 'hidden' }) + await toggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' }) expect(scaffold.ctx.get('computerUse')).toBeUndefined() expect(runtimeFiber() === persistentRuntimeFiber).toBe(true) expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault) @@ -211,7 +210,7 @@ it.skipIf(process.platform !== 'darwin').each([ } expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(2) expect(await readFile(settingsPath, 'utf8')).toBe(saved) - await (await offerCua()).click() + await toggle.click() await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) await setup.waitFor() expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1) @@ -249,9 +248,8 @@ it.skipIf(process.platform !== 'darwin').each([ expect.objectContaining({ id: 'agent-preset-registry', config: { default: 'standard', selectedDefault: 'ptc' } }), ])) await toggle.click() - await toggle.waitFor({ state: 'hidden' }) - await card.waitFor({ state: 'hidden' }) - await (await offerCua()).click() + await toggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' }) + await toggle.click() await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) await setup.waitFor() expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1) diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index 4835557ce8..72afb6648d 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -29,7 +29,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr const helper = await installNativeFixture(root) resources.closeFixture = await startFixtureHost(helper) const state = join(root, 'permissions.json'), audit = join(root, 'helper-events.jsonl') - const launchApplication = { name: 'Visual Studio Code.app', path: '/Applications/Visual Studio Code.app', + const launchApplication = { name: 'DSH Computer Use.app', path: '/Applications/DSH Computer Use.app', icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC' } await writeFile(state, JSON.stringify({ accessibility: false, screenRecording: false, launchApplication })) await writeFile(join(root, 'observe-helper'), '') @@ -89,10 +89,10 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr expect(await panel.getByRole('region', { name: 'Manage applications computer use can access', exact: true }).count()).toBe(1) await expect.poll(() => panel.locator('li img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1) expect(await panel.getByText('/fixture/app', { exact: true }).count()).toBe(0) - expect(await accessibility.getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1) + expect(await accessibility.getByText('DSH Computer Use.app', { exact: true }).count()).toBe(1) expect(await accessibility.textContent()).toContain('In System Settings, allow') expect(await accessibility.textContent()).toContain('to read interface controls, click, and type.') - expect(await panel.getByRole('group', { name: 'Screen Recording', exact: true }).getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1) + expect(await panel.getByRole('group', { name: 'Screen Recording', exact: true }).getByText('DSH Computer Use.app', { exact: true }).count()).toBe(1) await expect.poll(() => accessibility.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1) await expect.poll(() => panel.getAttribute('aria-busy')).toBe('false') await compareOrRefreshGolden(expected, await captureStableAria(page, '[data-cua-settings]', scaffold.workspaceCwd), webSnapshotMode()) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 9625333287..fa6c518add 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -563,7 +563,7 @@ describe('web e2e: the composer model switch is the default for later sessions', for (const palette of headingPalettes) { expect(palette.headingFills, `${palette.platform}, dark=${String(palette.dark)}`) .toEqual(['rgba(0, 0, 0, 0)']) - expect(palette.menuFill).toBe(palette.dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)') + expect(palette.menuFill).toBe(palette.dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)') expect(palette.searchBorder).toBe('rgba(0, 0, 0, 0)') expect(palette.positions).toEqual(['sticky']) expect(palette.headingRadii).toEqual([palette.platform === 'darwin' ? '0px' : palette.optionRadius]) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 68dd75f3d8..84a34241f8 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -188,7 +188,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await blankColumn.locator('[data-sidebar-right-toggle]').click() await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' }) await page.setViewportSize({ width: 767, height: blankViewport.height }) - await expect.poll(() => columns(page)).toEqual([56, 711, 0]) + await expect.poll(() => columns(page)).toEqual([0, 767, 0]) await page.locator('[data-sidebar-right-expand]').click() // The fullscreen shell keeps its viewport box while its docked content closes. await expect.poll(() => blankColumn.locator('[data-sidebar-right-open]').count()).toBe(1) @@ -425,7 +425,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => columns(page)).toEqual([420, viewport.width - 420 - normalWidth, normalWidth]) await page.setViewportSize({ width: 767, height: viewport.height }) await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, width: 767, height: viewport.height }) - await expect.poll(() => columns(page)).toEqual([56, 711, 0]) + await expect.poll(() => columns(page)).toEqual([0, 767, 0]) expect(await sidebarSnapshot(page)).toMatchObject({ mode: 'fullscreen', resizeHandleWidth: 0, coversViewport: true }) await checkpoint('A automatic fullscreen at 767px') await column.locator('[data-sidebar-right-mode="push"]').click() diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 79a5ed4bdb..a3d2eb21e6 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -714,7 +714,7 @@ else process.exit(1); const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() expect(initialFilesClose).toBe(1) await filesTab.click({ button: 'right' }) - expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(1) + await page.locator('[data-context-menu][role="menu"]').waitFor() await page.keyboard.press('Escape') await addTab.waitFor({ state: 'visible' }) const initialAdd = await addTab.count() diff --git a/apps/web/tests/expected/client-plugin-live/session-actions.expected.md b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md index 51df861f77..93f241e0fc 100644 --- a/apps/web/tests/expected/client-plugin-live/session-actions.expected.md +++ b/apps/web/tests/expected/client-plugin-live/session-actions.expected.md @@ -2,8 +2,10 @@ - menuitem "Pin session" - menuitem "Rename" - menuitem "Fork session" - - menuitem "Archive session" + - separator - menuitem "Copy Session ID" - separator - menuitem "Export session" - menuitem "Copy session ID" + - separator + - menuitem "Archive session" diff --git a/apps/web/tests/expected/client-plugin-live/session-trailing.expected.json b/apps/web/tests/expected/client-plugin-live/session-trailing.expected.json index 8b5c4cb664..83f9ecd3fb 100644 --- a/apps/web/tests/expected/client-plugin-live/session-trailing.expected.json +++ b/apps/web/tests/expected/client-plugin-live/session-trailing.expected.json @@ -86,7 +86,7 @@ "Fixture badge", "Fixture section" ], - "time": true, + "time": false, "pinned": false, "actions": true }, @@ -96,7 +96,7 @@ "Fixture badge", "Fixture section" ], - "time": true, + "time": false, "pinned": false, "actions": true }, @@ -106,7 +106,7 @@ "Fixture badge", "Fixture section" ], - "time": true, + "time": false, "pinned": false, "actions": true }, @@ -116,8 +116,8 @@ "Fixture badge", "Fixture section" ], - "time": true, - "pinned": true, + "time": false, + "pinned": false, "actions": true }, { @@ -136,7 +136,7 @@ "Fixture badge", "Fixture section" ], - "time": true, + "time": false, "pinned": false, "actions": true } diff --git a/apps/web/tests/expected/context-menus/image.txt b/apps/web/tests/expected/context-menus/image.txt new file mode 100644 index 0000000000..a8090be657 --- /dev/null +++ b/apps/web/tests/expected/context-menus/image.txt @@ -0,0 +1,3 @@ +Copy image +Download +Show in folder diff --git a/apps/web/tests/expected/context-menus/session.txt b/apps/web/tests/expected/context-menus/session.txt new file mode 100644 index 0000000000..5468d20e51 --- /dev/null +++ b/apps/web/tests/expected/context-menus/session.txt @@ -0,0 +1,6 @@ +Pin session +Rename +Fork session +Copy Session ID +Show in folder +Archive session diff --git a/apps/web/tests/expected/context-menus/workspace.txt b/apps/web/tests/expected/context-menus/workspace.txt new file mode 100644 index 0000000000..93c401728b --- /dev/null +++ b/apps/web/tests/expected/context-menus/workspace.txt @@ -0,0 +1,3 @@ +Rename +Show in folder +Delete workspace diff --git a/apps/web/tests/expected/cua-permissions/missing.expected.md b/apps/web/tests/expected/cua-permissions/missing.expected.md index cb4ade57ac..3b32eb2418 100644 --- a/apps/web/tests/expected/cua-permissions/missing.expected.md +++ b/apps/web/tests/expected/cua-permissions/missing.expected.md @@ -10,11 +10,11 @@ - button "Use persistent mode" - group "Accessibility": - strong: Accessibility - - paragraph: In System Settings, allow Visual Studio Code.app to read interface controls, click, and type. + - paragraph: In System Settings, allow DSH Computer Use.app to read interface controls, click, and type. - button "Request permission" - group "Screen Recording": - strong: Screen Recording - - paragraph: In System Settings, allow Visual Studio Code.app to capture application windows. + - paragraph: In System Settings, allow DSH Computer Use.app to capture application windows. - button "Request permission" - paragraph: Manage permissions in System Settings for the application named by macOS. - region "Manage applications computer use can access": diff --git a/apps/web/tests/expected/forge-panel/branch-details.expected.md b/apps/web/tests/expected/forge-panel/branch-details.expected.md new file mode 100644 index 0000000000..8c348d423a --- /dev/null +++ b/apps/web/tests/expected/forge-panel/branch-details.expected.md @@ -0,0 +1 @@ +- 'tooltip "Current branch: feature/a-long-current-branch-name-for-mobile-review"' diff --git a/apps/web/tests/expected/forge-panel/changes-view.expected.md b/apps/web/tests/expected/forge-panel/changes-view.expected.md index f8c94d3817..b6c825a833 100644 --- a/apps/web/tests/expected/forge-panel/changes-view.expected.md +++ b/apps/web/tests/expected/forge-panel/changes-view.expected.md @@ -1,5 +1,6 @@ - img "变更请求状态:需要处理" - text: "Add the pull request panel #7" +- button "提交评审" - button "在浏览器中打开" - button "全部 2 个文件" - text: +4 −2 @@ -7,16 +8,64 @@ - button "全部折叠" - button "左右对比" [pressed] - button "自动换行" [pressed] -- button "收起 panel.ts" [expanded] -- 'button "复制路径: src/panel.ts"': src/panel.ts -- text: +2 −2 -- button "panel.ts 的更多操作" -- text: 1 export const n1 = 1 1 export const n1 = 1 2 export const n2 = 2 2 export const n2 = 'two' 3 export const n3 = 3 3 export const n3 = 3 4 export const n4 = 4 4 export const n4 = 4 5 export const n5 = 5 5 export const n5 = 5 -- button "6 行未变更" -- text: 12 export const n12 = 12 12 export const n12 = 12 13 export const n13 = 13 13 export const n13 = 13 14 export const n14 = 14 14 export const n14 = 14 15 export const n15 = 15 15 export const n15 = 'fifteen' 16 export const n16 = 16 16 export const n16 = 16 17 export const n17 = 17 17 export const n17 = 17 18 export const n18 = 18 18 export const n18 = 18 -- button "其余未变更的行" -- button "收起 panel.md" [expanded] -- 'button "复制路径: docs/panel.md"': docs/panel.md -- text: 新文件 +2 −0 -- button "panel.md 的更多操作" -- text: "1 + # Panel 2 + Shows the change requests of a Session." +- region "全部 2 个文件": + - button "收起 panel.ts" [expanded] + - text: src/panel.ts +2 −2 + - button "panel.ts 的更多操作" + - button "选择第 L1 行": "1" + - text: export const n1 = 1 + - button "选择第 R1 行": "1" + - text: export const n1 = 1 + - button "选择第 L2 行": "2" + - text: export const n2 = 2 + - button "选择第 R2 行": "2" + - text: export const n2 = 'two' + - button "选择第 L3 行": "3" + - text: export const n3 = 3 + - button "选择第 R3 行": "3" + - text: export const n3 = 3 + - button "选择第 L4 行": "4" + - text: export const n4 = 4 + - button "选择第 R4 行": "4" + - text: export const n4 = 4 + - button "选择第 L5 行": "5" + - text: export const n5 = 5 + - button "选择第 R5 行": "5" + - text: export const n5 = 5 + - button "6 行未变更" + - button "选择第 L12 行": "12" + - text: export const n12 = 12 + - button "选择第 R12 行": "12" + - text: export const n12 = 12 + - button "选择第 L13 行": "13" + - text: export const n13 = 13 + - button "选择第 R13 行": "13" + - text: export const n13 = 13 + - button "选择第 L14 行": "14" + - text: export const n14 = 14 + - button "选择第 R14 行": "14" + - text: export const n14 = 14 + - button "选择第 L15 行": "15" + - text: export const n15 = 15 + - button "选择第 R15 行": "15" + - text: export const n15 = 'fifteen' + - button "选择第 L16 行": "16" + - text: export const n16 = 16 + - button "选择第 R16 行": "16" + - text: export const n16 = 16 + - button "选择第 L17 行": "17" + - text: export const n17 = 17 + - button "选择第 R17 行": "17" + - text: export const n17 = 17 + - button "选择第 L18 行": "18" + - text: export const n18 = 18 + - button "选择第 R18 行": "18" + - text: export const n18 = 18 + - button "其余未变更的行" + - button "收起 panel.md" [expanded] + - text: docs/panel.md 新文件 +2 −0 + - button "panel.md 的更多操作" + - button "选择第 R1 行": "1" + - text: "+ # Panel" + - button "选择第 R2 行": "2" + - text: + Shows the change requests of a Session. diff --git a/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md b/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md new file mode 100644 index 0000000000..5873dec5ae --- /dev/null +++ b/apps/web/tests/expected/forge-panel/file-tree-toggle.expected.md @@ -0,0 +1,77 @@ +- img "变更请求状态:需要处理" +- text: "Add the pull request panel #7" +- button "提交评审" +- button "在浏览器中打开" +- button "全部 2 个文件" +- text: +4 −2 +- button "刷新变更" +- button "全部折叠" +- button "左右对比" [pressed] +- button "自动换行" [pressed] +- button "将文件树移到右侧" [expanded] +- region "全部 2 个文件": + - button "收起 panel.ts" [expanded] + - text: src/panel.ts +2 −2 + - button "panel.ts 的更多操作" + - button "选择第 L1 行": "1" + - text: export const n1 = 1 + - button "选择第 R1 行": "1" + - text: export const n1 = 1 + - button "选择第 L2 行": "2" + - text: export const n2 = 2 + - button "选择第 R2 行": "2" + - text: export const n2 = 'two' + - button "选择第 L3 行": "3" + - text: export const n3 = 3 + - button "选择第 R3 行": "3" + - text: export const n3 = 3 + - button "选择第 L4 行": "4" + - text: export const n4 = 4 + - button "选择第 R4 行": "4" + - text: export const n4 = 4 + - button "选择第 L5 行": "5" + - text: export const n5 = 5 + - button "选择第 R5 行": "5" + - text: export const n5 = 5 + - button "6 行未变更" + - button "选择第 L12 行": "12" + - text: export const n12 = 12 + - button "选择第 R12 行": "12" + - text: export const n12 = 12 + - button "选择第 L13 行": "13" + - text: export const n13 = 13 + - button "选择第 R13 行": "13" + - text: export const n13 = 13 + - button "选择第 L14 行": "14" + - text: export const n14 = 14 + - button "选择第 R14 行": "14" + - text: export const n14 = 14 + - button "选择第 L15 行": "15" + - text: export const n15 = 15 + - button "选择第 R15 行": "15" + - text: export const n15 = 'fifteen' + - button "选择第 L16 行": "16" + - text: export const n16 = 16 + - button "选择第 R16 行": "16" + - text: export const n16 = 16 + - button "选择第 L17 行": "17" + - text: export const n17 = 17 + - button "选择第 R17 行": "17" + - text: export const n17 = 17 + - button "选择第 L18 行": "18" + - text: export const n18 = 18 + - button "选择第 R18 行": "18" + - text: export const n18 = 18 + - button "其余未变更的行" + - button "收起 panel.md" [expanded] + - text: docs/panel.md 新文件 +2 −0 + - button "panel.md 的更多操作" + - button "选择第 R1 行": "1" + - text: "+ # Panel" + - button "选择第 R2 行": "2" + - text: + Shows the change requests of a Session. +- separator "调整文件树宽度" +- navigation "全部 2 个文件": + - textbox "筛选文件…": src + - button "src" [expanded] + - button "src/panel.ts": panel.ts +2 −2 diff --git a/apps/web/tests/expected/forge-panel/filter-config-failed.expected.md b/apps/web/tests/expected/forge-panel/filter-config-failed.expected.md new file mode 100644 index 0000000000..eef3d4c6df --- /dev/null +++ b/apps/web/tests/expected/forge-panel/filter-config-failed.expected.md @@ -0,0 +1,11 @@ +- text: 与 origin/main 比较,包含未提交的修改 +- button "创建变更请求" +- button "分支" +- button "全部 0 个文件" [disabled] +- button "刷新变更" +- button "全部展开" [disabled] +- button "左右对比" [pressed] +- button "自动换行" [pressed] +- status: + - text: 无法完整读取 Git 过滤器配置,尚未执行比较。请检查 Git 配置和读取上限后重试 + - button "重试" diff --git a/apps/web/tests/expected/forge-panel/full-access.expected.md b/apps/web/tests/expected/forge-panel/full-access.expected.md new file mode 100644 index 0000000000..3f45ecb592 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/full-access.expected.md @@ -0,0 +1,4 @@ +- button "本地变更" +- text: HEAD → origin/main +- button "查看本会话的变更:1 个文件,新增 1 行,删除 0 行": +1 −0 1 个文件 +- button "创建变更请求" diff --git a/apps/web/tests/expected/forge-panel/local-card.expected.md b/apps/web/tests/expected/forge-panel/local-card.expected.md new file mode 100644 index 0000000000..24058934d3 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/local-card.expected.md @@ -0,0 +1,4 @@ +- button "Local changes" +- text: HEAD → origin/main +- 'button "View the changes of this Session: 60 files, 60 lines added, 0 lines deleted"': +60 −0 60 files +- button "Create change request" diff --git a/apps/web/tests/expected/forge-panel/panel.expected.md b/apps/web/tests/expected/forge-panel/panel.expected.md index 3450262cd7..f71db0bd64 100644 --- a/apps/web/tests/expected/forge-panel/panel.expected.md +++ b/apps/web/tests/expected/forge-panel/panel.expected.md @@ -12,6 +12,6 @@ - button "复制变更请求链接" - 'button "从本会话移除 #7"' - 'button "收起 #7" [expanded]' - - button "检查 1 项失败 · 1 项通过" + - button "检查 已加载 1 项检查,仍有更多" - text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突 - 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14 diff --git a/apps/web/tests/expected/forge-panel/reference.expected.md b/apps/web/tests/expected/forge-panel/reference.expected.md index cc5f2e87f6..4a34c8f7c9 100644 --- a/apps/web/tests/expected/forge-panel/reference.expected.md +++ b/apps/web/tests/expected/forge-panel/reference.expected.md @@ -7,6 +7,6 @@ - button "复制变更请求链接" - 'button "从本会话移除 #7"' - 'button "收起 #7" [expanded]' - - button "检查 1 项失败 · 1 项通过" + - button "检查 已加载 1 项检查,仍有更多" - text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突 - 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14 diff --git a/apps/web/tests/expected/forge-panel/review-composer.expected.md b/apps/web/tests/expected/forge-panel/review-composer.expected.md new file mode 100644 index 0000000000..d2c1d4682d --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-composer.expected.md @@ -0,0 +1,7 @@ +- region "评论 R2–4": + - text: 评论 R2–4 + - textbox "留下评论…": 请核对边界条件 + - button "取消" + - button "添加到输入框" + - button "开始评审" + - button "GitHub 评论选项" diff --git a/apps/web/tests/expected/forge-panel/review-guide.expected.md b/apps/web/tests/expected/forge-panel/review-guide.expected.md new file mode 100644 index 0000000000..a56f8cd077 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-guide.expected.md @@ -0,0 +1 @@ +- button "代码审查和变更 查看代码变更与本会话的变更请求" diff --git a/apps/web/tests/expected/forge-panel/review-message.expected.md b/apps/web/tests/expected/forge-panel/review-message.expected.md new file mode 100644 index 0000000000..c454458d69 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-message.expected.md @@ -0,0 +1,25 @@ +> ## User Comment 2 +> File: src/extra.ts +> Side: R +> Lines: 1 +> Source: Local (branch) +> Diff hunk: +> ```diff +> @@ -0,0 +1,1 @@ +> +export const extra = 1 +> ``` +> Comment: +> 请检查这项修改 + +> ## User Comment 1 +> File: src/app.ts +> Side: R +> Lines: 1 +> Source: Local (branch) +> Diff hunk: +> ```diff +> @@ -0,0 +1,1 @@ +> +export const app = 1 +> ``` +> Comment: +> 请解释这行代码 diff --git a/apps/web/tests/expected/forge-panel/review-quotes.expected.md b/apps/web/tests/expected/forge-panel/review-quotes.expected.md new file mode 100644 index 0000000000..602d647831 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-quotes.expected.md @@ -0,0 +1,6 @@ +- textbox "发消息或创建任务, / 调用指令, @ 文件或对话": + - paragraph: + - button "移除引用" + - text: "## User Comment 2 File: src/extra.ts Side: R Lines: 1 Source: Local (branch) Diff hunk: ```diff @@ -0,0 +1,1 @@ +export const extra = 1 ``` Comment: 请检查这项修改" + - button "移除引用" + - text: "## User Comment 1 File: src/app.ts Side: R Lines: 1 Source: Local (branch) Diff hunk: ```diff @@ -0,0 +1,1 @@ +export const app = 1 ``` Comment: 请解释这行代码" diff --git a/apps/web/tests/expected/forge-panel/review-record.expected.md b/apps/web/tests/expected/forge-panel/review-record.expected.md new file mode 100644 index 0000000000..3e678d6071 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-record.expected.md @@ -0,0 +1,4 @@ +- region "评论 R2–4": + - text: 你的评论 R2–4 已发布到 GitHub 请核对边界条件 + - button "添加到输入框" + - button "在 GitHub 查看" diff --git a/apps/web/tests/expected/forge-panel/review-summary-required.expected.md b/apps/web/tests/expected/forge-panel/review-summary-required.expected.md new file mode 100644 index 0000000000..3e384b28e1 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-summary-required.expected.md @@ -0,0 +1,14 @@ +- dialog "提交评审": + - heading "提交评审" [level=2] + - button "关闭" + - text: 待提交评论(1) + - textbox "评审总结…" + - status: 请求更改时,请填写评审总结 + - radio "评论" + - text: 评论 + - radio "批准" + - text: 批准 + - radio "请求更改" [checked] + - text: 请求更改 + - button "取消" + - button "提交评审" [disabled] diff --git a/apps/web/tests/expected/forge-panel/submit-review.expected.md b/apps/web/tests/expected/forge-panel/submit-review.expected.md new file mode 100644 index 0000000000..0687f7ba99 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/submit-review.expected.md @@ -0,0 +1,14 @@ +- dialog "提交评审": + - heading "提交评审" [level=2] + - button "关闭" + - text: 待提交评论(1) + - textbox "评审总结…": 需要补充边界检查 + - status: 请求更改时,请填写评审总结 + - radio "评论" + - text: 评论 + - radio "批准" + - text: 批准 + - radio "请求更改" [checked] + - text: 请求更改 + - button "取消" + - button "提交评审" diff --git a/apps/web/tests/expected/forge-panel/worktree-option.expected.md b/apps/web/tests/expected/forge-panel/worktree-option.expected.md new file mode 100644 index 0000000000..c9a85b8723 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/worktree-option.expected.md @@ -0,0 +1,3 @@ +- button "当前分支:main": main +- checkbox "新建工作树" +- text: 新建工作树 diff --git a/apps/web/tests/expected/layout-bottom/geometry.expected.md b/apps/web/tests/expected/layout-bottom/geometry.expected.md index 39188ce561..8e53379673 100644 --- a/apps/web/tests/expected/layout-bottom/geometry.expected.md +++ b/apps/web/tests/expected/layout-bottom/geometry.expected.md @@ -4,6 +4,6 @@ grown: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":0,"height": darwin: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":0,"height":440,"bottom":440},"sidebar":{"top":0,"height":440,"bottom":440},"rightbar":{"top":0,"height":440,"bottom":440},"overlay":{"top":0,"height":800,"bottom":800},"handles":[{"top":0,"height":440,"bottom":440}],"pageHeight":800} windows: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":40,"height":400,"bottom":440},"sidebar":{"top":40,"height":400,"bottom":440},"rightbar":{"top":40,"height":400,"bottom":440},"overlay":{"top":0,"height":800,"bottom":800},"handles":[{"top":40,"height":400,"bottom":440}],"pageHeight":800} browser: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":0,"height":440,"bottom":440},"sidebar":{"top":0,"height":440,"bottom":440},"rightbar":{"top":0,"height":440,"bottom":440},"overlay":{"top":0,"height":800,"bottom":800},"handles":[{"top":0,"height":440,"bottom":440}],"pageHeight":800} -narrow: {"bottom":{"top":240,"height":360,"bottom":600},"main":{"top":0,"height":240,"bottom":240},"sidebar":{"top":0,"height":240,"bottom":240},"rightbar":{"top":0,"height":240,"bottom":240},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600} +narrow: {"bottom":{"top":240,"height":360,"bottom":600},"main":{"top":0,"height":240,"bottom":240},"sidebar":{"top":0,"height":600,"bottom":600},"rightbar":{"top":0,"height":240,"bottom":240},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600} unregistered: {"bottom":{"top":600,"height":0,"bottom":600},"main":{"top":0,"height":600,"bottom":600},"sidebar":{"top":0,"height":600,"bottom":600},"rightbar":{"top":0,"height":600,"bottom":600},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600} collapsed: {"bottom":{"top":600,"height":0,"bottom":600},"main":{"top":0,"height":600,"bottom":600},"sidebar":{"top":0,"height":600,"bottom":600},"rightbar":{"top":0,"height":600,"bottom":600},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600} diff --git a/apps/web/tests/expected/markdown-colon-file-links.md b/apps/web/tests/expected/markdown-colon-file-links.md new file mode 100644 index 0000000000..fe7031c9f0 --- /dev/null +++ b/apps/web/tests/expected/markdown-colon-file-links.md @@ -0,0 +1,21 @@ +- paragraph: + - button "Relative colon:24–30" + - text: · + - button "Absolute colon:30" + - text: · + - button "Fragment:12" + - text: · + - button "Column:24:5" + - text: · + - button "Zero line" + - text: · + - button "Reversed range" + - text: · + - link "Bare URL": + - /url: index.ts:24 + - text: · Blocked script · + - link "Development server": + - /url: localhost:3000 + - text: · + - link "Mail": + - /url: mailto:10086 diff --git a/apps/web/tests/expected/menu-material/materials.expected.json b/apps/web/tests/expected/menu-material/materials.expected.json index 301564bef3..b0abc51804 100644 --- a/apps/web/tests/expected/menu-material/materials.expected.json +++ b/apps/web/tests/expected/menu-material/materials.expected.json @@ -1,24 +1,24 @@ { "web-light": { - "fill": "rgba(248, 249, 250, 0.58)", + "fill": "rgba(252, 253, 254, 0.7)", "blur": "blur(40px) saturate(1.5)", "radius": "16px", "shadow": "rgba(0, 0, 0, 0.04) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px" }, "web-dark": { - "fill": "rgba(67, 69, 74, 0.45)", + "fill": "rgba(48, 49, 54, 0.64)", "blur": "blur(40px) saturate(1.5)", "radius": "16px", "shadow": "rgba(255, 255, 255, 0.16) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px" }, "darwin-light": { - "fill": "rgba(248, 249, 250, 0.58)", + "fill": "rgba(252, 253, 254, 0.7)", "blur": "blur(40px) saturate(1.5)", "radius": "16px", "shadow": "rgba(0, 0, 0, 0.04) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px" }, "darwin-dark": { - "fill": "rgba(67, 69, 74, 0.45)", + "fill": "rgba(48, 49, 54, 0.64)", "blur": "blur(40px) saturate(1.5)", "radius": "16px", "shadow": "rgba(255, 255, 255, 0.16) 0px 0px 0px 0.5px, rgba(0, 0, 0, 0.04) 0px 3px 8px 0px, rgba(0, 0, 0, 0.05) 0px 0px 20px 0px" diff --git a/apps/web/tests/expected/menu-material/session-menu.expected.md b/apps/web/tests/expected/menu-material/session-menu.expected.md index ebb1786386..c7c535b9e4 100644 --- a/apps/web/tests/expected/menu-material/session-menu.expected.md +++ b/apps/web/tests/expected/menu-material/session-menu.expected.md @@ -2,5 +2,7 @@ - menuitem "Pin session" - menuitem "Rename" - menuitem "Fork session" - - menuitem "Archive session" + - separator - menuitem "Copy Session ID" + - separator + - menuitem "Archive session" diff --git a/apps/web/tests/expected/plugin-install-registry/installed.expected.md b/apps/web/tests/expected/plugin-install-registry/installed.expected.md index 2ddc96e196..d198049eca 100644 --- a/apps/web/tests/expected/plugin-install-registry/installed.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/installed.expected.md @@ -15,4 +15,5 @@ - text: 已完成 $ pnpm add mirrored-package - button "复制" - text: Installed from the registry pnpm names + - paragraph: 完整日志:{{cwd}}/.dsh-home/profiles/scaffold/.plugin-manager/logs/operation-{{suffix}}/pnpm.log - button "立即启用" diff --git a/apps/web/tests/expected/plugin-install-silent-failure/failed.expected.md b/apps/web/tests/expected/plugin-install-silent-failure/failed.expected.md new file mode 100644 index 0000000000..61630bd8b2 --- /dev/null +++ b/apps/web/tests/expected/plugin-install-silent-failure/failed.expected.md @@ -0,0 +1,14 @@ +- dialog "插件安装失败": + - button "返回编辑": 编辑 + - button "关闭" + - alert: 插件安装失败 + - paragraph: 安装命令失败,原因见安装详情 + - paragraph: silent-package + - paragraph: 版本 1.0.0 + - button "收起安装详情" [expanded] + - button "重试" + - paragraph: 安装位置:{{cwd}}/.dsh-home/profiles/scaffold + - text: 失败 $ pnpm add silent-package 退出码 1 + - button "复制" + - text: "Command failed with exit code 1: {{node}} add silent-package" + - paragraph: 完整日志:{{cwd}}/.dsh-home/profiles/scaffold/.plugin-manager/logs/operation-{{suffix}}/pnpm.log diff --git a/apps/web/tests/expected/plugin-manager/header-natural-motion.expected.md b/apps/web/tests/expected/plugin-manager/header-natural-motion.expected.md index e1561e6f7c..648b1e5f3b 100644 --- a/apps/web/tests/expected/plugin-manager/header-natural-motion.expected.md +++ b/apps/web/tests/expected/plugin-manager/header-natural-motion.expected.md @@ -6,4 +6,5 @@ - Tabs / 1280px: resting title 28, actions 28, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls. - Tabs / 600px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls. - Tabs / 470px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls. -- Tabs / 390px: resting title 28, actions 146, tabs 96; pinned actions 66, tabs 16, bar 114; stable range, reversible coordinates, same controls. +- Tabs / 390px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls. +- Tabs / 320px: resting title 28, actions 146, tabs 96; pinned actions 66, tabs 16, bar 114; stable range, reversible coordinates, same controls. diff --git a/apps/web/tests/expected/schedule-header.expected.md b/apps/web/tests/expected/schedule-header.expected.md index 96bff76bee..eb305c44f7 100644 --- a/apps/web/tests/expected/schedule-header.expected.md +++ b/apps/web/tests/expected/schedule-header.expected.md @@ -7,7 +7,7 @@ en 440px: title and New single-line, New beside the title; contained; New retain en 390px: title and New single-line, New beside the title; contained; New retains focus en 430px: title and New single-line, New beside the title; contained; New retains focus en 1024px: title and New single-line, New beside the title; contained; New retains focus -en 509px with sidebar expanded: title single-line; New moves below the title +en 220px page column: title single-line; New moves below the title zh 1680px: title and New single-line, New beside the title; contained; New retains focus zh 800px: title and New single-line, New beside the title; contained; New retains focus zh 520px: title and New single-line, New beside the title; contained; New retains focus diff --git a/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md index c8ab6426ab..16362fff3a 100644 --- a/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md +++ b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md @@ -1,2 +1,3 @@ - menu: - menuitem "Close" + - menuitem "Close Other Tabs" [disabled] diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md index ebb1786386..6724e2373d 100644 --- a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -2,5 +2,8 @@ - menuitem "Pin session" - menuitem "Rename" - menuitem "Fork session" - - menuitem "Archive session" + - separator - menuitem "Copy Session ID" + - menuitem "Show in Finder" + - separator + - menuitem "Archive session" diff --git a/apps/web/tests/expected/sidebar-drawer.expected.md b/apps/web/tests/expected/sidebar-drawer.expected.md new file mode 100644 index 0000000000..fafb1402b2 --- /dev/null +++ b/apps/web/tests/expected/sidebar-drawer.expected.md @@ -0,0 +1,24 @@ +390px: closed sidebar has no rail; main width 390px +open: main geometry retained; background inert; no resize handle +close: Session navigation, Plugins navigation, mask and Escape +retained: sidebar DOM and opener focus +768px: 56px rail; main width 712px; drawer focus transfers to the rail toggle +breakpoint motion: collapsed rail hides immediately and returns compact without crossfades; expanded sidebar and explicit toggles retain their slides +767px: hidden drawer; main width 767px; no page overflow +global panels: browser top inset 48px; macOS keeps its existing clearance +tooltips: sidebar and workspace controls escape the drawer and fit the viewport +search input: 16px on touch; 13px with a fine pointer +simulated keyboard: homepage and drawer retain 844px layout height without following the 360px visual viewport; Settings stays within the viewport +keyboard close: layout height stays 844px; sidebar search retained; Settings close remains reachable +shell coverage: drawer backing and mask cover 844px without synthetic safe-area insets +rotation: 844x390 then 390x844 restores shell height and sidebar breakpoint +folded landscape: 951px frame splits at 475.5px without a resize handle; unfolding restores 280px and search focus; portrait retains the drawer +fold transitions: sidebar controls follow the divider in both directions; reduced motion changes width without animation +drawer mask: opacity transitions from 0 to 1 on open and 1 to 0 on close +reduced motion: drawer and mask settle without transitions +Web header: only the sidebar opener remains, with 48px leading clearance +below 768px: plugin and automation pages use 20px insets; Web view tabs align at 20px, while Desktop and wider Web tabs retain 28px +touch selected Session: More remains visible; update time is hidden; pinned rows offer Unpin before More with Workspace action spacing +touch Workspace: actions appear only while expanded, including after folding the focused row +fine pointer: folded Workspace actions remain available on hover +fine pointer: selected Session actions stay hidden until hover, including at 390px diff --git a/apps/web/tests/expected/sidebar-menu-dismissal.expected.md b/apps/web/tests/expected/sidebar-menu-dismissal.expected.md new file mode 100644 index 0000000000..585e3877cd --- /dev/null +++ b/apps/web/tests/expected/sidebar-menu-dismissal.expected.md @@ -0,0 +1,2 @@ +View options and Session menus close when the sidebar becomes an inert drawer. +Focus returns to the visible sidebar opener; retained Session rows keep their DOM identity. diff --git a/apps/web/tests/fixtures/markdown-colon-file-links/session.v3.jsonl b/apps/web/tests/fixtures/markdown-colon-file-links/session.v3.jsonl new file mode 100644 index 0000000000..7ef9426138 --- /dev/null +++ b/apps/web/tests/fixtures/markdown-colon-file-links/session.v3.jsonl @@ -0,0 +1,10 @@ +{"version":3,"isSeeded":false,"createdAt":0,"cwd":"{{cwd}}","delegationDepth":0,"type":"session","id":"{{session:1}}"} +{"type":"turn/start","data":{"turn":1}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:1}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Link src/example.txt using relative :24-30, absolute :30, and relative #L12 destinations, plus line/column and invalid locations, localhost:3000, index.ts:24, mailto:10086, and a blocked script link."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Colon file references","messageSeqs":[3],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"[Relative colon:24–30](src/example.txt:24-30) · [Absolute colon:30]({{cwd}}/src/example.txt:30) · [Fragment:12](src/example.txt#L12) · [Column:24:5](src/example.txt:24:5) · [Zero line](src/example.txt:0) · [Reversed range](src/example.txt:30-24) · [Bare URL](index.ts:24) · [Blocked script](javascript:alert) · [Development server](localhost:3000) · [Mail](mailto:10086)"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:3}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["[Relative colon:24–30](src/example.txt:24-30) · [Absolute colon:30]({{cwd}}/src/example.txt:30) · [Fragment:12](src/example.txt#L12) · [Column:24:5](src/example.txt:24:5) · [Zero line](src/example.txt:0) · [Reversed range](src/example.txt:30-24) · [Bare URL](index.ts:24) · [Blocked script](javascript:alert) · [Development server](localhost:3000) · [Mail](mailto:10086)"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"[Relative colon:24–30](src/example.txt:24-30) · [Absolute colon:30]({{cwd}}/src/example.txt:30) · [Fragment:12](src/example.txt#L12) · [Column:24:5](src/example.txt:24:5) · [Zero line](src/example.txt:0) · [Reversed range](src/example.txt:30-24) · [Bare URL](index.ts:24) · [Blocked script](javascript:alert) · [Development server](localhost:3000) · [Mail](mailto:10086)"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js index 1c884c568f..db9eab44b9 100644 --- a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js +++ b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js @@ -101,8 +101,7 @@ window.__ModuleLoader__.load({ } }) ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () { - // The shipped rows end at Session ID (450); these follow as one group, - // opened by the export row's hairline. + // Legacy rows form their own ordered group before the built-in Archive action. yield registerSessionAction('fixture.export-session', 500, 'exportSession', true) yield registerSessionAction('fixture.copy-session-id', 600, 'copySessionId', false) }) diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 3422f1c8d7..6be6b70c39 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -77,13 +77,23 @@ if (args[0] === 'api' && args[5] === 'repos/octo/demo/pulls/9/merge') { process.stdout.write(JSON.stringify({ sha: 'c'.repeat(40), merged: true, message: 'Pull Request successfully merged' })) process.exit(0) } +if (args[0] === 'api' && args[3] === 'repos/octo/demo/pulls/7') { + process.stdout.write(JSON.stringify({ head: { sha: 'a'.repeat(40) }, state: 'open' })) + process.exit(0) +} +if (args[0] === 'api' && args[4] === 'POST' && ['repos/octo/demo/pulls/7/comments', 'repos/octo/demo/pulls/7/reviews'].includes(args[5])) { + const body = JSON.parse(readFileSync(0, 'utf8')) + appendFileSync(join(here, 'reviews.jsonl'), JSON.stringify({ endpoint: args[5], body }) + '\\n') + process.stdout.write(JSON.stringify({ html_url: 'https://github.com/octo/demo/pull/7#pullrequestreview-1' })) + process.exit(0) +} if (args[0] === 'pr' && args[1] === 'diff') { const refused = join(here, 'pr-' + args[2] + '.refused') if (existsSync(refused)) { process.stderr.write(readFileSync(refused, 'utf8')) process.exit(1) } - process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8')) + await new Promise(resolve => process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8'), resolve)) process.exit(0) } if (args[0] === 'api' && args[3] === '-H' && args[4] === 'Accept: application/vnd.github.raw+json') { @@ -103,10 +113,10 @@ if (args[0] === 'auth' && args[1] === 'status') { } if (args[0] === 'api' && args[1] === 'graphql') { const query = args.find(arg => arg.startsWith('query=')).slice('query='.length) - const check = (name, conclusion, completedAt) => ({ - __typename: 'CheckRun', name, status: 'COMPLETED', conclusion, + const check = (name, conclusion, completedAt, databaseId = 101) => ({ + __typename: 'CheckRun', id: 'check-' + databaseId, name, status: 'COMPLETED', conclusion, databaseId, detailsUrl: 'https://github.com/octo/demo/actions/runs/1', startedAt: '2026-10-09T00:00:00Z', completedAt, - checkSuite: { workflowRun: { workflow: { name: 'CI' } } }, + checkSuite: { app: { id: 'github-actions' }, workflowRun: { event: 'pull_request', workflow: { id: 'ci', name: 'CI' } } }, }) const pulls = { 7: { @@ -114,9 +124,9 @@ if (args[0] === 'api' && args[1] === 'graphql') { updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED', reviewThreads: { nodes: [{ isResolved: false }] }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: 'FAILURE', contexts: { - pageInfo: { hasNextPage: false }, - nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z'), check('test', 'FAILURE', '2026-10-09T00:02:07Z')], + commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-7', state: 'FAILURE', contexts: { + pageInfo: { hasNextPage: true, endCursor: 'checks-100' }, + nodes: Array.from({ length: 100 }, (_, i) => check('lint', 'FAILURE', '2026-10-08T00:00:42Z', i + 1)), } } } }] }, }, 9: { @@ -124,11 +134,18 @@ if (args[0] === 'api' && args[1] === 'graphql') { isDraft: !state.ready, updatedAt: '2026-10-09T00:00:00Z', closedAt: state.mergedAt, author: { login: 'octo' }, headRefName: 'feat/merge', headRefOid: state.head ?? 'b'.repeat(40), baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE', mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { nodes: [] }, - commits: { nodes: [{ commit: { statusCheckRollup: { state: 'SUCCESS', contexts: { - pageInfo: { hasNextPage: false }, nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z')], + commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-9', state: 'SUCCESS', contexts: { + pageInfo: { hasNextPage: false, endCursor: null }, nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z')], } } } }] }, }, } + if (query.includes('node(id: $id)')) { + process.stdout.write(JSON.stringify({ data: { node: { contexts: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z'), check('test', 'FAILURE', '2026-10-09T00:02:07Z')], + } } } })) + process.exit(0) + } const settings = { squashMergeAllowed: true, mergeCommitAllowed: true, rebaseMergeAllowed: true, viewerPermission: 'WRITE', viewerDefaultMergeMethod: 'SQUASH', } @@ -207,9 +224,12 @@ interface PanelLaunch { * Launch the bundle against a GitHub checkout of `main` and `release` and open the New Session screen of that workspace. * @param failureShot - name of the screenshot saved when the test fails. * @param scenario - normal conversation, a restoration follow-up, or a numbered PR lookup. + * @param options - desktop transport, checkout branch, browser input, sandbox runner availability, and diff output limit. * @returns the scaffold, page, and checkout. */ -async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal', desktop?: 'darwin' | 'win32'): Promise { +async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal', + options: { desktop?: 'darwin' | 'win32'; branch?: string; hasTouch?: boolean; missingSandbox?: boolean; maxDiffBytes?: number } = {}): Promise { + const { desktop } = options const resources: { scaffold?: WebScaffold; browser?: Browser } = {} const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-')) onTestFinished(async () => { @@ -227,7 +247,9 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' const panelText = `${Array.from({ length: 20 }, (_, index) => panelLine(index + 1, 'new')).join('\n')}\n` await writeFile(join(root, 'contents.json'), JSON.stringify({ [`src/panel.ts@${HEAD7}`]: panelText })) const overlay = join(root, 'forge.patch.yml') - await writeFile(overlay, `- id: forge-github\n config: ${JSON.stringify({ ghCommand: gh })}\n`) + await writeFile(overlay, `- id: forge-github\n config: ${JSON.stringify({ ghCommand: gh })}\n` + + (options.maxDiffBytes === undefined ? '' : `- id: forge-panel\n config: ${JSON.stringify({ maxDiffBytes: options.maxDiffBytes })}\n`) + + (options.missingSandbox ? `- id: sandbox\n config: ${JSON.stringify({ runnerCommand: [join(root, 'missing-sandbox')], runnerFailureSignatures: ['sandbox startup failed'] })}\n` : '')) const override = join(root, 'replay.override.json') // The borrowed scenario records one model call, so index 1 appends the reply to the follow-up. if (scenario === 'follow-up') await writeFile(override, JSON.stringify({ patches: [{ at: 1, entry: { kind: 'chunks', chunks: textReply(FOLLOW_UP_REPLY) } }] })) @@ -265,6 +287,7 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' await git(project, 'remote', 'add', 'origin', 'https://github.com/octo/demo.git') await git(project, 'branch', 'release') await git(project, 'commit', '--quiet', '--allow-empty', '-m', 'main only') + if (options.branch !== undefined) await git(project, 'checkout', '--quiet', '-b', options.branch) if (scenario === 'reference') { // The model's confined command must own the CLI double's writable call log. const modelBin = join(project, '.fixture-bin') @@ -275,7 +298,8 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' const browser = await chromium.launch() resources.browser = browser - const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai' }) + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai', + hasTouch: options.hasTouch ?? false }) if (desktop !== undefined) { // Only Electron's keyboard transport is substituted; the product registry and menus remain real. await page.addInitScript(({ device, config }) => { @@ -328,7 +352,7 @@ async function requestReleaseWorktree(failureShot: string, followUp = false): Pr const { page } = launched const option = page.locator('[data-forge-worktree]') await option.waitFor({ timeout: 20_000 }) - await option.getByRole('checkbox', { name: '工作树', exact: true }).check() + await option.getByRole('checkbox', { name: '新建工作树', exact: true }).check() await option.getByRole('button', { name: '基于 main', exact: true }).click() await page.getByRole('menuitem', { name: 'release', exact: true }).click() await option.getByRole('button', { name: '基于 release', exact: true }).waitFor() @@ -356,6 +380,68 @@ function worktreeRecords(events: readonly SessionEvent[]): unknown[] { }) } +it.skipIf(MODE === 'record')('reads local Git with full access when the sandbox runner cannot start', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-permissions', 'normal', { missingSandbox: true }) + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await writeFile(join(project, 'README.md'), 'demo\nchanged\n') + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + await page.locator('[data-forge-header]').click() + const panel = page.locator('[data-forge-panel]') + await panel.getByText('无法读取 Git 信息', { exact: true }).waitFor({ timeout: 20_000 }) + + const permission = page.locator('button[aria-label^="访问模式"]').first() + await permission.click() + await page.getByRole('menuitem', { name: '完全权限', exact: true }).click() + const confirmation = page.getByRole('dialog', { name: '确认启用完全权限?' }) + await confirmation.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check() + await confirmation.getByRole('button', { name: '启用完全权限' }).click() + const local = page.locator('[data-forge-local]') + await local.getByRole('button', { name: '查看本会话的变更:1 个文件,新增 1 行,删除 0 行', exact: true }).waitFor({ timeout: 20_000 }) + await compareOrRefreshGolden(join(EXPECTED, 'full-access.expected.md'), await captureStableAria(page, '[data-forge-local]', scaffold.workspaceCwd), MODE) + await local.getByRole('button', { name: '查看本会话的变更:1 个文件,新增 1 行,删除 0 行', exact: true }).click() + await page.locator('[data-forge-diff-file="README.md"]').getByText('changed', { exact: true }).waitFor() + + await permission.click() + await page.getByRole('menuitem', { name: '工作区内修改', exact: true }).click() + if (!await panel.isVisible()) await page.locator('[data-forge-header]').click() + await panel.getByText('无法读取 Git 信息', { exact: true }).waitFor({ timeout: 20_000 }) + await expect.poll(async () => (await readPersistedEvents(scaffold, sessionId)) + .filter(event => event.type === 'sandbox/mode').map(event => event.data.mode).slice(-2)) + .toEqual(['danger-full-access', 'workspace-write']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('reports incomplete filter configuration and retries after it is corrected', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-filter-config', 'normal', { maxDiffBytes: 512 }) + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await writeFile(join(project, 'README.md'), 'demo\nchanged\n') + const { settled } = await sendPrompt(page, scaffold) + await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + await page.locator('[data-forge-header]').click() + const openChanges = page.locator('[data-forge-local]').getByRole('button', { name: '查看本会话的变更:1 个文件,新增 1 行,删除 0 行', exact: true }) + await openChanges.waitFor({ timeout: 20_000 }) + const key = `filter.${'long'.repeat(200)}.clean` + await git(project, 'config', key, 'cat') + await openChanges.click() + const changes = page.locator('[data-forge-changes="local"]') + await changes.getByText('无法完整读取 Git 过滤器配置,尚未执行比较。请检查 Git 配置和读取上限后重试', { exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'filter-config-failed.expected.md'), + await captureStableAria(page, '[data-forge-changes="local"]', scaffold.workspaceCwd), MODE) + + await git(project, 'config', '--unset', key) + await changes.getByRole('button', { name: '重试', exact: true }).click() + await changes.locator('[data-forge-diff-file="README.md"]').getByText('changed', { exact: true }).waitFor() + expect(await changes.getByRole('status').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omits its URL', async () => { const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-numbered-reference', 'reference') const { settled } = await sendPrompt(page, scaffold) @@ -427,6 +513,11 @@ it.skipIf(MODE === 'record')('starts a Session in a worktree before its first me const row = page.locator(`[data-row-key="session:${sessionId}"]`) await row.getByRole('img', { name: '在自己的工作树中', exact: true }).waitFor({ timeout: 20_000 }) await row.getByRole('img', { name: '变更请求状态:需要处理 · 检查失败', exact: true }).waitFor({ timeout: 20_000 }) + // A fresh page cannot retain the previous browser source's status values. + await page.goto('about:blank') + await expect.poll(() => scaffold.ctx.storageDomain.get('forge_panel_change_requests')).toBeUndefined() + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await row.getByRole('img', { name: '变更请求状态:需要处理 · 检查失败', exact: true }).waitFor({ timeout: 20_000 }) await row.hover() const rowHover = page.locator('[data-forge-session-hover]') await rowHover.getByText('#7 Add the pull request panel', { exact: true }).waitFor({ timeout: 20_000 }) @@ -452,10 +543,14 @@ it.skipIf(MODE === 'record')('starts a Session in a worktree before its first me // The random branch suffix can read as a duration, so the branch is replaced before the capture normalizes durations. await compareOrRefreshGolden(join(EXPECTED, 'panel.expected.md'), await captureStableAria(page, '[data-forge-panel]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE) + const beforeChecks = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + expect(beforeChecks.some(args => args.includes('id=rollup-7'))).toBe(false) const checksRow = change.getByRole('button', { name: /^检查/ }) await checksRow.click() const checks = page.getByRole('menu') await expect.poll(() => checks.getByRole('menuitem').allTextContents()).toEqual([expect.stringMatching(/^test/), expect.stringMatching(/^lint/)]) + const afterChecks = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + expect(afterChecks.filter(args => args.includes('id=rollup-7'))).toHaveLength(1) await page.keyboard.press('Escape') await checks.waitFor({ state: 'detached' }) expect(await checksRow.getAttribute('aria-expanded')).toBe('false') @@ -590,7 +685,7 @@ it.skipIf(MODE === 'record')('marks a draft change request ready for review and }) it.skipIf(MODE === 'record')('shows the local changes of the Session by scope and the changes of its change requests, expanding unchanged lines', async () => { - const { scaffold, page, root, project, tripwire } = await launchPanel('web-e2e-forge-panel-changes') + const { scaffold, page, root, project, tripwire } = await launchPanel('web-e2e-forge-panel-changes', 'follow-up') // origin/main stops at the twelve-line README; the branch adds a commit, a staged file, an edit, and an untracked file. await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => `line ${index + 1}\n`).join('')) await git(project, 'commit', '--quiet', '-am', 'readme') @@ -611,7 +706,12 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an const session = scaffold.ctx.sessions.get(sessionId) if (session === undefined) throw new Error('The Session must stay loaded') const header = page.locator('[data-forge-header]') - await header.click() + await page.getByRole('button', { name: '打开右侧边栏', exact: true }).click() + const guide = page.locator('[data-sidebar-right-guide]') + await guide.getByRole('button', { name: /^代码审查和变更/ }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'review-guide.expected.md'), + await captureStableAria(page, '[data-sidebar-right-guide-entry="forge-panel"]', scaffold.workspaceCwd), MODE) + await guide.getByRole('button', { name: /^代码审查和变更/ }).click() // The New Session screen inspected the checkout before these changes, which came from outside the Session. await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() const card = page.locator('[data-forge-local]') @@ -626,13 +726,46 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an await local.locator('[data-forge-diff-file="notes.md"]').getByText('新文件 · 未提交', { exact: true }).waitFor() const readme = local.locator('[data-forge-diff-file="README.md"]') await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) - await readme.getByRole('button', { name: '复制路径: README.md', exact: true }).click() - await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('README.md') await page.evaluate(() => navigator.clipboard.writeText('unchanged')) + await readme.locator('[data-forge-file-label]').click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged') await readme.locator('[data-forge-path-spacer]').click() await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged') await readme.getByRole('button', { name: '6 行未变更', exact: true }).click() await readme.getByText('line 1', { exact: true }).first().waitFor({ timeout: 20_000 }) + await local.getByRole('button', { name: '全部 4 个文件', exact: true }).click() + const tree = page.getByRole('menu') + await tree.getByRole('textbox', { name: '筛选文件…' }).fill('extra') + await tree.getByRole('menuitem', { name: 'src/extra.ts', exact: true }).click() + for (const [path, comment] of [['src/app.ts', '请解释这行代码'], ['src/extra.ts', '请检查这项修改']] as const) { + const section = local.locator(`[data-forge-diff-file="${path}"]`) + const lineNumber = section.getByRole('button', { name: '选择第 R1 行', exact: true }) + await lineNumber.hover() + expect(await lineNumber.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + const content = await section.locator('[data-review-code]').first().boundingBox() + const fileBox = await section.boundingBox() + if (content === null || fileBox === null) throw new Error('Added file must be visible') + expect(content.x - fileBox.x).toBeLessThan(70) + await lineNumber.click() + expect(await section.getByRole('textbox', { name: '留下评论…' }).count()).toBe(0) + await section.getByRole('button', { name: '添加评论', exact: true }).click() + await section.getByRole('textbox', { name: '留下评论…' }).fill(comment) + await section.locator('[data-forge-comment]').getByRole('button', { name: '添加到输入框', exact: true }).click() + await section.locator('[data-forge-review-thread="agent"]').getByText(comment, { exact: true }).waitFor() + } + await compareOrRefreshGolden(join(EXPECTED, 'review-quotes.expected.md'), + await captureStableAria(page, '[data-composer-input]', scaffold.workspaceCwd), MODE) + expect((await readPersistedEvents(scaffold, sessionId)).some(event => event.type === 'user/message' + && event.data.content.some(block => block.type === 'text' && block.text.includes('## User Comment')))).toBe(false) + const reviewed = scaffold.whenTurnSettled() + await page.locator('[data-composer-input]').first().press('Enter') + await reviewed + await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor() + const comments = (await readPersistedEvents(scaffold, sessionId)).flatMap(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' && block.text.includes('## User Comment') ? [block.text] : []) : []) + expect(comments).toHaveLength(1) + await compareOrRefreshGolden(join(EXPECTED, 'review-message.expected.md'), comments.join('').trimEnd(), MODE) + await local.getByRole('button', { name: '分支', exact: true }).click() await page.getByRole('menuitem', { name: '已暂存', exact: true }).click() await local.getByText('暂存区与 HEAD 比较', { exact: true }).waitFor() @@ -648,20 +781,177 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click() const view = page.locator('[data-forge-changes="change"]') const panelFile = view.locator('[data-forge-diff-file="src/panel.ts"]') + const title = await view.getByText('Add the pull request panel', { exact: true }).boundingBox() + const submit = await view.getByRole('button', { name: '提交评审', exact: true }).boundingBox() + if (title === null || submit === null) throw new Error('PR title and submit action must be visible') + expect(Math.abs(title.y + title.height / 2 - submit.y - submit.height / 2)).toBeLessThan(1) + await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).waitFor({ timeout: 20_000 }) await compareOrRefreshGolden(join(EXPECTED, 'changes-view.expected.md'), await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE) + await page.getByRole('button', { name: '全屏', exact: true }).click() + await view.getByRole('navigation').getByRole('button', { name: 'src/panel.ts', exact: true }).waitFor() + const fileTree = view.getByRole('navigation') + const treeIsLeft = async () => { + const tree = await fileTree.boundingBox() + const diff = await view.locator('[data-forge-changes-scroll]').boundingBox() + if (tree === null || diff === null) throw new Error('Tree and diff must be visible') + return tree.x < diff.x + } + expect(await treeIsLeft()).toBe(true) + const directoryName = await fileTree.getByText('src', { exact: true }).boundingBox() + const fileName = await fileTree.getByText('panel.ts', { exact: true }).boundingBox() + if (directoryName === null || fileName === null) throw new Error('Tree entries must be visible') + expect(fileName.x).toBeGreaterThan(directoryName.x) + await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('src') + await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('') + await fileTree.getByRole('button', { name: 'src', exact: true }).click() + await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() + expect(await treeIsLeft()).toBe(false) + expect(await fileTree.getByRole('button', { name: 'src', exact: true }).getAttribute('aria-expanded')).toBe('false') + await page.setViewportSize({ width: 390, height: 844 }) + expect(await fileTree.isVisible()).toBe(false) + expect(await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).isVisible()).toBe(false) + await view.getByRole('button', { name: '全部 2 个文件', exact: true }).click() + await page.getByRole('menu').getByRole('textbox', { name: '筛选文件…' }).waitFor() + await page.keyboard.press('Escape') + await page.setViewportSize({ width: 1680, height: 1000 }) + expect(await treeIsLeft()).toBe(false) + await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() + expect(await fileTree.isVisible()).toBe(false) + await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() + expect(await treeIsLeft()).toBe(true) + expect(await fileTree.getByRole('button', { name: 'src', exact: true }).getAttribute('aria-expanded')).toBe('false') + await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('src') + await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() + await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() + await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() + expect(await fileTree.getByRole('textbox', { name: '筛选文件…' }).inputValue()).toBe('src') + await compareOrRefreshGolden(join(EXPECTED, 'file-tree-toggle.expected.md'), + await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE) + await fileTree.getByRole('textbox', { name: '筛选文件…' }).fill('') + await fileTree.getByRole('button', { name: 'src', exact: true }).click() + const resizeTree = view.getByRole('separator', { name: '调整文件树宽度', exact: true }) + const originalWidth = Number(await resizeTree.getAttribute('aria-valuenow')) + const handle = await resizeTree.boundingBox() + if (handle === null) throw new Error('Tree resize handle must be visible') + await page.mouse.move(handle.x + handle.width / 2, handle.y + 100) + await page.mouse.down() + await page.mouse.move(handle.x + 80, handle.y + 100, { steps: 5 }) + await page.mouse.up() + expect(Number(await resizeTree.getAttribute('aria-valuenow'))).toBeGreaterThan(originalWidth) + await resizeTree.press('Home') + expect(await fileTree.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const searchBounds = await fileTree.getByRole('textbox').boundingBox() + const treeBounds = await fileTree.boundingBox() + if (searchBounds === null || treeBounds === null) throw new Error('Tree search must be visible') + expect(searchBounds.x + searchBounds.width).toBeLessThanOrEqual(treeBounds.x + treeBounds.width) + await resizeTree.press('ArrowRight') + const resizedWidth = await resizeTree.getAttribute('aria-valuenow') + await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() + expect(await treeIsLeft()).toBe(false) + expect(await resizeTree.getAttribute('aria-valuenow')).toBe(resizedWidth) + await resizeTree.press('ArrowLeft') + expect(Number(await resizeTree.getAttribute('aria-valuenow'))).toBe(Number(resizedWidth) + 20) + await resizeTree.press('ArrowRight') + const rightHandle = await resizeTree.boundingBox() + if (rightHandle === null) throw new Error('Right tree resize handle must be visible') + await page.mouse.move(rightHandle.x + rightHandle.width / 2, rightHandle.y + 100) + await page.mouse.down() + await page.mouse.move(rightHandle.x - 80, rightHandle.y + 100, { steps: 5 }) + await page.mouse.up() + expect(Number(await resizeTree.getAttribute('aria-valuenow'))).toBeGreaterThan(Number(resizedWidth)) + const rightWidth = await resizeTree.getAttribute('aria-valuenow') + await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() + await view.getByRole('button', { name: /^(将文件树移到右侧|隐藏文件树|在左侧显示文件树)$/ }).click() + expect(await resizeTree.getAttribute('aria-valuenow')).toBe(rightWidth) + for (const side of ['old', 'new'] as const) { + const firstLine = await panelFile.locator(`[data-review-code][data-${side}-line="2"]`).boundingBox() + const lastLine = await panelFile.locator(`[data-review-code][data-${side}-line="4"]`).boundingBox() + if (firstLine === null || lastLine === null) throw new Error('Review text must be visible') + await page.mouse.move(firstLine.x + 7, firstLine.y + firstLine.height / 2) + await page.mouse.down() + await page.mouse.move(lastLine.x + 120, lastLine.y + lastLine.height / 2, { steps: 5 }) + await page.mouse.up() + await panelFile.getByRole('button', { name: '添加评论', exact: true }).waitFor() + expect(await panelFile.getByRole('textbox', { name: '留下评论…' }).count()).toBe(0) + const opposite = panelFile.locator(`[data-diff-side="${side === 'old' ? 'R' : 'L'}"]`).first() + expect(await opposite.evaluate(element => getComputedStyle(element).userSelect)).toBe('none') + await page.keyboard.press('ControlOrMeta+c') + const copied = await page.evaluate(() => navigator.clipboard.readText()) + expect(copied).toContain(side === 'old' ? 'n2 = 2' : "n2 = 'two'") + expect(copied).not.toContain(side === 'old' ? "n2 = 'two'" : 'n2 = 2') + expect(copied.match(/n3 = 3/g)).toHaveLength(1) + } + await panelFile.getByRole('button', { name: '添加评论', exact: true }).click() + await panelFile.getByRole('region', { name: '评论 R2–4', exact: true }).waitFor() + await panelFile.getByRole('textbox', { name: '留下评论…' }).fill('请核对边界条件') + await panelFile.getByRole('button', { name: 'GitHub 评论选项', exact: true }).click() + await page.getByRole('menuitem', { name: '发布单条评论', exact: true }).waitFor() + await page.keyboard.press('Escape') + await compareOrRefreshGolden(join(EXPECTED, 'review-composer.expected.md'), + await captureStableAria(page, '[data-forge-comment]', scaffold.workspaceCwd), MODE) + await page.getByRole('button', { name: '退出全屏', exact: true }).click() + const commentForm = panelFile.locator('[data-forge-comment]') + expect(await commentForm.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await page.getByRole('button', { name: '全屏', exact: true }).click() + await page.setViewportSize({ width: 390, height: 780 }) + await commentForm.getByRole('textbox').focus() + expect(await commentForm.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const narrowComment = await commentForm.boundingBox() + if (narrowComment === null) throw new Error('Comment form must remain visible') + expect(narrowComment.x + narrowComment.width).toBeLessThanOrEqual(390) + await page.setViewportSize({ width: 1680, height: 1000 }) + await panelFile.getByRole('button', { name: '开始评审', exact: true }).click() + await panelFile.locator('[data-forge-review-thread="pending"]').getByText('请核对边界条件', { exact: true }).waitFor() + await page.setViewportSize({ width: 1680, height: 500 }) + const changesScroll = view.locator('[data-forge-changes-scroll]') + await changesScroll.evaluate((element) => { element.scrollTop = element.scrollHeight }) + const reviewScroll = await changesScroll.evaluate(element => element.scrollTop) + expect(reviewScroll).toBeGreaterThan(0) + await view.getByRole('button', { name: '提交评审 (1)', exact: true }).click() + const reviewDialog = page.getByRole('dialog', { name: '提交评审', exact: true }) + await reviewDialog.waitFor() + expect(await reviewDialog.getByRole('textbox').count()).toBe(1) + expect(await reviewDialog.getByText('请核对边界条件', { exact: true }).count()).toBe(0) + expect(await changesScroll.evaluate(element => element.scrollTop)).toBe(reviewScroll) + await page.setViewportSize({ width: 390, height: 780 }) + expect(await reviewDialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const narrowDialog = await reviewDialog.boundingBox() + if (narrowDialog === null) throw new Error('Review dialog must remain visible') + expect(narrowDialog.x + narrowDialog.width).toBeLessThanOrEqual(390) + await page.setViewportSize({ width: 1680, height: 500 }) + await page.keyboard.press('Escape') + expect(await reviewDialog.count()).toBe(0) + await view.getByRole('button', { name: '提交评审 (1)', exact: true }).click() + await reviewDialog.getByRole('radio', { name: '请求更改', exact: true }).check() + await reviewDialog.getByText('请求更改时,请填写评审总结', { exact: true }).waitFor() + expect(await reviewDialog.getByRole('button', { name: '提交评审', exact: true }).isDisabled()).toBe(true) + await compareOrRefreshGolden(join(EXPECTED, 'review-summary-required.expected.md'), + await captureStableAria(page, '[role="dialog"][aria-label="提交评审"]', scaffold.workspaceCwd), MODE) + await reviewDialog.getByRole('textbox', { name: '评审总结…', exact: true }).fill('需要补充边界检查') + await compareOrRefreshGolden(join(EXPECTED, 'submit-review.expected.md'), + await captureStableAria(page, '[role="dialog"][aria-label="提交评审"]', scaffold.workspaceCwd), MODE) + await reviewDialog.getByRole('button', { name: '提交评审', exact: true }).click() + await page.getByRole('alert').filter({ hasText: '已提交评审' }).waitFor() + await panelFile.locator('[data-forge-review-thread="published"]').getByText('请核对边界条件', { exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'review-record.expected.md'), await captureStableAria(page, '[data-forge-review-thread="published"]', scaffold.workspaceCwd), MODE) + const published: unknown = JSON.parse((await readFile(join(root, 'reviews.jsonl'), 'utf8')).trim()) + expect(published).toEqual({ endpoint: 'repos/octo/demo/pulls/7/reviews', body: { commit_id: HEAD7, event: 'REQUEST_CHANGES', body: '需要补充边界检查', + comments: [{ path: 'src/panel.ts', side: 'RIGHT', line: 4, start_line: 2, start_side: 'RIGHT', body: '请核对边界条件' }] } }) + await page.getByRole('button', { name: '退出全屏', exact: true }).click() await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).click() await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 }) await panelFile.getByRole('button', { name: '2 行未变更', exact: true }).waitFor() - await panelFile.getByRole('button', { name: '复制路径: src/panel.ts', exact: true }).click() + await panelFile.getByRole('button', { name: 'panel.ts 的更多操作', exact: true }).click() + await page.getByRole('menuitem', { name: '复制路径', exact: true }).click() await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('src/panel.ts') const docsFile = view.locator('[data-forge-diff-file="docs/panel.md"]') await docsFile.getByRole('button', { name: '收起 panel.md', exact: true }).click() await page.setViewportSize({ width: 1280, height: 500 }) const scrollport = view.locator('[data-forge-changes-scroll]') await scrollport.evaluate((element) => { element.scrollTop = 100; element.dispatchEvent(new Event('scroll')) }) - await panelFile.getByRole('button', { name: '复制路径: src/panel.ts', exact: true }).click() + await panelFile.locator('[data-forge-file-label]').click() const position = await scrollport.evaluate(element => element.scrollTop) expect(position).toBeGreaterThan(0) const originalFile = await panelFile.elementHandle() @@ -672,7 +962,19 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an expect(await panelFile.evaluate((element, original) => element === original, originalFile)).toBe(true) await expect.poll(() => docsFile.getAttribute('data-expanded')).toBe(null) await expect.poll(() => scrollport.evaluate(element => element.scrollTop)).toBe(position) + expect(await panelFile.locator('[data-forge-review-thread="published"]').count()).toBe(1) await page.setViewportSize({ width: 1680, height: 1000 }) + await page.locator('[data-dockkit-tab]').filter({ hasText: '#7 的变更' }).locator('[data-dockkit-tab-close]').click() + await expect.poll(() => view.count()).toBe(0) + if (await header.getAttribute('aria-pressed') !== 'true') await header.click() + await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click() + await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).waitFor() + expect(await panelFile.locator('[data-forge-review-thread]').count()).toBe(0) + await view.getByRole('button', { name: '提交评审', exact: true }).click() + await reviewDialog.waitFor() + expect(await reviewDialog.getByRole('textbox').inputValue()).toBe('') + expect(await reviewDialog.getByRole('radio', { name: '评论', exact: true }).isChecked()).toBe(true) + await page.keyboard.press('Escape') // GitHub refuses the diff of pull request 9 as too large, so its view offers the web page instead of a retry. scaffold.ctx.forgeSession.track(session, DRAFT) @@ -697,8 +999,300 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an }) +it.skipIf(MODE === 'record')('retains a wrapped code line and tree width across tab and Session switches', async () => { + const { page, scaffold, root, tripwire } = await launchPanel('web-e2e-forge-reading-position') + const paths = ['src/before-0.ts', 'src/before-1.ts', 'src/before-2.ts', 'src/server.ts', 'src/after.ts'] + const patch = paths.map(path => [ + `diff --git a/${path} b/${path}`, 'index 1111111..2222222 100644', `--- a/${path}`, `+++ b/${path}`, + '@@ -1,650 +1,650 @@', '-const oldValue = true;', '+const newValue = true;', + ...Array.from({ length: 649 }, (_, index) => ` const value_${index + 2} = "${'x'.repeat(24 + (index + 2) % 70)}";`), '', + ].join('\n')).join('\n') + await writeFile(join(root, 'pr-7.diff'), patch) + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('Session must stay loaded') + scaffold.ctx.forgeSession.track(session, PULL) + // Keep the file tree and the Session sidebar visible together on every platform. + await page.setViewportSize({ width: 2400, height: 700 }) + await page.locator('[data-forge-header]').click() + await page.locator(`[data-forge-change="${PULL}"]`).getByRole('button', { name: /查看 #7 的变更/ }).click() + const view = page.locator('[data-forge-changes="change"]') + const scroll = view.locator('[data-forge-changes-scroll]') + const file = view.locator('[data-forge-diff-file="src/server.ts"]') + const code = file.locator('[data-review-code][data-new-line="300"]') + const separator = view.getByRole('separator', { name: '调整文件树宽度', exact: true }) + const beforeFile = view.locator('[data-forge-diff-file="src/before-0.ts"]') + // ResizeObserver runs between rendering frames, including when an ancestor becomes hidden. + const layout = () => page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + const reading = () => file.evaluate((element) => { + const viewport = element.closest('[data-forge-changes-scroll]')! + const top = Math.max(viewport.getBoundingClientRect().top, element.firstElementChild!.getBoundingClientRect().bottom) + const line = [...element.querySelectorAll('[data-review-code][data-new-line]')] + .find(row => row.getBoundingClientRect().bottom > top + 1)! + return { path: element.getAttribute('data-forge-diff-file'), line: line.dataset.newLine, + offset: line.getBoundingClientRect().top - top, scrollTop: viewport.scrollTop } + }) + await code.waitFor() + for (const width of [240, 320]) { + if (width === 320) { + for (let step = 0; step < 4; step += 1) await separator.press('ArrowRight') + await beforeFile.getByRole('button', { name: '收起 before-0.ts', exact: true }).click() + } + await layout() + await code.evaluate((element) => { + const viewport = element.closest('[data-forge-changes-scroll]')! + const header = element.closest('[data-forge-diff-file]')!.firstElementChild! + const top = viewport.getBoundingClientRect().top + header.getBoundingClientRect().height + viewport.scrollTop += element.getBoundingClientRect().top - top + 7 + }) + await layout() + const position = await reading() + expect(position.line).toBe('300') + expect(position.offset).toBe(-7) + for (const destination of ['tab', 'session', 'tab', 'session']) { + if (destination === 'tab') await page.getByRole('tab').filter({ hasText: '变更请求' }).click() + else await page.getByRole('button', { name: '新建会话', exact: true }).last().click() + await expect.poll(() => view.isVisible()).toBe(false) + await layout() + expect(await scroll.evaluate(element => element.clientWidth)).toBe(0) + if (destination === 'tab') await page.getByRole('tab').filter({ hasText: '#7 的变更' }).click() + else await page.locator(`[data-row-key="session:${sessionId}"]`).click() + await view.waitFor() + await layout() + expect(await separator.getAttribute('aria-valuenow')).toBe(String(width)) + expect(await reading()).toEqual(position) + expect(await beforeFile.getAttribute('data-expanded')).toBe(width === 240 ? '' : null) + } + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while preserving vertical scrolling and comment editing', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-file-keys') + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + for (const path of ['a.txt', 'b.txt', 'c.txt']) { + await writeFile(join(project, path), Array.from({ length: path === 'c.txt' ? 1 : 80 }, (_, line) => `line ${line + 1}\n`).join('')) + } + const { settled } = await sendPrompt(page, scaffold) + await settled + await page.locator('[data-forge-header]').click() + await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() + await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更:3 个文件/ }).click() + const view = page.locator('[data-forge-changes="local"]') + const scroll = view.getByRole('region', { name: '全部 3 个文件', exact: true }) + await scroll.waitFor() + await page.getByRole('button', { name: '全屏', exact: true }).click() + await page.setViewportSize({ width: 1280, height: 500 }) + const tree = view.getByRole('navigation') + const a = view.locator('[data-forge-diff-file="a.txt"]') + const b = view.locator('[data-forge-diff-file="b.txt"]') + const c = view.locator('[data-forge-diff-file="c.txt"]') + await b.getByRole('button', { name: '收起 b.txt', exact: true }).click() + await scroll.evaluate((element) => { element.scrollTop = 0 }) + await scroll.focus() + await page.keyboard.press('ArrowLeft') + expect(await scroll.evaluate(element => element.scrollTop)).toBe(0) + expect(await scroll.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none') + await page.keyboard.press('ArrowDown') + await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + await scroll.evaluate((element) => { element.scrollTop = 0 }) + await page.keyboard.press('ArrowRight') + await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true') + expect(await b.getAttribute('data-expanded')).toBe('') + expect(await scroll.evaluate(element => document.activeElement === element)).toBe(true) + await page.keyboard.press('ArrowRight') + await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true') + const lastPosition = await scroll.evaluate(element => element.scrollTop) + const lastBox = await c.boundingBox(), viewport = await scroll.boundingBox() + if (lastBox === null || viewport === null) throw new Error('The last file and scroll viewport must be rendered') + expect(lastBox.y).toBeGreaterThan(viewport.y) + expect(lastBox.y).toBeLessThan(viewport.y + viewport.height) + await page.keyboard.press('ArrowRight') + expect(await scroll.evaluate(element => element.scrollTop)).toBe(lastPosition) + await page.keyboard.press('ArrowLeft') + await expect.poll(() => tree.getByRole('button', { name: 'b.txt', exact: true }).getAttribute('aria-current')).toBe('true') + await page.keyboard.press('ArrowLeft') + await expect.poll(() => tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true') + await b.evaluate((element) => { + const viewport = element.closest('[data-forge-changes-scroll]')! + viewport.scrollTop += element.getBoundingClientRect().top - viewport.getBoundingClientRect().top + 100 + }) + await page.keyboard.press('ArrowRight') + await expect.poll(() => tree.getByRole('button', { name: 'c.txt', exact: true }).getAttribute('aria-current')).toBe('true') + await tree.getByRole('button', { name: 'a.txt', exact: true }).click() + await a.getByRole('button', { name: '选择第 R1 行', exact: true }).click() + await a.getByRole('button', { name: '添加评论', exact: true }).click() + const input = a.getByRole('textbox', { name: '留下评论…', exact: true }) + await input.fill('keep editing') + await input.press('ArrowLeft') + expect(await input.evaluate(element => (element as HTMLTextAreaElement).selectionStart)).toBe(11) + await input.press('ArrowRight') + expect(await input.inputValue()).toBe('keep editing') + expect(await tree.getByRole('button', { name: 'a.txt', exact: true }).getAttribute('aria-current')).toBe('true') + expect(await input.evaluate(element => document.activeElement === element)).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('keeps localized status columns separate and long file lists inside one vertical scroller', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-layout') + await page.locator('[data-forge-current-branch]').waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'worktree-option.expected.md'), + await captureStableAria(page, '[data-forge-worktree]', scaffold.workspaceCwd), MODE) + await page.getByRole('button', { name: '收起侧边栏', exact: true }).click() + await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(() => page.locator('[data-forge-worktree]') + .evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(390) + await page.setViewportSize({ width: 1680, height: 1000 }) + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await mkdir(join(project, 'src')) + await Promise.all(Array.from({ length: 60 }, (_, index) => writeFile(join(project, 'src', `file-${index}.ts`), `export const value = ${index}\n`))) + const { settled } = await sendPrompt(page, scaffold) + const session = scaffold.ctx.sessions.get(await settled) + if (session === undefined) throw new Error('The Session must stay loaded') + scaffold.ctx.forgeSession.track(session, PULL) + await page.locator('[data-forge-header]').click() + const panel = page.locator('[data-forge-panel]') + await panel.getByRole('button', { name: '刷新状态', exact: true }).click() + const local = panel.locator('[data-forge-local]') + await local.locator('button[aria-expanded]').click() + await expect.poll(() => local.locator('li').count(), { timeout: 20_000 }).toBe(60) + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 700 }) + const outerScrollers = await panel.evaluate((element) => { + const scrollers: string[] = [] + for (let ancestor = element.parentElement; ancestor !== null; ancestor = ancestor.parentElement) { + if (/auto|scroll/.test(getComputedStyle(ancestor).overflowY) && ancestor.scrollHeight > ancestor.clientHeight + 1) { + scrollers.push(ancestor.className) + } + } + return scrollers + }) + expect(outerScrollers).toEqual([]) + expect(await panel.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true) + await local.locator('li').last().scrollIntoViewIfNeeded() + expect(await local.locator('li').last().isVisible()).toBe(true) + } + await local.locator('button[aria-expanded]').click() + const change = panel.locator(`[data-forge-change="${PULL}"]`) + for (const [locale, labels] of [ + ['zh', ['检查', '评审', '合并', '变更']], + ['en', ['Checks', 'Review', 'Merge', 'Changes']], + ['de', ['Prüfungen', 'Review', 'Zusammenführen', 'Änderungen']], + ] as const) { + await scaffold.ctx.settings.update('locale', { preference: locale }) + await change.getByText(labels[0], { exact: true }).waitFor() + if (locale === 'en') { + await compareOrRefreshGolden(join(EXPECTED, 'local-card.expected.md'), + await captureStableAria(page, '[data-forge-local]', scaffold.workspaceCwd), MODE) + } + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 700 }) + await expect.poll(() => change.evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(width) + await change.getByText(labels[3], { exact: true }).hover() + for (const label of labels) { + const geometry = await change.getByText(label, { exact: true }).evaluate((element) => { + const value = element.nextElementSibling + if (value === null) throw new Error('Status label must have a value') + const range = document.createRange() + range.selectNodeContents(element) + const name = range.getBoundingClientRect() + const content = value.getBoundingClientRect() + const row = element.parentElement! + const icon = element.previousElementSibling! + const rowRect = row.getBoundingClientRect() + return { + gap: content.left - name.right, + iconInset: icon.getBoundingClientRect().left - rowRect.left, + rowInset: rowRect.left - row.closest('[data-forge-change]')!.getBoundingClientRect().left, + labelWidth: element.getBoundingClientRect().width, + valueOffset: content.left - rowRect.left, + right: content.right, + width: document.documentElement.clientWidth, + } + }) + expect(geometry.gap).toBeGreaterThanOrEqual(8) + expect(geometry.iconInset).toBe(8) + expect(geometry.rowInset).toBeGreaterThanOrEqual(6) + expect(geometry.rowInset).toBeLessThanOrEqual(7) + if (locale === 'zh') { + expect(geometry.labelWidth).toBe(32) + expect(geometry.valueOffset).toBe(76) + } + expect(geometry.right).toBeLessThanOrEqual(geometry.width) + } + } + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('keeps long branch details accessible by touch and keyboard across the hero row breakpoints', async () => { + const branch = 'feature/a-long-current-branch-name-for-mobile-review' + const { page, scaffold, tripwire } = await launchPanel('web-e2e-forge-branch-details', 'normal', { branch, hasTouch: true }) + const option = page.locator('[data-forge-worktree]') + const chip = option.locator('[data-forge-current-branch]') + await chip.waitFor() + await page.getByRole('button', { name: '收起侧边栏', exact: true }).click() + for (const [locale, label] of [['zh', `当前分支:${branch}`], ['en', `Current branch: ${branch}`], ['de', `Aktueller Branch: ${branch}`]] as const) { + await scaffold.ctx.settings.update('locale', { preference: locale }) + await option.getByRole('button', { name: label, exact: true }).waitFor() + for (const width of [390, 600, 601, 700, 1280]) { + await page.setViewportSize({ width, height: 844 }) + await chip.scrollIntoViewIfNeeded() + await expect.poll(() => option.evaluate((element) => { + const row = element.closest('[class*="heroWorkspaceRow"]')! + const ancestors: Element[] = [] + for (let node: Element | null = row; node !== null; node = node.parentElement) ancestors.push(node) + return { + fits: element.getBoundingClientRect().right <= innerWidth, + overflow: ancestors.filter(node => node.scrollWidth > node.clientWidth + 1).map(node => node.className), + } + })).toEqual({ fits: true, overflow: [] }) + } + } + await scaffold.ctx.settings.update('locale', { preference: 'en' }) + await option.getByRole('button', { name: `Current branch: ${branch}`, exact: true }).waitFor() + await page.setViewportSize({ width: 390, height: 844 }) + expect(await chip.locator('span').evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) + const tooltip = page.getByRole('tooltip', { exact: true, name: `Current branch: ${branch}` }) + await chip.tap() + await tooltip.waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'branch-details.expected.md'), await tooltip.ariaSnapshot(), MODE) + await chip.tap() + await tooltip.waitFor({ state: 'hidden' }) + await chip.tap() + await tooltip.waitFor() + await page.locator('[class*="heroWorkspaceRow"]').tap({ position: { x: 1, y: 1 } }) + await tooltip.waitFor({ state: 'hidden' }) + await chip.focus() + await page.keyboard.press('Tab') + await page.keyboard.press('Shift+Tab') + await tooltip.waitFor() + await page.keyboard.press('Escape') + await tooltip.waitFor({ state: 'hidden' }) + await page.keyboard.press('Enter') + await tooltip.waitFor() + await page.keyboard.press('Escape') + await tooltip.waitFor({ state: 'hidden' }) + expect(await option.getByRole('checkbox').isChecked()).toBe(false) + expect(await page.getByRole('menu').count()).toBe(0) + await scaffold.ctx.settings.update('locale', { preference: 'zh' }) + await option.getByRole('checkbox', { name: '新建工作树', exact: true }).waitFor() + const { settled } = await sendPrompt(page, scaffold) + await settled + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + it.skipIf(MODE === 'record').each(['darwin', 'win32'] as const)('closes diff menus when the %s desktop shortcut deactivates a retained tab', async (platform) => { - const { page, scaffold, project, tripwire } = await launchPanel(`web-e2e-forge-menu-${platform}`, 'normal', platform) + const { page, scaffold, project, tripwire } = await launchPanel(`web-e2e-forge-menu-${platform}`, 'normal', { desktop: platform }) await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') await writeFile(join(project, 'README.md'), 'review this change\n') @@ -708,7 +1302,7 @@ it.skipIf(MODE === 'record').each(['darwin', 'win32'] as const)('closes diff men await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更:1 个文件/ }).click() const view = page.locator('[data-forge-changes="local"]') - await view.getByRole('button', { name: '复制路径: README.md', exact: true }).waitFor() + await view.locator('[data-forge-diff-file="README.md"]').waitFor() for (const name of ['分支', '全部 1 个文件', 'README.md 的更多操作']) { await view.getByRole('button', { name, exact: true }).click() await page.getByRole('menu').waitFor() @@ -724,3 +1318,59 @@ it.skipIf(MODE === 'record').each(['darwin', 'win32'] as const)('closes diff men } expect(tripwire.warnings).toEqual([]) }) + +it.skipIf(MODE === 'record').each([120, 4000])('types a review comment beside %i changed lines', async (count) => { + const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-review-typing') + const patch = ['diff --git a/src/typing.ts b/src/typing.ts', 'index 1111111..2222222 100644', + '--- a/src/typing.ts', '+++ b/src/typing.ts', `@@ -1,${count} +1,${count} @@`, + ...Array.from({ length: count }, (_, index) => { + const context = index % 80 === 0 ? 'wrapped context '.repeat(600) : index % 5 === 0 ? 'context '.repeat(30) : '' + return `-export const value${index} = [${index}, '${context}']\n+export const value${index} = [${index + 1}, '${context}']` + }), ''].join('\n') + await writeFile(join(root, 'pr-7.diff'), patch) + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('Session must stay loaded') + scaffold.ctx.forgeSession.track(session, PULL) + await page.locator('[data-forge-header]').click() + const change = page.locator(`[data-forge-change="${PULL}"]`) + await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click() + const file = page.locator('[data-forge-diff-file="src/typing.ts"]') + const collapsed = file.getByRole('button', { name: /^文件较大,/ }) + await file.waitFor() + if (count > 120) await collapsed.click() + await file.getByRole('button', { name: '选择第 R5 行', exact: true }).click() + await file.getByRole('button', { name: '添加评论', exact: true }).click() + const input = file.getByRole('textbox', { name: '留下评论…', exact: true }) + await input.waitFor() + const field = await input.boundingBox() + if (field === null) throw new Error('Comment field must be visible') + await page.mouse.move(field.x + field.width - 3, field.y + field.height - 3) + await page.mouse.down() + await page.mouse.move(field.x + field.width - 3, field.y + field.height + 77, { steps: 5 }) + await page.mouse.up() + const resized = await input.boundingBox() + if (resized === null) throw new Error('Resized comment field must remain visible') + expect(resized.height).toBeGreaterThan(field.height + 50) + await input.fill('Review a wrapped line. '.repeat(80)) + expect(await input.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true) + expect(await input.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await input.fill('') + await page.keyboard.type('Check the boundary') + await page.keyboard.press('Enter') + const cdp = await page.context().newCDPSession(page) + try { + await cdp.send('Input.imeSetComposition', { text: '检查', selectionStart: 2, selectionEnd: 2 }) + expect(await input.inputValue()).toBe('Check the boundary\n检查') + expect(await file.locator('[data-forge-review-thread]').count()).toBe(0) + await cdp.send('Input.insertText', { text: '检查边界条件' }) + } finally { await cdp.detach() } + expect(await input.inputValue()).toBe('Check the boundary\n检查边界条件') + await input.fill(' \n') + expect(await file.getByRole('button', { name: '开始评审', exact: true }).isDisabled()).toBe(true) + await input.fill('检查边界条件') + await file.getByRole('button', { name: '开始评审', exact: true }).click() + await file.locator('[data-forge-review-thread="pending"]').getByText('检查边界条件', { exact: true }).waitFor() + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/apps/web/tests/hover-input.e2e.ts b/apps/web/tests/hover-input.e2e.ts index cae5220a89..8ec5679df0 100644 --- a/apps/web/tests/hover-input.e2e.ts +++ b/apps/web/tests/hover-input.e2e.ts @@ -95,18 +95,24 @@ it.each([false, true])('keeps workspace and archived search actions reachable wi expect(await shortcut.count()).toBe(1) expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch) expect(await shortcut.isVisible()).toBe(!hasTouch) - for (const key of ['ArrowDown', 'Tab']) { + if (hasTouch) { + await page.keyboard.press('Escape') + await page.setViewportSize({ width: 390, height: 900 }) + await page.locator('[data-sidebar-drawer]').waitFor() + await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).tap() + await activate(actions) + await archive.waitFor() + } + for (const key of ['ArrowDown', 'Home']) { await page.keyboard.press(key) expect(await page.locator('html').getAttribute('data-input-modality')).toBe('keyboard') expect(await page.getByRole('menu').evaluate(menu => menu.contains(document.activeElement))).toBe(true) expect(await shortcut.isVisible()).toBe(true) if (hasTouch) { - await page.setViewportSize({ width: 390, height: 900 }) await expect.poll(() => shortcut.evaluate((element) => { const bounds = element.getBoundingClientRect() return bounds.left >= 0 && bounds.right <= window.innerWidth })).toBe(true) - await page.setViewportSize({ width: 1280, height: 900 }) } await page.keyboard.press('Escape') if (!hasTouch) await row.hover() @@ -114,6 +120,13 @@ it.each([false, true])('keeps workspace and archived search actions reachable wi await archive.waitFor() expect(await shortcut.isVisible()).toBe(!hasTouch) } + if (hasTouch) { + await page.keyboard.press('Escape') + await page.setViewportSize({ width: 1280, height: 900 }) + await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + await activate(actions) + await archive.waitFor() + } await activate(archive) await row.waitFor({ state: 'detached' }) await activate(page.getByRole('button', { name: 'View options', exact: true })) @@ -224,19 +237,23 @@ it.each([false, true])('keeps actions and focus usable with touch=%s across layo await page.getByRole('tooltip').waitFor({ state: 'detached' }) const blockedSplit = panel.locator('[data-dockkit-split-button]').last() expect(await blockedSplit.isDisabled()).toBe(true) - const hit = await blockedSplit.evaluate((button) => { - const bounds = button.getBoundingClientRect() - const x = bounds.x + bounds.width / 2 - const y = bounds.y + bounds.height / 2 - const target = document.elementFromPoint(x, y) - return { x, y, wrapperReceivesInput: target === button.parentElement, cursor: target && getComputedStyle(target).cursor } - }) - expect(hit.wrapperReceivesInput).toBe(true) - expect(['auto', 'default']).toContain(hit.cursor) - // Coordinate input exercises CSS hit testing over the disabled button itself. - if (hasTouch) await page.touchscreen.tap(hit.x, hit.y) - else await page.mouse.click(hit.x, hit.y) - await page.getByRole('tooltip').filter({ hasText: 'Two panes is the limit' }).waitFor() + if (hasTouch) { + expect(await blockedSplit.isVisible()).toBe(false) + expect(await page.getByRole('tooltip').count()).toBe(0) + } else { + const hit = await blockedSplit.evaluate((button) => { + const bounds = button.getBoundingClientRect() + const x = bounds.x + bounds.width / 2 + const y = bounds.y + bounds.height / 2 + const target = document.elementFromPoint(x, y) + return { x, y, wrapperReceivesInput: target === button.parentElement, cursor: target && getComputedStyle(target).cursor } + }) + expect(hit.wrapperReceivesInput).toBe(true) + expect(['auto', 'default']).toContain(hit.cursor) + // Coordinate input exercises CSS hit testing over the disabled button itself. + await page.mouse.click(hit.x, hit.y) + await page.getByRole('tooltip').filter({ hasText: 'Two panes is the limit' }).waitFor() + } expect(await panel.locator('[data-dockkit-pane]').count()).toBe(2) expect(consoleWatch.pageErrors).toEqual([]) }) diff --git a/apps/web/tests/layout-bottom.e2e.ts b/apps/web/tests/layout-bottom.e2e.ts index ee213925b3..61c7f4f405 100644 --- a/apps/web/tests/layout-bottom.e2e.ts +++ b/apps/web/tests/layout-bottom.e2e.ts @@ -42,7 +42,8 @@ it('reserves bottom content height without remounting it on main-panel navigatio const viewportHeight = page.viewportSize()!.height expect(result.bottom).toEqual({ top: viewportHeight - height, height, bottom: viewportHeight }) expect(result.main).toEqual({ top: caption, height: viewportHeight - caption - height, bottom: viewportHeight - height }) - expect(result.sidebar).toEqual(result.main) + expect(result.sidebar).toEqual(page.viewportSize()!.width < 768 + ? { top: 0, height: viewportHeight, bottom: viewportHeight } : result.main) expect(result.rightbar).toEqual(result.main) expect(result.overlay).toEqual({ top: 0, height: viewportHeight, bottom: viewportHeight }) for (const handle of result.handles) expect(handle).toEqual(result.main) diff --git a/apps/web/tests/markdown-file-links.e2e.ts b/apps/web/tests/markdown-file-links.e2e.ts index 62b83dfafb..b3beacb3b9 100644 --- a/apps/web/tests/markdown-file-links.e2e.ts +++ b/apps/web/tests/markdown-file-links.e2e.ts @@ -156,6 +156,56 @@ describe('web e2e: Markdown file links', () => { await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'links.expected.md', 'preview.expected.md']) }) + it('replays colon line suffixes as locations and shares tabs with fragment links', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-colon-file-links')) + const fixture = new URL('./fixtures/markdown-colon-file-links/session.v3.jsonl', import.meta.url) + const id = await seedSession(scaffold, await readFile(fixture, 'utf8'), 'colon-file-links') + await page.reload({ waitUntil: 'load' }) + const workspace = page.getByRole('treeitem').first() + await workspace.waitFor() + if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click() + await page.locator(`[data-row-key="session:${id}"]`).click() + + const relative = page.getByRole('button', { name: 'Relative colon:24–30', exact: true }) + await relative.waitFor() + expect(await page.getByRole('button', { name: 'Development server', exact: true }).count()).toBe(0) + expect(await page.getByRole('link', { name: 'Development server', exact: true }).getAttribute('href')).toBe('localhost:3000') + expect(await page.getByRole('link', { name: 'Bare URL', exact: true }).getAttribute('href')).toBe('index.ts:24') + expect(await page.getByRole('link', { name: 'Blocked script', exact: true }).count()).toBe(0) + expect(await page.getByRole('button', { name: 'Blocked script', exact: true }).count()).toBe(0) + expect(await page.getByRole('link', { name: 'Mail', exact: true }).getAttribute('href')).toBe('mailto:10086') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/markdown-colon-file-links.md', import.meta.url)), + await relative.locator('..').ariaSnapshot(), MODE, + ) + const column = page.locator('[data-rightbar-col]') + for (const label of ['Zero line', 'Reversed range']) { + await page.getByRole('button', { name: label, exact: true }).click() + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) + .toBe(join(scaffold.workspaceCwd, 'src/example.txt')) + await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('source line 1\n') + expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt']) + } + for (const [label, line] of [['Relative colon:24–30', 24], ['Absolute colon:30', 30], ['Column:24:5', 24], ['Fragment:12', 12]] as const) { + const link = page.getByRole('button', { name: label, exact: true }) + await link.focus() + await link.press('Enter') + await expect.poll(() => column.locator(`[data-textpreview-target="${line}"]`).textContent()) + .toBe(`source line ${line}\n`) + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) + .toBe(join(scaffold.workspaceCwd, 'src/example.txt')) + expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt']) + } + await page.reload({ waitUntil: 'load' }) + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) + .toBe(join(scaffold.workspaceCwd, 'src/example.txt')) + await relative.click() + await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('source line 24\n') + expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + it('opens parent-path prose and read-card links while retaining each address across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-file-links')) // Return through the parent into this owned temp root; no shared parent file is created. diff --git a/apps/web/tests/markdown-image-refresh.e2e.ts b/apps/web/tests/markdown-image-refresh.e2e.ts index bade81cd1e..2a9dc421a3 100644 --- a/apps/web/tests/markdown-image-refresh.e2e.ts +++ b/apps/web/tests/markdown-image-refresh.e2e.ts @@ -1,9 +1,10 @@ // A rewritten local image file shows its current bytes in a newly rendered Markdown image. -import { writeFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-title' @@ -50,7 +51,11 @@ describe('web e2e: rewritten Markdown image files', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({}) + scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([ + { source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }, + ]), + }) await writeFile(join(scaffold.workspaceCwd, 'chart.svg'), chart(120, 'red')) await seedSession(scaffold, chartFixture('First chart'), 'markdown-image-refresh-first', undefined, { createdAt: WEB_FIXTURE_TIME - 120_000 }) @@ -81,6 +86,14 @@ describe('web e2e: rewritten Markdown image files', () => { await page.getByRole('treeitem').nth(1).click() await page.getByText('Second chart', { exact: true }).last().waitFor() await expect.poll(width, { timeout: 10_000 }).toBe(240) + await page.getByRole('img', { name: ALT }).click({ button: 'right' }) + const action = page.getByRole('menuitem', { name: 'Download', exact: true }) + await action.waitFor() + const [download] = await Promise.all([page.waitForEvent('download'), action.click()]) + expect(download.suggestedFilename()).toBe('chart.svg') + const path = await download.path() + if (path === null) throw new Error('Browser download unavailable') + expect(await readFile(path, 'utf8')).toBe(chart(240, 'blue')) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index bdd7c5da6f..eba073c473 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -216,8 +216,8 @@ async function awaitTableLayout(target: Page): Promise { if (element === null) return false const tracks = getComputedStyle(element).gridTemplateColumns.split(' ').map(Number.parseFloat) const root = element.querySelector('div[data-phase]') - // Mirrored from ui-layout's SIDEBAR_COLLAPSED; these tests use the Host compiler face. - return tracks[0] === 56 && tracks.at(-1) === 0 + // Desktop retains the rail; the mobile drawer leaves the whole width to the transcript. + return tracks[0] === (element.hasAttribute('data-sidebar-drawer') ? 0 : 56) && tracks.at(-1) === 0 && element.getAnimations().every(animation => animation.playState === 'finished' || animation.playState === 'idle') && root !== null diff --git a/apps/web/tests/menu-material.e2e.ts b/apps/web/tests/menu-material.e2e.ts index 9f96ba008a..4f65a889a8 100644 --- a/apps/web/tests/menu-material.e2e.ts +++ b/apps/web/tests/menu-material.e2e.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { chromium, type Locator } from 'playwright' +import sharp from 'sharp' import { expect, it, onTestFinished } from 'vitest' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, webSnapshotMode } from './scaffold.ts' @@ -48,13 +49,13 @@ it('shares menu transparency and blur across palettes and follows native menu bo }) expect(overlayFill).toBe(platform === 'darwin' ? dark ? 'rgba(48, 49, 54, 0.94)' : 'rgba(248, 249, 250, 0.94)' - : dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)') + : dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)') await account.click() const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Settings', exact: true }) }) await menu.waitFor() const appearance = await material(menu) expect(appearance).toMatchObject({ - fill: dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)', + fill: dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)', blur: 'blur(40px) saturate(1.5)', }) expect(appearance.shadow).toContain(`${dark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.04)'} 0px 0px 0px 0.5px`) @@ -109,4 +110,57 @@ it('shares menu transparency and blur across palettes and follows native menu bo await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode()) await page.keyboard.press('Escape') await expect.poll(() => page.locator('[data-menu-backing]').count()).toBe(0) + + await action.locator('xpath=ancestor::*[@role="treeitem"][1]').click({ button: 'right' }) + await sessionMenu.waitFor() + const popup = page.locator('[data-context-menu]') + const stripes = await page.evaluateHandle(() => { + const element = document.createElement('div') + element.style.cssText = 'position:fixed;inset:0;z-index:1099;pointer-events:none;background:repeating-linear-gradient(90deg,#000 0 4px,#fff 4px 8px)' + document.body.appendChild(element) + return element + }) + try { + // Hide text while preserving layout so pixel contrast measures only the material. + await popup.evaluate((node) => { + for (const child of node.children) { + if (child instanceof HTMLElement && child.getAttribute('aria-hidden') !== 'true') child.style.visibility = 'hidden' + } + }) + const box = (await popup.boundingBox())! + const clip = { x: Math.floor(box.x + box.width / 2 - 32), y: Math.floor(box.y + box.height / 2 - 16), width: 64, height: 32 } + for (const phase of ['enter', 'dismiss', 'replace']) { + await popup.evaluate((node, phase) => { + node.removeAttribute('data-menu-phase') + void (node as HTMLElement).offsetWidth + node.setAttribute('data-menu-phase', phase) + for (const animation of node.getAnimations({ subtree: true })) { + animation.pause() + animation.currentTime = Number(animation.effect!.getComputedTiming().duration) / 2 + } + }, phase) + const stats = await sharp(await page.screenshot({ clip })).stats() + // During a fade the unpainted fraction exposes stripes; a working blur still + // removes substantially more contrast than opacity without backdrop sampling. + expect(stats.channels[0]!.stdev, phase).toBeLessThan(90) + } + await popup.evaluate((node) => { + node.setAttribute('data-menu-phase', 'enter') + for (const animation of node.getAnimations({ subtree: true })) animation.finish() + }) + const settled = await sharp(await page.screenshot({ clip })).stats() + expect(settled.channels[0]!.stdev).toBeLessThan(3) + await popup.evaluate((node) => { + node.style.opacity = '0.999' + // The negative control measures backdrop isolation independently of the theme's fill alpha. + const material = node.querySelector(':scope > [aria-hidden="true"]')! + material.style.backgroundColor = 'transparent' + }) + const isolated = await sharp(await page.screenshot({ clip })).stats() + expect(isolated.channels[0]!.stdev).toBeGreaterThan(90) + } finally { + await stripes.evaluate((node) => { node.remove() }) + await stripes.dispose() + await page.keyboard.press('Escape') + } }) diff --git a/apps/web/tests/model-input-layout.ts b/apps/web/tests/model-input-layout.ts index e911bb3812..5cdacbfabd 100644 --- a/apps/web/tests/model-input-layout.ts +++ b/apps/web/tests/model-input-layout.ts @@ -27,5 +27,11 @@ export async function assertModelInputLayout(page: Page, editor: Locator): Promi } } finally { await page.setViewportSize(original) + // The frame reports its own width to the layout store from a rAF-throttled + // ResizeObserver, so the restored viewport is only in force once the shell + // has left drawer mode; a capture taken in between reads the narrow frame. + if (await page.locator('[data-sidebar-drawer]').count() > 0) { + await expect.poll(async () => page.locator('[data-sidebar-drawer]').count(), { timeout: 5_000 }).toBe(0) + } } } diff --git a/apps/web/tests/plan-control-row.e2e.ts b/apps/web/tests/plan-control-row.e2e.ts index c32960a14e..5d8f5b39ae 100644 --- a/apps/web/tests/plan-control-row.e2e.ts +++ b/apps/web/tests/plan-control-row.e2e.ts @@ -62,7 +62,17 @@ async function controlLayout(page: Page) { /** Wait for the responsive column and control row to finish changing width. */ async function resizeControls(page: Page, width: number): Promise { + const previousWidth = page.viewportSize()!.width await page.setViewportSize({ width, height: VIEWPORT.height }) + if (width >= 768 && width < 1024) { + const frame = page.locator('[data-sidebar-collapsed][data-rightbar-collapsed]') + if (previousWidth >= 1024) await frame.waitFor() + const open = page.getByRole('button', { name: 'Open sidebar', exact: true }) + if (await open.isVisible()) { + await open.click() + await expect.poll(() => frame.count()).toBe(0) + } + } await page.locator('[data-composer-card]').evaluate(async (card) => { let previous = card.getBoundingClientRect().width let stable = 0 @@ -201,7 +211,7 @@ describe('web e2e: plan chip click area at the narrow viewport', () => { // Find the content-dependent transition on this platform's fonts, never a // committed pixel threshold. The old 360px rule cannot satisfy this case. let narrowViewport: number | undefined - for (let width = 1100; width >= 650; width -= 10) { + for (let width = 1100; width >= 770; width -= 10) { await resizeControls(page, width) const layout = await controlLayout(page) if (layout.width > 360 && layout.icon && !layout.text) { @@ -267,7 +277,7 @@ describe('web e2e: plan chip click area at the narrow viewport', () => { await selectModel(page, LONG_MODEL) await expectControlLayout(page, false) - await resizeControls(page, 440) + await resizeControls(page, 384) await expectControlLayout(page, true) const trigger = page.getByRole('button', { name: /Select model/ }) expect(await trigger.getAttribute('title')).toContain(LONG_MODEL) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 39bd1ce0a5..8e78f856eb 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -110,9 +110,15 @@ describe('web e2e: plugin configuration pages', () => { await more.focus() await page.keyboard.press('Enter') await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() + // The More list omits the shipped bundles whose own rows do not run (the visibility rule lives in + // ui-plugin-manager), so it holds fewer cards than the installed optional-bundle list. const bundles = await scaffold.ctx.pluginManager.listBundles() + const hiddenBundles = new Set([ + '@deepseek-ai/dsh-experimental-terminal-bundle', + '@deepseek-ai/dsh-experimental-liveshare', + ]) expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) - .toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle' + .toBe(bundles.filter(bundle => !hiddenBundles.has(bundle.name) && bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length) expect(await basic.count()).toBe(0) expect(await extensions.count()).toBe(0) diff --git a/apps/web/tests/plugin-install-github.e2e.ts b/apps/web/tests/plugin-install-github.e2e.ts index a11f2b942b..7fc4ece9fd 100644 --- a/apps/web/tests/plugin-install-github.e2e.ts +++ b/apps/web/tests/plugin-install-github.e2e.ts @@ -1,6 +1,8 @@ // Real Git reaches a controlled proxy failure or stall; the browser // offers a mirror for a replacement spec, or another way once the install -// already asks the mirror, without retrying the failed address. +// already asks the mirror, without retrying the failed address. Without a +// configured fallback registry the same failure keeps the plain failed +// screen, whose details name the check's own Git run and complete log. import { once } from 'node:events' import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' import { createServer, type Socket } from 'node:net' @@ -121,3 +123,66 @@ it.each(['network', 'timeout'] as const)('offers a mirror after a GitHub %s, the expect(connections).toBeGreaterThan(checked) expect(tripwire.pageErrors).toEqual([]) }) + +it("shows the failed check's Git run and complete log in the installation details when no mirror is offered", async () => { + const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-github-details-')) + onTestFinished(() => rm(scratch, { recursive: true, force: true })) + const sockets = new Set() + const proxy = createServer((socket) => { + sockets.add(socket) + socket.on('error', () => {}) + socket.once('close', () => { sockets.delete(socket) }) + socket.once('data', () => socket.destroy()) + }) + onTestFinished(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve, reject) => { proxy.close((error) => { if (error) reject(error); else resolve() }) }) + }) + proxy.listen(0, '127.0.0.1') + await once(proxy, 'listening') + const address = proxy.address() + if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port') + const gitConfig = join(scratch, 'git.config') + await writeFile(gitConfig, `[http "https://github.com/"]\n proxy = http://127.0.0.1:${String(address.port)}\n`) + // Without a configured fallback registry the Host has no mirror to offer, so the failure keeps the + // plain failed screen whose details name the check's own run. + const overlay = join(scratch, 'cordis.patch.yml') + await writeFile(overlay, '- id: ui-plugin-manager\n config: { registryProbeEnabled: false }\n- id: plugin-manager\n config: { fallbackRegistries: [] }\n') + const scaffold = await launchWebScaffold({ + profile: { packages: [], packageManager: { command: process.execPath, args: [], env: { GIT_CONFIG_GLOBAL: gitConfig, GIT_CONFIG_NOSYSTEM: '1' } } }, + extraOverlayPath: overlay, + }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const profile = join(scaffold.harnessHome, 'profiles', 'scaffold') + const attempts = join(profile, '.attempts') + await writeFile(join(profile, 'config'), 'console.log("https://registry.npmjs.org/")\n') + await writeFile(attempts, '') + await writeFile(join(profile, 'add'), ` + const fs = require('node:fs'); + fs.appendFileSync('.attempts', JSON.stringify(process.argv) + '\\n'); + process.exitCode = 1; + `) + const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: ZH_BROWSER_LOCALE }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.waitForSelector('[class*="frame"]') + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + await page.getByRole('button', { name: '添加插件', exact: true }).click() + const spec = 'https://github.com/example/dsh-plugin.git' + let dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) + await dialog.getByRole('textbox', { name: '包名或地址' }).fill(spec) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + dialog = page.getByRole('dialog', { name: '插件安装失败', exact: true }) + await dialog.waitFor() + expect(await dialog.getByText('无法连接 github.com', { exact: false }).count()).toBe(1) + // The details carry the failed check's own Git run and the complete log, not an empty terminal. + await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() + await dialog.getByText(/git -c credential\.helper= ls-remote -- https:\/\/github\.com\/example\/dsh-plugin\.git HEAD/).waitFor() + await dialog.getByText(/fatal: unable to access/).waitFor() + expect(await dialog.getByText(/^完整日志:.*github-connection-.*git\.log$/).count()).toBe(1) + expect(await readFile(attempts, 'utf8')).toBe('') + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/apps/web/tests/plugin-install-registry.e2e.ts b/apps/web/tests/plugin-install-registry.e2e.ts index 5a29cceada..70d8f47520 100644 --- a/apps/web/tests/plugin-install-registry.e2e.ts +++ b/apps/web/tests/plugin-install-registry.e2e.ts @@ -124,6 +124,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding const installed = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) .split(process.execPath).join('{{node}}') .split(scaffold.harnessHome).join('{{harnessHome}}') + .replace(/operation-[A-Za-z0-9]+/g, 'operation-{{suffix}}') await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/installed.expected.md', import.meta.url)), installed, webSnapshotMode()) // The dialog opened again starts from the registry picked for the last install. await dialog.getByRole('button', { name: '立即启用', exact: true }).click() diff --git a/apps/web/tests/plugin-install-silent-failure.e2e.ts b/apps/web/tests/plugin-install-silent-failure.e2e.ts new file mode 100644 index 0000000000..4a487c24fe --- /dev/null +++ b/apps/web/tests/plugin-install-silent-failure.e2e.ts @@ -0,0 +1,59 @@ +// A real Host/Remote/browser composition with a controllable package-manager process: +// pnpm fails without printing anything, and the dialog still shows what failed and where +// the complete log lives instead of a generic sentence over an empty details panel. +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' + +it('reports a failed install whose pnpm printed nothing with its command line, output, and log file', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-silent-')) + const overlay = join(scratch, 'cordis.patch.yml') + await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`) + let scaffold: WebScaffold | undefined + try { + scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay }) + const browser = await chromium.launch() + try { + const profile = join(scaffold.harnessHome, 'profiles', 'scaffold') + // Node stands in for pnpm: `view` answers the check, and `add` exits unsuccessfully without printing + // anything, the way a silent or early-crashing package manager leaves a failed run with no terminal output. + await writeFile(join(profile, 'view'), 'console.log(JSON.stringify({ name: "silent-package", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }))\n') + await writeFile(join(profile, 'add'), 'process.exitCode = 1\n') + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.waitForSelector('[class*="frame"]') + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + await page.getByRole('button', { name: '添加插件', exact: true }).click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('textbox').fill('silent-package') + await dialog.getByRole('button', { name: '安装', exact: true }).click() + const failure = page.getByRole('dialog', { name: '插件安装失败', exact: true }) + await failure.waitFor({ timeout: 20_000 }) + // The sentence names the failed stage; the details carry the Host's own record of the run. + await failure.getByText('安装命令失败,原因见安装详情', { exact: true }).waitFor() + await failure.getByRole('button', { name: '查看安装详情', exact: true }).click() + await failure.getByText('pnpm add silent-package', { exact: true }).waitFor() + await failure.getByText(/Command failed with exit code 1:/).waitFor() + expect(await failure.getByText(/^完整日志:.*pnpm\.log$/).count()).toBe(1) + expect(await readFile(join(profile, 'package.json'), 'utf8')).not.toContain('silent-package') + const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + .split(process.execPath).join('{{node}}') + .split(scaffold.harnessHome).join('{{harnessHome}}') + .replace(/operation-[A-Za-z0-9]+/g, 'operation-{{suffix}}') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/plugin-install-silent-failure/failed.expected.md', import.meta.url)), snapshot, webSnapshotMode(), + ) + expect(tripwire.pageErrors).toEqual([]) + } finally { await browser.close() } + } finally { + await scaffold?.close() + await rm(scratch, { recursive: true, force: true }) + } +}) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index d968ac13e0..7963b22779 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -2,7 +2,7 @@ // managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero // model calls: everything is client state, seeded Session state, profile files, and the settings // document, so there is no fixture and a stray stream would fail loud on the open llm seam. -import { mkdtemp, readFile } from 'node:fs/promises' +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' @@ -268,8 +268,8 @@ describe('web e2e: plugin manager', () => { await bar.getByRole('tablist').waitFor() await add.focus() for (const [width, icons, narrowTabs, wraps] of [ - [1280, false, false, false], [600, true, false, false], - [470, true, true, false], [390, true, true, true], [300, true, true, true], + [1280, false, false, false], [544, true, false, false], + [390, true, true, false], [320, true, true, true], [244, true, true, true], ] as const) { await probe.setViewportSize({ width, height: 480 }) await body.evaluate((element) => { element.scrollTop = 180 }) @@ -840,16 +840,26 @@ describe('web e2e: plugin manager', () => { expect(await panel.getByRole('switch', { name: '启用 自定义插件索引', exact: true }).getAttribute('aria-checked')).toBe('false') expect(await panel.getByRole('switch', { name: '启用 开发者工具', exact: true }).count()).toBe(0) expect(await panel.getByText(/Cordis|Chrome DevTools/).count()).toBe(0) + // A bundle the installation ships but the profile runs no row of stays out of both lists: the + // LiveShare card appears only once its own plugins run (covered by the visibility scenario below). + expect(await panel.locator('[data-plugin-group="extensions"] [data-plugin-package="@deepseek-ai/dsh-experimental-liveshare"]').count()).toBe(0) await panel.getByRole('button', { name: '更多', exact: true }).click() await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() expect(await panel.getByText(experimentalBundleDescription('inspector-profile', 'zh'), { exact: true }).count()).toBe(1) const bundles = await scaffold.ctx.pluginManager.listBundles() expect(bundles.some(bundle => bundle.name === '@deepseek-ai/dsh-experimental-terminal-bundle')).toBe(true) expect(await panel.getByRole('switch', { name: '启用 持久终端', exact: true }).count()).toBe(0) + // The More list also omits LiveShare until its rows run, so the offered count is one lower than + // the eligible installed optional-bundle list. + expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-experimental-liveshare"]').count()).toBe(0) expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) .toBe(bundles.filter(bundle => bundle.name !== '@deepseek-ai/dsh-experimental-terminal-bundle' + && bundle.name !== '@deepseek-ai/dsh-experimental-liveshare' && bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length) - for (const name of ['@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) { + expect(bundles.find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) + .toMatchObject({ enabled: false, optional: false, availability: 'missing', listedByDefault: false }) + expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0) + for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) { expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)) .toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false }) expect(await panel.locator(`[data-plugin-package="${name}"]`).count()).toBe(0) @@ -1433,6 +1443,76 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('reveals LiveShare only while its plugin rows run, and hides it again when they stop', async () => { + // A dedicated scaffold keeps this profile patch away from the shared fixture profile above. + const visibilityScaffold = await launchWebScaffold({ profile: { packages: [] } }) + const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + try { + const probe = await context.newPage() + onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-liveshare-visibility')) + const consoleWatch = watchConsole(probe) + await probe.goto(visibilityScaffold.authenticatedUrl, { waitUntil: 'load' }) + await probe.getByRole('button', { name: '插件', exact: true }).click() + const panel = probe.locator('[data-plugin-panel]') + await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 20_000 }) + const liveShare = '@deepseek-ai/dsh-experimental-liveshare' + const card = panel.locator(`[data-plugin-package="${liveShare}"]`) + // The profile is a managed one (the plugin manager lists its bundles) whose base bundle keeps HMR + // on, so writing the patch file recomposes the running loader; each step first waits for that + // recomposition and then re-reads the Host bundle list through the page's own Refresh. + const patchFile = join(visibilityScaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') + const declarationsRow = (): boolean => [...visibilityScaffold.ctx.loader.entries()] + .some(entry => entry.options.id === 'liveshare-viewer-declarations') + const refresh = panel.getByRole('button', { name: '刷新', exact: true }) + + // Default install: the package ships, but no row it declares runs, so neither list offers it. + await expect.poll(() => card.count(), { + timeout: 20_000, message: 'the default profile lists a LiveShare card before its rows run', + }).toBe(0) + await panel.getByRole('button', { name: '更多', exact: true }).click() + await probe.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() + await expect.poll(() => card.count(), { + timeout: 20_000, message: 'the default More list offers a LiveShare card before its rows run', + }).toBe(0) + await panel.getByRole('button', { name: '返回插件', exact: true }).click() + + // Enabling through cordis.patch.yml runs one of the bundle's declared rows while the profile still + // selects no such bundle: the card joins the experimental list. + await writeFile(patchFile, [ + '- insert:', + ' - id: liveshare-viewer-declarations', + " name: '@deepseek-ai/dsh-experimental-liveshare/viewer-declarations'", + '', + ].join('\n')) + await expect.poll(declarationsRow, { + timeout: 30_000, message: 'the profile patch did not compose the LiveShare declarations row', + }).toBe(true) + await refresh.click() + await expect.poll(() => card.count(), { + timeout: 30_000, message: 'the patch-enabled bundle did not join the experimental list', + }).toBe(1) + + // Removing the row stops it: the card leaves both lists again. + await writeFile(patchFile, '[]\n') + await expect.poll(declarationsRow, { + timeout: 30_000, message: 'the profile patch did not remove the LiveShare declarations row', + }).toBe(false) + await refresh.click() + await expect.poll(() => card.count(), { + timeout: 30_000, message: 'the card stayed listed after its rows stopped', + }).toBe(0) + await panel.getByRole('button', { name: '更多', exact: true }).click() + await probe.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() + await expect.poll(() => card.count(), { + timeout: 20_000, message: 'the More list kept the card after its rows stopped', + }).toBe(0) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await context.close() + await visibilityScaffold.close() + } + }, 120_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ diff --git a/apps/web/tests/plugin-sticky-motion.e2e.ts b/apps/web/tests/plugin-sticky-motion.e2e.ts index ada88dbe66..86fecc4656 100644 --- a/apps/web/tests/plugin-sticky-motion.e2e.ts +++ b/apps/web/tests/plugin-sticky-motion.e2e.ts @@ -83,8 +83,8 @@ it('preserves both header layouts and follows the scroll position without changi await page.locator('[data-plugin-bar][data-natural]').waitFor() await page.evaluate(() => document.fonts.ready) // Without tabs, whether the actions fit beside the title at 470px depends on the platform font, - // so that width is pinned only with tabs. - for (const width of hasTabs ? [1280, 600, 470, 390] : [1280, 600, 390]) { + // so that width is pinned only with tabs. At 320px the tabs and actions wrap onto separate rows. + for (const width of hasTabs ? [1280, 600, 470, 390, 320] : [1280, 600, 390]) { await page.setViewportSize({ width, height: 480 }) await scrollTo(page, 0) const resting = await reference(page, false) @@ -95,7 +95,7 @@ it('preserves both header layouts and follows the scroll position without changi expect(initial.tabs).toEqual(resting.tabs) if (hasTabs && width <= 600) { const tabs = initial.tabs! - if (width === 390) { + if (width === 320) { expect(initial.toolbar.y).toBeGreaterThanOrEqual(tabs.y + tabs.height) } else { expect(initial.toolbar.x).toBeGreaterThanOrEqual(tabs.x + tabs.width) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index daa7944c36..3cf7695139 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -672,7 +672,11 @@ async function toggleOptionalBundles(page: Page): Promise { inactive: string[] restored: boolean }> = [] - for (const entry of bundles.filter(candidate => candidate.optional)) { + // The packed worker composition cannot mount the Liveshare declaration row and its share host in + // the same patch batch without racing the declaration service; the ordinary Web scaffold covers + // that bundle's enable/disable path (plugin-manager.e2e.ts), so the packed toggle list skips it. + for (const entry of bundles.filter(candidate => candidate.optional + && candidate.name !== '@deepseek-ai/dsh-experimental-liveshare')) { const enable = await remote<{ application: string; error?: unknown }>('pluginManager/setBundleEnabled', { name: entry.name, enabled: true }) const plugins = await remote('pluginManager/listPlugins', {}) const disable = await remote<{ application: string }>('pluginManager/setBundleEnabled', { name: entry.name, enabled: false }) @@ -690,6 +694,7 @@ async function toggleOptionalBundles(page: Page): Promise { }) } return { + cua: bundles.filter(entry => entry.name === '@deepseek-ai/dsh-cua-bundle'), optional: bundles.filter(entry => entry.optional).map(entry => ({ name: entry.name, enabled: entry.enabled, availability: entry.availability, error: entry.error ?? null, title: typeof entry.meta?.title, icon: typeof entry.meta?.icon, metaError: entry.meta?.error ?? null, @@ -701,18 +706,16 @@ async function toggleOptionalBundles(page: Page): Promise { }) expect(result.optional.map(entry => entry.name).sort()).toEqual([...OPTIONAL_BUNDLES].sort()) expect(result.optional.filter(entry => entry.enabled || entry.availability !== 'installation')).toEqual([]) - expect(result.optional.filter(entry => entry.error !== null)).toEqual([ - expect.objectContaining({ name: '@deepseek-ai/dsh-cua-bundle', error: { code: 'unsupported-platform' } }), - ]) + expect(result.optional.filter(entry => entry.error !== null)).toEqual([]) + expect(result.cua).toHaveLength(1) + expect(result.cua[0]).toMatchObject({ name: '@deepseek-ai/dsh-cua-bundle', optional: false, + availability: 'missing', error: { code: 'unsupported-platform' } }) // Metadata resolves through the Worker's Node internal modules: every optional bundle shows its title and icon. expect(result.optional.filter(entry => entry.metaError !== null || entry.title !== 'object' || entry.icon !== 'string')).toEqual([]) expect(result.inventoryErrors).toEqual([]) console.info(`preview boot: optional bundle toggles ${JSON.stringify(result.toggles.map(({ name, enable, disable, error }) => ({ name, enable, disable, error })))}`) - expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toMatchObject({ - enable: 'failed', disable: 'applied', error: { code: 'unsupported-platform' }, - changed: 0, inactive: [], restored: true, - }) - const supported = result.toggles.filter(toggle => toggle.name !== '@deepseek-ai/dsh-cua-bundle') + expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toBeUndefined() + const supported = result.toggles expect(supported.filter(toggle => toggle.enable !== 'applied' || toggle.disable !== 'applied') .map(({ name, enable, disable }) => ({ name, enable, disable }))).toEqual([]) // Each bundle changes Loader rows, every enabled row it changes runs, and switching it off restores the prior rows. diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index fb45d6c76a..b452ce6c5f 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -868,6 +868,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { await page.getByRole('menuitem', { name: 'Delete task', exact: true }).click() const confirmation = page.getByRole('dialog', { name: 'Delete this task?' }) await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() + expect(await moreActions.evaluate(button => button === document.activeElement)).toBe(true) expect((await scaffold.ctx.schedule.catalog()).some(task => task.id === other.id)).toBe(true) await moreActions.click() await page.getByRole('menuitem', { name: 'Delete task', exact: true }).click() diff --git a/apps/web/tests/schedule-header.e2e.ts b/apps/web/tests/schedule-header.e2e.ts index fc79fd571c..631fa3e464 100644 --- a/apps/web/tests/schedule-header.e2e.ts +++ b/apps/web/tests/schedule-header.e2e.ts @@ -58,10 +58,8 @@ it('keeps the automation title intact before moving New to another line', async facts.push(`${language} ${width}px: title and New single-line, New beside the title; contained; New retains focus`) } if (language === 'en') { - // 509px collapses the left sidebar to its rail; expanding it leaves the - // page column narrow enough (measured 172px) to force the wrap. - await page.setViewportSize({ width: 509, height: 1000 }) - await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click() + // Constrain this page independently of the shell's drawer breakpoint. + await header.evaluate((element) => { element.style.width = '220px' }) await expect.poll(() => header.evaluate((element) => { const title = element.querySelector('h1')! const button = element.querySelector('button')! @@ -76,7 +74,7 @@ it('keeps the automation title intact before moving New to another line', async : buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const, } })).toEqual({ titleSingleLine: true, placement: 'below' }) - facts.push('en 509px with sidebar expanded: title single-line; New moves below the title') + facts.push('en 220px page column: title single-line; New moves below the title') } expect(consoleWatch.pageErrors).toEqual([]) } finally { diff --git a/apps/web/tests/schedule-phone.e2e.ts b/apps/web/tests/schedule-phone.e2e.ts index b28049ba3d..e82104196a 100644 --- a/apps/web/tests/schedule-phone.e2e.ts +++ b/apps/web/tests/schedule-phone.e2e.ts @@ -7,6 +7,7 @@ import { expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-schedule' import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { revealNavigationDrawer } from './support.ts' const PHONE_TASK_SESSION = SessionId('schedule-phone-web-e2e') const PHONE_TASK_TITLE = 'Review overdue deployment' @@ -25,6 +26,7 @@ interface ViewFacts { /** Open the Automation tasks page and return its panel. */ async function openTaskManager(page: Page, url: string): Promise { await page.goto(url, { waitUntil: 'load' }) + await revealNavigationDrawer(page, 'zh') await page.getByRole('button', { name: '自动化任务', exact: true }).click() const panel = page.getByTestId('task-manager-page') await panel.getByRole('heading', { name: '自动化任务', exact: true }).waitFor({ timeout: 30_000 }) diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts index ecf6a75e16..601221e96d 100644 --- a/apps/web/tests/shortcuts-panels.e2e.ts +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -260,12 +260,12 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await menuTab.click({ button: 'right' }) await page.getByRole('menu').waitFor() expect(await page.getByRole('tooltip').count()).toBe(0) - expect(await page.getByRole('menuitem').getAttribute('aria-keyshortcuts')).toBeNull() + expect(await page.getByRole('menuitem', { name: 'Close', exact: true }).getAttribute('aria-keyshortcuts')).toBe(aria) if (attempt === 0) { await compareOrRefreshGolden(join(expected, 'tab-menu.expected.md'), - await captureStableAria(page, '[data-dockkit-tab-menu]', scaffold.workspaceCwd), mode) + await captureStableAria(page, '[data-context-menu][role="menu"]', scaffold.workspaceCwd), mode) } else { - await page.getByRole('menuitem').focus() + await page.getByRole('menuitem', { name: 'Close', exact: true }).focus() } await page.keyboard.press('Escape') await page.getByRole('menu').waitFor({ state: 'detached' }) diff --git a/apps/web/tests/sidebar-alignment.e2e.ts b/apps/web/tests/sidebar-alignment.e2e.ts index 6ea3f3dedb..82afdd29f2 100644 --- a/apps/web/tests/sidebar-alignment.e2e.ts +++ b/apps/web/tests/sidebar-alignment.e2e.ts @@ -553,6 +553,11 @@ describe('web e2e: sidebar search alignment and row action targets', () => { expect(tooltipBox.x + tooltipBox.width).toBe(anchorBox.x + anchorBox.width) await page.mouse.move(1000, 400) await tooltip.waitFor({ state: 'detached' }) + if (kind === 'session') { + expect(await menuButton.evaluate(element => element === document.activeElement)).toBe(true) + expect(await tooltipAnchor.isVisible()).toBe(true) + } + await page.mouse.click(1000, 400) expect(await tooltipAnchor.isVisible()).toBe(false) expect(await page.getByRole('tooltip').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/sidebar-drawer.e2e.ts b/apps/web/tests/sidebar-drawer.e2e.ts new file mode 100644 index 0000000000..99b5c3fd43 --- /dev/null +++ b/apps/web/tests/sidebar-drawer.e2e.ts @@ -0,0 +1,737 @@ +/** Narrow sidebar geometry, navigation, focus and retained content in the shipped profile. */ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Locator } from 'playwright' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' + +/** A returning rail uses its compact layout without wide-content or icon-entry animations. */ +async function assertCompactRail(sidebar: Locator): Promise { + expect(await sidebar.getByRole('button', { name: 'New session', exact: true }).count()).toBe(1) + expect(await sidebar.evaluate((el) => { + const root = el.firstElementChild! + return { + fits: root.getBoundingClientRect().width <= el.parentElement!.getBoundingClientRect().width, + animations: root.getAnimations({ subtree: true }) + .filter((animation): animation is CSSAnimation => + animation instanceof CSSAnimation && /wide-in|rail-in|rail-fade-in/.test(animation.animationName)) + .map(animation => animation.animationName), + } + })).toEqual({ fits: true, animations: [] }) +} + +it.each(['light', 'dark'] as const)('keeps the macOS drawer opaque and excludes inert content from window drag regions in %s mode', async (colorScheme) => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 520, height: 680 }, colorScheme, locale: 'en-US' }) + await page.goto(scaffold.authenticatedUrl) + const opener = page.getByRole('button', { name: 'Open sidebar', exact: true }) + await opener.waitFor() + await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' }) + await opener.click() + const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true }) + await drawer.waitFor() + const column = page.locator('[data-slot="sidebar"]').locator('..') + const background = await column.evaluate((el) => { + const style = getComputedStyle(el) + return { color: style.backgroundColor, image: style.backgroundImage } + }) + expect(background.image).toBe('none') + expect(background.color).toMatch(/^rgb\(/) + expect(await drawer.getByRole('button', { name: 'New session', exact: true }).last().evaluate((el) => { + const actual = getComputedStyle(el).backgroundColor + const root = document.documentElement + const platform = root.dataset.platform + delete root.dataset.platform + try { + return actual === getComputedStyle(el).backgroundColor + } finally { + root.dataset.platform = platform + } + })).toBe(true) + const inactiveContent = page.locator('[inert], [inert] *') + expect(await inactiveContent.count()).toBeGreaterThan(0) + expect(await inactiveContent.evaluateAll(elements => elements + .filter(el => getComputedStyle(el).getPropertyValue('app-region') !== 'none') + .map(el => `${el.tagName}.${el.getAttribute('class')}: ${getComputedStyle(el).getPropertyValue('app-region')}`))) + .toEqual([]) + expect(await drawer.locator('[data-window-drag]').first().evaluate(el => getComputedStyle(el).getPropertyValue('app-region'))) + .toBe('drag') + const collapse = drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }) + await collapse.click() + await drawer.waitFor({ state: 'hidden' }) + await page.setViewportSize({ width: 1200, height: 800 }) + const sidebar = page.locator('[data-slot="sidebar"]').locator('..') + await expect.poll(() => sidebar.evaluate(el => getComputedStyle(el).backgroundImage)).not.toBe('none') + const activeDragRows = page.locator('[data-window-drag]:not([inert] [data-window-drag])') + expect(await activeDragRows.count()).toBeGreaterThan(0) + expect(await activeDragRows.evaluateAll(rows => rows.every(row => getComputedStyle(row).getPropertyValue('app-region') === 'drag'))) + .toBe(true) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +it('hides a collapsed rail immediately when it becomes a drawer', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 768, height: 844 }, locale: 'en-US' }) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + const sidebar = page.locator('[data-slot="sidebar"]') + const sidebarColumn = sidebar.locator('..') + await expect.poll(async () => (await sidebarColumn.boundingBox())?.width).toBe(56) + await sidebarColumn.evaluate((el) => { + el.parentElement!.style.setProperty('--ds-transition-duration-slow', '100s') + }) + await page.setViewportSize({ width: 767, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor() + const state = await sidebarColumn.evaluate(el => ({ + visibility: getComputedStyle(el).visibility, + transitions: el.getAnimations().filter(animation => animation instanceof CSSTransition) + .map(animation => animation.transitionProperty), + })) + expect(state).toEqual({ visibility: 'hidden', transitions: [] }) + await page.setViewportSize({ width: 768, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + await assertCompactRail(sidebar) + await page.setViewportSize({ width: 767, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor() + await page.getByRole('button', { name: 'Open sidebar', exact: true }).click() + const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true }) + await drawer.waitFor() + expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true) + await sidebarColumn.evaluate((el) => { + for (const animation of el.parentElement!.getAnimations({ subtree: true })) animation.finish() + }) + await drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true) + await sidebarColumn.evaluate((el) => { + for (const animation of el.parentElement!.getAnimations({ subtree: true })) animation.finish() + }) + await page.setViewportSize({ width: 768, height: 844 }) + await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + await assertCompactRail(sidebar) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +it('retains the closing slide when an expanded sidebar becomes a collapsed drawer', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 1024, height: 844 }, locale: 'en-US' }) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor() + const sidebarColumn = page.locator('[data-slot="sidebar"]').locator('..') + await expect.poll(async () => (await sidebarColumn.boundingBox())?.width).toBe(280) + await sidebarColumn.evaluate((el) => { + el.parentElement!.style.setProperty('--ds-transition-duration-slow', '100s') + }) + await page.setViewportSize({ width: 767, height: 844 }) + await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').waitFor() + expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +/** Sample the CSS fade at its midpoint without depending on the host's frame rate. */ +async function assertMaskFade(frame: Locator, from: number, to: number): Promise { + const sample = await frame.locator('[data-sidebar-mask]').evaluate((el) => { + const fade = el.getAnimations().find(animation => animation instanceof CSSTransition && animation.transitionProperty === 'opacity') + if (fade === undefined || !(fade.effect instanceof KeyframeEffect)) throw new Error('Drawer mask opacity transition is missing') + fade.pause() + fade.currentTime = Number(fade.effect.getComputedTiming().duration) / 2 + return { + endpoints: fade.effect.getKeyframes().map(keyframe => Number(keyframe.opacity)), + opacity: Number(getComputedStyle(el).opacity), + } + }) + expect(sample.endpoints).toEqual([from, to]) + expect(sample.opacity).toBeGreaterThan(0) + expect(sample.opacity).toBeLessThan(1) + await frame.evaluate((el) => { + for (const animation of el.getAnimations({ subtree: true })) { + if (animation instanceof CSSTransition) animation.finish() + } + el.style.removeProperty('--ds-transition-duration-slow') + }) +} + +/** Sample both width transitions on the same clock to detect detached sidebar controls. */ +async function assertFoldResize(toggle: Locator, from: number, to: number): Promise { + await expect.poll(() => toggle.evaluate((button, target) => { + const frame = button.closest('[data-animating]') + return frame !== null && parseFloat(frame.style.gridTemplateColumns) === target && frame.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === 'grid-template-columns') + }, to)).toBe(true) + const sample = await toggle.evaluate((button) => { + const frame = button.closest('[data-animating]')! + const transitions = frame.getAnimations({ subtree: true }).filter((animation): animation is CSSTransition => + animation instanceof CSSTransition && ['grid-template-columns', 'width'].includes(animation.transitionProperty)) + const grid = transitions.find(animation => animation.transitionProperty === 'grid-template-columns') + if (grid === undefined) throw new Error('Fold width transition is missing') + const at = (progress: number) => { + for (const transition of transitions) { + transition.pause() + transition.currentTime = Number(transition.effect!.getComputedTiming().duration) * progress + } + const width = parseFloat(getComputedStyle(frame).gridTemplateColumns) + return { width, gap: frame.getBoundingClientRect().left + width - button.getBoundingClientRect().right } + } + const start = at(0) + const middle = at(0.5) + const end = at(1) + for (const transition of transitions) transition.finish() + frame.style.removeProperty('--ds-transition-duration-slow') + return { endpoints: [start.width, end.width], start, middle } + }) + expect(sample.endpoints).toEqual([from, to]) + expect(sample.middle.width).toBeGreaterThan(Math.min(from, to)) + expect(sample.middle.width).toBeLessThan(Math.max(from, to)) + expect(sample.middle.gap).toBeCloseTo(sample.start.gap, 1) +} + +it('overlays the conversation and closes after navigation without losing its sidebar content', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + const seed = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8') + const sessionId = await seedSession(scaffold, seed, 'session-c4421b66-252e-44c0-80ed-6e320abeba25') + await workspace.attachSession(sessionId) + await scaffold.ctx.sessionController.rename({ sessionId, title: 'Drawer navigation' }) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, locale: 'en-US' }) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + const opener = page.getByRole('button', { name: 'Open sidebar', exact: true }) + await opener.waitFor() + const frame = page.locator('[data-sidebar-drawer]') + expect(await page.locator('[data-shell-leading]').getByRole('button').count()).toBe(1) + expect(await frame.evaluate(el => getComputedStyle(el).getPropertyValue('--dsh-frame-leading-clearance').trim())) + .toBe('48px') + await expect.poll(() => frame.getAttribute('data-sidebar-collapsed')).toBe('true') + const sidebar = page.locator('[data-slot="sidebar"]') + const original = await sidebar.elementHandle() + const main = page.locator('[data-slot="main"]').locator('..') + const closed = await main.boundingBox() + // A long test-owned duration keeps the transition available until its + // animation clock is sampled and finished explicitly. + await frame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await opener.tap() + const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true }) + await drawer.waitFor() + await assertMaskFade(frame, 0, 1) + await expect.poll(() => main.boundingBox()).toEqual(closed) + expect(closed?.width).toBe(390) + expect(await main.evaluate(el => el.closest('[inert]') !== null)).toBe(true) + expect(await page.locator('[data-side="sidebar"]').count()).toBe(0) + const workspaceRow = drawer.locator('[data-row-key^="workspace:"]').first() + const workspaceButtons = workspaceRow.getByRole('button', { includeHidden: true }) + expect(await workspaceRow.getAttribute('aria-expanded')).toBe('true') + expect(await workspaceButtons.count()).toBe(2) + for (const button of await workspaceButtons.all()) await button.waitFor() + await workspaceRow.tap() + expect(await workspaceRow.getAttribute('aria-expanded')).toBe('false') + for (const button of await workspaceButtons.all()) expect(await button.isVisible()).toBe(false) + await workspaceRow.tap() + for (const button of await workspaceButtons.all()) await button.waitFor() + await drawer.getByRole('button', { name: 'Search sessions', exact: true }).tap() + const search = drawer.getByPlaceholder('Search session names') + expect(await search.evaluate(el => getComputedStyle(el).fontSize)).toBe('16px') + await drawer.getByRole('button', { name: 'Clear search', exact: true }).tap() + await frame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await page.getByRole('treeitem').filter({ has: page.getByText('Drawer navigation', { exact: true }) }).tap() + await assertMaskFade(frame, 1, 0) + await drawer.waitFor({ state: 'hidden' }) + await page.getByRole('navigation', { name: 'Session hierarchy' }).getByText('Drawer navigation', { exact: true }).waitFor() + const chatTab = page.getByRole('tab', { name: 'Chat', exact: true }) + const tabInset = () => chatTab.evaluate((tab) => { + const label = document.createRange() + label.selectNodeContents(tab) + return label.getBoundingClientRect().left - tab.closest('header')!.getBoundingClientRect().left + }) + expect(await tabInset()).toBeCloseTo(20, 1) + // Restore CSS-probe markers in the same synchronous task, before rendering. + const desktopInsets = await chatTab.evaluate((tab) => { + const root = document.documentElement + try { + return ['darwin', 'win32'].map((platform) => { + root.dataset.platform = platform + const label = document.createRange() + label.selectNodeContents(tab) + return label.getBoundingClientRect().left - tab.closest('header')!.getBoundingClientRect().left + }) + } finally { + delete root.dataset.platform + } + }) + for (const inset of desktopInsets) expect(inset).toBeCloseTo(28, 1) + await page.setViewportSize({ width: 768, height: 844 }) + await frame.waitFor({ state: 'detached' }) + expect(await tabInset()).toBeCloseTo(28, 1) + await page.setViewportSize({ width: 390, height: 844 }) + await frame.waitFor() + await opener.click() + const selectedRow = drawer.getByRole('treeitem', { selected: true }) + const more = selectedRow.getByRole('button', { name: 'Session actions for Drawer navigation', exact: true }) + await more.waitFor() + expect(await selectedRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false) + expect(await selectedRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false) + expect(await selectedRow.getByRole('button').count()).toBe(1) + await more.tap() + const menu = page.getByRole('menu') + await menu.getByRole('menuitem', { name: 'Archive session', exact: true }).waitFor() + await menu.getByRole('menuitem', { name: 'Pin session', exact: true }).tap() + await menu.waitFor({ state: 'hidden' }) + const unpin = selectedRow.getByRole('button', { name: 'Unpin session', exact: true }) + await expect.poll(() => selectedRow.getByLabel('Pinned', { exact: true }).count()).toBe(1) + expect(await unpin.isVisible()).toBe(true) + const timestamp = selectedRow.getByText(/^(?:now|\d+min)$/, { exact: true }) + expect(await timestamp.count()).toBe(1) + expect(await timestamp.textContent()).toMatch(/^(?:now|\d+min)$/) + expect(await timestamp.isVisible()).toBe(false) + const unpinBox = (await unpin.boundingBox())! + const moreBox = (await more.boundingBox())! + const folderMoreBox = (await workspaceButtons.nth(0).boundingBox())! + const folderNewBox = (await workspaceButtons.nth(1).boundingBox())! + expect(moreBox.x - unpinBox.x).toBeCloseTo(folderNewBox.x - folderMoreBox.x, 1) + await unpin.tap() + await expect.poll(() => selectedRow.getByLabel('Pinned', { exact: true }).count()).toBe(0) + expect(await drawer.isVisible()).toBe(true) + await more.tap() + await menu.getByRole('menuitem', { name: 'Pin session', exact: true }).tap() + await menu.waitFor({ state: 'hidden' }) + await unpin.waitFor() + await more.tap() + await menu.getByRole('menuitem', { name: 'Unpin session', exact: true }).tap() + await menu.waitFor({ state: 'hidden' }) + await expect.poll(() => selectedRow.getByLabel('Pinned', { exact: true }).count()).toBe(0) + await drawer.getByRole('button', { name: 'Plugins', exact: true }).tap() + await drawer.waitFor({ state: 'hidden' }) + const pluginPanel = page.locator('[data-plugin-panel]') + await pluginPanel.waitFor() + for (const width of [390, 767, 768]) { + await page.setViewportSize({ width, height: 844 }) + expect(await pluginPanel.evaluate(el => Number.parseFloat(getComputedStyle(el).paddingLeft))) + .toBeCloseTo(width < 768 ? 20 : width * 0.04, 1) + } + await page.setViewportSize({ width: 390, height: 844 }) + await frame.waitFor() + await opener.click() + await drawer.getByRole('button', { name: 'Automation tasks', exact: true }).tap() + await drawer.waitFor({ state: 'hidden' }) + const tasksContent = page.getByTestId('task-manager-page').locator('[class*="pageContent"]') + await tasksContent.waitFor() + for (const width of [390, 767, 768]) { + await page.setViewportSize({ width, height: 844 }) + expect(await tasksContent.evaluate(el => Number.parseFloat(getComputedStyle(el).paddingLeft))) + .toBeCloseTo(width < 768 ? 20 : width * 0.04, 1) + } + await page.setViewportSize({ width: 390, height: 844 }) + await frame.waitFor() + await opener.click() + await drawer.waitFor() + await page.touchscreen.tap(370, 420) + await drawer.waitFor({ state: 'hidden' }) + await opener.click() + await page.keyboard.press('Escape') + await drawer.waitFor({ state: 'hidden' }) + expect(await opener.evaluate(el => el === document.activeElement)).toBe(true) + expect(await sidebar.evaluate((el, previous) => el === previous, original)).toBe(true) + await original?.dispose() + + await page.setViewportSize({ width: 768, height: 844 }) + await expect.poll(() => frame.count()).toBe(0) + await expect.poll(async () => Math.round((await main.boundingBox())!.width)).toBe(712) + await page.setViewportSize({ width: 767, height: 844 }) + await expect.poll(() => frame.count()).toBe(1) + + await page.emulateMedia({ reducedMotion: 'reduce' }) + await opener.tap() + const mask = frame.locator('[data-sidebar-mask]') + expect(await mask.evaluate(el => getComputedStyle(el).opacity)).toBe('1') + expect(await mask.evaluate(el => el.getAnimations().length)).toBe(0) + await drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }).tap() + expect(await mask.evaluate(el => getComputedStyle(el).opacity)).toBe('0') + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await expect.poll(async () => Math.round((await main.boundingBox())!.width)).toBe(767) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(767) + await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' }) + await expect.poll(() => main.evaluate(el => getComputedStyle(el).paddingTop)).toBe('0px') + await page.evaluate(() => { delete document.documentElement.dataset.platform }) + await expect.poll(() => main.evaluate(el => getComputedStyle(el).paddingTop)).toBe('48px') + + await page.setViewportSize({ width: 390, height: 844 }) + await drawer.waitFor({ state: 'hidden' }) + await opener.tap() + await drawer.getByRole('button', { name: 'New session', exact: true }).last().tap() + await drawer.waitFor({ state: 'hidden' }) + const home = page.locator('[data-phase="hero"]') + await home.waitFor() + const editor = home.locator('[contenteditable="true"]') + await editor.tap() + const homeEditorBox = await editor.boundingBox() + // Chromium cannot raise a phone keyboard; simulate document panning + // before the visual viewport publishes the matching offset. + const restoreViewport = await page.evaluateHandle(() => { + const viewport = window.visualViewport! + const scrollY = Object.getOwnPropertyDescriptor(window, 'scrollY')! + Object.defineProperty(window, 'scrollY', { configurable: true, value: 80 }) + Object.defineProperties(viewport, { + height: { configurable: true, value: 360 }, + offsetTop: { configurable: true, value: 0 }, + }) + viewport.dispatchEvent(new Event('resize')) + viewport.dispatchEvent(new Event('scroll')) + window.dispatchEvent(new Event('scroll')) + return () => { + Object.defineProperty(window, 'scrollY', scrollY) + Reflect.deleteProperty(viewport, 'height') + Reflect.deleteProperty(viewport, 'offsetTop') + window.dispatchEvent(new Event('scroll')) + viewport.dispatchEvent(new Event('resize')) + } + }) + const root = page.locator('#root') + await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + expect(await editor.boundingBox()).toEqual(homeEditorBox) + await page.evaluate(() => { + Object.defineProperty(window.visualViewport!, 'offsetTop', { configurable: true, value: 80 }) + window.visualViewport!.dispatchEvent(new Event('scroll')) + }) + await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + await expect.poll(async () => (await main.boundingBox())?.height).toBe(844) + await opener.tap() + await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844) + const searchWithKeyboard = drawer.getByPlaceholder('Search session names') + await drawer.getByRole('button', { name: 'Search sessions', exact: true }).tap() + await searchWithKeyboard.tap() + await searchWithKeyboard.fill('retained search') + await drawer.getByRole('button', { name: 'Account menu', exact: true }).tap() + await page.getByRole('menuitem', { name: 'Settings', exact: true }).tap() + const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + await settings.waitFor() + const settingsBox = (await settings.boundingBox())! + expect(settingsBox.x).toBeGreaterThanOrEqual(0) + expect(settingsBox.y).toBeGreaterThanOrEqual(0) + expect(settingsBox.x + settingsBox.width).toBeLessThanOrEqual(390) + expect(settingsBox.y + settingsBox.height).toBeLessThanOrEqual(844) + const closeSettings = settings.getByRole('button', { name: 'Close', exact: true }) + const closeBox = (await closeSettings.boundingBox())! + expect(closeBox.y).toBeGreaterThanOrEqual(0) + expect(closeBox.y + closeBox.height).toBeLessThanOrEqual(844) + await closeSettings.tap() + await settings.waitFor({ state: 'hidden' }) + await expect.poll(() => searchWithKeyboard.inputValue()).toBe('retained search') + await restoreViewport.evaluate((restore) => { restore() }) + await restoreViewport.dispose() + await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844) + await frame.evaluate((el) => { el.scrollLeft = 10 }) + expect(await frame.evaluate(el => el.scrollLeft)).toBe(0) + await expect.poll(() => mask.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 }) + await expect.poll(() => drawer.evaluate((el) => { + const frame = el.closest('[data-sidebar-drawer]')! + let column = el + while (column.parentElement !== frame) column = column.parentElement! + const { y, height } = column.getBoundingClientRect() + return { y, height } + })).toEqual({ y: 0, height: 844 }) + await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844) + await page.setViewportSize({ width: 844, height: 390 }) + await expect.poll(async () => (await root.boundingBox())?.height).toBe(390) + await expect.poll(() => frame.count()).toBe(0) + await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(async () => (await root.boundingBox())?.height).toBe(844) + await expect.poll(() => frame.count()).toBe(1) + + const posture = await page.context().newCDPSession(page) + try { + await page.setViewportSize({ width: 951, height: 669 }) + await expect.poll(() => frame.count()).toBe(0) + await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } }) + await opener.tap() + await expect.poll(() => main.boundingBox()).toEqual({ x: 475.5, y: 0, width: 475.5, height: 669 }) + expect(await page.locator('[data-side="sidebar"]').count()).toBe(0) + const foldSearch = page.getByPlaceholder('Search session names') + if (!(await foldSearch.isVisible())) await page.getByRole('button', { name: 'Search sessions', exact: true }).tap() + await foldSearch.fill('fold search') + const wideFrame = page.locator('[data-rightbar-collapsed]') + const collapseSidebar = page.getByRole('button', { name: 'Collapse sidebar', exact: true }) + await wideFrame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await posture.send('Emulation.clearDevicePostureOverride') + await assertFoldResize(collapseSidebar, 475.5, 280) + await expect.poll(() => main.boundingBox()).toEqual({ x: 280, y: 0, width: 671, height: 669 }) + expect(await page.locator('[data-side="sidebar"]').count()).toBe(1) + expect(await foldSearch.evaluate(el => el === document.activeElement)).toBe(true) + expect(await foldSearch.inputValue()).toBe('fold search') + await wideFrame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') }) + await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } }) + await assertFoldResize(collapseSidebar, 280, 475.5) + await expect.poll(async () => (await main.boundingBox())?.x).toBe(475.5) + expect(await foldSearch.evaluate(el => el === document.activeElement)).toBe(true) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await posture.send('Emulation.clearDevicePostureOverride') + await expect.poll(async () => (await main.boundingBox())?.x).toBe(280) + // Scroll-driven text fades remain registered even with reduced motion. + expect(await wideFrame.evaluate(el => el.getAnimations({ subtree: true }) + .filter(animation => animation.timeline === document.timeline).length)).toBe(0) + await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } }) + await expect.poll(async () => (await main.boundingBox())?.x).toBe(475.5) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + await page.setViewportSize({ width: 669, height: 951 }) + await expect.poll(() => frame.getAttribute('data-sidebar-collapsed')).toBe('true') + await drawer.waitFor({ state: 'hidden' }) + await opener.tap() + await expect.poll(async () => (await drawer.boundingBox())?.width).toBe(280) + expect(await main.boundingBox()).toEqual({ x: 0, y: 0, width: 669, height: 951 }) + } finally { + await posture.detach() + } + + const pointerPage = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: 'en-US' }) + try { + await pointerPage.goto(scaffold.authenticatedUrl) + await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).click() + expect(await pointerPage.getByPlaceholder('Search session names').evaluate(el => getComputedStyle(el).fontSize)).toBe('13px') + const pointerWorkspace = pointerPage.locator('[data-row-key^="workspace:"]').first() + const pointerWorkspaceButtons = pointerWorkspace.getByRole('button', { includeHidden: true }) + await pointerWorkspace.click() + expect(await pointerWorkspace.getAttribute('aria-expanded')).toBe('false') + const collapseSidebar = pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }) + await collapseSidebar.focus() + await collapseSidebar.hover() + for (const button of await pointerWorkspaceButtons.all()) expect(await button.isVisible()).toBe(false) + await pointerWorkspace.hover() + for (const button of await pointerWorkspaceButtons.all()) await button.waitFor() + await pointerWorkspace.click() + for (const label of ['Collapse sidebar', 'View options']) { + await pointerPage.getByRole('button', { name: label, exact: true }).hover() + const tooltip = pointerPage.getByRole('tooltip').filter({ hasText: label }) + await tooltip.waitFor() + expect(await tooltip.evaluate(el => el.closest('[data-slot="sidebar"]'))).toBeNull() + const box = (await tooltip.boundingBox())! + expect(box.x).toBeGreaterThanOrEqual(12) + expect(box.x + box.width).toBeLessThanOrEqual(378) + } + const pointerRow = pointerPage.getByRole('treeitem').filter({ has: pointerPage.getByText('Drawer navigation', { exact: true }) }) + await pointerRow.click() + await pointerPage.getByRole('dialog', { name: 'Navigation', exact: true }).waitFor({ state: 'hidden' }) + await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).click() + await pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).hover() + await expect.poll(() => pointerRow.getByRole('button', { name: 'Session actions for Drawer navigation', exact: true }).isVisible()).toBe(false) + await pointerRow.hover() + for (const label of ['Session actions for Drawer navigation', 'Archive session', 'Pin session']) { + await pointerRow.getByRole('button', { name: label, exact: true }).waitFor() + } + await pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + const pointerDrawer = pointerPage.getByRole('dialog', { name: 'Navigation', exact: true }) + await pointerDrawer.waitFor({ state: 'hidden' }) + await pointerPage.setViewportSize({ width: 767, height: 844 }) + await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).press('Enter') + await pointerDrawer.waitFor() + expect(await pointerDrawer.evaluate(el => el.contains(document.activeElement))).toBe(true) + await pointerPage.getByRole('button', { name: 'Search sessions', exact: true }).click() + await expect.poll(() => pointerPage.getByPlaceholder('Search session names') + .evaluate(el => el === document.activeElement)).toBe(true) + await pointerPage.setViewportSize({ width: 768, height: 844 }) + await pointerPage.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' }) + const pointerSidebar = pointerPage.locator('[data-slot="sidebar"]') + await expect.poll(() => pointerSidebar.getByRole('button', { name: 'New session', exact: true }).count()).toBe(1) + const railToggle = pointerSidebar.getByRole('button', { name: 'Open sidebar', exact: true }) + expect(await railToggle.evaluate(el => el === document.activeElement)).toBe(true) + } finally { + await pointerPage.close() + } + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + const result = [ + '390px: closed sidebar has no rail; main width 390px', + 'open: main geometry retained; background inert; no resize handle', + 'close: Session navigation, Plugins navigation, mask and Escape', + 'retained: sidebar DOM and opener focus', + '768px: 56px rail; main width 712px; drawer focus transfers to the rail toggle', + 'breakpoint motion: collapsed rail hides immediately and returns compact without crossfades; expanded sidebar and explicit toggles retain their slides', + '767px: hidden drawer; main width 767px; no page overflow', + 'global panels: browser top inset 48px; macOS keeps its existing clearance', + 'tooltips: sidebar and workspace controls escape the drawer and fit the viewport', + 'search input: 16px on touch; 13px with a fine pointer', + 'simulated keyboard: homepage and drawer retain 844px layout height without following the 360px visual viewport; Settings stays within the viewport', + 'keyboard close: layout height stays 844px; sidebar search retained; Settings close remains reachable', + 'shell coverage: drawer backing and mask cover 844px without synthetic safe-area insets', + 'rotation: 844x390 then 390x844 restores shell height and sidebar breakpoint', + 'folded landscape: 951px frame splits at 475.5px without a resize handle; unfolding restores 280px and search focus; portrait retains the drawer', + 'fold transitions: sidebar controls follow the divider in both directions; reduced motion changes width without animation', + 'drawer mask: opacity transitions from 0 to 1 on open and 1 to 0 on close', + 'reduced motion: drawer and mask settle without transitions', + 'Web header: only the sidebar opener remains, with 48px leading clearance', + 'below 768px: plugin and automation pages use 20px insets; Web view tabs align at 20px, while Desktop and wider Web tabs retain 28px', + 'touch selected Session: More remains visible; update time is hidden; pinned rows offer Unpin before More with Workspace action spacing', + 'touch Workspace: actions appear only while expanded, including after folding the focused row', + 'fine pointer: folded Workspace actions remain available on hover', + 'fine pointer: selected Session actions stay hidden until hover, including at 390px', + ].join('\n') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-drawer.expected.md', import.meta.url)), result, webSnapshotMode()) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +it('dismisses retained sidebar menus at the drawer breakpoint and preserves their rows', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + const seed = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8') + const sessionId = await seedSession(scaffold, seed, 'session-c4421b66-252e-44c0-80ed-6e320abeba25') + await workspace.attachSession(sessionId) + await scaffold.ctx.sessionController.rename({ sessionId, title: 'Menu regression' }) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 1024, height: 844 }, locale: 'en-US' }) + await page.goto(scaffold.authenticatedUrl) + const row = page.locator(`[data-row-key="session:${sessionId}"]`) + await row.waitFor() + const original = await row.elementHandle() + for (const label of ['View options', 'Session actions for Menu regression']) { + await page.setViewportSize({ width: 1024, height: 844 }) + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor() + if (label.startsWith('Session')) await row.hover() + await page.getByRole('button', { name: label, exact: true }).click() + await page.getByRole('menu').waitFor() + await page.setViewportSize({ width: 767, height: 844 }) + await page.getByRole('menu').waitFor({ state: 'hidden' }) + const opener = page.getByRole('button', { name: 'Open sidebar', exact: true }) + await expect.poll(() => opener.evaluate(element => element === document.activeElement)).toBe(true) + await opener.click() + await row.waitFor() + expect(await row.evaluate((element, before) => element === before, original)).toBe(true) + } + await original?.dispose() + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-menu-dismissal.expected.md', import.meta.url)), [ + 'View options and Session menus close when the sidebar becomes an inert drawer.', + 'Focus returns to the visible sidebar opener; retained Session rows keep their DOM identity.', + ].join('\n'), webSnapshotMode()) + } finally { await browser.close() } + } finally { await scaffold.close() } +}) + +it('keeps drawer shortcuts and hit targets above fullscreen and floating right panels', async () => { + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) + try { + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + const seed = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8') + const sessionId = await seedSession(scaffold, seed, 'session-c4421b66-252e-44c0-80ed-6e320abeba25') + await workspace.attachSession(sessionId) + await scaffold.ctx.sessionController.rename({ sessionId, title: 'Drawer overlay checks' }) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 844 }, locale: 'en-US' }) + await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) }) + await page.goto(scaffold.authenticatedUrl) + const row = page.getByRole('treeitem').filter({ has: page.getByText('Drawer overlay checks', { exact: true }) }) + await row.click() + await page.locator('[data-sidebar-right-expand]').click() + await page.locator('[data-sidebar-right-guide-entry="files"]').click() + const panel = page.locator('[data-sidebar-right-open]') + const primary = await page.evaluate(() => /mac/i.test(navigator.platform) ? 'Meta' : 'Control') + const toggle = `${primary}+Alt+b` + await row.hover() + const card = page.getByRole('button', { name: 'Copy: Drawer overlay checks', exact: true }) + await card.waitFor() + await page.setViewportSize({ width: 390, height: 844 }) + await card.waitFor({ state: 'hidden' }) + expect(await row.count()).toBe(1) + const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true }) + await expect.poll(() => panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen') + const assertAboveRightPanel = async (coveredPoint?: { x: number; y: number }) => { + await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').waitFor() + await page.keyboard.press(toggle) + await drawer.waitFor() + const collapse = drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }) + await expect.poll(() => collapse.evaluate(el => document.activeElement === el)).toBe(true) + await expect.poll(() => drawer.evaluate((el) => { + const box = el.getBoundingClientRect() + return el.contains(document.elementFromPoint(box.left + 140, 30)) + })).toBe(true) + expect(await page.evaluate(() => document.elementFromPoint(380, 420)?.hasAttribute('data-sidebar-mask'))).toBe(true) + if (coveredPoint !== undefined) { + expect(await drawer.evaluate((el, point) => { + const hit = document.elementFromPoint(point.x, point.y) + return el.contains(hit) || hit?.hasAttribute('data-sidebar-mask') + }, coveredPoint)).toBe(true) + } + await page.keyboard.press(`${primary}+Alt+k`) + await expect.poll(() => drawer.getByPlaceholder('Search session names').evaluate(el => document.activeElement === el)).toBe(true) + await page.keyboard.press(toggle) + await drawer.waitFor({ state: 'hidden' }) + expect(await panel.evaluate(el => el.closest('[inert]') === null)).toBe(true) + } + await page.keyboard.press(`${primary}+Alt+k`) + await drawer.waitFor() + await expect.poll(() => drawer.getByPlaceholder('Search session names') + .evaluate(el => el === document.activeElement)).toBe(true) + await page.keyboard.press(toggle) + await drawer.waitFor({ state: 'hidden' }) + await assertAboveRightPanel() + await page.setViewportSize({ width: 1280, height: 844 }) + await expect.poll(() => panel.getAttribute('data-sidebar-right-panel')).toBe('push') + const tab = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + const bounds = (await tab.boundingBox())! + await page.mouse.move(bounds.x + 6, bounds.y + bounds.height / 2) + await page.mouse.down() + try { + await page.mouse.move(190, 400, { steps: 8 }) + } finally { await page.mouse.up() } + const floating = page.locator('[data-dockkit-float]') + await floating.waitFor() + await page.setViewportSize({ width: 390, height: 844 }) + const box = (await floating.boundingBox())! + expect(box.x).toBeLessThan(390) + expect(box.x + box.width).toBeGreaterThan(0) + const point = { x: (Math.max(0, box.x) + Math.min(390, box.x + box.width)) / 2, y: box.y + 40 } + await expect.poll(() => floating.evaluate((el, at) => el.contains(document.elementFromPoint(at.x, at.y)), point)).toBe(true) + await assertAboveRightPanel(point) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 307e1adf12..4e5be429ce 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -1128,8 +1128,28 @@ describe('web e2e: shipped right Sidebar', () => { expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1) await column.locator('[data-dockkit-tab]').first().click({ button: 'right' }) - expect(await page.locator('[data-dockkit-tab-menu]').isVisible()).toBe(false) + // Wait for prepared content, rather than passing before the opening delay. + const emptyMenu = page.locator('[data-context-menu][role="menu"]') + await emptyMenu.waitFor({ state: 'attached' }) + expect(await emptyMenu.evaluate(node => getComputedStyle(node).display)).toBe('none') + expect(await emptyMenu.isVisible()).toBe(false) expect(await page.getByRole('menu').count()).toBe(0) + expect(await column.locator('[data-dockkit-tab]').first().evaluate(node => node.contains(document.activeElement))).toBe(true) + const backingSize = await page.locator('[data-menu-backing]').evaluate((node) => { + const root = document.documentElement + const platform = root.getAttribute('data-platform') + root.setAttribute('data-platform', 'darwin') + try { + const { width, height } = node.getBoundingClientRect() + return { width, height } + } finally { + if (platform === null) root.removeAttribute('data-platform') + else root.setAttribute('data-platform', platform) + } + }) + expect(backingSize).toEqual({ width: 0, height: 0 }) + await page.keyboard.press('Escape') + await emptyMenu.waitFor({ state: 'detached' }) // Any other tab standing alone closes together with the column. Open the // sample file, close the guide (an ordinary close with two tabs), then @@ -1231,13 +1251,10 @@ describe('web e2e: shipped right Sidebar', () => { // is the secondary press. expect(await column.locator('[data-dockkit-tab-more]').count()).toBe(0) await column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }).click({ button: 'right' }) - const menu = page.locator('[data-dockkit-tab-menu]') + const menu = page.locator('[data-context-menu][role="menu"]') await expect.poll(async () => await menu.count()).toBe(1) - // The kit's one item; the extension seat is declared and rendered, and - // with no registrant it contributes nothing, which is what "declared, not - // speculative" looks like from the outside. - await expect.poll(async () => await menu.getByRole('menuitem').allInnerTexts()).toEqual(['Close']) + await expect.poll(() => menu.ariaSnapshot()).toBe('- menu:\n - menuitem "Close"\n - menuitem "Close Other Tabs"') // The menu hangs below the strip that clips its overflow. Its pixels are // its own: a hit test at its centre lands on it, not on whatever the // strip would have shown through a clipped box. diff --git a/apps/web/tests/stats-truncated-tooltip.e2e.ts b/apps/web/tests/stats-truncated-tooltip.e2e.ts index 18e7d5e64c..fc3e25b419 100644 --- a/apps/web/tests/stats-truncated-tooltip.e2e.ts +++ b/apps/web/tests/stats-truncated-tooltip.e2e.ts @@ -92,7 +92,7 @@ describe('web e2e: truncated composer statistics', () => { const cut = () => label.evaluate(element => element.scrollWidth > element.clientWidth) expect(await cut()).toBe(false) - await page.setViewportSize({ width: 390, height: 900 }) + await page.setViewportSize({ width: 334, height: 900 }) await expect.poll(cut).toBe(true) await usagePill.hover() await expect.poll(() => tooltip.textContent()).toBe(USAGE_READING) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 953f701fc9..ad741f474d 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -200,6 +200,23 @@ export function probeFreePort(): Promise { }) } +/** + * Open collapsed mobile navigation before using a sidebar action. + * @param page - browser page with the mounted sidebar. + * @param locale - current UI language. + */ +export async function revealNavigationDrawer(page: Page, locale: 'en' | 'zh' | 'ja'): Promise { + const drawer = await page.evaluate(() => window.innerWidth < 768 + && !document.documentElement.hasAttribute('data-windows-titlebar')) + await page.waitForFunction(expected => Boolean(document.querySelector('[data-sidebar-drawer]')) === expected, drawer) + const closed = page.locator('[data-sidebar-drawer][data-sidebar-collapsed]') + if (await closed.count() === 0) return + await page.locator('[data-shell-leading]').getByRole('button', { + name: { en: 'Open sidebar', zh: '打开侧边栏', ja: 'サイドバーを開く' }[locale], exact: true, + }).click() + await closed.waitFor({ state: 'detached' }) +} + /** * Drive the hero's workspace picker through the composed directory dialog * until the live composer unlocks. A fresh world has no Workspace, so the boot @@ -219,6 +236,7 @@ export function probeFreePort(): Promise { */ export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) + await revealNavigationDrawer(page, 'en') await page.getByRole('button', { name: 'Add workspace', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) @@ -245,6 +263,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo */ export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace', modelAvailable = true): Promise { mkdirSync(join(root, name), { recursive: true }) + await revealNavigationDrawer(page, 'zh') await page.getByRole('button', { name: '添加工作区', exact: true }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) @@ -346,6 +365,14 @@ export function conversationContextKey(kind: string, id: string): string { */ export async function openSettings(page: Page, locale: 'en' | 'zh' | 'ja'): Promise { const labels = { en: ['Account menu', 'Settings'], zh: ['账号菜单', '设置'], ja: ['アカウントメニュー', '設定'] } as const - await page.getByRole('button', { name: labels[locale][0], exact: true }).click() + const trigger = page.getByRole('button', { + name: labels[locale][0], exact: true, includeHidden: true, + }) + await trigger.waitFor({ state: 'attached' }) + await revealNavigationDrawer(page, locale) + if (!await trigger.isVisible()) { + await page.getByRole('button', { name: { en: 'Open sidebar', zh: '打开侧边栏', ja: 'サイドバーを開く' }[locale], exact: true }).click() + } + await trigger.click() await page.getByRole('menuitem', { name: labels[locale][1], exact: true }).click() } diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts index 87c468a901..70fd84f7a1 100644 --- a/apps/web/tests/window-drag-coverage.e2e.ts +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -107,10 +107,10 @@ async function collectedRegions(page: Page, selector: string): Promise { const top = document.elementFromPoint(x, y) - // A pane's programmatic focus target also contains its window-drag strip. - // The strip owns those pixels; controls inside it still need their own subtraction. - const strip = top?.closest('[data-dockkit-strip][data-window-drag]') - if (element.matches('[data-dockkit-pane][tabindex="-1"]') + // Panes and the navigation dialog contain their own window-drag rows. + // Those rows own their pixels; controls inside still subtract themselves. + const strip = top?.closest('[data-window-drag]') + if (element.matches('[data-dockkit-pane][tabindex="-1"], [data-shortcut-modal="sidebar"][role="dialog"]') && strip != null && element.contains(strip)) return false return top !== null && (top === element || element.contains(top)) }) @@ -503,6 +503,32 @@ describe('web e2e: macOS window drag coverage', () => { } }, 120_000) + it('keeps the drawer titlebar draggable without swallowing its close button', async () => { + const { page, tripwire } = await desktopPage() + try { + await page.setViewportSize({ width: 571, height: 696 }) + await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').waitFor() + await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click() + const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true }) + await drawer.waitFor() + await settled(page) + const toggle = drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }) + const button = (await toggle.boundingBox())! + const seat = await rectOf(page, '[data-shell-leading]') + const regions = await collectedRegions(page, INTERACTIVE_SELECTOR) + const y = button.y + button.height / 2 + expect(isDraggableAt(regions, seat.x + seat.width / 2, y), 'the inactive header controls behind the drawer').toBe(true) + expect(isDraggableAt(regions, (seat.x + seat.width + button.x) / 2, y), 'the gap before the close button').toBe(true) + expect(isDraggableAt(regions, button.x + button.width / 2, y), 'the close button').toBe(false) + expect(swallowedBoxes(regions)).toEqual([]) + await toggle.click() + await drawer.waitFor({ state: 'hidden' }) + expect(tripwire.pageErrors).toEqual([]) + } finally { + await page.close() + } + }) + it('keeps an entry page’s chrome row draggable', async () => { const { page, tripwire } = await desktopPage() try { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index ba6cb7cbe3..096c159be4 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -567,7 +567,10 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo const consoleWatch = watchConsole(hoverPage) await hoverPage.goto(scaffold.authenticatedUrl) const row = await seededSessionRow(hoverPage) + await row.click() + await hoverPage.getByText('DONE', { exact: true }).waitFor() const title = row.locator('[class*="title"]') + await expect.poll(() => title.innerText()).not.toBe('Untitled') const card = hoverPage.getByRole('button', { name: `Copy: ${await title.innerText()}` }) const tooltip = hoverPage.getByRole('tooltip') const observations: { stage: string; details: boolean; tooltips: string[] }[] = [] @@ -575,6 +578,7 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo observations.push({ stage, details: await card.count() > 0, tooltips: await tooltip.allTextContents() }) } const clockStart = Date.now() + await hoverPage.getByRole('button', { name: 'Account menu', exact: true }).hover() await hoverPage.clock.install({ time: clockStart }) await hoverPage.clock.pauseAt(clockStart + 1000) await title.hover() @@ -612,9 +616,11 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo const menu = row.getByRole('button', { name: /^Session actions for / }) await menu.click() + await hoverPage.clock.runFor(1000) await hoverPage.getByRole('menu').waitFor() expect(await card.count()).toBe(0) await hoverPage.keyboard.press('Escape') + await hoverPage.clock.runFor(1000) await hoverPage.getByRole('menu').waitFor({ state: 'detached' }) await title.hover() await hoverPage.clock.runFor(1600) @@ -821,8 +827,10 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo await item.hover() await page.waitForTimeout(POINTER_HOLD_MS) expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) - // Pointer-leave dismissal still applies once the pointer genuinely leaves. + // Context menus stay open across pointer departure and dismiss on an outside press. await page.getByRole('button', { name: 'Account menu', exact: true }).hover() + expect(await item.isVisible()).toBe(true) + await page.locator('[data-row-key]').first().click() await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) @@ -862,9 +870,8 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - // Initial Workspace reconnection can focus the composer after the tree renders. - // Finish that navigation before the next test opens a path editor. - await page.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 15_000 }) + // The last Session can leave the new-session page with no selected row. + // Wait for composer focus so reconnection cannot steal the next editor's focus. await expect.poll( () => page.locator('[data-composer-input][contenteditable="true"]') .evaluate(element => element === document.activeElement), diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 44dcc4fbdd..75efc1547d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,9 +23,11 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ + "tests/sidebar-drawer.e2e.ts", "tests/creation-recommendations.e2e.ts", "tests/plugin-recommendations.e2e.ts", "tests/japanese-plugin-metadata.e2e.ts", + "tests/context-menus.e2e.ts", "tests/additional-locales.e2e.ts", "tests/rich-text-quotes.e2e.ts", "tests/hover-input.e2e.ts", @@ -113,6 +115,7 @@ "tests/plugin-hmr-lifecycle.e2e.ts", "tests/plugin-install-registry.e2e.ts", "tests/plugin-install-github.e2e.ts", + "tests/plugin-install-silent-failure.e2e.ts", "tests/client-plugin-live.e2e.ts", "tests/icon-skin.e2e.ts", "tests/draft-initialization.e2e.ts", diff --git a/benchmarks/session-corpus/README.i18n.yaml b/benchmarks/session-corpus/README.i18n.yaml index 2162840b3e..5e02d08e37 100644 --- a/benchmarks/session-corpus/README.i18n.yaml +++ b/benchmarks/session-corpus/README.i18n.yaml @@ -12,8 +12,8 @@ en: 66a769ce60d49ff3 zh: 7e0bfa7c598157fa /session-corpus-benchmarks/run: - en: 2560e832041ae481 - zh: 2fbbff71135e52ab + en: c770082e58aa8194 + zh: 339d659595a1f97d /session-corpus-benchmarks/measurements: en: c357c231c3bfb588 zh: 3d901a2caccf2587 diff --git a/benchmarks/session-corpus/README.md b/benchmarks/session-corpus/README.md index d504417d77..f55fc563ec 100644 --- a/benchmarks/session-corpus/README.md +++ b/benchmarks/session-corpus/README.md @@ -18,7 +18,7 @@ Measure the Web Host's Session list, content search, and fork over synthetic cor From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`. Do not overlap timing runs with builds or other benchmarks. Seeding writes about 1.5 GB of Zstandard logs under a private temporary root, which the test removes after success or failure. -The test reports every fresh-process sample, the CPU model, available parallelism, platform, and Node version, and enforces median budgets. Its last case fails when the file, including seeding, exceeds five minutes. A failed worker reports its exit, signal, timeout, and stderr tail. The required benchmark lane discovers this file automatically. +The test reports every fresh-process sample, the CPU model, available parallelism, platform, and Node version, and enforces median budgets. Its last case fails when the file, including seeding, exceeds five minutes. Fork workers use a fixed V8 heap limit; peak RSS still measures the whole process. A failed worker reports its exit, signal, timeout, and stderr tail. The required benchmark lane discovers this file automatically. diff --git a/benchmarks/session-corpus/README.zh.md b/benchmarks/session-corpus/README.zh.md index 1077b5233e..e7b1f1586a 100644 --- a/benchmarks/session-corpus/README.zh.md +++ b/benchmarks/session-corpus/README.zh.md @@ -18,7 +18,7 @@ 在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/session-corpus/session-corpus.bench.ts`。不要让计时运行与构建或其他基准重叠。播种过程会在私有临时根目录下写入约 1.5 GB 的 Zstandard 日志,测试成功或失败后都会删除该目录。 -测试报告每个新进程样本、CPU 型号、可用并行度、平台和 Node 版本,并约束中位数预算。当整个文件(包括播种)超过五分钟时,最后一个用例失败。worker 失败时报告退出状态、信号、超时和 stderr 末尾。必需基准通道自动发现此文件。 +测试报告每个新进程样本、CPU 型号、可用并行度、平台和 Node 版本,并约束中位数预算。当整个文件(包括播种)超过五分钟时,最后一个用例失败。Fork worker 使用固定的 V8 堆上限;峰值 RSS 仍测量整个进程。worker 失败时报告退出状态、信号、超时和 stderr 末尾。必需基准通道自动发现此文件。 diff --git a/benchmarks/session-corpus/session-corpus.bench.ts b/benchmarks/session-corpus/session-corpus.bench.ts index 00bc2d2979..3bbb66809c 100644 --- a/benchmarks/session-corpus/session-corpus.bench.ts +++ b/benchmarks/session-corpus/session-corpus.bench.ts @@ -29,6 +29,8 @@ const CORPUS = { search: 1_000, fork: 1_000, list: 3_000 } as const const FILE_LIMIT_MS = 300_000 /** Per-child deadline: an endpoint slower than its budget still reports a measurement instead of a kill. */ const WORKER_TIMEOUT_MS = FILE_LIMIT_MS +/** Fixed old-space capacity makes fork RSS comparable across hosted V8 heap defaults. */ +const FORK_HEAP_LIMIT_MB = 512 /** Fresh processes per list and fork-strata scenario; the median enforces each budget. */ const ATTEMPTS = { list: 3, fork: 3 } as const /** Concurrent anchor-preparation processes; standard hosted runners have two CPUs. */ @@ -119,7 +121,10 @@ function serialDeadline(workers: number): number { } async function run(args: readonly string[]): Promise { - const outcome = await runBuiltBenchmarkWorker({ worker: WORKER, args, timeoutMs: WORKER_TIMEOUT_MS, exposeGc: true }) + const outcome = await runBuiltBenchmarkWorker({ + worker: WORKER, args, timeoutMs: WORKER_TIMEOUT_MS, exposeGc: true, + ...(args[1] === 'fork' ? { heapLimitMb: FORK_HEAP_LIMIT_MB } : {}), + }) if (outcome.report === undefined) { const stderr = outcome.stderr.trim().split('\n').slice(-20).join('\n') throw new Error(`session-corpus worker ${args[1] ?? ''} failed: exit=${String(outcome.exitCode)}, ` diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 39ebd5cd9c..d1766b9e80 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -74,4 +74,4 @@ Hunt these in any doc; [dsh-doc](../.agents/skills/dsh-doc/SKILL.md) runs this l ## Repository references -Use relative Markdown links for current files and tags or PR numbers for historical references. `verify-md-links` checks local targets. [Reference validation](../scripts/verify-repository-references.ts) rejects actual commit identifiers and disallowed organization URLs in maintained files. +Use relative Markdown links for current files and tags or PR numbers for historical references. `verify-md-links` checks local targets. [Reference validation](../scripts/verify-repository-references.ts) rejects commit identifiers containing a hexadecimal letter and disallowed organization URLs in maintained files. Decimal-only tokens are ignored to avoid collisions with numeric data, even when they identify commits. diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index cb8f632442..f9140a3c2d 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: cbe99e1985f2deae - zh: 6e524e7a644ae2d6 + en: 5e42f2392f0457d6 + zh: a694691c079e36fb diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 14a1a92890..faf80ef4fa 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -84,9 +84,10 @@ flowchart LR pkg_llm_replay["llm-replay"] pkg_agent_loop["agent-loop"] pkg_compaction_basic["compaction-basic"] + pkg_session_log_deepseek["session-log-deepseek"] + svc_sessionLogDeepSeek["ctx.sessionLogDeepSeek
Session-log upload policy"] pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] svc_deepseekLlmApiExtensions["ctx.deepseekLlmApiExtensions
Official DeepSeek request extensions"] - pkg_session_log_deepseek["session-log-deepseek"] pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] pkg_token_meter["token-meter"] svc_tokenMeter["ctx.tokenMeter
Replay token measurement"] @@ -312,6 +313,8 @@ flowchart LR pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] + pkg_experimental_liveshare["experimental-liveshare"] + svc_liveshareShares["ctx.liveshareShares
Owner share management Remote"] pkg_action_cards --> svc_actionCards pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel @@ -369,6 +372,7 @@ flowchart LR pkg_experimental_forge --> svc_forge pkg_experimental_forge_github --> svc_forge pkg_experimental_forge_session --> svc_forgeSession + pkg_experimental_liveshare --> svc_liveshareShares pkg_experimental_ptc_runtime_python --> svc_ptcRuntime pkg_experimental_speech_to_text --> svc_speechToText pkg_experimental_speech_to_text_sensevoice --> svc_speechToText @@ -419,6 +423,7 @@ flowchart LR pkg_schedule --> svc_schedule pkg_session --> svc_sessions pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions + pkg_session_log_deepseek --> svc_sessionLogDeepSeek pkg_session_persistence --> svc_sessionPersistence pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_projection --> svc_sessionProjections @@ -658,7 +663,7 @@ flowchart LR | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams. | | `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop. | | `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | A Session projection derives bindings from shell results and binding records and publishes their URLs without status to clients; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry. | -| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | Streams one panel state per followed Session: a read-only Git inspection in the Session sandbox, the recognized code host and its sign-in, and the bound change requests with polled status. | +| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | Streams one panel state per followed Session: Git inspection using Session permissions, the recognized code host and its sign-in, and the bound change requests with polled status. | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | Explicit experimental creation from a pinned local commit. Existing write permissions govern checkout and shared Git metadata; the working-directory service owns the resulting Session directory. | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | One Session projection owns the effective execution directory. The filesystem validates changes; user context reports them while original metadata and write grants stay fixed. | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | @@ -669,6 +674,7 @@ flowchart LR | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | +| `ctx.sessionLogDeepSeek` | `core` | [`session-log-deepseek`](../packages/session/session-log-deepseek) | - | - | - | Plugins register caller-owned exclusions before the canonical Session-log request contribution is prepared; unmatched requests use normal upload eligibility. | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | @@ -751,5 +757,6 @@ flowchart LR | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | [`lsp-stdio`](../packages/lsp/lsp-stdio) | [`tool-lsp`](../packages/lsp/tool-lsp) | - | Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result. | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Owns the in-memory definition registry, the vm sandbox for host halves, and the request-run round trip; browser pages reach the same service over the wire through its remote namespace. | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Registers host inspect providers, mirrors the client provider manifest, and routes client queries through the dynamic Cordis transport. | +| `ctx.liveshareShares` | `service` | [`experimental-liveshare`](../packages/experimental/liveshare) | - | - | - | Publishes the owner share-management operations as one Typert Remote namespace that the owner client face calls; every method delegates to the same operations the same-origin routes used. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 4e517bbab6..5156a43822 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -86,9 +86,10 @@ flowchart LR pkg_llm_replay["llm-replay"] pkg_agent_loop["agent-loop"] pkg_compaction_basic["compaction-basic"] + pkg_session_log_deepseek["session-log-deepseek"] + svc_sessionLogDeepSeek["ctx.sessionLogDeepSeek
Session-log upload policy"] pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"] svc_deepseekLlmApiExtensions["ctx.deepseekLlmApiExtensions
Official DeepSeek request extensions"] - pkg_session_log_deepseek["session-log-deepseek"] pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"] pkg_token_meter["token-meter"] svc_tokenMeter["ctx.tokenMeter
Replay token measurement"] @@ -314,6 +315,8 @@ flowchart LR pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] + pkg_experimental_liveshare["experimental-liveshare"] + svc_liveshareShares["ctx.liveshareShares
Owner share management Remote"] pkg_action_cards --> svc_actionCards pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel @@ -371,6 +374,7 @@ flowchart LR pkg_experimental_forge --> svc_forge pkg_experimental_forge_github --> svc_forge pkg_experimental_forge_session --> svc_forgeSession + pkg_experimental_liveshare --> svc_liveshareShares pkg_experimental_ptc_runtime_python --> svc_ptcRuntime pkg_experimental_speech_to_text --> svc_speechToText pkg_experimental_speech_to_text_sensevoice --> svc_speechToText @@ -421,6 +425,7 @@ flowchart LR pkg_schedule --> svc_schedule pkg_session --> svc_sessions pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions + pkg_session_log_deepseek --> svc_sessionLogDeepSeek pkg_session_persistence --> svc_sessionPersistence pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_projection --> svc_sessionProjections @@ -660,7 +665,7 @@ flowchart LR | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | 负责浏览器认证与共享 HTTP 请求分发;API 适配器注册端点和流。 | | `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | 提供方映射远端与变更请求 URL、按 head 分支查找变更请求并批量读取状态;同一变更请求的监听方共享一个轮询循环。 | | `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Session 投影从 shell 结果与绑定记录派生绑定,并把不含状态的 URL 发布给客户端;推送结果触发按 head 分支的发现,Session 监听方通过 forge 注册表轮询已绑定的变更请求。 | -| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | 为每个被跟随的 Session 推送一份面板状态:在 Session 沙箱中进行的只读 Git 检查、识别出的代码托管平台及其登录状态,以及带轮询状态的已绑定变更请求。 | +| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | 为每个被跟随的 Session 推送一份面板状态:使用 Session 权限进行的 Git 检查、识别出的代码托管平台及其登录状态,以及带轮询状态的已绑定变更请求。 | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | 显式实验组合从固定的本地提交创建工作树。检出目录与共享 Git 元数据受现有写权限约束;工作目录服务拥有创建后的 Session 目录。 | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | 一个 Session 投影拥有有效执行目录。文件系统验证变更,用户上下文报告目录信息,原始元数据与写入授权保持不变。 | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | @@ -671,6 +676,7 @@ flowchart LR | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | +| `ctx.sessionLogDeepSeek` | `core` | [`session-log-deepseek`](../packages/session/session-log-deepseek) | - | - | - | 插件在准备权威 Session 日志请求贡献前注册归调用方所有的排除条件;没有条件匹配的请求应用普通上传资格判断。 | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | @@ -753,5 +759,6 @@ flowchart LR | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | [`lsp-stdio`](../packages/lsp/lsp-stdio) | [`tool-lsp`](../packages/lsp/tool-lsp) | - | 提供方注册与选择,加上恰好四种操作的标准化查询执行;该 seam 不提供协议逃生口,后端必须转换为标准化请求和结果。 | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 拥有内存定义注册表、Host 半的 vm 沙箱和 request-run 往返流程;浏览器页面通过其 Remote 命名空间在线访问同一服务。 | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 注册 Host inspect 提供方、镜像 Client 提供方 manifest,并通过动态 Cordis 传输路由 Client 查询。 | +| `ctx.liveshareShares` | `service` | [`experimental-liveshare`](../packages/experimental/liveshare) | - | - | - | 以单个 Typert Remote 命名空间发布 owner 分享管理操作,供 owner 客户端面调用;每个方法都委托给同源路由原先使用的同一组操作。 | 维护模式:混合模式。服务从 Cordis 声明中发现;接口、实现和消费方角色在 `scripts/gen-doc-graphs.ts` 中分类,并设有完整性守卫。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 97ef33d221..a0a770bb22 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -729,7 +729,7 @@ export interface Config { ## `@deepseek-ai/dsh-compaction-basic` - `inject`: `llm` · `tokenMeter` · `sessions` -- `source`: [`packages/compaction/compaction-basic/src/types.ts:40`](../packages/compaction/compaction-basic/src/types.ts) +- `source`: [`packages/compaction/compaction-basic/src/types.ts:46`](../packages/compaction/compaction-basic/src/types.ts) ```ts config-catalog /** Basic compaction configuration with an optional exact-target policy table. */ @@ -1126,7 +1126,7 @@ export type Config = CuaConfig ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:205`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:232`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1160,7 +1160,7 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ maxOutputBytes?: number } ``` @@ -2997,7 +2997,7 @@ export interface JsonRpcConfig { - `inject`: `deepseekLlmApiExtensions` · `sessions` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/session/session-log-deepseek/src/index.ts:39`](../packages/session/session-log-deepseek/src/index.ts) +- `source`: [`packages/session/session-log-deepseek/src/index.ts:47`](../packages/session/session-log-deepseek/src/index.ts) ```ts config-catalog /** Session-log request contribution configuration. */ @@ -4832,6 +4832,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | | `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) | | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-context-menu` | — | [`packages/client/ui-context-menu/src/index.ts`](../packages/client/ui-context-menu/src/index.ts) | | `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | | `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | @@ -4839,6 +4840,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) | | `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-file-actions` | — | [`packages/client/ui-file-actions/src/index.ts`](../packages/client/ui-file-actions/src/index.ts) | | `@deepseek-ai/dsh-client-ui-file-drag` | — | [`packages/client/ui-file-drag/src/index.ts`](../packages/client/ui-file-drag/src/index.ts) | | `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) | | `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) | @@ -4988,6 +4990,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | +| `@deepseek-ai/dsh-experimental-liveshare` | — | [`packages/experimental/liveshare/src/index.ts`](../packages/experimental/liveshare/src/index.ts) | | `@deepseek-ai/dsh-experimental-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-ralph-bundle` | — | [`packages/experimental/ralph-bundle/src/index.ts`](../packages/experimental/ralph-bundle/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 747809634a..7e9c6f3506 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -731,7 +731,7 @@ export interface Config { ## `@deepseek-ai/dsh-compaction-basic` - `inject`: `llm` · `tokenMeter` · `sessions` -- `source`: [`packages/compaction/compaction-basic/src/types.ts:40`](../packages/compaction/compaction-basic/src/types.ts) +- `source`: [`packages/compaction/compaction-basic/src/types.ts:46`](../packages/compaction/compaction-basic/src/types.ts) ```ts config-catalog /** Basic compaction configuration with an optional exact-target policy table. */ @@ -1128,7 +1128,7 @@ export type Config = CuaConfig ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:205`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:232`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1162,7 +1162,7 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ maxOutputBytes?: number } ``` @@ -2999,7 +2999,7 @@ export interface JsonRpcConfig { - `inject`: `deepseekLlmApiExtensions` · `sessions` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/session/session-log-deepseek/src/index.ts:39`](../packages/session/session-log-deepseek/src/index.ts) +- `source`: [`packages/session/session-log-deepseek/src/index.ts:47`](../packages/session/session-log-deepseek/src/index.ts) ```ts config-catalog /** Session-log request contribution configuration. */ @@ -4834,6 +4834,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | | `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) | | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-context-menu` | — | [`packages/client/ui-context-menu/src/index.ts`](../packages/client/ui-context-menu/src/index.ts) | | `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | | `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | @@ -4841,6 +4842,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) | | `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-file-actions` | — | [`packages/client/ui-file-actions/src/index.ts`](../packages/client/ui-file-actions/src/index.ts) | | `@deepseek-ai/dsh-client-ui-file-drag` | — | [`packages/client/ui-file-drag/src/index.ts`](../packages/client/ui-file-drag/src/index.ts) | | `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) | | `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) | @@ -4990,6 +4992,7 @@ export interface Config { | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | +| `@deepseek-ai/dsh-experimental-liveshare` | — | [`packages/experimental/liveshare/src/index.ts`](../packages/experimental/liveshare/src/index.ts) | | `@deepseek-ai/dsh-experimental-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-ralph-bundle` | — | [`packages/experimental/ralph-bundle/src/index.ts`](../packages/experimental/ralph-bundle/src/index.ts) | diff --git a/docs/cordis-api/context.md b/docs/cordis-api/context.md index 320b23de1c..453f607768 100644 --- a/docs/cordis-api/context.md +++ b/docs/cordis-api/context.md @@ -110,7 +110,7 @@ The root context of the application (every child context shares it). @experiment ```ts cordis-catalog /** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */ -baseUrl?: string +baseUrl?: string | undefined ``` Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. diff --git a/docs/cordis-api/context.zh.md b/docs/cordis-api/context.zh.md index b97fe51eb8..a5c7e36b4b 100644 --- a/docs/cordis-api/context.zh.md +++ b/docs/cordis-api/context.zh.md @@ -112,7 +112,7 @@ root: this ```ts cordis-catalog /** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */ -baseUrl?: string +baseUrl?: string | undefined ``` 用于解析相对插件/模块说明符的基础 URL,前提是运行时设置了该值。 diff --git a/docs/cordis-api/events.i18n.yaml b/docs/cordis-api/events.i18n.yaml index 68421a5f2c..f423afad17 100644 --- a/docs/cordis-api/events.i18n.yaml +++ b/docs/cordis-api/events.i18n.yaml @@ -9,29 +9,29 @@ en: 9d809e3de33dd783 zh: b965aee4a65c6c1a /events/ctx-parallel-name-args: - en: e583c71acb606915 - zh: 0560679b20f8d7f0 + en: 9c0a63f484e33428 + zh: 09ee4c26931324af /events/ctx-emit-name-args: - en: ff6f4023b66f8164 - zh: af16d24d663a7eb7 + en: b41bd01cf219cbd6 + zh: 2e9660a05dc07cfc /events/ctx-serial-name-args: - en: c431c0e3ac72e854 - zh: c43b7d899ea8161c + en: 46708104f4d6a034 + zh: f1b7fcb92a5b05c0 /events/ctx-bail-name-args: - en: 85b8b2178c3e3c33 - zh: ad8af3e0743e231c + en: 6577e113d5b8ce40 + zh: b925f8070cf387f7 /events/ctx-waterfall-name-args: - en: 3b32d0511b7da48b - zh: e0ac62e59d8e2152 + en: 105fda32f60c7cea + zh: c67e6c6051c9a1af /events/ctx-on-name-listener-options: - en: 74a1fe55f4a47ec8 - zh: 87d37038af3bab0e + en: ec7c7e8a8b15d460 + zh: 79eba67c31873b0b /events/ctx-once-name-listener-options: - en: 64802f0580705984 - zh: 3f6ce2d7ce1e760d + en: 27143e45f63622dd + zh: 095f804dfa47ad37 /events/eventoptions: - en: 937453f689836c80 - zh: ebf0cc0f28127245 + en: 14ed1031e4a7abee + zh: 2380e2f73f8931af /events/dispatchmode: - en: 1c8548fb249ed794 - zh: 86b92a28f39b2a12 + en: 1081bbd7aa27baf8 + zh: cc4434892b0bb93f diff --git a/docs/cordis-api/events.md b/docs/cordis-api/events.md index 8b8eb2358e..1916532331 100644 --- a/docs/cordis-api/events.md +++ b/docs/cordis-api/events.md @@ -26,7 +26,7 @@ Dispatch an event, running all listeners concurrently. **Returns** a promise resolving once every listener has settled. -[Source](../../vendor/cordis/src/events.ts#L44) +[Source](../../vendor/cordis/src/events.ts#L43) ### ctx.emit(name, ...args) @@ -46,7 +46,7 @@ Dispatch an event synchronously, ignoring listener return values. - `name` — the event name. - `args` — arguments passed to every listener. -[Source](../../vendor/cordis/src/events.ts#L53) +[Source](../../vendor/cordis/src/events.ts#L52) ### ctx.serial(name, ...args) @@ -69,7 +69,7 @@ Dispatch an event, awaiting listeners in order until one bails. **Returns** the first bail value (non-null, non-false, non-undefined), if any. -[Source](../../vendor/cordis/src/events.ts#L63) +[Source](../../vendor/cordis/src/events.ts#L62) ### ctx.bail(name, ...args) @@ -92,7 +92,7 @@ Dispatch an event, calling listeners in order until one bails. **Returns** the first bail value (non-null, non-false, non-undefined), if any. -[Source](../../vendor/cordis/src/events.ts#L73) +[Source](../../vendor/cordis/src/events.ts#L72) ### ctx.waterfall(name, ...args) @@ -120,7 +120,7 @@ Each listener wraps the rest of the chain: calling `next()` invokes the next lis **Returns** the outermost listener's return value. -[Source](../../vendor/cordis/src/events.ts#L86) +[Source](../../vendor/cordis/src/events.ts#L85) ### ctx.on(name, listener, options?) @@ -144,7 +144,7 @@ Register an event listener owned by the current fiber. **Returns** a disposer removing the listener; `true` if it was still registered. -[Source](../../vendor/cordis/src/events.ts#L97) +[Source](../../vendor/cordis/src/events.ts#L96) ### ctx.once(name, listener, options?) @@ -168,7 +168,7 @@ Same as `on()`, but the listener disposes itself after its first call. **Returns** a disposer removing the listener; `true` if it was still registered. -[Source](../../vendor/cordis/src/events.ts#L106) +[Source](../../vendor/cordis/src/events.ts#L105) ## EventOptions @@ -184,7 +184,7 @@ interface EventOptions { } ``` -[Source](../../vendor/cordis/src/events.ts#L112) +[Source](../../vendor/cordis/src/events.ts#L111) ## DispatchMode @@ -204,4 +204,4 @@ Event dispatch strategy used by the event service. type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' ``` -[Source](../../vendor/cordis/src/events.ts#L32) +[Source](../../vendor/cordis/src/events.ts#L31) diff --git a/docs/cordis-api/events.zh.md b/docs/cordis-api/events.zh.md index 60b0580496..b66a1fc4d3 100644 --- a/docs/cordis-api/events.zh.md +++ b/docs/cordis-api/events.zh.md @@ -28,7 +28,7 @@ parallel(thisArg: NoInfer>, name: K, **返回值**:一个 Promise,在所有监听器均已完成后兑现。 -[源码](../../vendor/cordis/src/events.ts#L44) +[源码](../../vendor/cordis/src/events.ts#L43) ### ctx.emit(name, ...args) @@ -48,7 +48,7 @@ emit(thisArg: NoInfer>, name: K, ... - `name`:事件名称。 - `args`:传递给每个监听器的参数。 -[源码](../../vendor/cordis/src/events.ts#L53) +[源码](../../vendor/cordis/src/events.ts#L52) ### ctx.serial(name, ...args) @@ -71,7 +71,7 @@ serial(thisArg: NoInfer>, name: K, . **返回值**:第一个提前终止值(非 null、非 false 且非 undefined);如果没有,则不返回此类值。 -[源码](../../vendor/cordis/src/events.ts#L63) +[源码](../../vendor/cordis/src/events.ts#L62) ### ctx.bail(name, ...args) @@ -94,7 +94,7 @@ bail(thisArg: NoInfer>, name: K, ... **返回值**:第一个提前终止值(非 null、非 false 且非 undefined);如果没有,则不返回此类值。 -[源码](../../vendor/cordis/src/events.ts#L73) +[源码](../../vendor/cordis/src/events.ts#L72) ### ctx.waterfall(name, ...args) @@ -122,7 +122,7 @@ waterfall(thisArg: NoInfer>, name: K **返回值**:最外层监听器的返回值。 -[源码](../../vendor/cordis/src/events.ts#L86) +[源码](../../vendor/cordis/src/events.ts#L85) ### ctx.on(name, listener, options?) @@ -146,7 +146,7 @@ on(name: K, listener: Events[K], options?: boolean | Eve **返回值**:一个用于移除监听器的资源释放函数;如果调用该函数时监听器仍处于注册状态,则返回 `true`。 -[源码](../../vendor/cordis/src/events.ts#L97) +[源码](../../vendor/cordis/src/events.ts#L96) ### ctx.once(name, listener, options?) @@ -170,7 +170,7 @@ once(name: K, listener: Events[K], options?: boolean | E **返回值**:一个用于移除监听器的资源释放函数;如果调用该函数时监听器仍处于注册状态,则返回 `true`。 -[源码](../../vendor/cordis/src/events.ts#L106) +[源码](../../vendor/cordis/src/events.ts#L105) ## EventOptions @@ -186,7 +186,7 @@ interface EventOptions { } ``` -[源码](../../vendor/cordis/src/events.ts#L112) +[源码](../../vendor/cordis/src/events.ts#L111) ## DispatchMode @@ -206,4 +206,4 @@ interface EventOptions { type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' ``` -[源码](../../vendor/cordis/src/events.ts#L32) +[源码](../../vendor/cordis/src/events.ts#L31) diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 042af3ba02..aeba613660 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -9,14 +9,14 @@ en: b5b3510f4b6dd13a zh: a0c70471b6d779b5 /fiber/ctx-effect-execute-label: - en: ef3418fd5ffbe86d - zh: c98935435da8bc7e + en: 38bab93cf931eec1 + zh: 50fed09622da4926 /fiber/ctx-fiber: en: a10e38bf26b4ecfe zh: 7c91817984c95c99 /fiber/the-fiber-class: - en: 0e0fcb134ed44957 - zh: 326f4232aa0e65d1 + en: 31ca1205552db9ef + zh: 5fa2e5b9a7396576 /fiber/the-fiber-class/fiber-uid: en: 2f0eb3658bb8497e zh: 65d3c17ca8bd4f95 @@ -24,50 +24,53 @@ en: 1b527d85759324f9 zh: 54aa40db2705b95c /fiber/the-fiber-class/fiber-config: - en: 1efe137a7ef0e599 - zh: bb4b9818fb48f4e9 + en: 73b05dc38761b681 + zh: d663668f3e8fd33f /fiber/the-fiber-class/fiber-state: - en: 999ad29dfc29661b - zh: 20245d11c338e3f1 + en: eda419fecddc4dc8 + zh: 1a1b90f405cf3cbc /fiber/the-fiber-class/fiber-dispose: - en: 29e304d183eb9104 - zh: 9ac5023f518e06c4 -/fiber/the-fiber-class/fiber-store: - en: c4185e5d051136a2 - zh: 5714d9e4f5461ca3 + en: 43f4ad79a728a492 + zh: 71b216695d0afb22 +/fiber/the-fiber-class/fiber-committed: + en: 0485c9d782bf90da + zh: 74304cd05e9c0796 +/fiber/the-fiber-class/fiber-provisions: + en: 1cf8235d0ca54124 + zh: 3d2e39064853165f /fiber/the-fiber-class/fiber-inertia: en: 2a3b1d3bc37bf986 zh: 83bd257ee685d38b /fiber/the-fiber-class/fiber-name: - en: e5c2ecca13167a32 - zh: 284a461f0f1cd35b + en: cdc0677ce1d83361 + zh: a58305521a0626de /fiber/the-fiber-class/fiber-assertactive: - en: cceb503899634d09 - zh: a8381af1a23436f3 + en: 0c3e8008ff2d69e2 + zh: 7b454626d3e3a3b6 /fiber/the-fiber-class/fiber-effect-execute-label: - en: 55dcfacf295bbcf2 - zh: 6e3ec27bb8da5a3d + en: 0e905aa798ce3830 + zh: bbd0b0c537097d37 /fiber/the-fiber-class/fiber-geteffects: - en: 4d739a9c186fd9c2 - zh: f80fd3b87e79b004 + en: 729a1000bda8ee36 + zh: 5bfc19df3addab80 /fiber/the-fiber-class/fiber-rawconfig: - en: 898aab51825bd8c3 - zh: b9ab6339574002df + en: 80d4007e42bd8b47 + zh: 14c11dc09c6402ce /fiber/the-fiber-class/fiber-effectiveconfig: - en: 93ac680251e7023f - zh: 8ea7d7de5db39b8e + en: 8e9fcdcf458c8bde + zh: 36d21cafbf4f0198 /fiber/the-fiber-class/fiber-resolveconfig-config: - en: d7319852e2a1c3a9 - zh: 3300fac049227006 + en: f8a9ca27b288bd4c + zh: 03ce360b5200fbdb /fiber/the-fiber-class/fiber-await: - en: c9a6544cacdbc28e - zh: 636bf93efad95bc9 + en: 3f228c1861466386 + zh: 43b3f3acfc3d314b /fiber/the-fiber-class/fiber-restart: - en: 6ffc1baba98d689f - zh: 7e13bc92225c7e0a + en: 152116f669f52e95 + zh: 11d3cd941ce32d88 /fiber/the-fiber-class/fiber-update-config-nosave: - en: 5c176a6e67073e92 - zh: ee07edc8dbf7c1f3 + en: a4f711258eb70e47 + zh: fff4d880d820de10 /fiber/effect: en: 007d5b214e8a8610 zh: d74c26fbb1b8aeb6 diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index e85cc7b3e6..3bfc3a00e9 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L415) +[Source](../../vendor/cordis/src/fiber.ts#L421) ### ctx.fiber @@ -51,7 +51,7 @@ The fiber (plugin runtime instance) that owns this context. Runtime instance of one plugin application. -A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`. +A fiber tracks dependency state, raw and effective config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`. [Source](../../vendor/cordis/src/fiber.ts#L184) @@ -80,11 +80,11 @@ The context this fiber's plugin runs in (extends the parent context). ### fiber.config ```ts cordis-catalog -/** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */ +/** Raw plugin input retained for the next activation. */ public config: any ``` -The validated plugin config (updated by `update()`); read through effectiveConfig. +Raw plugin input retained for the next activation. [Source](../../vendor/cordis/src/fiber.ts#L190) @@ -97,7 +97,7 @@ public state Current lifecycle state; transitions emit `internal/status`. -[Source](../../vendor/cordis/src/fiber.ts#L194) +[Source](../../vendor/cordis/src/fiber.ts#L192) ### fiber.dispose @@ -108,16 +108,27 @@ public readonly dispose: () => Promise Dispose this fiber: unload the plugin, then settle once cleanup finished. -[Source](../../vendor/cordis/src/fiber.ts#L196) +[Source](../../vendor/cordis/src/fiber.ts#L194) -### fiber.store +### fiber.committed ```ts cordis-catalog -/** Snapshot of required service implementations while loaded; `undefined` otherwise. */ -public store: Dict | undefined +/** Required implementations retained through dependent teardown. */ +public committed: Readonly> | undefined ``` -Snapshot of required service implementations while loaded; `undefined` otherwise. +Required implementations retained through dependent teardown. + +[Source](../../vendor/cordis/src/fiber.ts#L196) + +### fiber.provisions + +```ts cordis-catalog +/** Service implementations owned by this fiber, including isolated provisions. */ +public readonly provisions: Set +``` + +Service implementations owned by this fiber, including isolated provisions. [Source](../../vendor/cordis/src/fiber.ts#L198) @@ -141,7 +152,7 @@ get name() The plugin's display name, inherited from the nearest named ancestor, else `'root'`. -[Source](../../vendor/cordis/src/fiber.ts#L336) +[Source](../../vendor/cordis/src/fiber.ts#L342) ### fiber.assertActive() @@ -159,7 +170,7 @@ Throw if the fiber has already been disposed. **Returns** nothing when the fiber is still active. -[Source](../../vendor/cordis/src/fiber.ts#L351) +[Source](../../vendor/cordis/src/fiber.ts#L357) ### fiber.effect(execute, label?) @@ -190,7 +201,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L415) +[Source](../../vendor/cordis/src/fiber.ts#L421) ### fiber.getEffects() @@ -207,7 +218,7 @@ Return metadata for currently registered effects. **Returns** one `EffectMeta` tree per labeled live effect. -[Source](../../vendor/cordis/src/fiber.ts#L568) +[Source](../../vendor/cordis/src/fiber.ts#L574) ### fiber.rawConfig @@ -218,21 +229,22 @@ get rawConfig(): unknown Raw input retained for the next activation, without evaluating expressions or validating it. -[Source](../../vendor/cordis/src/fiber.ts#L642) +[Source](../../vendor/cordis/src/fiber.ts#L649) ### fiber.effectiveConfig ```ts cordis-catalog /** - * The config object published by the existing activation/update lifecycle, without cloning or re-parsing. - * It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. + * The object passed to plugin activation or the latest successful update that retained the instance. + * Candidate resolution does not publish it; volatile updates retain its references. + * Undefined before activation and readable after unload; callers requiring a live instance check `state`. */ get effectiveConfig(): unknown ``` -The config object published by the existing activation/update lifecycle, without cloning or re-parsing. It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. +The object passed to plugin activation or the latest successful update that retained the instance. Candidate resolution does not publish it; volatile updates retain its references. Undefined before activation and readable after unload; callers requiring a live instance check `state`. -[Source](../../vendor/cordis/src/fiber.ts#L655) +[Source](../../vendor/cordis/src/fiber.ts#L664) ### fiber.resolveConfig(config) @@ -254,7 +266,7 @@ Evaluate and validate a candidate using this fiber's `internal/config` hooks and **Returns** The candidate's resolved config, retaining the schema's result identity. -[Source](../../vendor/cordis/src/fiber.ts#L667) +[Source](../../vendor/cordis/src/fiber.ts#L676) ### fiber.await() @@ -272,7 +284,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../vendor/cordis/src/fiber.ts#L731) +[Source](../../vendor/cordis/src/fiber.ts#L754) ### fiber.restart() @@ -290,35 +302,29 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../vendor/cordis/src/fiber.ts#L745) +[Source](../../vendor/cordis/src/fiber.ts#L768) ### fiber.update(config, noSave?) ```ts cordis-catalog /** - * Validate and apply new config, then restart the plugin. - * - * Runs the `internal/update` waterfall first, so update hooks (and HMR) - * can veto or replace the restart. - * - * @param config — the new raw config; validated before anything restarts. - * @param noSave — hint for persistence hooks not to write the change back. - * @returns nothing; the restart runs behind the `internal/update` waterfall. - * @throws {ValidationError} when the new config fails validation. + * Resolve a candidate, retain its raw config and apply it through the update waterfall. + * @param config Raw config; pending fibers defer resolution until activation. + * @param noSave Suppress the persistence notification when true. + * @returns The awaited update/restart result, or undefined when a synchronous listener handles it. + * @throws {ValidationError} when an active fiber's candidate is invalid. */ -update(config: any, noSave = false) +update(config: any, noSave = false): Awaitable ``` -Validate and apply new config, then restart the plugin. +Resolve a candidate, retain its raw config and apply it through the update waterfall. -Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto or replace the restart. +- `config` — Raw config; pending fibers defer resolution until activation. +- `noSave` — Suppress the persistence notification when true. -- `config` — the new raw config; validated before anything restarts. -- `noSave` — hint for persistence hooks not to write the change back. +**Returns** The awaited update/restart result, or undefined when a synchronous listener handles it. -**Returns** nothing; the restart runs behind the `internal/update` waterfall. - -[Source](../../vendor/cordis/src/fiber.ts#L764) +[Source](../../vendor/cordis/src/fiber.ts#L783) ## Effect diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index d529013e73..88d253452d 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -36,7 +36,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L415) +[源码](../../vendor/cordis/src/fiber.ts#L421) ### ctx.fiber @@ -53,7 +53,7 @@ fiber: Fiber 单次插件应用的运行时实例。 -fiber 会跟踪 `ctx.plugin()` 返回的插件上下文所对应的依赖状态、经过校验的配置、生命周期作用和清理操作。 +fiber 会跟踪 `ctx.plugin()` 返回的插件上下文所对应的依赖状态、原始配置与有效配置、生命周期作用和清理操作。 [源码](../../vendor/cordis/src/fiber.ts#L184) @@ -82,11 +82,11 @@ public readonly ctx: Context ### fiber.config ```ts cordis-catalog -/** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */ +/** Raw plugin input retained for the next activation. */ public config: any ``` -经过校验的插件配置(由 `update()` 更新);通过 `effectiveConfig` 读取。 +为下次激活保留的原始插件输入。 [源码](../../vendor/cordis/src/fiber.ts#L190) @@ -99,7 +99,7 @@ public state 当前生命周期状态;状态转换会发出 `internal/status`。 -[源码](../../vendor/cordis/src/fiber.ts#L194) +[源码](../../vendor/cordis/src/fiber.ts#L192) ### fiber.dispose @@ -110,16 +110,27 @@ public readonly dispose: () => Promise dispose 此 fiber:卸载插件,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L196) +[源码](../../vendor/cordis/src/fiber.ts#L194) -### fiber.store +### fiber.committed ```ts cordis-catalog -/** Snapshot of required service implementations while loaded; `undefined` otherwise. */ -public store: Dict | undefined +/** Required implementations retained through dependent teardown. */ +public committed: Readonly> | undefined ``` -加载期间所需服务实现的快照;其他情况下为 `undefined`。 +在依赖方清理完成前保留的所需服务实现。 + +[源码](../../vendor/cordis/src/fiber.ts#L196) + +### fiber.provisions + +```ts cordis-catalog +/** Service implementations owned by this fiber, including isolated provisions. */ +public readonly provisions: Set +``` + +此 fiber 拥有的服务实现,包括隔离域中的服务。 [源码](../../vendor/cordis/src/fiber.ts#L198) @@ -143,7 +154,7 @@ get name() 插件的显示名称,继承自最近的具名祖先;如果不存在,则为 `'root'`。 -[源码](../../vendor/cordis/src/fiber.ts#L336) +[源码](../../vendor/cordis/src/fiber.ts#L342) ### fiber.assertActive() @@ -161,7 +172,7 @@ assertActive() **返回**:fiber 仍处于活动状态时不返回任何内容。 -[源码](../../vendor/cordis/src/fiber.ts#L351) +[源码](../../vendor/cordis/src/fiber.ts#L357) ### fiber.effect(execute, label?) @@ -192,7 +203,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L415) +[源码](../../vendor/cordis/src/fiber.ts#L421) ### fiber.getEffects() @@ -209,7 +220,7 @@ getEffects() **返回**:每个带标签的活动作用对应一棵 `EffectMeta` 树。 -[源码](../../vendor/cordis/src/fiber.ts#L568) +[源码](../../vendor/cordis/src/fiber.ts#L574) ### fiber.rawConfig @@ -218,23 +229,25 @@ getEffects() get rawConfig(): unknown ``` + 保留供下次激活使用的原始输入,不执行表达式求值或验证。 -[源码](../../vendor/cordis/src/fiber.ts#L642) +[源码](../../vendor/cordis/src/fiber.ts#L649) ### fiber.effectiveConfig ```ts cordis-catalog /** - * The config object published by the existing activation/update lifecycle, without cloning or re-parsing. - * It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. + * The object passed to plugin activation or the latest successful update that retained the instance. + * Candidate resolution does not publish it; volatile updates retain its references. + * Undefined before activation and readable after unload; callers requiring a live instance check `state`. */ get effectiveConfig(): unknown ``` -既有激活/更新生命周期发布的配置对象,不克隆或重新解析。首次解析前为 undefined,卸载后仍可读取;需要运行实例的调用方应检查 `state`。 +传给插件激活过程或最近一次成功保留实例的更新的对象。候选配置求值不发布结果;volatile 更新保留其中的引用。激活前为 undefined,卸载后仍可读取;需要运行中实例的调用方还应检查 `state`。 -[源码](../../vendor/cordis/src/fiber.ts#L655) +[源码](../../vendor/cordis/src/fiber.ts#L664) ### fiber.resolveConfig(config) @@ -250,13 +263,14 @@ get effectiveConfig(): unknown resolveConfig(config: unknown): unknown ``` + 通过此 fiber 的 `internal/config` 钩子和运行时 schema 对候选值求值并验证。此操作不发布结果、不替换原始输入,也不提交运行中的 volatile 引用。钩子访问的注入服务必须已经可用。 - `config`:原始候选输入。 **返回**候选配置的解析结果,保留 schema 返回对象的身份。 -[源码](../../vendor/cordis/src/fiber.ts#L667) +[源码](../../vendor/cordis/src/fiber.ts#L676) ### fiber.await() @@ -274,7 +288,7 @@ async await() **返回**:进入稳定状态后的此 fiber。 -[源码](../../vendor/cordis/src/fiber.ts#L731) +[源码](../../vendor/cordis/src/fiber.ts#L754) ### fiber.restart() @@ -292,35 +306,29 @@ dispose 此插件,并立即使用其当前配置重新加载。 **返回**一个在重新加载完成后兑现的 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L745) +[源码](../../vendor/cordis/src/fiber.ts#L768) ### fiber.update(config, noSave?) ```ts cordis-catalog /** - * Validate and apply new config, then restart the plugin. - * - * Runs the `internal/update` waterfall first, so update hooks (and HMR) - * can veto or replace the restart. - * - * @param config — the new raw config; validated before anything restarts. - * @param noSave — hint for persistence hooks not to write the change back. - * @returns nothing; the restart runs behind the `internal/update` waterfall. - * @throws {ValidationError} when the new config fails validation. + * Resolve a candidate, retain its raw config and apply it through the update waterfall. + * @param config Raw config; pending fibers defer resolution until activation. + * @param noSave Suppress the persistence notification when true. + * @returns The awaited update/restart result, or undefined when a synchronous listener handles it. + * @throws {ValidationError} when an active fiber's candidate is invalid. */ -update(config: any, noSave = false) +update(config: any, noSave = false): Awaitable ``` -校验并应用新配置,然后重新启动插件。 +求值候选配置,保留原始输入,并通过更新 waterfall 应用结果。 -首先运行 `internal/update` waterfall(瀑布式事件),因此更新钩子(以及 HMR(热模块替换))可以否决或取代重新启动操作。 +- `config` — 原始配置;pending fiber 将求值推迟到激活时。 +- `noSave` — 为 true 时不发出持久化通知。 -- `config`:新的原始配置;在任何内容重新启动前进行校验。 -- `noSave`:提示持久化钩子不要写回此变更。 +**返回** 可等待的更新或重启结果;同步监听器处理更新时为 undefined。 -**返回**无返回值;重启由 `internal/update` waterfall 执行。 - -[源码](../../vendor/cordis/src/fiber.ts#L764) +[源码](../../vendor/cordis/src/fiber.ts#L783) ## Effect diff --git a/docs/cordis-api/inherited.md b/docs/cordis-api/inherited.md index cc62683cb5..c414eaedd5 100644 --- a/docs/cordis-api/inherited.md +++ b/docs/cordis-api/inherited.md @@ -21,15 +21,16 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie ## Inherited events (cordis core + loader/hmr/timer) -- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:331`](../../vendor/cordis/src/events.ts)) -- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:333`](../../vendor/cordis/src/events.ts)) -- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:341`](../../vendor/cordis/src/events.ts)) -- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:343`](../../vendor/cordis/src/events.ts)) -- `internal/config` — Waterfall: a fiber config is being resolved before validation. ([`vendor/cordis/src/events.ts:339`](../../vendor/cordis/src/events.ts)) -- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:345`](../../vendor/cordis/src/events.ts)) -- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:347`](../../vendor/cordis/src/events.ts)) -- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:349`](../../vendor/cordis/src/events.ts)) -- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:351`](../../vendor/cordis/src/events.ts)) +- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:337`](../../vendor/cordis/src/events.ts)) +- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:339`](../../vendor/cordis/src/events.ts)) +- `internal/service` — A service binding was refreshed in the matching isolation scope. ([`vendor/cordis/src/events.ts:341`](../../vendor/cordis/src/events.ts)) +- `internal/update` — Waterfall: apply resolved config; omitting next() keeps the current instance. ([`vendor/cordis/src/events.ts:348`](../../vendor/cordis/src/events.ts)) +- `internal/commit` — Persist raw config from a programmatic update unless noSave is set. ([`vendor/cordis/src/events.ts:355`](../../vendor/cordis/src/events.ts)) +- `internal/config` — Waterfall: resolve a candidate after injections are active without publishing it. ([`vendor/cordis/src/events.ts:362`](../../vendor/cordis/src/events.ts)) +- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:364`](../../vendor/cordis/src/events.ts)) +- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:366`](../../vendor/cordis/src/events.ts)) +- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:368`](../../vendor/cordis/src/events.ts)) +- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:370`](../../vendor/cordis/src/events.ts)) - `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) - `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts)) - `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts)) diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index 79b48d6dc5..c5248fa3f7 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -9,8 +9,8 @@ en: 1da81051addc4b47 zh: 3e8d7cd3f8c1cdba /official-deepseek-llm-api-wire-extensions/request-headers: - en: 740e92e903ecb594 - zh: f934d3c9e5c56bfe + en: d3d6a2c8240b80e9 + zh: debf42ed01233728 /official-deepseek-llm-api-wire-extensions/body-extension-transaction: en: 48001cfd1281a23b zh: d410530ae8aa2823 @@ -18,8 +18,8 @@ en: f89059050613f3e8 zh: da5cf2a4eeb81b75 /official-deepseek-llm-api-wire-extensions/dsh-session-log: - en: de1a3f2c377d74e3 - zh: 7c803345f71117c5 + en: 0d033f72951e2920 + zh: 767efca0dc39ac03 /official-deepseek-llm-api-wire-extensions/dsh-session-log/wire-session-header: en: ccd8fcd773df21c4 zh: 7ea020068ad093ae diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index 1a30f7c463..e9a270d98e 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -28,7 +28,7 @@ The [`DeepSeekLlmApiExtensionRegistry`](../packages/llm/deepseek-llm-api-extensi | `x-deepseek-harness-session-id` | Model requests carrying a Session id | The exact request `sessionId` string | | `x-deepseek-harness-compact` | Model requests whose purpose is `compaction` | The literal string `1` | -Credential failure happens before anonymous-user-id resolution, so an unauthorized request neither sends these headers nor creates the identity file. A direct request without a Session omits `x-deepseek-harness-session-id`. Session-title requests have no additional purpose header; the ordinary Session-id rule still applies when one carries a `sessionId`. +Credential failure happens before anonymous-user-id resolution, so an unauthorized request neither sends these headers nor creates the identity file. A direct request without a Session omits `x-deepseek-harness-session-id`. Session-title and Auto review requests have no additional purpose header; the ordinary Session-id rule still applies when one carries a `sessionId`. ## Body-extension transaction @@ -73,7 +73,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling ## `dsh_session_log` -[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is enabled by default. It applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field, as does a request whose first pending event alone exceeds `maxBytes` or cannot be serialized; a composition disables it with `enabled: false`. The examples below use logical Session format 2 only to illustrate the wire fields; they do not identify the [current writer format](session-format-status.md). +[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is enabled by default. It applies to a request with a live Session and at least one event; a direct request, a stale Session id, an empty log, or a request matched by a registered upload exclusion omits the field, as does a request whose first pending event alone exceeds `maxBytes` or cannot be serialized; a composition disables it with `enabled: false`. The examples below use logical Session format 2 only to illustrate the wire fields; they do not identify the [current writer format](session-format-status.md). ```json { diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index dcccecc6a7..d498e8f951 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -28,7 +28,7 @@ | `x-deepseek-harness-session-id` | 携带会话 id 的模型请求 | 确切的请求 `sessionId` 字符串 | | `x-deepseek-harness-compact` | 用途为 `compaction` 的模型请求 | 字面字符串 `1` | -凭据失败发生在解析匿名用户 id 之前,因此未授权请求既不会发送这些标头,也不会创建身份文件。没有会话的直接请求会省略 `x-deepseek-harness-session-id`。会话标题请求没有额外的用途标头;请求携带 `sessionId` 时,仍然适用普通的会话 id 规则。 +凭据失败发生在解析匿名用户 id 之前,因此未授权请求既不会发送这些标头,也不会创建身份文件。没有会话的直接请求会省略 `x-deepseek-harness-session-id`。会话标题请求和 Auto 审查请求没有额外的用途标头;请求携带 `sessionId` 时,仍然适用普通的会话 id 规则。 ## 正文扩展事务 @@ -73,7 +73,7 @@ ## `dsh_session_log` -[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认启用。它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段,第一条待发事件本身超过 `maxBytes` 或无法序列化的请求也会省略该字段;组合可用 `enabled: false` 禁用它。下方示例使用逻辑 Session 格式 2 仅为说明协议字段,并不标识[当前写入格式](session-format-status.zh.md)。 +[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认启用。它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id、空日志或命中已注册上传排除条件的请求会省略该字段,第一条待发事件本身超过 `maxBytes` 或无法序列化的请求也会省略该字段;组合可用 `enabled: false` 禁用它。下方示例使用逻辑 Session 格式 2 仅为说明协议字段,并不标识[当前写入格式](session-format-status.zh.md)。 ```json { diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 999b732bc7..406a86b628 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,8 +21,8 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: 12810e1c6ca8b8e4 - zh: b505d34cdf27856d + en: eb79b6f8ea745a9f + zh: de676c78231c823a /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 diff --git a/docs/development.md b/docs/development.md index 76e34201cb..406b6c1a7d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,7 +66,7 @@ The repository uses isolated Host and Client aggregates. An ordinary package is | `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | | `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | -Host aggregate type checks run with a 8 GiB Node old-space limit across build, release, and Windows diagnostic commands. This is a per-compiler limit; concurrent build steps need additional memory. +Host and Client aggregate type checks run with an 8 GiB Node old-space limit. This is a per-compiler limit; concurrent build steps need additional memory. `pnpm run build:official --serial` prevents build steps from overlapping, even with `DSH_GATE_CONCURRENCY` set; the DSH publication workflow uses this option. Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow: diff --git a/docs/development.zh.md b/docs/development.zh.md index ac61221c52..8dcedc8938 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -70,7 +70,7 @@ pnpm run typecheck | `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | | `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` 包 extends。 | 否 | -构建、发布和 Windows 诊断命令中的 Host aggregate 类型检查为 Node 老生代堆设置 8 GiB 上限。这是单个编译进程的上限;并发构建步骤还需要额外内存。 +Host 和 Client aggregate 类型检查为 Node 老生代堆设置 8 GiB 上限。这是单个编译进程的上限;并发构建步骤还需要额外内存。`pnpm run build:official --serial` 阻止构建步骤重叠,即使设置了 `DSH_GATE_CONCURRENCY` 也不例外;DSH 发布 workflow 使用此选项。 Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 54c5b0c4b6..f0ba4a3687 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -13,13 +13,13 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team` | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -48,9 +48,9 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | @@ -59,11 +59,11 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:427`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:433`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:440`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:419`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | -| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:447`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:432`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:438`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:445`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:424`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | +| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:452`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | @@ -83,9 +83,9 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | | `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | @@ -104,14 +104,16 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | Event string | Dispatchers | Listeners | | --- | --- | --- | | `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | +| `connection/reset` | `gateway` (`emit`) | `terminal-controller`, `ui-settings` | | `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | | `internal/service` | - | `client-ui-forge`, `gateway` | -| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | -| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | +| `internal/status` | - | [`agent`](../packages/core/agent), `claude-code-mods`, `inspector`, `translator`, [`web`](../packages/web/web) | | `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | +| `locale/change` | `locale` (`emit`) | `locale` | | `slots/changed` | `ui-renderer` (`emit`) | - | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ccbe7ca34a..32e57e9d06 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -15,13 +15,13 @@ | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team` | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -50,9 +50,9 @@ | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | @@ -61,11 +61,11 @@ | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:427`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:433`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:440`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:419`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | -| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:447`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:432`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:438`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:445`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:424`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - | +| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:452`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | @@ -85,9 +85,9 @@ | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | | `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | @@ -106,14 +106,16 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | | `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | +| `connection/reset` | `gateway` (`emit`) | `terminal-controller`, `ui-settings` | | `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | | `internal/service` | - | `client-ui-forge`, `gateway` | -| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | -| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | +| `internal/status` | - | [`agent`](../packages/core/agent), `claude-code-mods`, `inspector`, `translator`, [`web`](../packages/web/web) | | `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | +| `locale/change` | `locale` (`emit`) | `locale` | | `slots/changed` | `ui-renderer` (`emit`) | - | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 546a5f5925..433befe3a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -165,12 +165,14 @@ flowchart TD pkg_client_ui_brand_official["client-ui-brand-official"] pkg_client_ui_chat["client-ui-chat"] pkg_client_ui_commands["client-ui-commands"] + pkg_client_ui_context_menu["client-ui-context-menu"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] pkg_client_ui_directory_picker_browse["client-ui-directory-picker-browse"] pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_dockkit["client-ui-dockkit"] pkg_client_ui_entry_customization["client-ui-entry-customization"] + pkg_client_ui_file_actions["client-ui-file-actions"] pkg_client_ui_file_drag["client-ui-file-drag"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] @@ -286,6 +288,7 @@ flowchart TD pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] + pkg_experimental_liveshare["experimental-liveshare"] pkg_experimental_liveshare_protocol["experimental-liveshare-protocol"] pkg_experimental_plugin_market_bundle["experimental-plugin-market-bundle"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] @@ -498,6 +501,7 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_launch_environment + pkg_experimental_liveshare --> pkg_scope pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -1459,6 +1463,7 @@ flowchart TD pkg_client_test_runtime --> pkg_client_modules pkg_client_test_runtime --> pkg_client_store pkg_client_test_runtime --> pkg_client_ui_chat + pkg_client_test_runtime --> pkg_client_ui_context_menu pkg_client_test_runtime --> pkg_client_ui_conversation pkg_client_test_runtime --> pkg_client_ui_renderer pkg_client_test_runtime --> pkg_client_ui_session @@ -1543,12 +1548,14 @@ flowchart TD | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | — | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | — | +| [`client-ui-context-menu`](../packages/client/ui-context-menu) | `client` | — | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | — | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | — | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — | | [`client-ui-entry-customization`](../packages/client/ui-entry-customization) | `client` | — | +| [`client-ui-file-actions`](../packages/client/ui-file-actions) | `client` | — | | [`client-ui-file-drag`](../packages/client/ui-file-drag) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | @@ -1654,6 +1661,7 @@ flowchart TD | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | +| [`experimental-liveshare`](../packages/experimental/liveshare) | `experimental` | [`scope`](../packages/core/scope) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | @@ -1860,7 +1868,7 @@ flowchart TD | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 03de9ceeeb..3cc4f214b7 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -167,12 +167,14 @@ flowchart TD pkg_client_ui_brand_official["client-ui-brand-official"] pkg_client_ui_chat["client-ui-chat"] pkg_client_ui_commands["client-ui-commands"] + pkg_client_ui_context_menu["client-ui-context-menu"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] pkg_client_ui_directory_picker_browse["client-ui-directory-picker-browse"] pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_dockkit["client-ui-dockkit"] pkg_client_ui_entry_customization["client-ui-entry-customization"] + pkg_client_ui_file_actions["client-ui-file-actions"] pkg_client_ui_file_drag["client-ui-file-drag"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] @@ -288,6 +290,7 @@ flowchart TD pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] + pkg_experimental_liveshare["experimental-liveshare"] pkg_experimental_liveshare_protocol["experimental-liveshare-protocol"] pkg_experimental_plugin_market_bundle["experimental-plugin-market-bundle"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] @@ -500,6 +503,7 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_launch_environment + pkg_experimental_liveshare --> pkg_scope pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -1461,6 +1465,7 @@ flowchart TD pkg_client_test_runtime --> pkg_client_modules pkg_client_test_runtime --> pkg_client_store pkg_client_test_runtime --> pkg_client_ui_chat + pkg_client_test_runtime --> pkg_client_ui_context_menu pkg_client_test_runtime --> pkg_client_ui_conversation pkg_client_test_runtime --> pkg_client_ui_renderer pkg_client_test_runtime --> pkg_client_ui_session @@ -1545,12 +1550,14 @@ flowchart TD | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | — | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | — | +| [`client-ui-context-menu`](../packages/client/ui-context-menu) | `client` | — | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | — | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | — | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | — | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — | | [`client-ui-entry-customization`](../packages/client/ui-entry-customization) | `client` | — | +| [`client-ui-file-actions`](../packages/client/ui-file-actions) | `client` | — | | [`client-ui-file-drag`](../packages/client/ui-file-drag) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | @@ -1656,6 +1663,7 @@ flowchart TD | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | +| [`experimental-liveshare`](../packages/experimental/liveshare) | `experimental` | [`scope`](../packages/core/scope) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | @@ -1862,7 +1870,7 @@ flowchart TD | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ffdbea91d5..dce8443743 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 1387c4505dde2faf zh: 48570fa1c13d6910 /session-persistence-event-catalog/persistence-type-fingerprints: - en: 519d46d6078d3f68 - zh: ec9aec262312c573 + en: 31c6dab0878b05a8 + zh: 176d0f7cbdc3fce3 /session-persistence-event-catalog/event-envelope: en: 56414ffd00cdc32a zh: 6dc11eb013e95e41 @@ -186,14 +186,14 @@ en: 6c6a27578dff5828 zh: 9a5add2bc0df2d0a /session-persistence-event-catalog/events/session/session-title-llm-request-log-only: - en: 50d9e29d3d2bfe52 - zh: 3ff1d959cf7776b9 + en: 59b02baa2be32ca0 + zh: 3637c99652ca89cf /session-persistence-event-catalog/events/session-log-deepseek: en: 7b827672c80aecdd zh: 7b827672c80aecdd /session-persistence-event-catalog/events/session-log-deepseek/session-log-deepseek-delivery-accepted-log-only: - en: 4e9ce9182ec05808 - zh: 7f643a6756ec8f76 + en: a3e2059fcc540b64 + zh: cc9933fd677560f0 /session-persistence-event-catalog/events/step: en: bd0dcbbc352b771a zh: bd0dcbbc352b771a @@ -753,8 +753,11 @@ en: 1e5fb67af2382e55 zh: 1e5fb67af2382e55 /session-persistence-event-catalog/resolved-persistence-types/set: - en: b2a724cef8d97ef7 - zh: b2a724cef8d97ef7 + en: 8e70b63b25a29f58 + zh: 8e70b63b25a29f58 +/session-persistence-event-catalog/resolved-persistence-types/share-viewer: + en: 761fbf929bc6e6ed + zh: 761fbf929bc6e6ed /session-persistence-event-catalog/resolved-persistence-types/skill-catalog: en: 9483a26e4c21d639 zh: 9483a26e4c21d639 @@ -1008,11 +1011,11 @@ en: a6a2e38fdd6d127a zh: 1fe82356d89f5769 /session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest: - en: cf3979c698e2b2ac - zh: 6252c8b668faa706 + en: fd75d1f4b9bd643c + zh: dde892ea9752f298 /session-persistence-event-catalog/resolved-persistence-types/developermessage: - en: 16992572a6919e52 - zh: 853f4873fd520444 + en: c6171fd8144d74b1 + zh: 2f3dd34cc8d7ed92 /session-persistence-event-catalog/resolved-persistence-types/epochheader: en: 3bdc8ecd0bdf0c9c zh: 0fe5b75c174ebab4 @@ -1122,11 +1125,11 @@ en: 6dbe099c62f7c226 zh: 6fd788f63c038c91 /session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata: - en: 5d61649043e317aa - zh: bdf9b9d553d291cf + en: 06b510217b6056c4 + zh: 5b3a1e022f96de57 /session-persistence-event-catalog/resolved-persistence-types/message: - en: 56953b055da0dc2b - zh: dcaa193ddb2fece0 + en: 7a8ec632289bc7f9 + zh: 5c90cb4f3d5d0a67 /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete: en: c582953270d4ca44 zh: 966ccfe2e7660a2a @@ -1137,14 +1140,14 @@ en: f58575a1fc96df42 zh: 9f0c6ed9033e5c9d /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating: - en: 70ade19513d0070f - zh: ff9acb50974a32ae + en: 65fb012fcc0fa7db + zh: b829c586714e45e2 /session-persistence-event-catalog/resolved-persistence-types/messagesource: - en: b1bd7c8bd2309d45 - zh: d434e41517c3620d + en: ddeed90cb6112496 + zh: cefb41765c45b22f /session-persistence-event-catalog/resolved-persistence-types/message~2: - en: dcd71b3190faff96 - zh: b2c07224705aa52e + en: 8e96551640079569 + zh: a06d587638d42237 /session-persistence-event-catalog/resolved-persistence-types/modelmessagesource: en: 5f9154ca34235ab9 zh: a833105910ebfa82 @@ -1164,11 +1167,8 @@ en: 3e36e2f29ff1b506 zh: 3dcc1a1dced59d9f /session-persistence-event-catalog/resolved-persistence-types/presentedfile~2: - en: bb11552744e477b8 - zh: afb881f3ebf97e68 -/session-persistence-event-catalog/resolved-persistence-types/principal: - en: 6d0d81e0e833403e - zh: fa47cd75ee75f948 + en: 764b3e83192daae3 + zh: aa3f87bffe8fb501 /session-persistence-event-catalog/resolved-persistence-types/ptcdispatcheventdata: en: c0f7b22b41cb0608 zh: de14a1b5cac17665 @@ -1206,11 +1206,11 @@ en: 9a54c4478ad3b620 zh: 02c4c1fd7e252770 /session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata: - en: 46baac9dcdfb1b63 - zh: 30a357bb8405c147 + en: 1f9f610fa0e05abf + zh: 11560710a1b871de /session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata: - en: 035b656ba6500174 - zh: fc9d4d7cd154ca78 + en: 465fe144e60dd62f + zh: 29e1029426106c3b /session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity: en: 9531468264499498 zh: 9da23c0ad2776563 @@ -1218,8 +1218,11 @@ en: 13de8e35b749db45 zh: 01e4f3bcb6784fc8 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlesource: - en: d842f1883e1b97d1 - zh: 41356f94354511ce + en: 8e07874aa7841b0b + zh: d93527a0f8837075 +/session-persistence-event-catalog/resolved-persistence-types/shareviewerprincipal: + en: a7b8c3f842059eec + zh: 4b345faa13cc39f6 /session-persistence-event-catalog/resolved-persistence-types/skillcatalogsource: en: 2c3148e4a9f0c8f9 zh: dab42cb485067bcd @@ -1317,14 +1320,14 @@ en: 778debd61e0284a2 zh: 2cc82ad9e80f18b1 /session-persistence-event-catalog/resolved-persistence-types/turnendreason: - en: 3cbc1b5ae251451f - zh: a95ff0555e368432 + en: 953127f7cf1e0866 + zh: 3613ef1aafd8191a /session-persistence-event-catalog/resolved-persistence-types/usermessage: - en: 1b30ae0450f156bd - zh: 00bb0efae7ac741e + en: a15a08beb2b824da + zh: 4fd8dc6b9680c4cf /session-persistence-event-catalog/resolved-persistence-types/usermessage~2: - en: 1f242f48a3c8c230 - zh: 2516be50ce573b0c + en: 85e537b82f1da7d8 + zh: 73a1b9d4b5d29ab0 /session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome: en: d0a3f9aab18c3de5 zh: 76ac69a835ca4e29 @@ -1371,9 +1374,12 @@ en: f692844b29923b2e zh: 72f12bca08d63267 /session-persistence-event-catalog/resolved-persistence-types/union-2-variants~2: - en: 69d360cf5a1ae6b4 - zh: f23faf39a2a0e6f1 + en: 4a1acf31ea15d403 + zh: d1ebe94ffd308763 /session-persistence-event-catalog/resolved-persistence-types/union-2-variants~3: + en: 4318d8c6211d4e95 + zh: d08f3f8c11c32506 +/session-persistence-event-catalog/resolved-persistence-types/union-2-variants~4: en: 5a1af9849b0967ea zh: 1085d793bbc47360 /session-persistence-event-catalog/resolved-persistence-types/union-3-variants: @@ -1482,8 +1488,11 @@ en: a07969828eed2e6f zh: e06893743af544b5 /session-persistence-event-catalog/resolved-persistence-types/dialect-handlerid-matcher-point: - en: eeba88520aacd126 - zh: 8e2ca4e2bd2c1e3a + en: 9748b4f0facfe9f8 + zh: 83ae2cd2cd02b804 +/session-persistence-event-catalog/resolved-persistence-types/displayname-providerid-subjectid-tenantid: + en: d2c98ed9b98325ae + zh: bf8c2f7967cf9cc8 /session-persistence-event-catalog/resolved-persistence-types/end-start: en: 4b7a8793663d3fef zh: 249459c825d330c3 @@ -1503,11 +1512,11 @@ en: 4cb1c48095df98dc zh: 5a03e44761af559e /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: - en: 27f5b76003ac1ead - zh: 2a5a0490cc22512b + en: 7d1610d20e987988 + zh: 1297cba0f2a2c73d /session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn: - en: 598021cdfe2a8687 - zh: 02e63a8b453f2f4e + en: 4f9635604ed5b84d + zh: 74810a2d049907e6 /session-persistence-event-catalog/resolved-persistence-types/height-width: en: a5df50710083ac25 zh: aad3ccf6f6de70e4 @@ -1524,11 +1533,11 @@ en: 41f73c58a77dfa28 zh: af77f4aee3cb4a40 /session-persistence-event-catalog/resolved-persistence-types/inherited: - en: 7e64cae3bd1b71e9 - zh: 7451c0639044c59a + en: 8b5c65ce7b57514f + zh: 231494d71e5d635a /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: df2dd98c35b34bd2 - zh: ee25992a3f29d561 + en: 5a3be677a8dc51ff + zh: c5fc8b80f73e72d9 /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: en: 8e86cd838e3e4a49 zh: f5fec2a8adf1f3cc @@ -1668,8 +1677,11 @@ en: 3c986a3b9cbc4f95 zh: 875bc0c6d786f80e /session-persistence-event-catalog/resolved-persistence-types/kind-model-selection-form-snapshot: - en: 509f9a91aedb2da1 - zh: db1071678bfb98d1 + en: b0e849caa4bd0be7 + zh: e5d0dbcf8a326caf +/session-persistence-event-catalog/resolved-persistence-types/kind-owner: + en: 686b6465397b96c2 + zh: c21c1cc3c9df4fd8 /session-persistence-event-catalog/resolved-persistence-types/kind-parent: en: 5ab43c283829de53 zh: 5ed4e90922a3a88b @@ -1854,11 +1866,11 @@ en: 08be4349548406a9 zh: 8e7cf9966198ef78 /session-persistence-event-catalog/resolved-persistence-types/kind-tool-registry: - en: 5a0bbf4c76266ac5 - zh: 0b20854f90f9d74d + en: 4e102391d685eff5 + zh: 9762243a13014f3d /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 3d3ae20cba45bae8 - zh: 04ad200556cd8ced + en: 984cac7296874653 + zh: 13b4ee707a8ce305 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1920,8 +1932,8 @@ en: 04b34b8ffe7e7764 zh: a792d5371516fd9d /session-persistence-event-catalog/resolved-persistence-types/sessionformatversion-sessionid-throughseq: - en: 8aaa02b06896492d - zh: 4dd0e1ee40d8f22d + en: 8063597183ef767f + zh: b064698c54089f07 /session-persistence-event-catalog/resolved-persistence-types/shadowedrange-shadowedseqs-shadowedtokencount: en: 7a2ab7b62244f43b zh: 1084e3f8876feebc @@ -1944,11 +1956,11 @@ en: 374b8adee8d571fa zh: b21057efc58e6490 /session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected: - en: a990181189cf77ac - zh: 70198727356d1ad3 + en: da6e084997b20cdc + zh: 1780f6fcf3496de1 /session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced: - en: f3b491e65c79b3a0 - zh: 2d3e53ceb67a55a3 + en: 1a79ed1a97145a14 + zh: d6f658a85c1e65f7 /session-persistence-event-catalog/resolved-persistence-types/type-approval-asked: en: 9c77f18b0dad22f0 zh: 8894cf2878fe90db @@ -1992,11 +2004,11 @@ en: ba73fb10b7e2ffc9 zh: 8d5c97e63e5f16da /session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented: - en: 17956f440c284ea3 - zh: 5dbfd68d50483047 + en: b9c35ef9a9e1d585 + zh: 7b8ad008c8b7389c /session-persistence-event-catalog/resolved-persistence-types/type-developer-message: - en: ded2f3b02f776800 - zh: 4d6619c283a18e3c + en: b0a3c504326f42f9 + zh: f13a1c1ca28f7da3 /session-persistence-event-catalog/resolved-persistence-types/type-disabled: en: 585627a50ece4e5f zh: 157837aee3da0ac3 @@ -2070,11 +2082,11 @@ en: acf7e21e4d21ebff zh: fb83a853634d204a /session-persistence-event-catalog/resolved-persistence-types/type-session-title: - en: f0acf1c873cd9e2a - zh: b8e175e14306afd4 + en: 5a42c1482248e385 + zh: c7ba7d071e4756de /session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request: - en: ab4f20d890eed5e5 - zh: 6e4035a166821157 + en: d88fd740ff167f04 + zh: fe6598a1bf4cd9bf /session-persistence-event-catalog/resolved-persistence-types/type-step-end: en: 7fc233c4a569dae2 zh: bc2b3fed4a397c00 @@ -2151,11 +2163,11 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: e8b2eedeb83ac7fb - zh: 036143c1ff567cf5 + en: a1d9300dd7a7b043 + zh: 3cb1e0e6834e9765 /session-persistence-event-catalog/resolved-persistence-types/type-user-message: - en: 5b8341a673284ec1 - zh: e3264a827babffeb + en: 44035d460a9d926b + zh: 8006b6dbfc58d5ae /session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request: en: 99c8837344178a47 zh: bf746f5678346596 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index bb09364340..96128feabb 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30) | +| `event:agent/inbox/spliced` | event | `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -36,7 +36,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f` | [`{ type: "developer/message" }`](#persistence-type-sha256-95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f) | +| `event:developer/message` | event | `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` | [`{ type: "developer/message" }`](#persistence-type-sha256-70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -58,7 +58,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5) | +| `event:session/title-llm-request` | event | `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -80,7 +80,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f` | [`{ type: "user/message" }`](#persistence-type-sha256-39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f) | +| `event:user/message` | event | `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` | [`{ type: "user/message" }`](#persistence-type-sha256-5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -878,7 +878,7 @@ Source: [`packages/session/session-title/src/index.ts:77`](../packages/session/s Types: [SessionTitleLlmRequestEventData](subsystems/session-title.md) -Source: [`packages/session/session-title-llm/src/index.ts:57`](../packages/session/session-title-llm/src/index.ts) +Source: [`packages/session/session-title-llm/src/index.ts:63`](../packages/session/session-title-llm/src/index.ts) ### `session-log-deepseek/*` @@ -898,7 +898,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:57`](../packages/sessi } ``` -Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/session/session-log-deepseek/src/types.ts) +Source: [`packages/session/session-log-deepseek/src/types.ts:96`](../packages/session/session-log-deepseek/src/types.ts) ### `step/*` @@ -2539,6 +2539,14 @@ SHA-256: `b765ee31033cea463863faf3fc6a0a1b85c47c6a2f13fe1ce833abe56105114b` `"set"` + + +### `"share-viewer"` + +SHA-256: `6ba7edb6447b6a3e7947dddf5e4116c1526000c4e62882a6d65edc67ba797c2c` + +`"share-viewer"` + ### `"skill-catalog"` @@ -3375,7 +3383,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web | `body` | required | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | required | `string` | - + @@ -3383,7 +3391,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web ### `DeveloperMessage` -SHA-256: `cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0` +SHA-256: `64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428` Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3392,7 +3400,7 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"developer"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -4091,7 +4099,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr | `step` | required | `number` | | `turn` | required | `number` | - + @@ -4099,15 +4107,15 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr ### `Message` -SHA-256: `4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d` +SHA-256: `c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36` Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) One of: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0) -- [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14) +- [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) +- [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4184,7 +4192,7 @@ One of: - `"negative"` - `"positive"` - + @@ -4192,7 +4200,7 @@ One of: ### `MessageSource` -SHA-256: `b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591` +SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4304,7 +4312,7 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `tool-jobs` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "tool-jobs", form: "snapshot" }`](#persistence-type-sha256-c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49) | | `tool-jobs` | not declared | none | [`{ kind: "tool-jobs" }`](#persistence-type-sha256-24ce31aa528272e59f21cd26da9eca08968bcc97529756a749bfdab71746d9c8) | | `tool-registry` | not declared | none | [`{ kind: "tool-registry" }`](#persistence-type-sha256-8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543) | -| `user` | not declared | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def) | +| `user` | not declared | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302) | | `user` | not declared | none | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | | `user-approval` | `"catalog"` | none | [`{ kind: "user-approval", form: "catalog" }`](#persistence-type-sha256-825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8) | | `user-approval` | `"instructions"` | none | [`{ kind: "user-approval", form: "instructions" }`](#persistence-type-sha256-aa2bf9e82b9fdeb94a42af20031fffa30e4e23f01f4ac39757427d4c64c407f0) | @@ -4317,13 +4325,13 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | not declared | none | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc` +SHA-256: `ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72` -Array of [`Message`](#persistence-type-sha256-4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d). +Array of [`Message`](#persistence-type-sha256-c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36). @@ -4441,22 +4449,6 @@ SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345` Array of [`PresentedFile`](#persistence-type-sha256-b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97). - - - - - - -### `Principal` - -SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` - -Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) · [`packages/typert/protocol/src/types.ts:461`](../packages/typert/protocol/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `kind` | required | `"owner"` | - @@ -4707,7 +4699,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ | `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | required | `string` | - + @@ -4715,15 +4707,15 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ ### `SessionTitleLlmRequestEventData` -SHA-256: `9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f` +SHA-256: `7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54` -Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) +Sources: [`packages/session/session-title-llm/src/index.ts:43`](../packages/session/session-title-llm/src/index.ts) | Property | Presence | Type | |---|---|---| | `maxTokens` | required | `number` | | `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | required | [`Message[]`](#persistence-type-sha256-d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc) | +| `messages` | required | [`Message[]`](#persistence-type-sha256-ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72) | | `reasoningEffort` | optional | `string` | | `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | required | `string` | @@ -4776,6 +4768,24 @@ Sources: [`packages/session/session-title/src/types.ts:28`](../packages/session/ | `provider` | not declared | `provider`: `string` | [`{ kind: "provider" }`](#persistence-type-sha256-b81e9de81a481b24e1e1f9adf038bfc59e57b4f35ce62f8f1e45284186f4f812) | | `user` | not declared | none | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | + + + + + + +### `ShareViewerPrincipal` + +SHA-256: `4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b` + +Sources: [`packages/experimental/liveshare/src/host/viewer-principal.ts:24`](../packages/experimental/liveshare/src/host/viewer-principal.ts) + +| Property | Presence | Type | +|---|---|---| +| `actor` | required | [`{ displayName?, providerId, subjectId, tenantId }`](#persistence-type-sha256-0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623) | +| `kind` | required | `"share-viewer"` | +| `shareId` | required | `string` | + @@ -5380,7 +5390,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5388,7 +5398,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src ### `UserMessage` -SHA-256: `8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14` +SHA-256: `4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce` Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5397,19 +5407,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"user"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. Attribution-only additions: `action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292` +SHA-256: `7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3` -Array of [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14). +Array of [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce). @@ -5673,6 +5683,17 @@ One of: - `"fork"` - `"fresh"` + + +### `union (2 variants)` + +SHA-256: `824a4aad44a54e245c67196817d67946734a2dc0eee0b0b6834a5b2a6ef352ce` + +| kind | Form property | Other required fields | Full definition | +|---|---|---|---| +| `owner` | not declared | none | [`{ kind: "owner" }`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | +| `share-viewer` | not declared | `actor`: [`{ displayName?, providerId, subjectId, tenantId }`](#persistence-type-sha256-0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623); `shareId`: `string` | [`ShareViewerPrincipal`](#persistence-type-sha256-4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b) | + ### `union (2 variants)` @@ -6198,6 +6219,21 @@ Sources: [`packages/experimental/hook-protocol/src/types.ts:19`](../packages/exp | `point` | required | `string` | | `turn` | required | `number` | + + +### `{ displayName?, providerId, subjectId, tenantId }` + +SHA-256: `0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623` + +Sources: [`packages/experimental/liveshare/src/host/viewer-principal.ts:29`](../packages/experimental/liveshare/src/host/viewer-principal.ts) + +| Property | Presence | Type | +|---|---|---| +| `displayName` | optional | `string` | +| `providerId` | required | `string` | +| `subjectId` | required | `string` | +| `tenantId` | required | `string` | + ### `{ end, start }` @@ -6294,18 +6330,18 @@ Sources: [`packages/core/session/src/types.ts:398`](../packages/core/session/src | `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | optional | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2` +SHA-256: `53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a` Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `headerSeq` | optional | `number` | -| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0) | +| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) | | `step` | required | `number` | | `turn` | required | `number` | @@ -6389,17 +6425,17 @@ Sources: [`packages/core/session/src/types.ts:435`](../packages/core/session/src |---|---|---| | `inherited` | optional | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c` +SHA-256: `d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401` Sources: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| -| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292) | +| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3) | | `outcome` | optional | `"canceled"` | | `removedCount` | optional | `number` | | `start` | required | `number` | @@ -6953,6 +6989,18 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` | `kind` | required | `"model-selection"` | | `sections` | required | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | + + +### `{ kind: "owner" }` + +SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` + +Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `kind` | required | `"owner"` | + ### `{ kind: "parent" }` @@ -7651,11 +7699,11 @@ Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/inde |---|---|---| | `kind` | required | `"tool-registry"` | - + ### `{ kind: "user" }` -SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` +SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302` Sources: [`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) @@ -7663,7 +7711,7 @@ Sources: [`packages/api/session-controller/src/types.ts:397`](../packages/api/se |---|---|---| | `clientTimeZone` | optional | `string` | | `kind` | required | `"user"` | -| `principal` | optional | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | +| `principal` | optional | [`union (2 variants)`](#persistence-type-sha256-824a4aad44a54e245c67196817d67946734a2dc0eee0b0b6834a5b2a6ef352ce) | | `rpcId` | required | `string` | @@ -7931,7 +7979,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:64`](../packages/web SHA-256: `8bb8c3751a6ce64b61aba1ec7ae798c00a5803c4d868a0430873216c6569ed52` -Sources: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/session/session-log-deepseek/src/types.ts) +Sources: [`packages/session/session-log-deepseek/src/types.ts:96`](../packages/session/session-log-deepseek/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8046,17 +8094,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | required | `number` | | `type` | required | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30` +SHA-256: `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c) | +| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8297,17 +8345,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | required | `number` | | `type` | required | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f` +SHA-256: `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2) | +| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8707,17 +8755,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | required | `number` | | `type` | required | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5` +SHA-256: `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f) | +| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -9140,17 +9188,17 @@ Sources: [`packages/llm/llm/src/types.ts:502`](../packages/llm/llm/src/types.ts) | `type` | required | `"usage"` | | `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f` +SHA-256: `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` | Property | Presence | Type | |---|---|---| -| `data` | required | [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14) | +| `data` | required | [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index f46e8ed471..c890b50507 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -25,7 +25,7 @@ | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30) | +| `event:agent/inbox/spliced` | event | `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -38,7 +38,7 @@ | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f` | [`{ type: "developer/message" }`](#persistence-type-sha256-95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f) | +| `event:developer/message` | event | `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` | [`{ type: "developer/message" }`](#persistence-type-sha256-70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -60,7 +60,7 @@ | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5) | +| `event:session/title-llm-request` | event | `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -82,7 +82,7 @@ | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f` | [`{ type: "user/message" }`](#persistence-type-sha256-39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f) | +| `event:user/message` | event | `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` | [`{ type: "user/message" }`](#persistence-type-sha256-5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -880,7 +880,7 @@ export type SessionEvent = { 类型:[SessionTitleLlmRequestEventData](subsystems/session-title.zh.md) -来源:[`packages/session/session-title-llm/src/index.ts:57`](../packages/session/session-title-llm/src/index.ts) +来源:[`packages/session/session-title-llm/src/index.ts:63`](../packages/session/session-title-llm/src/index.ts) ### `session-log-deepseek/*` @@ -900,7 +900,7 @@ export type SessionEvent = { } ``` -来源:[`packages/session/session-log-deepseek/src/types.ts:81`](../packages/session/session-log-deepseek/src/types.ts) +来源:[`packages/session/session-log-deepseek/src/types.ts:96`](../packages/session/session-log-deepseek/src/types.ts) ### `step/*` @@ -2541,6 +2541,14 @@ SHA-256: `b765ee31033cea463863faf3fc6a0a1b85c47c6a2f13fe1ce833abe56105114b` `"set"` + + +### `"share-viewer"` + +SHA-256: `6ba7edb6447b6a3e7947dddf5e4116c1526000c4e62882a6d65edc67ba797c2c` + +`"share-viewer"` + ### `"skill-catalog"` @@ -3377,7 +3385,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` | `body` | 必需 | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | 必需 | `string` | - + @@ -3385,7 +3393,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` ### `DeveloperMessage` -SHA-256: `cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0` +SHA-256: `64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428` 来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3394,7 +3402,7 @@ SHA-256: `cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"developer"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -4093,7 +4101,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` | `step` | 必需 | `number` | | `turn` | 必需 | `number` | - + @@ -4101,15 +4109,15 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` ### `Message` -SHA-256: `4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d` +SHA-256: `c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36` 来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) 以下类型之一: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0) -- [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14) +- [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) +- [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4186,7 +4194,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` - `"negative"` - `"positive"` - + @@ -4194,7 +4202,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` ### `MessageSource` -SHA-256: `b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591` +SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` 来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4306,7 +4314,7 @@ SHA-256: `b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591` | `tool-jobs` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "tool-jobs", form: "snapshot" }`](#persistence-type-sha256-c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49) | | `tool-jobs` | 未声明 | 无 | [`{ kind: "tool-jobs" }`](#persistence-type-sha256-24ce31aa528272e59f21cd26da9eca08968bcc97529756a749bfdab71746d9c8) | | `tool-registry` | 未声明 | 无 | [`{ kind: "tool-registry" }`](#persistence-type-sha256-8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543) | -| `user` | 未声明 | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def) | +| `user` | 未声明 | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302) | | `user` | 未声明 | 无 | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | | `user-approval` | `"catalog"` | 无 | [`{ kind: "user-approval", form: "catalog" }`](#persistence-type-sha256-825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8) | | `user-approval` | `"instructions"` | 无 | [`{ kind: "user-approval", form: "instructions" }`](#persistence-type-sha256-aa2bf9e82b9fdeb94a42af20031fffa30e4e23f01f4ac39757427d4c64c407f0) | @@ -4319,13 +4327,13 @@ SHA-256: `b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591` | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | 未声明 | 无 | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc` +SHA-256: `ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72` -[`Message`](#persistence-type-sha256-4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d) 的数组。 +[`Message`](#persistence-type-sha256-c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36) 的数组。 @@ -4443,22 +4451,6 @@ SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345` [`PresentedFile`](#persistence-type-sha256-b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97) 的数组。 - - - - - - -### `Principal` - -SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` - -来源:[`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) · [`packages/typert/protocol/src/types.ts:461`](../packages/typert/protocol/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `kind` | 必需 | `"owner"` | - @@ -4709,7 +4701,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` | `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | 必需 | `string` | - + @@ -4717,15 +4709,15 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` ### `SessionTitleLlmRequestEventData` -SHA-256: `9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f` +SHA-256: `7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54` -来源:[`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) +来源:[`packages/session/session-title-llm/src/index.ts:43`](../packages/session/session-title-llm/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `maxTokens` | 必需 | `number` | | `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc) | +| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72) | | `reasoningEffort` | 可选 | `string` | | `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | 必需 | `string` | @@ -4778,6 +4770,24 @@ SHA-256: `30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f` | `provider` | 未声明 | `provider`: `string` | [`{ kind: "provider" }`](#persistence-type-sha256-b81e9de81a481b24e1e1f9adf038bfc59e57b4f35ce62f8f1e45284186f4f812) | | `user` | 未声明 | 无 | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | + + + + + + +### `ShareViewerPrincipal` + +SHA-256: `4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b` + +来源:[`packages/experimental/liveshare/src/host/viewer-principal.ts:24`](../packages/experimental/liveshare/src/host/viewer-principal.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `actor` | 必需 | [`{ displayName?, providerId, subjectId, tenantId }`](#persistence-type-sha256-0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623) | +| `kind` | 必需 | `"share-viewer"` | +| `shareId` | 必需 | `string` | + @@ -5382,7 +5392,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5390,7 +5400,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` ### `UserMessage` -SHA-256: `8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14` +SHA-256: `4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce` 来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5399,19 +5409,19 @@ SHA-256: `8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"user"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. 仅表示归属的新增 kind:`action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292` +SHA-256: `7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3` -[`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14) 的数组。 +[`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) 的数组。 @@ -5675,6 +5685,17 @@ SHA-256: `50cf02d875f8a1f1411f4a6fbcffab9120ccef5f74a47331fbf29480e5f68f48` - `"fork"` - `"fresh"` + + +### `union (2 variants)` + +SHA-256: `824a4aad44a54e245c67196817d67946734a2dc0eee0b0b6834a5b2a6ef352ce` + +| kind | form 属性 | 其他必需字段 | 完整定义 | +|---|---|---|---| +| `owner` | 未声明 | 无 | [`{ kind: "owner" }`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | +| `share-viewer` | 未声明 | `actor`: [`{ displayName?, providerId, subjectId, tenantId }`](#persistence-type-sha256-0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623); `shareId`: `string` | [`ShareViewerPrincipal`](#persistence-type-sha256-4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b) | + ### `union (2 variants)` @@ -6200,6 +6221,21 @@ SHA-256: `119abe2b9c0b2f46ecb31d82b9acba4d4b30e049f655e2475e8b4ae12d5a9e25` | `point` | 必需 | `string` | | `turn` | 必需 | `number` | + + +### `{ displayName?, providerId, subjectId, tenantId }` + +SHA-256: `0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623` + +来源:[`packages/experimental/liveshare/src/host/viewer-principal.ts:29`](../packages/experimental/liveshare/src/host/viewer-principal.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `displayName` | 可选 | `string` | +| `providerId` | 必需 | `string` | +| `subjectId` | 必需 | `string` | +| `tenantId` | 必需 | `string` | + ### `{ end, start }` @@ -6296,18 +6332,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` | `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | 可选 | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2` +SHA-256: `53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a` 来源:[`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `headerSeq` | 可选 | `number` | -| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0) | +| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) | | `step` | 必需 | `number` | | `turn` | 必需 | `number` | @@ -6391,17 +6427,17 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` |---|---|---| | `inherited` | 可选 | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c` +SHA-256: `d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401` 来源:[`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| -| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292) | +| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3) | | `outcome` | 可选 | `"canceled"` | | `removedCount` | 可选 | `number` | | `start` | 必需 | `number` | @@ -6955,6 +6991,18 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` | `kind` | 必需 | `"model-selection"` | | `sections` | 必需 | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | + + +### `{ kind: "owner" }` + +SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` + +来源:[`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `kind` | 必需 | `"owner"` | + ### `{ kind: "parent" }` @@ -7653,11 +7701,11 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` |---|---|---| | `kind` | 必需 | `"tool-registry"` | - + ### `{ kind: "user" }` -SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` +SHA-256: `a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302` 来源:[`packages/api/session-controller/src/types.ts:397`](../packages/api/session-controller/src/types.ts) @@ -7665,7 +7713,7 @@ SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` |---|---|---| | `clientTimeZone` | 可选 | `string` | | `kind` | 必需 | `"user"` | -| `principal` | 可选 | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | +| `principal` | 可选 | [`union (2 variants)`](#persistence-type-sha256-824a4aad44a54e245c67196817d67946734a2dc0eee0b0b6834a5b2a6ef352ce) | | `rpcId` | 必需 | `string` | @@ -7933,7 +7981,7 @@ SHA-256: `9a2a9029f8d7ede05336980d8342737557f487b913bfd28853d0ab5214600ab5` SHA-256: `8bb8c3751a6ce64b61aba1ec7ae798c00a5803c4d868a0430873216c6569ed52` -来源:[`packages/session/session-log-deepseek/src/types.ts:81`](../packages/session/session-log-deepseek/src/types.ts) +来源:[`packages/session/session-log-deepseek/src/types.ts:96`](../packages/session/session-log-deepseek/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8048,17 +8096,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | 必需 | `number` | | `type` | 必需 | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30` +SHA-256: `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c) | +| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8299,17 +8347,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | 必需 | `number` | | `type` | 必需 | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f` +SHA-256: `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2) | +| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8709,17 +8757,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | 必需 | `number` | | `type` | 必需 | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5` +SHA-256: `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f) | +| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -9142,17 +9190,17 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` | `type` | 必需 | `"usage"` | | `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f` +SHA-256: `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14) | +| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.i18n.yaml b/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.i18n.yaml new file mode 100644 index 0000000000..4202182528 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-13-liveshare-share-viewer-principal.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 docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-13-liveshare-share-viewer-principal: + en: 9b8097ab5922733e + zh: e52f33004c75f18f +/2026-10-13-liveshare-share-viewer-principal/summary: + en: 32551ff4ee358694 + zh: f6cfc403bed4f780 +/2026-10-13-liveshare-share-viewer-principal/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-13-liveshare-share-viewer-principal/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-13-liveshare-share-viewer-principal/compatibility: + en: 3c4faef3cc5010bb + zh: 051485cd767ec6bc +/2026-10-13-liveshare-share-viewer-principal/verification: + en: 427079a2393d0ffe + zh: 17249b4e0fc0faeb +/2026-10-13-liveshare-share-viewer-principal/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.md b/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.md new file mode 100644 index 0000000000..a22251e96d --- /dev/null +++ b/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.md @@ -0,0 +1,60 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-13-liveshare-share-viewer-principal + +English | [中文](2026-10-13-liveshare-share-viewer-principal.zh.md) + +## Summary + +Adds the LiveShare share-viewer principal, including the Relay-asserted display name when available, to the user-rpc message source of prompts a share reader submits. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-13-liveshare-share-viewer-principal +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-25-skill-catalog-incomplete" + after: "cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-25-skill-catalog-incomplete" + after: "70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-25-skill-catalog-incomplete" + after: "2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c" + decision: same-version + - root: "event:user/message" + previous: "2026-10-25-skill-catalog-incomplete" + after: "5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451" + decision: same-version +``` + + +## Compatibility + +The share-viewer variant is written only for a prompt that a LiveShare share reader's Peer submits; owner prompts still omit principal, so their sources and every existing Session log are unchanged. The value carries the share id and the Relay-asserted provider, tenant, and subject ids. It also carries an optional display name when the Relay asserts one, for attribution only; credentials and avatars are never persisted, and authorization still uses the identity triple. Readers that do not know the optional field keep showing the durable subject id. The LiveShare host consumes it in two places: the `agent/pre-step` admission check re-reads the Relay's grant for every entering message whose principal is a share viewer, and rejects the step when that grant no longer holds; the same step records, in memory for that turn only, that the running turn was viewer-started, which confines that turn to the shared tool surface and leaves an owner-started turn on the Session's native tools. That marker is never appended to the Session log and is recomputed from the next admitted step. A build without LiveShare keeps the message an ordinary user message because the kind stays 'user', and a principal kind this package does not drive is left to whoever drives it. A current reader of an older log finds no share-viewer principal and admits those prompts as ordinary input, as before. The field is not part of any model request. SESSION_FORMAT_VERSION does not change. + + +## Verification + +pnpm exec vitest run packages/experimental/liveshare/tests/relay-target-admission.host.spec.ts packages/experimental/liveshare/tests/relay-pre-step-admission.host.spec.ts packages/experimental/liveshare/tests/share-host-listeners.spec.ts packages/experimental/liveshare packages/experimental/liveshare-protocol: a share reader's prompt persists source.principal in agent/inbox/spliced, recovered reader input is refused at the `agent/pre-step` admission check, an admitted one enters the model, and a principal this package does not drive is left alone; the optional displayName is persisted for attribution, accepted only as a non-empty string, and missing or malformed values fall back to the subject id; 35 tests passed plus the two-package LiveShare and protocol suites. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.schema.json b/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.schema.json new file mode 100644 index 0000000000..dcce773ee6 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.schema.json @@ -0,0 +1,253 @@ +{ + "formatVersion": 2, + "roots": [ + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451"} + ], + "types": [], + "nodes": { + "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "02b2c23f2d94b028": {"kind":"object","properties":[{"name":"kind","type":"c5687b1313206a6f","optional":false}],"indices":[]}, + "05205074b24d8340": {"kind":"literal","value":"action-cards"}, + "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, + "07e1a58c58b593f5": {"kind":"object","properties":[{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "09655b34d7f3cf1e": {"kind":"literal","value":"system-prompt"}, + "09f3f8dc417f2782": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"8af841fc9b265ade","optional":false},{"name":"source","type":"65a8400e451e667e","optional":false}],"indices":[]}, + "0a92c5bdfa06c2e2": {"kind":"object","properties":[{"name":"displayName","type":"4cd48f3e5108bd6e","optional":true},{"name":"providerId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subjectId","type":"4cd48f3e5108bd6e","optional":false},{"name":"tenantId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "0ed8d1108fe7a5e1": {"kind":"object","properties":[{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "0eeb17e1df89b1eb": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "0fe52387f6505270": {"kind":"literal","value":"plan-mode"}, + "11638dc58a31496f": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]}, + "1379f0b72275994d": {"kind":"literal","value":"developer/message"}, + "16d0507cb94b4cfc": {"kind":"literal","value":"relay"}, + "1922cd79a70806c1": {"kind":"literal","value":"skill-invocation"}, + "1938c8c20f1cba7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1e709346005826eb": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1f0133217eeeffd8": {"kind":"literal","value":"tool-call"}, + "1f1385dbc3e875c0": {"kind":"literal","value":"repeat-tool-reminder"}, + "1fdad624334d3548": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "2152caf1a71e588e": {"kind":"object","properties":[{"name":"kind","type":"2e4debca12f2c449","optional":false},{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "2235d957bf4b7c6c": {"kind":"object","properties":[{"name":"kind","type":"7b1ce60193fa8b69","optional":false}],"indices":[]}, + "224aa11d4d5f88c4": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "23af3759678a30de": {"kind":"literal","value":"dsh-session-title-llm"}, + "24b10f7cdae64928": {"kind":"literal","value":"runtime-context"}, + "24ce31aa528272e5": {"kind":"object","properties":[{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "260523b412c19ad8": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "2765e9119edf5816": {"kind":"literal","value":"model"}, + "277b61f9c6f81704": {"kind":"literal","value":"file"}, + "28936c614bd4e7e2": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "290f32bfd81ff85a": {"kind":"object","properties":[{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "297fbd8067d7815a": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"}, + "2e4debca12f2c449": {"kind":"literal","value":"team-message"}, + "2e505098f9209e52": {"kind":"object","properties":[{"name":"data","type":"7ff62f6599c083ce","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, + "2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]}, + "2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"}, + "312aee2f0c42aabb": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "31907de956375182": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"6b23ed9ce1b397e6","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "3247e566f4340029": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "335e242de1fcc17b": {"kind":"union","types":["bcf0caf62d964b2f","e438a9d4fb883b3e"]}, + "383cf73ae4748e5b": {"kind":"literal","value":"recall"}, + "3b04790e08b35241": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "3b4f2c9ef2c1217e": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "3bd652ebfa8726b3": {"kind":"union","types":["0657d3ca5ac4e7bf","938f7c7357bc403e"]}, + "3bdb8dc6989655b0": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "3c2535ca0a2062f1": {"kind":"literal","value":"reasoning"}, + "3e2ece4967cfebe9": {"kind":"literal","value":"next-step"}, + "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"}, + "4035903993dfee62": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"7e9f1072c17f7aa8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "43e311235aa537df": {"kind":"object","properties":[{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"83d6916021eede8f","optional":false}],"indices":[]}, + "45f005e6bbb76452": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "4991dfc415c9efeb": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "49dab019d00e8c26": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "4a9bef9f27a94252": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"82470020b5bfcb1a","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4b259f7f8f0a44ad": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"isError","type":"aa93010d0e521ad7","optional":true},{"name":"role","type":"a13d8f379222ece8","optional":false},{"name":"source","type":"d9c462b9f62fdd3e","optional":false},{"name":"toolCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4cd48f3e5108bd6e": {"kind":"primitive","type":"string"}, + "4d60dddaa65d3da8": {"kind":"object","properties":[{"name":"actor","type":"0a92c5bdfa06c2e2","optional":false},{"name":"kind","type":"6ba7edb6447b6a3e","optional":false},{"name":"shareId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4e44c9118152f682": {"kind":"literal","value":"text"}, + "4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]}, + "4e97c3d85c0fc817": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"}, + "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, + "52c6113cb0e53f34": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "53d4f8256bc58109": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"64ef542b1bfc9883","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "58015da1c1950a66": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "587bb11da730e06a": {"kind":"literal","value":"session/title-llm-request"}, + "589d0d91239681f8": {"kind":"literal","value":"developer"}, + "58f94390b982f8a5": {"kind":"literal","value":"image/png"}, + "5bb231bc5afdb532": {"kind":"object","properties":[{"name":"data","type":"4035903993dfee62","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, + "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "5cdbf342b7748d15": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "5d03ba38734809bc": {"kind":"array","element":"0657d3ca5ac4e7bf"}, + "5d7a64ed6aac5551": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"}, + "64ef542b1bfc9883": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"7e9f1072c17f7aa8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]}, + "65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, + "672c4aa2a9b736e3": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1922cd79a70806c1","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "68f44f59e91c4139": {"kind":"literal","value":"user"}, + "6b23ed9ce1b397e6": {"kind":"literal","value":"compact-checkpoint"}, + "6ba7edb6447b6a3e": {"kind":"literal","value":"share-viewer"}, + "6ccb6bc423fb3952": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "6ea608bdc8a7a63d": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "6ebcc2dbec9f2dab": {"kind":"literal","value":"skill-catalog"}, + "6f22a9d02b9a1f11": {"kind":"object","properties":[{"name":"baseline","type":"dacf3d5f3e4ff619","optional":true},{"name":"baselineIdentity","type":"4cd48f3e5108bd6e","optional":true},{"name":"changes","type":"2ab12a0e345658f9","optional":false},{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"fbd2c74b346a1595","optional":false}],"indices":[]}, + "6f44f32be9bcd9a8": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "6f9e00383836275e": {"kind":"literal","value":"snapshot"}, + "70d2acdec1ebcfea": {"kind":"object","properties":[{"name":"data","type":"53d4f8256bc58109","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, + "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]}, + "7382c4fc95fe8c5d": {"kind":"literal","value":"remove"}, + "7554565be416b034": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "756b5e23a5cb2c7f": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "78a9be340090b735": {"kind":"literal","value":"tmux-context"}, + "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"}, + "7c203f3d4dfa6638": {"kind":"array","element":"4035903993dfee62"}, + "7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "7e9f1072c17f7aa8": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, + "7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "7f13860ee89a2d35": {"kind":"literal","value":"session-reference"}, + "7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "7ff62f6599c083ce": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"ff2cbb53582d1827","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]}, + "80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "8067b0d232c350f1": {"kind":"literal","value":"time-context"}, + "814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]}, + "82470020b5bfcb1a": {"kind":"literal","value":"agent-message"}, + "824a4aad44a54e24": {"kind":"union","types":["4d60dddaa65d3da8","deebfc7c62177ea8"]}, + "825ca23416b1ea9d": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "829a2fb3be6efd4b": {"kind":"literal","value":"agent/inbox/spliced"}, + "83079c8a3f733ac3": {"kind":"object","properties":[{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8338ee303e2bd448": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "83d6916021eede8f": {"kind":"literal","value":"tool-removal"}, + "83da72c6857601c7": {"kind":"object","properties":[{"name":"action","type":"904bf499d98be4a3","optional":false},{"name":"digest","type":"4cd48f3e5108bd6e","optional":true},{"name":"path","type":"4cd48f3e5108bd6e","optional":false},{"name":"scope","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "854db3efdfb0cd39": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "85d6d96ff8842974": {"kind":"literal","value":"answered"}, + "85f9b505b7bdff1f": {"kind":"literal","value":"tool-addition"}, + "85fe96bf15d5c755": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "8620189c8e335827": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "86e1e384495ee09a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "87b1a33564b9a4d6": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "88841202942c7683": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8a046b52331959aa": {"kind":"object","properties":[{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "8af841fc9b265ade": {"kind":"literal","value":"system"}, + "8c1c4f72755a78ea": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8c627d7a45fa406c": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8e611bf46821e500": {"kind":"object","properties":[{"name":"tool","type":"fcf427290c6213c2","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"85f9b505b7bdff1f","optional":false}],"indices":[]}, + "8f324cea3916c051": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]}, + "904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]}, + "938f7c7357bc403e": {"kind":"primitive","type":"null"}, + "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"}, + "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"}, + "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, + "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]}, + "99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "99e7e7d7fed55ae6": {"kind":"literal","value":"tool-jobs"}, + "99f0e98184709ad1": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "9ac52f0f84fec2eb": {"kind":"literal","value":"model-selection"}, + "9edc162949abf93b": {"kind":"object","properties":[{"name":"deliveryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"db6c5246911d47a7","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"ruleId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "9f65c43e340bba31": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "a13d8f379222ece8": {"kind":"literal","value":"tool"}, + "a15a19ef63cf5d1b": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "a16e567c8cd2c55c": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "a2066bd52f61218b": {"kind":"object","properties":[{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a4afec59b75aad5d": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"824a4aad44a54e24","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a720757f704a2512": {"kind":"literal","value":"tool-registry"}, + "a9059a81430b3207": {"kind":"literal","value":"hooks-claude-code"}, + "aa2bf9e82b9fdeb9": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "aa93010d0e521ad7": {"kind":"primitive","type":"boolean"}, + "aae343bc3d663c83": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "aba175fb20ffc711": {"kind":"array","element":"83079c8a3f733ac3"}, + "ada310bf0bdb8fed": {"kind":"object","properties":[{"name":"kind","type":"68f44f59e91c4139","optional":false}],"indices":[]}, + "ade057f4fdc387ae": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "aec150797b655c5f": {"kind":"literal","value":"catalog"}, + "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b2a5c8c008a31e3d": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "b3681c026a0b98e9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "b4ec0af39fd52ed9": {"kind":"object","properties":[{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b52f215a6cd69105": {"kind":"array","element":"8064c03c87c357fd"}, + "b613f83572fe2fbf": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b6f94c17dd9bbd7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b70f3e81fe11b7a5": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "b765ee31033cea46": {"kind":"literal","value":"set"}, + "b79641a9d25e7566": {"kind":"object","properties":[{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "b815d7b91cdd9fc6": {"kind":"literal","value":"goal"}, + "ba2a1a8f194b1bb2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "ba4c40b4381e0dcd": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "baccc97ec34be6c4": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "bb94f3217aa56c88": {"kind":"literal","value":"hooks-codex"}, + "bbc5696e3f13c758": {"kind":"opaque","reason":"unknown"}, + "bcf0caf62d964b2f": {"kind":"object","properties":[{"name":"endSeq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"op","type":"f6cb462f808309e3","optional":false},{"name":"startSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "bf16d2e98a7f1389": {"kind":"literal","value":"subagent-settled"}, + "c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]}, + "c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]}, + "c5687b1313206a6f": {"kind":"literal","value":"cordis-host-runner"}, + "c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"}, + "c78b9097bfae83cd": {"kind":"union","types":["09f3f8dc417f2782","4035903993dfee62","4b259f7f8f0a44ad","64ef542b1bfc9883","fa87537f4a9c1e25"]}, + "cb1c8959e1726f82": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "cdf988172b589b23": {"kind":"object","properties":[{"name":"data","type":"d4d960185026266e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, + "d4d960185026266e": {"kind":"object","properties":[{"name":"inserted","type":"7c203f3d4dfa6638","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, + "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"}, + "d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]}, + "d9edf1862e4471d6": {"kind":"literal","value":"schedule"}, + "dacf3d5f3e4ff619": {"kind":"literal","value":true}, + "db06ef35b27a3842": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "db6c5246911d47a7": {"kind":"literal","value":"webhook"}, + "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]}, + "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "de25352d2dc41963": {"kind":"literal","value":"user/message"}, + "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"}, + "deaae5cd30987d4f": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "deebfc7c62177ea8": {"kind":"object","properties":[{"name":"kind","type":"95a3ffccbf3c8afc","optional":false}],"indices":[]}, + "df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"}, + "e438a9d4fb883b3e": {"kind":"literal","value":"append"}, + "e7f4cbb3a57f4cbd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "e8de0f457fed5b9f": {"kind":"literal","value":"notice"}, + "ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"}, + "edbbfff1128099fd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "ee577a0cc18664c9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "f19bee84bf3d2090": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "f230c0b583e23b25": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "f29d63336e8266d8": {"kind":"object","properties":[{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "f2da0f27c1e6add5": {"kind":"object","properties":[{"name":"attachment","type":"f19bee84bf3d2090","optional":false},{"name":"type","type":"277b61f9c6f81704","optional":false}],"indices":[]}, + "f4a66f654996c093": {"kind":"object","properties":[{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f56c5b59ca016131": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"7f13860ee89a2d35","optional":false},{"name":"references","type":"df843efd87c78cf8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, + "f6329fb30c7ea801": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f6605d6c57933aea": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f68c335306535a00": {"kind":"literal","value":"user-approval"}, + "f6afe41e3926f422": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "f6cb462f808309e3": {"kind":"literal","value":"replace"}, + "f7283223aacdc39b": {"kind":"object","properties":[{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f88f1b7b6bb733f6": {"kind":"literal","value":1}, + "f97f47b0eb87905e": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fa87537f4a9c1e25": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"95c0e88fcf0bafb2","optional":false},{"name":"source","type":"719c51f67074f970","optional":false}],"indices":[]}, + "fb8b6a2e53cb9cf4": {"kind":"literal","value":"image"}, + "fbd2c74b346a1595": {"kind":"literal","value":"agent-instructions"}, + "fcf427290c6213c2": {"kind":"primitive","type":"never"}, + "fcf81d3c58c13593": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"}, + "ff2cbb53582d1827": {"kind":"array","element":"c78b9097bfae83cd"} + } +} diff --git a/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.zh.md b/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.zh.md new file mode 100644 index 0000000000..7250b05ac2 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-liveshare-share-viewer-principal.zh.md @@ -0,0 +1,60 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-13-liveshare-share-viewer-principal + +[English](2026-10-13-liveshare-share-viewer-principal.md) | 中文 + +## 概述 + +为分享读者提交的提示词,在 user-rpc 消息来源中加入 LiveShare 的 share-viewer principal;Relay 断言了显示名时一并保存。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-13-liveshare-share-viewer-principal +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-25-skill-catalog-incomplete" + after: "cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-25-skill-catalog-incomplete" + after: "70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-25-skill-catalog-incomplete" + after: "2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c" + decision: same-version + - root: "event:user/message" + previous: "2026-10-25-skill-catalog-incomplete" + after: "5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451" + decision: same-version +``` + + +## 兼容性 + +只有 LiveShare 分享读者的 Peer 提交提示词时才写入 share-viewer 变体;所有者的提示词仍不写 principal,因此其来源与所有已有会话日志都不变。该值含分享 ID 和 Relay 断言的 provider、tenant、subject ID;Relay 断言了显示名时还包含可选的 displayName,仅供展示。凭据与头像永不持久化,授权仍只使用身份三元组;不认识该可选字段的读取方继续显示持久化的 subject id。LiveShare Host 在两个地方消费它:`agent/pre-step` 准入校验会为每条带分享查看者 principal 的进入消息重新读取 Relay 授权,授权不再成立时拒绝该步骤;同一步骤还会在当前轮次的内存状态中记录该轮次由查看者发起,使该轮次只运行共享工具面,而所有者自己发起的轮次保留会话原生工具。该标记不写入会话日志,并从下一个准入步骤重新计算。未装 LiveShare 的构建因 kind 仍为 'user' 而照常把它当作普通用户消息;本包不驱动的 principal 种类交由驱动它的插件处理。当前读取方读取旧日志时找不到 share-viewer principal,与以往一样把这些提示词当作普通输入放行。该字段不进入任何模型请求。SESSION_FORMAT_VERSION 不变。 + + +## 验证 + +pnpm exec vitest run packages/experimental/liveshare/tests/relay-target-admission.host.spec.ts packages/experimental/liveshare/tests/relay-pre-step-admission.host.spec.ts packages/experimental/liveshare/tests/share-host-listeners.spec.ts packages/experimental/liveshare packages/experimental/liveshare-protocol:分享读者的提示词在 agent/inbox/spliced 中持久化 source.principal,恢复的读者输入在 `agent/pre-step` 准入校验中被拒绝,已准入的输入进入模型,本包不驱动的 principal 种类不受影响;可选 displayName 仅用于展示,只接受非空字符串,缺失或非法值回退 subject id;35 个测试通过,另有 LiveShare 与协议两个包的全量测试。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 40d842e063..e9e8287e17 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: e3511a4ac3bc4457 - zh: 828f3c39f80d5370 + en: 5912b107ada0bfc4 + zh: b997083d6fed3f07 /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 74c575b300..e0a594594d 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 622 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 626 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 12eb600771..4202dfcc4e 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 622 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 626 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 9d08f764a6..aa46bc962e 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -5,7 +5,7 @@ {"key":"JsonlHeaderLine","kind":"header","digest":"18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f"}, {"key":"SessionEventEnvelope","kind":"envelope","digest":"5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc"}, {"key":"event:agent-preset/selected","kind":"event","event":"agent-preset/selected","surface":false,"digest":"a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b"}, - {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30"}, + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e"}, {"key":"event:approval/asked","kind":"event","event":"approval/asked","surface":false,"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1"}, {"key":"event:approval/decided","kind":"event","event":"approval/decided","surface":false,"digest":"bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3"}, {"key":"event:approval/policy","kind":"event","event":"approval/policy","surface":false,"digest":"26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78"}, @@ -18,7 +18,7 @@ {"key":"event:compaction/start","kind":"event","event":"compaction/start","surface":false,"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965"}, {"key":"event:compaction/summary","kind":"event","event":"compaction/summary","surface":false,"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602"}, {"key":"event:deliverables/presented","kind":"event","event":"deliverables/presented","surface":false,"digest":"13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176"}, - {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f"}, {"key":"event:feedback/message-delete","kind":"event","event":"feedback/message-delete","surface":false,"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796"}, {"key":"event:feedback/message-put","kind":"event","event":"feedback/message-put","surface":false,"digest":"b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf"}, {"key":"event:feedback/record","kind":"event","event":"feedback/record","surface":false,"digest":"b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8"}, @@ -40,7 +40,7 @@ {"key":"event:session-log-deepseek/delivery-accepted","kind":"event","event":"session-log-deepseek/delivery-accepted","surface":false,"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9"}, {"key":"event:session/end-seed","kind":"event","event":"session/end-seed","surface":false,"digest":"5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e"}, {"key":"event:session/title","kind":"event","event":"session/title","surface":false,"digest":"1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5"}, - {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c"}, {"key":"event:step/end","kind":"event","event":"step/end","surface":false,"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05"}, {"key":"event:step/start","kind":"event","event":"step/start","surface":false,"digest":"4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986"}, {"key":"event:subagent/catalog","kind":"event","event":"subagent/catalog","surface":false,"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac"}, @@ -62,7 +62,7 @@ {"key":"event:tool/result","kind":"event","event":"tool/result","surface":true,"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397"}, {"key":"event:turn/end","kind":"event","event":"turn/end","surface":false,"digest":"0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"}, {"key":"event:turn/start","kind":"event","event":"turn/start","surface":false,"digest":"aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8"}, - {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451"}, {"key":"event:web/deepseek-search-llm-request","kind":"event","event":"web/deepseek-search-llm-request","surface":false,"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52"}, {"key":"event:working-directory/change","kind":"event","event":"working-directory/change","surface":false,"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3"}, {"key":"event:workspace/changes","kind":"event","event":"workspace/changes","surface":false,"digest":"e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"} @@ -84,6 +84,7 @@ {"digest":"09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363","names":["packages/llm/llm/src/message.ts#SystemMessage"],"sources":["packages/llm/llm/src/message.ts:151"]}, {"digest":"0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3","names":[],"sources":["packages/core/session/src/types.ts:209"]}, {"digest":"0a8a062bc9c3c0d97fd97742262f2ea53dd32297f834e81114870d38ac3dc6da","names":[],"sources":[]}, + {"digest":"0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623","names":[],"sources":["packages/experimental/liveshare/src/host/viewer-principal.ts:29"]}, {"digest":"0aa62ab51b36c0e64b134bd03ba8725d758744180a1a447da49beaad194f219f","names":[],"sources":[]}, {"digest":"0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555","names":["packages/llm/llm/src/types.ts#ReplayEnvelope"],"sources":["packages/llm/llm/src/types.ts:474"]}, {"digest":"0af3ecfb8c60d416fc40a7cc4fb2c4851a8e46608b2b38ab9e510670c971aa1f","names":[],"sources":[]}, @@ -109,7 +110,6 @@ {"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3","names":[],"sources":[]}, {"digest":"12a0cfa619ac4a5fc6c3e8e359c8dae15769edc61497a575935c78d5842f8e42","names":[],"sources":[]}, {"digest":"12d5808fac1996b9b3c2afa806e03022252ba2d7b88f835862ae1896591f60c7","names":["packages/llm/llm/src/types.ts#ImageBlock"],"sources":["packages/llm/llm/src/types.ts:86"]}, - {"digest":"12f9a5a7506bca46d6ef8e3c71826c4bf40e1389bfe0236cfffaea0c3fb367d2","names":[],"sources":["packages/core/session/src/types.ts:317"]}, {"digest":"1379f0b72275994d20f1ddca2d60a96ab16ec80397ca9f1af21bdc67ba5c269c","names":[],"sources":[]}, {"digest":"13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176","names":[],"sources":[]}, {"digest":"14230f9b8b5797bb7e8c621fa636531cfbde2ffba1ef5f1fd058216ff21952a0","names":[],"sources":[]}, @@ -183,6 +183,7 @@ {"digest":"2da725e554cd89301d829ed9dc45a572c3cd1b93b5e91d0d71f007976d8a0863","names":[],"sources":[]}, {"digest":"2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f","names":["packages/schedule/schedule/src/types.ts#ScheduleChange"],"sources":["packages/schedule/schedule/src/types.ts:256"]}, {"digest":"2e4debca12f2c449f96dc6574f3fbf2fb346193443114a241d01a236362ec48c","names":[],"sources":[]}, + {"digest":"2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c","names":[],"sources":[]}, {"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:129"]}, {"digest":"2f1e6a7ebace43d727ae138ddcd352eb3813960009e5805b8c5ac84300c97716","names":["packages/attachment/attachment/src/types.ts#ImageMediaType"],"sources":["packages/attachment/attachment/src/types.ts:8"]}, {"digest":"2f3f201c8846e19c20bb75c02280e3d4eb3c57509e4715f0c0a4d1d4f91a7a06","names":[],"sources":[]}, @@ -207,7 +208,6 @@ {"digest":"383cf73ae4748e5beb281ae74857ba1ffb92ae01075b9fbf51af78b666007a8f","names":[],"sources":[]}, {"digest":"3860b4757d512a2869be833acb0faf834335b1b056bf3f83df5474dce95f8fa6","names":[],"sources":["packages/llm/llm/src/assistant-stream.ts:35"]}, {"digest":"390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackRating"],"sources":["packages/feedback/message-feedback/src/types.ts:17"]}, - {"digest":"39adb7fce8475af6402acd925ea79697b3341c22422ac32833d71217d84e987f","names":[],"sources":[]}, {"digest":"39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f","names":["packages/jobs/jobs/src/types.ts#JobToolCall"],"sources":["packages/jobs/jobs/src/types.ts:16"]}, {"digest":"3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408","names":[],"sources":[]}, {"digest":"3b1be838223869fe0a08210db85bf773796ed3f2373ac16555dff227cade0c48","names":[],"sources":[]}, @@ -226,6 +226,7 @@ {"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796","names":[],"sources":[]}, {"digest":"3f74ba74acd10644c1ed6947a411d8088bd2cfac74f61bb8540f96d68e31f004","names":[],"sources":[]}, {"digest":"3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f","names":["packages/schedule/schedule/src/types.ts#LegacyAfterScheduleRecord"],"sources":["packages/schedule/schedule/src/types.ts:161"]}, + {"digest":"4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, {"digest":"408927267422a7b2beb6df354ff7c1406d929a5646554f48adf85eb8372f811c","names":[],"sources":[]}, {"digest":"4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41","names":[],"sources":[]}, {"digest":"42e0916e0dda5f6d1e7bb05d8514717147c036a79c9f36085683469516c1fd3f","names":[],"sources":[]}, @@ -252,13 +253,13 @@ {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]}, {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:388","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b","names":["packages/experimental/liveshare/src/host/viewer-principal.ts#ShareViewerPrincipal"],"sources":["packages/experimental/liveshare/src/host/viewer-principal.ts:24"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, {"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:69","packages/web/web-search-deepseek/src/provider.ts:66"]}, {"digest":"4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a","names":["packages/subagent/subagent/src/continuation-messages.ts#SubagentSettledMessageSource"],"sources":["packages/subagent/subagent/src/continuation-messages.ts:29"]}, {"digest":"4edec1b770e8ec4ce1220bf42edc5ac46713a1b48d4caa778dcfc98bad1f28a3","names":[],"sources":[]}, {"digest":"4f507598cf476214aa7646ce7841a7064a9dcb05d0a1eaaefaf79c2b738f160f","names":[],"sources":[]}, - {"digest":"4f9125fa028a56286fac70176ecc670d12f4402d8cbe112e14f0639f5e8e5c9d","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, {"digest":"4f91f187baeabef5c7950c4780d31bbe39f3345dff591a6cf26ac7e135ac1cf4","names":[],"sources":["packages/context/session-reference/src/types.ts:18"]}, {"digest":"4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc","names":[],"sources":[]}, {"digest":"50cf02d875f8a1f1411f4a6fbcffab9120ccef5f74a47331fbf29480e5f68f48","names":[],"sources":[]}, @@ -272,6 +273,7 @@ {"digest":"530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6","names":["packages/core/session/src/types.ts#RequestHeaderReason"],"sources":["packages/core/session/src/types.ts:279"]}, {"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac","names":[],"sources":[]}, {"digest":"53cf1704ae0709c7678bcf070c83c619fc48ca1b5c173b74cfa8702f22a8e492","names":[],"sources":[]}, + {"digest":"53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a","names":[],"sources":["packages/core/session/src/types.ts:317"]}, {"digest":"54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0","names":[],"sources":["packages/core/session/src/types.ts:363"]}, {"digest":"557e148d30b79ee48645850832b30c717d7c3b64641deb5d6133b8b4e91ee0b8","names":[],"sources":[]}, {"digest":"5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c","names":[],"sources":[]}, @@ -288,6 +290,7 @@ {"digest":"5abab3a27fd2736660ecddd94b28d6bbf114986dd894880153bd1be3ccacb563","names":[],"sources":[]}, {"digest":"5b232a9b9bfcc0fc6794dab7865bcf203a65785c5de5aa647a51228bff628413","names":[],"sources":[]}, {"digest":"5b6fb1f226ff56402db08a6aae82af76100b14056271875f69ec526a1ae05d51","names":[],"sources":["packages/interaction/commands/src/types.ts:112"]}, + {"digest":"5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451","names":[],"sources":[]}, {"digest":"5bb69b88517f6214f2af4d1a6d2b771e271ad3ad695cc9c2c272069ae698c9bc","names":[],"sources":[]}, {"digest":"5bc66269cfaf614c0ecda8ecb469f23101a7609793fc2b693ac23c4aecdb575e","names":[],"sources":[]}, {"digest":"5bd310104cfe448e530beb11422d2f791e2e32e8e95fdc528a15f9ca693dae75","names":[],"sources":[]}, @@ -312,6 +315,7 @@ {"digest":"62e6a429ff3f390a46ac39fb468692032f42936ca0db19b53454184189401379","names":[],"sources":[]}, {"digest":"62ecb62dc0fe1b5e70217f8a80c39256a40ee3a1917be14e5ca899d20c35f7fd","names":[],"sources":[]}, {"digest":"6487b10ba7542f3c447b1e41a1c6a590b7bfafe0b9b66b49f34028da85bb970f","names":["packages/subagent/subagent/src/descriptor.ts#ContinuableSubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:72"]}, + {"digest":"64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, {"digest":"6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25","names":["packages/experimental/agent-team/src/types.ts#TeamMemberSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:48"]}, {"digest":"65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad","names":["packages/llm/llm/src/message.ts#SystemPromptMessageSource"],"sources":["packages/llm/llm/src/message.ts:35"]}, {"digest":"65d583e66518c5236015e0846470fb32efcf92375ed306aef14e50a9d2cd2099","names":["packages/skill/tool-skill/src/index.ts#SkillCatalogSource"],"sources":["packages/skill/tool-skill/src/index.ts:35"]}, @@ -326,6 +330,7 @@ {"digest":"69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199","names":[],"sources":[]}, {"digest":"6ab45ac76839722df912f3ba50f1c6b41f2d614bde11f1aabfae04117ccdc80d","names":[],"sources":[]}, {"digest":"6b23ed9ce1b397e602788dbe033693bf97642a69e23fcab1cc45bf6f13b6d619","names":[],"sources":[]}, + {"digest":"6ba7edb6447b6a3e7947dddf5e4116c1526000c4e62882a6d65edc67ba797c2c","names":[],"sources":[]}, {"digest":"6bbe793f6a93dc8d50a4d4d3fbb6f8ed15650bcb7e151d28669628f7b7c3fc25","names":["packages/goal/goal/src/types.ts#GoalSnapshot"],"sources":["packages/goal/goal/src/types.ts:60"]}, {"digest":"6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5","names":[],"sources":["packages/core/session/src/types.ts:338"]}, {"digest":"6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800","names":[],"sources":["packages/core/session/src/types.ts:196"]}, @@ -334,7 +339,6 @@ {"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]}, {"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]}, {"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]}, - {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:397"]}, {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]}, {"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]}, {"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]}, @@ -345,6 +349,7 @@ {"digest":"6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20","names":["packages/llm/llm-retry/src/types.ts#LlmRetryStartedEventData"],"sources":["packages/llm/llm-retry/src/types.ts:43"]}, {"digest":"6f9e00383836275e46c0a9915767f9bb561be5fc30aae490174908a93e99a912","names":[],"sources":[]}, {"digest":"70b082fe78991edf13cf66e1b4d06cd2dcff9cacfd76f8cfb996bfb61939e7b2","names":[],"sources":[]}, + {"digest":"70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f","names":[],"sources":[]}, {"digest":"7180844e1066230c5e891f1b9ccd942423b0fe48aac6ded7eef2a285b288a732","names":[],"sources":[]}, {"digest":"719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91","names":["packages/llm/llm/src/message.ts#ModelMessageSource"],"sources":["packages/llm/llm/src/message.ts:24"]}, {"digest":"71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd","names":[],"sources":["packages/llm/llm/src/assistant-stream.ts:28"]}, @@ -362,23 +367,27 @@ {"digest":"7a651f3b672c52fb615f22c0ce9cb3cc5b22aad92e6bbeefb0808fd7b9233a50","names":[],"sources":[]}, {"digest":"7af85bf70d4eafce63e739adae38ea85501e68df3a0e22f97f3a0be90fd9e4cb","names":[],"sources":[]}, {"digest":"7b1ce60193fa8b69292e5843858d9cee7dc565dcb40d33c136e5d7e8b8d4296a","names":[],"sources":[]}, + {"digest":"7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3","names":[],"sources":[]}, {"digest":"7c6129b6961cf4b2a030f36c78d2b0a5be38617651a9b81322e054ee512bee06","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackDelete"],"sources":["packages/feedback/message-feedback/src/types.ts:46"]}, {"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397","names":[],"sources":[]}, {"digest":"7d226b9fd95b4ba213d4237c22e930a91c3c769c95ca7f92977b9d625ba13438","names":["packages/goal/goal/src/domain.ts#GoalChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:44"]}, {"digest":"7d54a5a5e629e2e7783bfac72683bcc5e969e9f43426b9289826aaf294ff6344","names":["packages/feedback/command-feedback/src/types.ts#FeedbackRecord"],"sources":["packages/feedback/command-feedback/src/types.ts:27"]}, {"digest":"7d65acf0a3a43a8f445ce5d3320b42eca492739fc931b2a7516bfb8368dc2c68","names":["packages/llm/llm/src/types.ts#ContentBlockType"],"sources":["packages/llm/llm/src/types.ts:155"]}, {"digest":"7df62978a2b7de8540e714152297743e0a622209a46a5ac9513fc0fad73ca23b","names":[],"sources":[]}, + {"digest":"7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"7eaef7a763ba5b949b8464bc8add8980a3a5ab566014b69787aeba71e5dfe07b","names":[],"sources":[]}, {"digest":"7f13860ee89a2d35ce09f958e85a04566cd59cb1de1d73a9619a9b631961dc71","names":[],"sources":[]}, {"digest":"7f7fd5a6b0064f597534b29ff62ef26e786dffccf5e14f654a7d4fcea2c35f04","names":[],"sources":[]}, {"digest":"7f9365bae9fcb73e1e34061a1acfe0422c1a5fd351848cace9bebbe806f28462","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunEndData"],"sources":["packages/workflow/tool-workflow/src/types.ts:36"]}, {"digest":"7f959241c24128b4983b559984811861dc59774b85905c8fa57c6cb0148a3dbc","names":["packages/goal/goal/src/domain.ts#GoalMessageSource"],"sources":["packages/goal/goal/src/domain.ts:47"]}, + {"digest":"7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:43"]}, {"digest":"8064c03c87c357fd1575cb9395ce86e6dcbe2d275bf7717864a02cddf01827d5","names":["packages/llm/llm/src/types.ts#ContentBlock"],"sources":["packages/llm/llm/src/types.ts:157"]}, {"digest":"80677c8c8b804189a0fde8aa70dd4ad7af8bb12dfd03027e8e023c8592c838bc","names":[],"sources":[]}, {"digest":"8067b0d232c350f189738e60ee3c9939d46ca3b32b45db5525237b077893f649","names":[],"sources":[]}, {"digest":"811229eedcec57979dbadca7dbea832a59f8580cff30cb217fca5e2da16314bd","names":[],"sources":[]}, {"digest":"814d9434e1ddf9078612b3b34322f26e9363b469213335007f7e9ba7fee9464e","names":["packages/llm/llm/src/types.ts#ReasoningBlock"],"sources":["packages/llm/llm/src/types.ts:75"]}, {"digest":"82470020b5bfcb1a843e2aa05602af7d37df99e82d01f015c7c589ba054fd89d","names":[],"sources":[]}, + {"digest":"824a4aad44a54e245c67196817d67946734a2dc0eee0b0b6834a5b2a6ef352ce","names":[],"sources":[]}, {"digest":"825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8","names":[],"sources":[]}, {"digest":"829a2fb3be6efd4ba36aef8ed2d381a4c6fb2a0aaf50c823a6f3f8290786b809","names":[],"sources":[]}, {"digest":"83079c8a3f733ac3fa603eefa0fbb4737ca4d8b69e7e2e7298b60f9ac4098693","names":[],"sources":["packages/skill/tool-skill/src/index.ts:43"]}, @@ -404,8 +413,7 @@ {"digest":"8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad","names":[],"sources":["packages/interaction/permission-presets/src/index.ts:53"]}, {"digest":"8ad45ad053b4d89f8865c17cc9ff3b8748e86045f3f0fb816269a34af2619141","names":[],"sources":["packages/experimental/hook-protocol/src/types.ts:31"]}, {"digest":"8af841fc9b265ade3441caf9fe40dd24db24226befba48150e6d0eeccc1389c3","names":[],"sources":[]}, - {"digest":"8bb8c3751a6ce64b61aba1ec7ae798c00a5803c4d868a0430873216c6569ed52","names":[],"sources":["packages/session/session-log-deepseek/src/types.ts:81"]}, - {"digest":"8bca2b4f53ec7745b0484c6e7d3a6beb2ddd6cea82fc81786eaa3c0d065bbc14","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, + {"digest":"8bb8c3751a6ce64b61aba1ec7ae798c00a5803c4d868a0430873216c6569ed52","names":[],"sources":["packages/session/session-log-deepseek/src/types.ts:96"]}, {"digest":"8c1c4f72755a78ea163523a46565316e36e9b644382eb48e6635c71a6f412dd2","names":[],"sources":[]}, {"digest":"8c627d7a45fa406cea879a3922105e27ed4c3c7966e264afaf11ea7a330f2dd1","names":[],"sources":[]}, {"digest":"8cdd3fdd295cee53035de16a1b23411716f75e9f994fe9510cac5d7fef605c76","names":[],"sources":[]}, @@ -426,12 +434,10 @@ {"digest":"94c56aeb1564ad735eb773d88636b40761d063efa1610f1308004d32923f5378","names":[],"sources":[]}, {"digest":"95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65","names":[],"sources":["packages/llm/llm/src/types.ts:498"]}, {"digest":"95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd","names":[],"sources":[]}, - {"digest":"95acdbd066cf445d37092e5fdfe2e8717ef6ca1aed50f70e2e6037a6989a1f2f","names":[],"sources":[]}, {"digest":"95c0e88fcf0bafb2087f912ad9e2369de6ae6530cc0c4c2772c96205b57f7f60","names":[],"sources":[]}, {"digest":"9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114","names":[],"sources":[]}, {"digest":"96bfb916a727cd4445a594df264c523fc039749a31bfed924b8cc7bb308a5bb0","names":[],"sources":[]}, {"digest":"96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c","names":["packages/core/agent/src/types.ts#InboxTarget"],"sources":["packages/core/agent/src/types.ts:40"]}, - {"digest":"9808c3d0f0a1be8fe313b4148ba22c691c4d59bd2c67aef3c63cfe8043148c5f","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:37"]}, {"digest":"9890113e97dd3541787bfe6704f2b223d3a890b5325ca0acae2b0398909b413e","names":[],"sources":["packages/sandbox/sandbox-policy/src/session-mode.ts:33"]}, {"digest":"99135d8a3937977955eaac5f8f04f930dcd1562f8450a54a51cde9b921dd59a6","names":[],"sources":[]}, {"digest":"9947aa5938fe19a895298da412917acbf9ffb5093a73ab8baa257e6ae9e63a07","names":[],"sources":[]}, @@ -449,7 +455,6 @@ {"digest":"9e07693fd7cd9babe0688c22c986e4d519ae1c0e40eeea13b2dc583bed2abc86","names":[],"sources":[]}, {"digest":"9e2c06d8653585b33d72d5c1a80da79c8a24a88c284409bca15cc0f0ead5ebfb","names":[],"sources":[]}, {"digest":"9e41386b3a0c9572b0d63078492ebb3997da7d3a830d44e0259418bd02f4bcb2","names":[],"sources":["packages/compaction/compaction/src/types.ts:38","packages/compaction/compaction/src/types.ts:84"]}, - {"digest":"9e8043098fb89db6d7c92a92c70146c5c8d24fa88f733c687fa29e5e7a58ab30","names":[],"sources":[]}, {"digest":"9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94","names":[],"sources":["packages/experimental/webhook/src/types.ts:74"]}, {"digest":"9f65c43e340bba31cdc0fc37bd93ccb68d5e9b36f47a68c207e95a8e68620a5d","names":[],"sources":[]}, {"digest":"a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb","names":[],"sources":[]}, @@ -467,6 +472,7 @@ {"digest":"a3c6f1ea38e9a61d7a9706057ba65aabe02dfe6d9dd90d4736aa62325d874531","names":[],"sources":[]}, {"digest":"a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa","names":[],"sources":[]}, {"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/experimental/agent-team/src/types.ts:232"]}, + {"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:397"]}, {"digest":"a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc","names":[],"sources":[]}, {"digest":"a6b41f5bede8094d7bf635be5d69b1bd7a25e006ca9cf9415bceb9322f1a7feb","names":["packages/experimental/hook-protocol/src/types.ts#HookDialect"],"sources":["packages/experimental/hook-protocol/src/types.ts:48"]}, {"digest":"a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:14"]}, @@ -484,7 +490,6 @@ {"digest":"aa7bc2817014ca6c45719b1c193c5c131be718a0921f34345098ff250f444626","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackPut"],"sources":["packages/feedback/message-feedback/src/types.ts:38"]}, {"digest":"aa93010d0e521ad7c42273f58d5c9b8bf1dfc94edf1b805a3971b364a35c1877","names":[],"sources":[]}, {"digest":"aacda84a69739238aa782eea902d3073017d9d74b9808de2bf5a83c9738cbdc6","names":[],"sources":[]}, - {"digest":"aada2771a250136cc3a839b0507e9c6502622a2897716903942297bcb6661292","names":[],"sources":[]}, {"digest":"aae343bc3d663c83f2e8b5e1c7eea5d509dc7a5361bc8bd8b5ac3bb8d3b1ed56","names":[],"sources":[]}, {"digest":"ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7","names":[],"sources":["packages/core/session/src/types.ts:220","packages/llm/llm/src/types.ts:166"]}, {"digest":"aba175fb20ffc71112c55efe70458ff5d7dbfb83501892b4ba8d077f0ea5d7ba","names":[],"sources":[]}, @@ -503,7 +508,6 @@ {"digest":"b0127044ab31a702bddfd785d345f5abd7a70876746e895ce443afa3e60ddf2d","names":[],"sources":[]}, {"digest":"b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1","names":[],"sources":[]}, {"digest":"b0b0eb1b16490f6bee135b2377e8f8c39227974c799ad7d49ff418af23ecbc35","names":["packages/attachment/attachment/src/types.ts#ImageAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:11"]}, - {"digest":"b112459dcb0686091c31ce6d213b2a4934b5ad19d37cc5b599e46f5a9d715591","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081","names":["packages/core/session/src/types.ts#RequestContext"],"sources":["packages/core/session/src/types.ts:260"]}, {"digest":"b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7","names":[],"sources":[]}, {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/experimental/agent-team/src/types.ts:230"]}, @@ -556,7 +560,6 @@ {"digest":"c219efbdfd53b836786202ab4532913d555798f0a8615a4e9629736e3799fb70","names":[],"sources":[]}, {"digest":"c2deb7c9183735d4e0fffddf5c623f4e34daa1c8e96b111982e957dfcbbcf896","names":["packages/workflow/workflow/src/types.ts#WorkflowAgentOutcome"],"sources":["packages/workflow/workflow/src/types.ts:110"]}, {"digest":"c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3","names":[],"sources":[]}, - {"digest":"c433ef580a5b229bbe68274a7164aa3a8e8040d7217f010b610ca0696a84253c","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, {"digest":"c43b119ce579b3e9983a0d3adc4ba402ebb9ba773d89738a2dab01ecf5813943","names":[],"sources":[]}, {"digest":"c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49","names":[],"sources":[]}, {"digest":"c55827fba7018198772c50e37cb7d3f60a3d8bb6e94b07de72dee0d9f8968005","names":[],"sources":["packages/interaction/user-questions/src/types.ts:134"]}, @@ -564,6 +567,7 @@ {"digest":"c589e938cc0e4989df8b82abef6339de621f0dc4fe62c230d145b94996af7f92","names":[],"sources":[]}, {"digest":"c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6","names":["packages/util/values/src/index.ts#JsonValue"],"sources":["packages/util/values/src/index.ts:4"]}, {"digest":"c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262","names":[],"sources":[]}, + {"digest":"c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, {"digest":"c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff","names":[],"sources":[]}, {"digest":"c99e20441bf15341e9db54385e558e5c03d21dff2eb1ac08ccbf47c2d8747954","names":[],"sources":[]}, {"digest":"ca085dba2c877feceebf3c62f9d39c6c2a4d2314d97b94b3927a780fe8fe2bb2","names":[],"sources":[]}, @@ -575,14 +579,14 @@ {"digest":"cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc","names":[],"sources":[]}, {"digest":"cdca80b8dad24d4e7b1d5a288a8475ba8d8d46be385bd2da85f122ee836f1670","names":[],"sources":[]}, {"digest":"cdee10e87dbed8440d8b6935888624884878efc7af95402e94171b29f173dced","names":[],"sources":["packages/llm/llm-retry/src/types.ts:17"]}, - {"digest":"cebb7e9f3f663e660a531d9764ecfb9fa7fc73c22bf8d628f888e9ecf18c5cf0","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, + {"digest":"cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e","names":[],"sources":[]}, {"digest":"cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:21"]}, {"digest":"cffcdc19881ab61ec0a9ce18743996f7ac44ac8889f34dfac36ae745574aa22f","names":["packages/goal/goal/src/domain.ts#GoalClearChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:35"]}, {"digest":"d0fbb0ca1bbba059830826f33f801da1aced35a2504376d61e020bdfec8d50c0","names":[],"sources":[]}, {"digest":"d28765f87d6b6ab408e0b2f3e7e297791b004b806d8e302c49903ebae815feb2","names":[],"sources":[]}, - {"digest":"d2b617d9dfcc4e0a4b60732d8ba1effc632e3b75124b93b064c07814a0a716bc","names":[],"sources":[]}, {"digest":"d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d","names":[],"sources":["packages/llm/llm/src/types.ts:501"]}, {"digest":"d4c799e3d51cae1040362ba4968efc484739e14416c9c1df20c2f108b4ef2eb5","names":[],"sources":[]}, + {"digest":"d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, {"digest":"d4e4d575abeb4b72d616dc65c17ac0eaa943cc135609395044c38d5afcb10e31","names":[],"sources":[]}, {"digest":"d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a","names":[],"sources":[]}, {"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9","names":[],"sources":[]}, @@ -607,7 +611,7 @@ {"digest":"de25352d2dc4196309bd84b5bac6319f6d5c898ea0e82bb5ae66c5738d0d426f","names":[],"sources":[]}, {"digest":"de30b1fa12c75f81df6aa4096e1595296d2d04e2be14a7c29849cf8028b79048","names":[],"sources":[]}, {"digest":"deaae5cd30987d4f49398e8f9d994c125907e7f928300745fb1dbf15f145d0e7","names":[],"sources":[]}, - {"digest":"deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5","names":["packages/typert/protocol/src/types.ts#Principal"],"sources":["packages/typert/protocol/src/types.ts:457","packages/typert/protocol/src/types.ts:461"]}, + {"digest":"deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5","names":[],"sources":["packages/typert/protocol/src/types.ts:457"]}, {"digest":"df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528","names":[],"sources":[]}, {"digest":"df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18","names":[],"sources":[]}, {"digest":"dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd","names":["packages/experimental/agent-team/src/types.ts#TeamMemberPhase"],"sources":["packages/experimental/agent-team/src/types.ts:45"]}, @@ -638,7 +642,6 @@ {"digest":"e95f957154b2b1b58692af53d4131180b6dd19e2264869f876dbaa959991494e","names":[],"sources":[]}, {"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/experimental/agent-team/src/types.ts:234"]}, {"digest":"eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c","names":[],"sources":["packages/core/session/src/types.ts:201"]}, - {"digest":"eaa9125aec0fb28478096c46e73e21bf512ab0d84d826bfb41e7308707734cb5","names":[],"sources":[]}, {"digest":"ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8","names":[],"sources":[]}, {"digest":"ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93","names":[],"sources":[]}, {"digest":"ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7","names":[],"sources":["packages/core/session/src/types.ts:234"]}, @@ -689,7 +692,8 @@ {"digest":"fe1a7acdeed4d0f5ab483396eaa5b3dc97263b7c10eb4da2f0028cb01728bfc9","names":[],"sources":[]}, {"digest":"fe792a6cd430494503837e21e79f91677704ccdd9e870c679aabd7d43ed47ddd","names":[],"sources":[]}, {"digest":"fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394","names":["packages/llm/llm/src/types.ts#TokenUsage"],"sources":["packages/llm/llm/src/types.ts:182"]}, - {"digest":"fee1caf8ab6a65c1bc981f5a1730164490f22a2a89ffa3d0b76f1f432c44c8b5","names":[],"sources":[]} + {"digest":"fee1caf8ab6a65c1bc981f5a1730164490f22a2a89ffa3d0b76f1f432c44c8b5","names":[],"sources":[]}, + {"digest":"ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72","names":[],"sources":[]} ], "nodes": { "0116b1f89c05221d": {"kind":"literal","value":"web_search_20250305"}, @@ -708,6 +712,7 @@ "09f3f8dc417f2782": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"8af841fc9b265ade","optional":false},{"name":"source","type":"65a8400e451e667e","optional":false}],"indices":[]}, "0a30c3be336cdd4f": {"kind":"object","properties":[{"name":"kind","type":"1e0b0b66b6f0c53f","optional":false},{"name":"reason","type":"1f8c126cfab3df81","optional":false}],"indices":[]}, "0a8a062bc9c3c0d9": {"kind":"literal","value":"subagent/model-selection-policy"}, + "0a92c5bdfa06c2e2": {"kind":"object","properties":[{"name":"displayName","type":"4cd48f3e5108bd6e","optional":true},{"name":"providerId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subjectId","type":"4cd48f3e5108bd6e","optional":false},{"name":"tenantId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "0aa62ab51b36c0e6": {"kind":"object","properties":[],"indices":[{"key":"4cd48f3e5108bd6e","value":"bbc5696e3f13c758"}]}, "0acd94fe8794574c": {"kind":"object","properties":[{"name":"blocks","type":"b1664fa89f8eab49","optional":true},{"name":"response","type":"bbc5696e3f13c758","optional":false}],"indices":[]}, "0af3ecfb8c60d416": {"kind":"literal","value":"turn/start"}, @@ -733,7 +738,6 @@ "124aa62078ca3311": {"kind":"object","properties":[{"name":"data","type":"da60ed58a8fa23d7","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"d4c799e3d51cae10","optional":false}],"indices":[]}, "12a0cfa619ac4a5f": {"kind":"literal","value":"read-only"}, "12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]}, - "12f9a5a7506bca46": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"cebb7e9f3f663e66","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "1379f0b72275994d": {"kind":"literal","value":"developer/message"}, "13d3d180f977bf78": {"kind":"object","properties":[{"name":"data","type":"1528539c63db8b23","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1a2718b00937b236","optional":false}],"indices":[]}, "14230f9b8b5797bb": {"kind":"literal","value":"chunk"}, @@ -807,6 +811,7 @@ "2da725e554cd8930": {"kind":"literal","value":"fork"}, "2e3523541e942ef6": {"kind":"union","types":["18dc21f12bbac8ad","8e8e91646a5cae3f","e6063d0451dc0202","eea687d437880bd5"]}, "2e4debca12f2c449": {"kind":"literal","value":"team-message"}, + "2e505098f9209e52": {"kind":"object","properties":[{"name":"data","type":"7ff62f6599c083ce","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "2eafefdc6f79a492": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"targetId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]}, "2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"}, @@ -831,7 +836,6 @@ "383cf73ae4748e5b": {"kind":"literal","value":"recall"}, "3860b4757d512a28": {"kind":"object","properties":[{"name":"args","type":"93c33d9687613293","optional":false},{"name":"dt","type":"5d03ba38734809bc","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"time0","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"616f93a5047de668","optional":false}],"indices":[]}, "390eed1028aba027": {"kind":"union","types":["b1f62380f6746fed","df5d9709b7db2a72"]}, - "39adb7fce8475af6": {"kind":"object","properties":[{"name":"data","type":"8bca2b4f53ec7745","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, "39cf2d3ff8bac81c": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"rootCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "3b04790e08b35241": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, "3b1be838223869fe": {"kind":"object","properties":[{"name":"data","type":"e4c18e294232c3ba","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"c589e938cc0e4989","optional":false}],"indices":[]}, @@ -850,6 +854,7 @@ "3ee93b06f3a12585": {"kind":"object","properties":[{"name":"data","type":"7c6129b6961cf4b2","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44818000e56acb97","optional":false}],"indices":[]}, "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"}, "3fdf9582c3b5597d": {"kind":"object","properties":[{"name":"afterSeconds","type":"0657d3ca5ac4e7bf","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"5b232a9b9bfcc0fc","optional":false},{"name":"prompt","type":"4cd48f3e5108bd6e","optional":false},{"name":"scheduledAt","type":"4cd48f3e5108bd6e","optional":false},{"name":"title","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "4035903993dfee62": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"7e9f1072c17f7aa8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, "4208123b50df5006": {"kind":"object","properties":[{"name":"data","type":"ad0c38a0adb82a94","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"3e0eff5946255ff0","optional":false}],"indices":[]}, "42e0916e0dda5f6d": {"kind":"object","properties":[{"name":"data","type":"7f9365bae9fcb73e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fe792a6cd4304945","optional":false}],"indices":[]}, @@ -876,13 +881,13 @@ "4b7ddaf3710b88ff": {"kind":"array","element":"a99f76fd149051c3"}, "4bb451330ff699f3": {"kind":"object","properties":[{"name":"kind","type":"b38f206d59bad891","optional":false}],"indices":[]}, "4cd48f3e5108bd6e": {"kind":"primitive","type":"string"}, + "4d60dddaa65d3da8": {"kind":"object","properties":[{"name":"actor","type":"0a92c5bdfa06c2e2","optional":false},{"name":"kind","type":"6ba7edb6447b6a3e","optional":false},{"name":"shareId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "4e011753f8263f43": {"kind":"literal","value":"team/message/queued"}, "4e44c9118152f682": {"kind":"literal","value":"text"}, "4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]}, "4e97c3d85c0fc817": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"}, "4f507598cf476214": {"kind":"literal","value":"series"}, - "4f9125fa028a5628": {"kind":"union","types":["09f3f8dc417f2782","4b259f7f8f0a44ad","8bca2b4f53ec7745","cebb7e9f3f663e66","fa87537f4a9c1e25"]}, "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, "4fb59762612c3e3a": {"kind":"object","properties":[{"name":"data","type":"b1ebd5cda47d90d9","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"18fcdd273a2f2f9d","optional":false}],"indices":[]}, "50cf02d875f8a1f1": {"kind":"union","types":["2da725e554cd8930","dd2780e7e0768433"]}, @@ -896,6 +901,7 @@ "530da6c0267813bb": {"kind":"union","types":["34d1ab5c5df37818","4f507598cf476214","e7ce49f12953583f","f789af5ab844827e"]}, "53750b9abf61efb7": {"kind":"object","properties":[{"name":"data","type":"f731ffe80810b028","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44c8c77aa9aa100b","optional":false}],"indices":[]}, "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "53d4f8256bc58109": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"64ef542b1bfc9883","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "54f9276a1617d7c1": {"kind":"object","properties":[{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"stream","type":"84bbfdce7d2eab5b","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "557e148d30b79ee4": {"kind":"literal","value":"agent-preset/selected"}, "5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, @@ -912,6 +918,7 @@ "5abab3a27fd27366": {"kind":"literal","value":"text-delta"}, "5b232a9b9bfcc0fc": {"kind":"literal","value":"after"}, "5b6fb1f226ff5640": {"kind":"object","properties":[{"name":"commandId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"1ab2ef1296f157ba","optional":false},{"name":"sourceEventSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"text","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "5bb231bc5afdb532": {"kind":"object","properties":[{"name":"data","type":"4035903993dfee62","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, "5bd310104cfe448e": {"kind":"literal","value":"resource-cost"}, @@ -936,6 +943,7 @@ "62e6a429ff3f390a": {"kind":"union","types":["1d8798b1a5e8c545","1e346446e183ee76","34d1ab5c5df37818","7624ca28c61ca0ea","ad22a5ca5bbaab2e","d9196086e41e4dd5"]}, "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"}, "6487b10ba7542f3c": {"kind":"object","properties":[{"name":"agentModel","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentProvider","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentReasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"persona","type":"4cd48f3e5108bd6e","optional":true},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"toolFilter","type":"3cfc3a56502da1f8","optional":true},{"name":"version","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "64ef542b1bfc9883": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"7e9f1072c17f7aa8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "6598cd6da253948d": {"kind":"object","properties":[{"name":"context","type":"50cf02d875f8a1f1","optional":false},{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"error","type":"4cd48f3e5108bd6e","optional":true},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"phase","type":"dfce5db4aa42b7eb","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]}, "65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, @@ -950,6 +958,7 @@ "69d238a6e9b08d67": {"kind":"literal","value":"interrupted"}, "6ab45ac76839722d": {"kind":"literal","value":"assistant/message"}, "6b23ed9ce1b397e6": {"kind":"literal","value":"compact-checkpoint"}, + "6ba7edb6447b6a3e": {"kind":"literal","value":"share-viewer"}, "6bbe793f6a93dc8d": {"kind":"object","properties":[{"name":"blockedReason","type":"84ac8c1a66ffc697","optional":true},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"maxGoalRounds","type":"0657d3ca5ac4e7bf","optional":false},{"name":"objective","type":"4cd48f3e5108bd6e","optional":false},{"name":"phase","type":"b8327af7ee576795","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "6bc8e51124d0874b": {"kind":"object","properties":[{"name":"message","type":"09f3f8dc417f2782","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "6c03546143cdf6ae": {"kind":"object","properties":[{"name":"kind","type":"e6411b09c1d982f7","optional":false}],"indices":[]}, @@ -958,7 +967,6 @@ "6ccb6bc423fb3952": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "6cda0d6d3b4336d8": {"kind":"literal","value":"feedback/message-put"}, "6cf082d0fb6034de": {"kind":"literal","value":"producer"}, - "6dacb1584f2bfbe2": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"deebfc7c62177ea8","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "6e52dd55a1d48954": {"kind":"object","properties":[{"name":"agentPreset","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "6ea608bdc8a7a63d": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, "6ebcc2dbec9f2dab": {"kind":"literal","value":"skill-catalog"}, @@ -969,6 +977,7 @@ "6f72ece8f317cd08": {"kind":"object","properties":[{"name":"retry","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "6f9e00383836275e": {"kind":"literal","value":"snapshot"}, "70b082fe78991edf": {"kind":"literal","value":"request/context"}, + "70d2acdec1ebcfea": {"kind":"object","properties":[{"name":"data","type":"53d4f8256bc58109","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, "7180844e1066230c": {"kind":"literal","value":"service-stability"}, "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]}, "71bb3254ec8f1e49": {"kind":"object","properties":[{"name":"dt","type":"5d03ba38734809bc","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"texts","type":"93c33d9687613293","optional":false},{"name":"time0","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"688e853dc149f0d3","optional":false}],"indices":[]}, @@ -986,23 +995,27 @@ "7a651f3b672c52fb": {"kind":"literal","value":"session/title"}, "7af85bf70d4eafce": {"kind":"object","properties":[{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"}, + "7c203f3d4dfa6638": {"kind":"array","element":"4035903993dfee62"}, "7c6129b6961cf4b2": {"kind":"object","properties":[{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "7c9f44e90a0058f4": {"kind":"object","properties":[{"name":"data","type":"5302e76315127761","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"570e850224346133","optional":false}],"indices":[]}, "7d226b9fd95b4ba2": {"kind":"union","types":["1df34a6c086866ff","cffcdc19881ab61e"]}, "7d54a5a5e629e2e7": {"kind":"object","properties":[{"name":"category","type":"3305a4bb6b6c29b4","optional":true},{"name":"text","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, "7d65acf0a3a43a8f": {"kind":"union","types":["1f0133217eeeffd8","277b61f9c6f81704","3c2535ca0a2062f1","4e44c9118152f682","83d6916021eede8f","85f9b505b7bdff1f","fb8b6a2e53cb9cf4"]}, "7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "7e9f1072c17f7aa8": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, "7f13860ee89a2d35": {"kind":"literal","value":"session-reference"}, "7f7fd5a6b0064f59": {"kind":"object","properties":[{"name":"data","type":"5dd766ccd1702a11","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"10e386801903f9d4","optional":false}],"indices":[]}, "7f9365bae9fcb73e": {"kind":"object","properties":[{"name":"runId","type":"4cd48f3e5108bd6e","optional":false},{"name":"stopReason","type":"8465ece5e9d9007c","optional":false}],"indices":[]}, "7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "7ff62f6599c083ce": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"ff2cbb53582d1827","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]}, "80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, "8067b0d232c350f1": {"kind":"literal","value":"time-context"}, "811229eedcec5797": {"kind":"literal","value":"allowed-once"}, "814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]}, "82470020b5bfcb1a": {"kind":"literal","value":"agent-message"}, + "824a4aad44a54e24": {"kind":"union","types":["4d60dddaa65d3da8","deebfc7c62177ea8"]}, "825ca23416b1ea9d": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, "829a2fb3be6efd4b": {"kind":"literal","value":"agent/inbox/spliced"}, "83079c8a3f733ac3": {"kind":"object","properties":[{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, @@ -1029,7 +1042,6 @@ "8ad45ad053b4d89f": {"kind":"object","properties":[{"name":"decision","type":"4cd48f3e5108bd6e","optional":false},{"name":"durationMs","type":"0657d3ca5ac4e7bf","optional":false},{"name":"exitCode","type":"0657d3ca5ac4e7bf","optional":true},{"name":"handlerId","type":"4cd48f3e5108bd6e","optional":false},{"name":"point","type":"4cd48f3e5108bd6e","optional":false},{"name":"stderrSummary","type":"4cd48f3e5108bd6e","optional":true},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "8af841fc9b265ade": {"kind":"literal","value":"system"}, "8bb8c3751a6ce64b": {"kind":"object","properties":[{"name":"sessionFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"throughSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "8bca2b4f53ec7745": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"b112459dcb068609","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "8c1c4f72755a78ea": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "8c627d7a45fa406c": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "8cdd3fdd295cee53": {"kind":"literal","value":"command/done"}, @@ -1050,12 +1062,10 @@ "94c56aeb1564ad73": {"kind":"literal","value":"in_progress"}, "95080295115fbd83": {"kind":"object","properties":[{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"5abab3a27fd27366","optional":false}],"indices":[]}, "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"}, - "95acdbd066cf445d": {"kind":"object","properties":[{"name":"data","type":"12f9a5a7506bca46","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"}, "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, "96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]}, - "9808c3d0f0a1be8f": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"d2b617d9dfcc4e0a","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "9890113e97dd3541": {"kind":"object","properties":[{"name":"mode","type":"fab2bb09bc5e3b9d","optional":false},{"name":"source","type":"76632533f67879dc","optional":true}],"indices":[]}, "99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, "9947aa5938fe19a8": {"kind":"union","types":["576a2de8355eb098","a99ec0993c62c058"]}, @@ -1073,7 +1083,6 @@ "9e07693fd7cd9bab": {"kind":"literal","value":"ask"}, "9e2c06d8653585b3": {"kind":"array","element":"1cde1bedde79c5e8"}, "9e41386b3a0c9572": {"kind":"object","properties":[{"name":"end","type":"0657d3ca5ac4e7bf","optional":false},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "9e8043098fb89db6": {"kind":"object","properties":[{"name":"data","type":"c433ef580a5b229b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "9edc162949abf93b": {"kind":"object","properties":[{"name":"deliveryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"db6c5246911d47a7","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"ruleId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "9f65c43e340bba31": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, "a0a2e5c42e1c9294": {"kind":"object","properties":[{"name":"data","type":"2e3523541e942ef6","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"0e7b2a97f54f1afd","optional":false}],"indices":[]}, @@ -1091,6 +1100,7 @@ "a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "a3edd1efd1fef21b": {"kind":"literal","value":"workspace-write"}, "a40d12070f6f4a12": {"kind":"object","properties":[{"name":"task","type":"51edec100659da3a","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, + "a4afec59b75aad5d": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"824a4aad44a54e24","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "a6567ccb2e530606": {"kind":"object","properties":[{"name":"data","type":"0d47a8f3d847243c","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"0a8a062bc9c3c0d9","optional":false}],"indices":[]}, "a6b41f5bede8094d": {"kind":"union","types":["15495614ccfa9cbd","aff60ef947c18b01"]}, "a6da9ad5d42ae83a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"runId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, @@ -1108,7 +1118,6 @@ "aa7bc2817014ca6c": {"kind":"object","properties":[{"name":"item","type":"3d7d07060a7be779","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "aa93010d0e521ad7": {"kind":"primitive","type":"boolean"}, "aacda84a69739238": {"kind":"literal","value":"subagent"}, - "aada2771a250136c": {"kind":"array","element":"8bca2b4f53ec7745"}, "aae343bc3d663c83": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, "ab7ec5f9eb2c6dfa": {"kind":"object","properties":[{"name":"kind","type":"68141cbe3994a5ff","optional":false}],"indices":[]}, "aba175fb20ffc711": {"kind":"array","element":"83079c8a3f733ac3"}, @@ -1127,7 +1136,6 @@ "b0127044ab31a702": {"kind":"object","properties":[{"name":"data","type":"ca59530d1a4f3e19","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1986c2664009c991","optional":false}],"indices":[]}, "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "b112459dcb068609": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6dacb1584f2bfbe2","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "b158f3ff69237148": {"kind":"object","properties":[{"name":"contextWindow","type":"0657d3ca5ac4e7bf","optional":true},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"systemPromptUpdate","type":"f311671e07bc7fa6","optional":true}],"indices":[]}, "b1664fa89f8eab49": {"kind":"array","element":"bbc5696e3f13c758"}, "b1ebd5cda47d90d9": {"kind":"object","properties":[{"name":"member","type":"6598cd6da253948d","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, @@ -1180,7 +1188,6 @@ "c219efbdfd53b836": {"kind":"literal","value":"step/end"}, "c2deb7c9183735d4": {"kind":"union","types":["1ebc6f9ff3aa9e3a","2142e391b9ccb60d","b57a2889408de36e"]}, "c2f49004d9ae38a9": {"kind":"union","types":["1ebc6f9ff3aa9e3a","b57a2889408de36e","ef226a90ddac6cd8"]}, - "c433ef580a5b229b": {"kind":"object","properties":[{"name":"inserted","type":"aada2771a250136c","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, "c43b119ce579b3e9": {"kind":"literal","value":"paused"}, "c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, "c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]}, @@ -1188,6 +1195,7 @@ "c589e938cc0e4989": {"kind":"literal","value":"tool/call"}, "c592ce75aab73fca": {"kind":"union","types":["0657d3ca5ac4e7bf","4cd48f3e5108bd6e","529e0ccd0e34079a","72ec79127a9c0d02","938f7c7357bc403e","aa93010d0e521ad7"]}, "c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"}, + "c78b9097bfae83cd": {"kind":"union","types":["09f3f8dc417f2782","4035903993dfee62","4b259f7f8f0a44ad","64ef542b1bfc9883","fa87537f4a9c1e25"]}, "c8e6f48878cc2a2f": {"kind":"literal","value":"active"}, "c99e20441bf15341": {"kind":"literal","value":"feedback/record"}, "ca085dba2c877fec": {"kind":"literal","value":"sandbox/mode"}, @@ -1199,14 +1207,14 @@ "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "cdca80b8dad24d4e": {"kind":"literal","value":"tool-workflow/run-start"}, "cdee10e87dbed844": {"kind":"object","properties":[{"name":"delayMs","type":"0657d3ca5ac4e7bf","optional":false},{"name":"failure","type":"5f2117d1856a5ab4","optional":false},{"name":"maxRetries","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mode","type":"3bc287e765d58dc3","optional":false},{"name":"policyKey","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"retry","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "cebb7e9f3f663e66": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"b112459dcb068609","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "cdf988172b589b23": {"kind":"object","properties":[{"name":"data","type":"d4d960185026266e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "cef171adf021a186": {"kind":"object","properties":[{"name":"arguments","type":"bbc5696e3f13c758","optional":false},{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"error","type":"a81059ca06999da1","optional":true},{"name":"isError","type":"aa93010d0e521ad7","optional":false},{"name":"meta","type":"c592ce75aab73fca","optional":true},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"parentCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"rootCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "cffcdc19881ab61e": {"kind":"object","properties":[{"name":"cleared","type":"0b6d718c693fdf7f","optional":false},{"name":"clearedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"kind","type":"237c004c73b6b288","optional":false},{"name":"operation","type":"e7e154d47d5fbc53","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, "d0fbb0ca1bbba059": {"kind":"literal","value":"delete"}, "d28765f87d6b6ab4": {"kind":"literal","value":"one-shot"}, - "d2b617d9dfcc4e0a": {"kind":"array","element":"4f9125fa028a5628"}, "d4b739491689cc6b": {"kind":"object","properties":[{"name":"block","type":"8064c03c87c357fd","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"eefbea819caeeea9","optional":false}],"indices":[]}, "d4c799e3d51cae10": {"kind":"literal","value":"working-directory/change"}, + "d4d960185026266e": {"kind":"object","properties":[{"name":"inserted","type":"7c203f3d4dfa6638","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"}, "d595ec73b32b016a": {"kind":"object","properties":[{"name":"data","type":"a40d12070f6f4a12","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"b57ae1b37e52bee0","optional":false}],"indices":[]}, "d63b8b8ffad9c02f": {"kind":"object","properties":[{"name":"data","type":"8bb8c3751a6ce64b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"47e5d84f1a93450a","optional":false}],"indices":[]}, @@ -1262,7 +1270,6 @@ "e95f957154b2b1b5": {"kind":"literal","value":"assistant/attempt"}, "ea08ec1df0c33108": {"kind":"object","properties":[{"name":"message","type":"2eafefdc6f79a492","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, "eaa71be0fbf0f702": {"kind":"object","properties":[{"name":"kind","type":"ad3b56aa2fc1ad42","optional":false}],"indices":[]}, - "eaa9125aec0fb284": {"kind":"object","properties":[{"name":"data","type":"9808c3d0f0a1be8f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"}, "ec38b5949af8eaca": {"kind":"object","properties":[{"name":"data","type":"2fe5026daf14bd4f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"2aed07493b1c65b6","optional":false}],"indices":[]}, "ec8b5ff7c77ca689": {"kind":"object","properties":[{"name":"kind","type":"b2245a906e13aa94","optional":false}],"indices":[]}, @@ -1313,6 +1320,7 @@ "fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "fe792a6cd4304945": {"kind":"literal","value":"tool-workflow/run-end"}, "fec5442b9ee4122e": {"kind":"object","properties":[{"name":"cacheReadTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"cacheWriteTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"inputTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"outputTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"reasoningTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"totalTokens","type":"0657d3ca5ac4e7bf","optional":true}],"indices":[]}, - "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"} + "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"}, + "ff2cbb53582d1827": {"kind":"array","element":"c78b9097bfae83cd"} } } diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml index f6ace06890..f418069ce3 100644 --- a/docs/rescope.i18n.yaml +++ b/docs/rescope.i18n.yaml @@ -6,8 +6,8 @@ en: c8aa4d3384ea1306 zh: 5aa030587d5b634d /vendored-package-rescope/name-mapping: - en: cc0f91dc4bb82605 - zh: 81d5031752c6ea74 + en: 239fa7993825ac62 + zh: df35fa9f5ef42a05 /vendored-package-rescope/what-the-rename-does-not-touch: en: 9bb7d09f8cccf695 zh: 2a8f3f60ae5116f3 diff --git a/docs/rescope.md b/docs/rescope.md index 2e25989742..85917bbfce 100644 --- a/docs/rescope.md +++ b/docs/rescope.md @@ -8,13 +8,13 @@ The Cordis framework and its foundation libraries are vendored under [`vendor/`] | Directory | Upstream name | Published name | Upstream version | Role | |---|---|---|---|---| -| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.7 | Framework core: `Context`, `Service`, `Fiber`, events | +| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.11 | Framework core: `Context`, `Service`, `Fiber`, events | | `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | Shared utilities the framework and Schemastery build on | | `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | Config schemas (`Schema`) behind every plugin's `Config` | | `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` loading, plugin resolution, repository cache | | `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | Config includes and patch overlays | | `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | Nested plugin groups | -| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | Disposal-aware timers on `ctx` | +| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | Disposal-aware timers on `ctx` | | `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | Hot module replacement for plugins and config | | `vendor/logger-console/` | `@cordisjs/plugin-logger-console` | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | Console logger exporter | diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md index 7b888ede0c..8d13ed539b 100644 --- a/docs/rescope.zh.md +++ b/docs/rescope.zh.md @@ -8,13 +8,13 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R | 目录 | 上游名 | 发布名 | 上游版本 | 角色 | |---|---|---|---|---| -| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.7 | 框架核心:`Context`、`Service`、`Fiber`、事件 | +| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.11 | 框架核心:`Context`、`Service`、`Fiber`、事件 | | `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | 框架与 Schemastery 共用的基础工具 | | `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | 配置 schema(`Schema`),每个插件的 `Config` 都基于它 | | `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` 装载、插件解析、repository 缓存 | | `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 配置包含与 patch 叠加 | | `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 嵌套插件分组 | -| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | `ctx` 上随 disposal 回收的定时器 | +| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.4 | `ctx` 上随 disposal 回收的定时器 | | `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 插件与配置的热替换 | | `vendor/logger-console/` | `@cordisjs/plugin-logger-console` | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 控制台日志导出 | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 7f6b7de64c..069f78cc33 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -3,5 +3,5 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md /subsystems: - en: dcbb2f834453e166 - zh: d73f32a7baeec42d + en: 81b2c48d922de5a9 + zh: cf0db50b34d2feb3 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index e14535c2f6..86e30a5519 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -52,6 +52,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [voice-input.md](voice-input.md) | experimental named recognizers, transient audio and revision-guarded draft insertion | | [translation.md](translation.md) | experimental anonymous text translation and explicit provider requests | | [agent-team.md](agent-team.md) | Agent Teams: implicit Lead identity, named continuable teammates, direct peer inbox delivery, and shared task DAG | +| [liveshare.md](liveshare.md) | the opt-in LiveShare bundle: nested share modes, a fork's ceiling and durability, enforcement installation, and the constraint a shared Session's externally driven turns run under | | [claude-code-mods.md](claude-code-mods.md) | Claude Code mods through the experimental bridge: every event, `$` member, chain rule, drawing feature, and example mod that differs from Claude Code | | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` | | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 6239e5f2c0..289a6a71f3 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -52,6 +52,7 @@ | [voice-input.md](voice-input.zh.md) | 实验性具名语音识别、临时音频和受版本保护的草稿插入 | | [translation.md](translation.zh.md) | 实验性免登录文本翻译与显式 Provider 请求 | | [agent-team.md](agent-team.zh.md) | Agent Teams:隐式 Lead 身份、具名 continuable teammate、直接 peer inbox 投递 与共享任务 DAG | +| [liveshare.md](liveshare.zh.md) | 可选的 LiveShare bundle:嵌套分享模式、fork 的上限与持久性、上限安装,以及共享会话外部驱动轮次所受的约束 | | [claude-code-mods.md](claude-code-mods.zh.md) | 经实验性桥接运行的 Claude Code 模组:与 Claude Code 不同的每个事件、`$` 成员、链规则、绘制特性与示例模组 | | [web.md](web.zh.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | | [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 7754efcc03..a1b1b47591 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,8 +6,8 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: 8813788cd8a86903 - zh: 3c30cb180fc1a33d + en: 2cf6a1f62dcc1de6 + zh: b6d519b60f6f5c79 /profile-management/market-records: en: 3dfc62d837d61b9b zh: ec223be315f3dc62 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 157ef5da64..48b9b179e0 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -18,7 +18,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginRegistries` carries the configured first registry, `null` for the one pnpm's own configuration names, the fallbacks asked after it, and `resolved`, the URL pnpm's own configuration names or `null` while unread. `InspectOptions.registry` names the registry a lookup asks first. -`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `bundle` and `version` name the package a finished installation added and its manifest version; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from. +`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the run's exit status, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing or the GitHub connection check hit its deadline; a terminated run records the coerced status 1 because its process returned none. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `bundle` and `version` name the package a finished installation added and its manifest version; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from. ## Market records @@ -297,7 +297,8 @@ Manage profile files and apply their declared reload lifecycle. /** * Install a package using the same pnpm implementation as dsh plugin. GitHub * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; - * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An + * only network failures or timeouts stop installation, and a stop streams the check's diagnostic + * as the installation's install-log run under its request id, while pnpm owns authentication and transport fallback. An * HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs * find that only SSH can read it. A run * that fails, is cancelled, or adds a package without a bundle patch restores @@ -532,11 +533,11 @@ Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin #### `plugin-manager/install-log` — emit -One chunk of a pnpm run's output, streamed as the run produces it. +One chunk of an installation run's output, streamed as the run produces it. ```ts cordis-catalog /** - * One chunk of a pnpm run's output, streamed as the run produces it. + * One chunk of an installation run's output, streamed as the run produces it. * @mode emit * @param chunk - the chunk and the run it belongs to. */ diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 1e5f78cf0e..d80c05fb53 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -18,7 +18,7 @@ `PluginRegistries` 携带配置的第一个注册表(`null` 即 pnpm 自身配置指定的那个)、随后依次询问的备选注册表,以及 `resolved`——pnpm 自身配置指向的 URL,未读到时为 `null`。`InspectOptions.registry` 指定一次查询首先询问的注册表。 -`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`bundle` 与 `version` 给出完成的安装新增的包及其清单版本;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 +`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录运行的退出状态、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,或 GitHub 连接检查到达期限,还记录 `timedOut`;被终止的运行记录强制填入的状态 1,因为它的进程没有返回任何状态。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`bundle` 与 `version` 给出完成的安装新增的包及其清单版本;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 ## 市场记录 @@ -297,7 +297,8 @@ Manage profile files and apply their declared reload lifecycle. /** * Install a package using the same pnpm implementation as dsh plugin. GitHub * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; - * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An + * only network failures or timeouts stop installation, and a stop streams the check's diagnostic + * as the installation's install-log run under its request id, while pnpm owns authentication and transport fallback. An * HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs * find that only SSH can read it. A run * that fails, is cancelled, or adds a package without a bundle patch restores @@ -532,11 +533,11 @@ Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin #### `plugin-manager/install-log` — emit -One chunk of a pnpm run's output, streamed as the run produces it. +One chunk of an installation run's output, streamed as the run produces it. ```ts cordis-catalog /** - * One chunk of a pnpm run's output, streamed as the run produces it. + * One chunk of an installation run's output, streamed as the run produces it. * @mode emit * @param chunk - the chunk and the run it belongs to. */ diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 02e7a5d4f6..a2b168f3fd 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -30,8 +30,8 @@ en: 057db6d8bf356c0f zh: 1441b39e5142b0bf /core/repo-wide-type-patterns/the-map-derived-union-pattern: - en: 84c039e929edb897 - zh: 84241bb993f23ef2 + en: 87a19311e2a97270 + zh: 8df92c72793e8275 /core/repo-wide-type-patterns/branded-ids: en: 10f8ac0f97e7c54a zh: 75cd2311aabfe464 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 86df19aa94..48ff2ce633 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -450,12 +450,13 @@ declare module '@deepseek-ai/dsh-llm' { } ``` -Five canonical maps use this pattern; a plugin author extends these: +Six canonical maps use this pattern; a plugin author extends these: | Map | Package | Derives | Catalog | |---|---|---|---| | `ContentBlockMap` | dsh-llm | `ContentBlock` | [llm-streaming.md](llm-streaming.md#content-blocks-and-messages) | | `MessageSourceMap` | dsh-llm | `MessageSource` | [llm-streaming.md](llm-streaming.md#content-blocks-and-messages) | +| `LlmRequestPurposeMap` | dsh-llm | `LlmRequestPurpose` | [llm-streaming.md](llm-streaming.md#the-model-request-and-result) | | `FinishReasonMap` | dsh-llm | `FinishReason` | [llm-streaming.md](llm-streaming.md#the-model-request-and-result) | | `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.md) | | `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.md) | diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index db4bb206f5..19acf51b51 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -460,12 +460,13 @@ declare module '@deepseek-ai/dsh-llm' { } ``` -五个规范 map 使用此模式;插件作者扩展它们: +六个规范 map 使用此模式;插件作者扩展它们: | Map | 包 | 派生 | 目录 | |---|---|---|---| | `ContentBlockMap` | dsh-llm | `ContentBlock` | [llm-streaming.md](llm-streaming.zh.md#content-blocks-and-messages) | | `MessageSourceMap` | dsh-llm | `MessageSource` | [llm-streaming.md](llm-streaming.zh.md#content-blocks-and-messages) | +| `LlmRequestPurposeMap` | dsh-llm | `LlmRequestPurpose` | [llm-streaming.zh.md](llm-streaming.zh.md#the-model-request-and-result) | | `FinishReasonMap` | dsh-llm | `FinishReason` | [llm-streaming.md](llm-streaming.zh.md#the-model-request-and-result) | | `TurnEndReasonMap` | dsh-session | `TurnEndReason` | [session.md](session.zh.md) | | `SessionEventMap` | dsh-session | `SessionEvent` | [session.md](session.zh.md) | diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml index 53a7964033..92bc2298cd 100644 --- a/docs/subsystems/forge.i18n.yaml +++ b/docs/subsystems/forge.i18n.yaml @@ -12,8 +12,8 @@ en: 26ccfa2e342e3e67 zh: fe6cc2a622757c48 /forges/change-request-actions: - en: 00e4490e416099d2 - zh: 5724d08b6ae230f7 + en: 87d2c81d9514267f + zh: 1daf31c5b0127a1c /forges/session-bindings: en: 13dfd36f2166bba0 zh: a369434f52f04d57 diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 4bb2251a52..2a3cdf2087 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -45,6 +45,8 @@ type ChangeRequestKey = Branded<'ChangeRequestKey'> interface ForgeProvider { /** Forge family name stored in every repository this provider returns. */ readonly forge: string + /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ + submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ @@ -56,6 +58,8 @@ interface ForgeProvider { refs: readonly ChangeRequestRef[], signal: AbortSignal, ): Promise> + /** Read all checks for one change request on demand; omission declares that only the polling preview is available. */ + fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise /** Report whether the provider can reach `host` with current credentials. */ @@ -246,6 +250,37 @@ interface ForgeActionFailure { } ``` +`ctx.forge.submitReview` delegates an explicit comment or completed review to the provider. A provider that omits this capability returns unsupported. Consumers retain pending drafts and reconcile unknown write outcomes before resubmitting. + +```ts type-equiv +/** One inline comment addressed to the old or new side of a pull request diff. */ +interface ForgeLineComment { + readonly path: string + readonly side: 'L' | 'R' + readonly startLine: number + readonly endLine: number + readonly body: string +} +``` + +```ts type-equiv +/** A single published comment or one completed review containing all pending comments. */ +type ForgeReviewRequest = { + /** Commit the user reviewed; providers must check it before writing and address that commit explicitly. */ + readonly headSha: string +} & ( + | { readonly kind: 'comment'; readonly comment: ForgeLineComment } + | { readonly kind: 'review'; readonly event: 'comment' | 'approve' | 'request-changes'; readonly body: string; readonly comments: readonly ForgeLineComment[] } +) +``` + +```ts type-equiv +/** Result of a review write; unknown outcomes must be checked on the host before another submission. */ +type ForgeReviewResult = + | { readonly ok: true; readonly url: string } + | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } +``` + ## Session bindings `ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results trigger head-branch discovery through `ctx.forge`; the panel also reports the branch its checkout tracks for the same discovery. Track, dismiss, and discovery actions are `plugin:forge-session/binding` records, which stay outside the model-visible conversation. The `forgeChangeRequests` projection's client view, which the Session summary carries, lists the bindings without their status. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules. @@ -735,6 +770,15 @@ async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Read complete check details independently of periodic polling. + * @param ref - change request served by a registered provider. + * @param signal - cancellation of the detail view. + * @returns a complete snapshot or the provider's failure; unsupported providers return unavailable. + * @throws on cancellation, provider disposal, or expiry of this read's own `fetchTimeoutMs` deadline. + */ +async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise + /** * Read the changes of a change request as its host computes them. * @param ref - change request served by a registered provider. @@ -744,6 +788,15 @@ async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): */ async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Publish one comment or a completed review, then refresh the watched status. + * @param ref - change request served by a registered provider. + * @param request - explicitly selected commit, comments, and review decision. + * @param signal - request cancellation. + * @returns a publication URL or a failure, including an unknown write outcome. + */ +async submitReview(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** * Read the text of one file at a commit of a change request's repository. * @param ref - change request served by a registered provider. @@ -798,7 +851,7 @@ Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental ### `ctx.forgePanel` — `ForgePanelController` -Serves the forge panel. Following a Session inspects the checkout of its current directory read-only in the Session's execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it while the Session's first message waits in the inbox, or at the first step proposal of a message no wait took. After a failed or stopped attempt the message keeps waiting for another attempt or for the request to be withdrawn. A message also waits while the worktree the Session works in is gone, until that worktree is checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. +Serves the forge panel. Following a Session inspects the checkout of its current directory using the Session's permissions and execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it while the Session's first message waits in the inbox, or at the first step proposal of a message no wait took. After a failed or stopped attempt the message keeps waiting for another attempt or for the request to be withdrawn. A message also waits while the worktree the Session works in is gone, until that worktree is checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. ```ts cordis-catalog /** @@ -862,13 +915,14 @@ Serves the forge panel. Following a Session inspects the checkout of its current /** * Read the changes one scope covers in the checkout of the Session's - * current directory, with Git confined to the Session's read-only sandbox. + * current directory, with Git using the Session's sandbox policy. * @param session - Session whose checkout is read. * @param scope - which changes to read. * @param signal - request cancellation. * @returns the files, the base of the `branch` scope, and the untracked files left out. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/no-fork-point` for the `branch` - * scope of a checkout without one; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. + * scope of a checkout without one; `forgePanel/filter-config-failed` when filter configuration cannot be read + * completely, before any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. */ @Remote async localDiff(session: Session, scope: ForgeLocalScope, signal: AbortSignal): Promise @@ -885,6 +939,18 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async localText(session: Session, scope: ForgeLocalScope, path: string, signal: AbortSignal): Promise +/** + * Load complete checks for the change request whose list the user opened. + * @param session - Session retaining the visible binding. + * @param url - binding URL. + * @param headSha - commit displayed by the card; a different head rejects the result. + * @param signal - cancellation when the list closes. + * @returns the complete snapshot at the displayed head. + * @throws `forgePanel/not-bound` for an invisible binding, `forgePanel/stale-head` for a moved head, + * or `forgePanel/checks-failed` when the provider cannot complete the read. + */ +@Remote async changeRequestChecks(session: Session, url: string, headSha: string, signal: AbortSignal): Promise + /** * Read the changes of a change request of a Session as its code host * computes them, at the head commit of its latest status. A patch already @@ -915,6 +981,16 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise +/** + * Publish a line comment or completed review for a visible Session binding. + * @param session - Session retaining the change request. + * @param url - visible binding URL. + * @param request - reviewed head, ranges, plain text, and explicit review decision. + * @param signal - request cancellation. + * @returns confirmed publication or a typed failure; unknown outcomes require reconciliation on the host. + */ +@Remote async submitReview(session: Session, url: string, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** * Mark a draft change request of a Session ready for review; the change * request is polled again once the host answers. diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 0819fa9f6c..5c78d7bb02 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -45,6 +45,8 @@ type ChangeRequestKey = Branded<'ChangeRequestKey'> interface ForgeProvider { /** Forge family name stored in every repository this provider returns. */ readonly forge: string + /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ + submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ repositoryFromRemote(url: string): ForgeRepository | undefined /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ @@ -56,6 +58,8 @@ interface ForgeProvider { refs: readonly ChangeRequestRef[], signal: AbortSignal, ): Promise> + /** Read all checks for one change request on demand; omission declares that only the polling preview is available. */ + fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise /** Report whether the provider can reach `host` with current credentials. */ @@ -246,6 +250,37 @@ interface ForgeActionFailure { } ``` +`ctx.forge.submitReview` 将明确指定的评论或完整评审交给 provider;未实现此能力的 provider 返回不支持。消费者保存待提交草稿,并在再次提交前核对结果不明的写操作。 + +```ts type-equiv +/** One inline comment addressed to the old or new side of a pull request diff. */ +interface ForgeLineComment { + readonly path: string + readonly side: 'L' | 'R' + readonly startLine: number + readonly endLine: number + readonly body: string +} +``` + +```ts type-equiv +/** A single published comment or one completed review containing all pending comments. */ +type ForgeReviewRequest = { + /** Commit the user reviewed; providers must check it before writing and address that commit explicitly. */ + readonly headSha: string +} & ( + | { readonly kind: 'comment'; readonly comment: ForgeLineComment } + | { readonly kind: 'review'; readonly event: 'comment' | 'approve' | 'request-changes'; readonly body: string; readonly comments: readonly ForgeLineComment[] } +) +``` + +```ts type-equiv +/** Result of a review write; unknown outcomes must be checked on the host before another submission. */ +type ForgeReviewResult = + | { readonly ok: true; readonly url: string } + | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } +``` + ## Session 绑定 `ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现;面板也会报告其检出所跟踪的分支,触发同样的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。`forgeChangeRequests` 投影的客户端视图随 Session 摘要下发,列出绑定但不含其状态。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。 @@ -735,6 +770,15 @@ async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Read complete check details independently of periodic polling. + * @param ref - change request served by a registered provider. + * @param signal - cancellation of the detail view. + * @returns a complete snapshot or the provider's failure; unsupported providers return unavailable. + * @throws on cancellation, provider disposal, or expiry of this read's own `fetchTimeoutMs` deadline. + */ +async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise + /** * Read the changes of a change request as its host computes them. * @param ref - change request served by a registered provider. @@ -744,6 +788,15 @@ async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): */ async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Publish one comment or a completed review, then refresh the watched status. + * @param ref - change request served by a registered provider. + * @param request - explicitly selected commit, comments, and review decision. + * @param signal - request cancellation. + * @returns a publication URL or a failure, including an unknown write outcome. + */ +async submitReview(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** * Read the text of one file at a commit of a change request's repository. * @param ref - change request served by a registered provider. @@ -798,7 +851,7 @@ Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental ### `ctx.forgePanel` — `ForgePanelController` -Serves the forge panel. Following a Session inspects the checkout of its current directory read-only in the Session's execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it while the Session's first message waits in the inbox, or at the first step proposal of a message no wait took. After a failed or stopped attempt the message keeps waiting for another attempt or for the request to be withdrawn. A message also waits while the worktree the Session works in is gone, until that worktree is checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. +Serves the forge panel. Following a Session inspects the checkout of its current directory using the Session's permissions and execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it while the Session's first message waits in the inbox, or at the first step proposal of a message no wait took. After a failed or stopped attempt the message keeps waiting for another attempt or for the request to be withdrawn. A message also waits while the worktree the Session works in is gone, until that worktree is checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. ```ts cordis-catalog /** @@ -862,13 +915,14 @@ Serves the forge panel. Following a Session inspects the checkout of its current /** * Read the changes one scope covers in the checkout of the Session's - * current directory, with Git confined to the Session's read-only sandbox. + * current directory, with Git using the Session's sandbox policy. * @param session - Session whose checkout is read. * @param scope - which changes to read. * @param signal - request cancellation. * @returns the files, the base of the `branch` scope, and the untracked files left out. * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/no-fork-point` for the `branch` - * scope of a checkout without one; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. + * scope of a checkout without one; `forgePanel/filter-config-failed` when filter configuration cannot be read + * completely, before any comparison; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. */ @Remote async localDiff(session: Session, scope: ForgeLocalScope, signal: AbortSignal): Promise @@ -885,6 +939,18 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async localText(session: Session, scope: ForgeLocalScope, path: string, signal: AbortSignal): Promise +/** + * Load complete checks for the change request whose list the user opened. + * @param session - Session retaining the visible binding. + * @param url - binding URL. + * @param headSha - commit displayed by the card; a different head rejects the result. + * @param signal - cancellation when the list closes. + * @returns the complete snapshot at the displayed head. + * @throws `forgePanel/not-bound` for an invisible binding, `forgePanel/stale-head` for a moved head, + * or `forgePanel/checks-failed` when the provider cannot complete the read. + */ +@Remote async changeRequestChecks(session: Session, url: string, headSha: string, signal: AbortSignal): Promise + /** * Read the changes of a change request of a Session as its code host * computes them, at the head commit of its latest status. A patch already @@ -915,6 +981,16 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise +/** + * Publish a line comment or completed review for a visible Session binding. + * @param session - Session retaining the change request. + * @param url - visible binding URL. + * @param request - reviewed head, ranges, plain text, and explicit review decision. + * @param signal - request cancellation. + * @returns confirmed publication or a typed failure; unknown outcomes require reconciliation on the host. + */ +@Remote async submitReview(session: Session, url: string, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** * Mark a draft change request of a Session ready for review; the change * request is polled again once the host answers. diff --git a/docs/subsystems/liveshare.i18n.yaml b/docs/subsystems/liveshare.i18n.yaml new file mode 100644 index 0000000000..e1ba0426b4 --- /dev/null +++ b/docs/subsystems/liveshare.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for liveshare.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 docs/subsystems/liveshare.md +/liveshare: + en: 196b06ffccedbdef + zh: 0ca8df19400afc40 +/liveshare/enabling-and-visibility: + en: f6144f8e81359a9c + zh: 1924869ac7475b59 +/liveshare/share-modes: + en: 985cb7a5c20c1cbd + zh: a5f5372dddfefaa3 +/liveshare/forks-and-their-ceiling: + en: 357a2147f48f5bac + zh: a8a297603f984ede +/liveshare/retries-recovery-and-revocation: + en: 6b1346e73ab6eab9 + zh: 888cfc08c6bc8522 +/liveshare/constraining-a-shared-session: + en: 289e8955320935f6 + zh: 7038c4283a4f33c6 +/liveshare/viewer-states: + en: b5b4d4d83dbb58c2 + zh: 498f26938dc872df diff --git a/docs/subsystems/liveshare.md b/docs/subsystems/liveshare.md new file mode 100644 index 0000000000..78c59825ee --- /dev/null +++ b/docs/subsystems/liveshare.md @@ -0,0 +1,158 @@ +# LiveShare + +English | [中文](liveshare.zh.md) + +The opt-in LiveShare bundle (`@deepseek-ai/dsh-experimental-liveshare`) lets one owner share a single Session with an external viewer through the separate Liveshare server. A viewer never receives the owner's tools: the owner host reads each viewer request itself, dispatches it as that viewer's own Peer, whose `share-viewer` principal the Gateway decides through the [remote access](../../packages/api/remote-access/README.md) policy this package registers, and constrains everything a shared Session runs on the viewer's behalf to the workspace the share granted. The [package README](../../packages/experimental/liveshare/README.md) owns configuration, the client faces, and the limitations; the [Owner note](../../.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.md) owns the security posture behind them. + +Source: [`packages/experimental/liveshare/src/host/fork-enforcement.ts`](../../packages/experimental/liveshare/src/host/fork-enforcement.ts) + + + +## Enabling and visibility + +The package ships with dsh but stays out of the plugin manager's lists until a profile enables it. A profile either inserts the two rows the bundle declares into its own `cordis.patch.yml`, which keeps the bundle unselected while its plugins run, or selects the bundle in `dsh.profile.bundles`. The [package README](../../packages/experimental/liveshare/README.md#default-visibility-and-enabling) owns the exact entries and the placeholders a deployment replaces. + +The plugin manager lists the card under **Experimental plugins** only while at least one row the bundle declares runs, and removing the rows, disabling them, or deselecting the bundle takes it out of both that list and **More** again. Both routes apply on the profile's next recomposition rather than mid-task: with HMR enabled the profile is re-read, and without it the change waits for the next start. + +## Share modes + +`ViewerSessionMode` has two tiers, and both name a sandbox: `view` reads the shared Session and drives its loop inside a read-only sandbox in the granted workspace, so it never writes a file; it may use the read and search surface (`read`, `read_image`, `glob`, `grep`, `present`) and the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), with every declared path checked against the grant. `write` runs in a bounded writable sandbox that also modifies files inside that workspace by adding bounded `edit` and `write`, under the Host's write ceiling. Forking is not a tier: the share carries its own `allowsFork` switch, independent of the tier. Attachment upload is not a tier either: every Session a share serves the reader accepts files, whatever the tier and the fork switch. The withdrawn `fork-readonly` name is not read back as `view`: a persisted owner record that names it is rejected when the owner state loads. Nothing infers `write`, and a share whose mode the host cannot establish stays readable but is never driven. + +## Forks and their ceiling + +A fork creates a new Session the viewer owns and leaves the source untouched. Fork authorization checks the share, its fork switch, expiry, viewer admission, source identity, and the requested boundary. `READ_ONLY_FORK_CEILING` permits the view surface — `read`, `read_image`, `glob`, `grep`, `present`, `todo_write`, `skill`, `web_search`, `web_fetch` — and `WRITE_FORK_CEILING` adds the Host’s bounded `edit` and `write`. Each path-bearing tool gets a confined replacement that resolves every declared path against the granted root and delegates with its canonical form; `bash`, `pwsh`, `cua`, `plugin_manager`, the `schedule_*` tools, and every name the surface does not list are refused. + +Each child is recorded with the share that authorized it, the trusted viewer the Liveshare server asserted, and the boundary it started from, so a viewer's list shows only its own children and a removed share stops them. A child is driven only after its ceiling is installed on its own scope: `ForkRegistry` refuses every prompt for a child whose guard is not installed (`fork-ceiling-uninstalled`), and installation is verified per child rather than per host. A child whose scope refuses the confined replacement or the limit is refused instead of being published with the deployment's own tools. + +Cold source and child activation uses the Host’s ordinary Agent lookup, not a Remote `session/prepare` endpoint. Serial `agent/created` listeners install scoped enforcement before queued work is released; an already live Agent needs its own installation check. `forkLineage` is a LiveShare-owned, per-viewer UI projection, not a Session-summary field, durable log event, or permission grant. + +## Retries, recovery, and revocation + +One logical fork request may be sent more than once. Its identity binds the trusted viewer, the share, the source, and the caller's operation id; the boundary is compared against that identity rather than folded into it, so a retry repeats the child it already produced, an identity naming another boundary conflicts instead of silently receiving the earlier child, and the reservation is durable before any child exists. The host records the child the owner's fork answer returned, and a retry of a recorded operation replays that answer. The owner's `SessionForkRequest` mints the child id, so a host that dies between creating the child and recording it cannot name that child on retry and creates a second one; closing that window needs a Session Controller request that accepts a caller-supplied child identity. + +Child tool guards recheck the share’s current mode, expiry, viewer admission, and revocation record. Viewer withdrawal cancels affected child work and pending calls. A `write` to `view` downgrade denies later writes through the write guard without granting any new tool, and does not promise immediate cancellation of a running turn. Removing a share revokes recorded children and stops the Session's running turn; the source enforcement layer is released once no share over the Session is left, and with Agent disposal. Revoked children retain their logs, and the source’s owner-visible history is never rewritten. + +## Constraining a shared Session + +Every share lets its viewer drive the *source* Session through the normal prompt path, and any share at either tier installs the bounded sandbox while it is active. `installSourceSessionAccess` registers a confined replacement for every view-surface tool the deployment ships in that Session's own scope, and bounded `edit` and `write` replacements whenever any share over the Session allows writing. The layer is mounted on the Session, but it confines only the turns a share viewer starts: `agent/pre-step` records, in Host memory, whether a turn's admitted input names a share viewer, and the guard and the replacements read that answer for every execution of that turn. A viewer-started turn runs those replacements and nothing else; a turn the owner starts keeps the Session's own tools, including `bash` and its unrestricted filesystem tools. The answer is decided when the turn is admitted and never written to the Session log; a restart recomputes it from the next admitted step. An owner's steering input inside a viewer's turn stays confined with it. Stopping every share over the Session disposes the layer, which restores the Session's own tools; narrowing the last write share to `view` re-installs the read-only layer. + +The installation reports whether the source Session is actually confined. A deployment without the `read` tool, or a scope that refuses that replacement's registration, leaves the guard in place and reports the refusal; a tool whose own replacement failed stays refused while the rest of the surface runs, and the reader refuses the prompt with `source-write-unavailable`: no viewer-started turn of a shared Session is driven against the owner's own tools because a limit could not be installed. + +Each operation re-reads the Relay grants of the shares that keep the Session drivable, and each reading is synchronous with the tool call it authorizes, so input queued past the grant that admitted it cannot act on that stale admission. The layer is per Session: several shares over one Session share the one installation, and a share that narrows or ends changes what the layer permits — its bounded write is refused once no active share may write, and the layer is released once no share is left. The Host does not attribute a running turn to the viewer that admitted it, so ending one share's grant stops the Session's running turn rather than singling out that viewer's work; the in-memory viewer-start marker only decides which tool surface that turn runs. + +Confined file tools resolve every path their own arguments declare, check that the granted root contains each result, and delegate with the target’s canonical path. A pathless `glob` or `grep` searches the Session’s working directory, which the replacement supplies and checks like a named path; a `present` call checks every file it lists; the tools with no local file effect are delegated unchanged. Bounded edits run the same containment check, use `fs/edit-intent`, and publish the version `editText` returns; bounded writes use `fs/write-intent` and publish the version `writeText` returns. After the asynchronous intent wait, the Host resolves the current Session sandbox policy and rechecks share authority. It passes that policy as the fifth `editText` or `writeText` argument, after `signal`, so the sandboxing backend fences the mutation by it. Containment is checked by path, so a directory inside the grant that a local process replaces with a symlink between the check and the open can redirect the operation; anchoring operations to a directory handle is deferred to a separate change. + +A viewer's source prompt and source stop both travel the standard Gateway dispatch under the `share-viewer` policy's source-write role: the policy admits them while the share's tier is established and its Session is confined, and the owner's own Session controller then stops whatever turn the Session is running. Cancellation performs no per-viewer comparison, so a reader's stop of the shared Session also ends a turn the owner started. The [Session Controller](../../packages/api/session-controller/README.md) reads `remoteAccess.allows()` again inside `prompt` immediately before delivery and in the same synchronous step as a `cancel`, so a viewer whose access ends after the Gateway admitted its call delivers or cancels nothing. Queued external source input refreshes Liveshare server authority in `agent/pre-step`; missing authority rejects the step before a model request. + + + +## Viewer states + +A viewer read that ends in a refusal names its own condition rather than one failure for every cause: the Liveshare server answers a missing link, a share the owner stopped, an expired share, an owner whose machine holds no route, an expired sign-in, a reader who has not asked, a request still waiting, a request the owner refused, and a notification the owner never received each with their own status and code, and a page that cannot reach the Liveshare server states that itself. `VIEWER_STATE_ANSWERS` in `@deepseek-ai/dsh-experimental-liveshare-protocol` is the whole vocabulary, and the [shared protocol](../../packages/experimental/liveshare-protocol/README.md) owns the answer each condition carries. No answer widens what a viewer may read: an offline owner, a stopped share, and a refused request all deny. + +The workspace feed a viewer's page renders is filtered through the same policy as its session list: a workspace is delivered while it holds the shared Session or a branch that viewer made from it, and its membership and its manual order keep only those. A workspace that loses them leaves the feed when that workspace or the workspace order next changes, and the stream then replaces the reader's baseline; an access change alone sends no frame. A reader therefore sees the owner's workspace names and grouping around the one Session it was granted, never the rest of the owner's workspaces. + +Viewer streams bind the opening actor across polling, replacement, and cancellation. Session notifications, control baselines, and workspace membership expose only the source and that actor’s children: the Gateway forwards a reader only the `api-session/*` notifications the policy allows on those Sessions, and the Session and Workspace controllers filter their lists and streams through the same policy; interactive approvals and questions stay on the Host. Refused reads open no upstream subscription. Expiry and revocation cancel affected pending calls and streams; unpolled streams are reclaimed after 120 seconds. Worker IPC responses and cancellations must match the request’s token, share id, Liveshare server id, and original socket or worker generation; late answers cannot settle a replacement request. + +The Liveshare server’s optional `share-access-state` capability publishes a complete, versioned authority snapshot on connection and committed mutations, including mutations on another Liveshare server instance. Authenticated `GET /__liveshare/requests` returns the same fields. The Host clears cached authority on connection loss, rejects stale revisions within a generation, and fences asynchronous refreshes against generation changes. Missing complete authority refuses admission; this does not establish immediate distributed stop semantics. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.liveshareShares` — `LiveshareShares` + +Owner-only Remote surface over the share-management operations. + +The Gateway decides each call through remote access, so a `share-viewer` Peer is refused before a method runs; the owner Peer reaches the one implementation that owns each share-management side effect. `create` takes its Session as the `sessionId` of its single request object, so the Session resource kind's default resolver names that Session for the call without a reader declared here. + +```ts cordis-catalog +/** + * Publish one Session and store the share. + * @param request - Session, title, access, lifetime, and both switches the owner chose. + * @returns The created share's link and stored values. + */ +@Remote('create') create(request: CreateShareRequest): Promise + +/** + * Refresh titles and Liveshare server authority, then list this host's shares. + * @returns Every share this host publishes. + */ +@Remote('list') list(): Promise + +/** + * Stop one share locally, revoke its forks, and delete its row on the Liveshare server when reachable. + * @param request - Share to stop. + * @returns Whether the row on the Liveshare server still needs a retry. + */ +@Remote('stop') stop(request: StopShareRequest): Promise + +/** + * Change one share's tier, fork switch, or both. + * @param request - Share and the switches to change. + * @returns The permissions the share now carries. + */ +@Remote('updatePermissions') updatePermissions(request: UpdateSharePermissionsRequest): Promise + +/** + * Replace one share's exposed plugin endpoints. + * @param request - Share and the selection to store. + * @returns The capability selection the share now carries. + */ +@Remote('setCapabilities') setCapabilities(request: SetShareCapabilitiesRequest): Promise + +/** + * Read the endpoints plugins currently offer. + * @returns The live offer list. + */ +@Remote('capabilities') capabilities(): ShareCapabilityOffers + +/** + * Read the Liveshare server address the owner saved. + * @returns The current Liveshare server state, with both address fields absent while none is saved. + */ +@Remote('relay') relay(): OwnerRelayState + +/** + * Choose the Liveshare server address later shares publish to. + * @param request - Normalized address or null to clear the saved one. + * @returns The Liveshare server state after the change. + */ +@Remote('setRelay') setRelay(request: SetRelayRequest): Promise + +/** + * Report this host's owner identity for one Liveshare server address. + * @param request - Address to report, or the effective one when absent. + * @returns The owner identity status, without the credential. + */ +@Remote('identity') identity(request: OwnerIdentityRequest): Promise + +/** + * Read the Liveshare server's durable audit trail for one share. + * @param request - Share whose audit is read. + * @returns The audit events, or a structured failure when the Liveshare server cannot answer. + */ +@Remote('audit') audit(request: ShareAuditRequest): Promise + +/** + * Check that the effective Liveshare server answers at all. + * @returns The anonymous connectivity result. + */ +@Remote('testRelay') testRelay(): Promise + +/** + * Register one throwaway share to check whether this host may publish. + * @param request - Session the throwaway registration names. + * @returns The Liveshare server's decision about this host's credential. + */ +@Remote('testPublish') testPublish(request: TestPublishRequest): Promise +``` + +Source: [`packages/experimental/liveshare/src/host/share-remote.ts`](../../packages/experimental/liveshare/src/host/share-remote.ts) + diff --git a/docs/subsystems/liveshare.zh.md b/docs/subsystems/liveshare.zh.md new file mode 100644 index 0000000000..e794ce64cf --- /dev/null +++ b/docs/subsystems/liveshare.zh.md @@ -0,0 +1,158 @@ +# LiveShare + +[English](liveshare.md) | 中文 + +可选的 LiveShare bundle(`@deepseek-ai/dsh-experimental-liveshare`)让一位所有者通过独立的 Liveshare 服务端 把一个会话分享给外部 viewer。viewer 永远拿不到所有者的工具:所有者宿主机自己读取 viewer 的每个请求,以该 viewer 自己的 Peer 分派它,Gateway 依据本包注册的 [remote access](../../packages/api/remote-access/README.zh.md) 策略判定该 Peer 的 `share-viewer` principal,并把共享会话代表 viewer 运行的一切限制在分享所授予的工作区内。[包 README](../../packages/experimental/liveshare/README.zh.md)负责配置、客户端界面与限制;[Owner 说明](../../.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md)负责其背后的安全姿态。 + +源码:[`packages/experimental/liveshare/src/host/fork-enforcement.ts`](../../packages/experimental/liveshare/src/host/fork-enforcement.ts) + + + +## 启用与可见性 + +本包随 dsh 一同交付,但在 profile 启用它之前不会出现在插件管理器的任何列表中。profile 可以把自己的 `cordis.patch.yml` 插入组合包声明的两条行,这样组合包本身保持未选中而插件照常运行;也可以改在 `dsh.profile.bundles` 中选中该组合包。[包 README](../../packages/experimental/liveshare/README.zh.md#default-visibility-and-enabling)负责具体条目和部署需要替换的占位符。 + +插件管理器只有在组合包声明的行至少有一条在运行时才把卡片列在**实验性插件**下;删除这些行、把它们置为 disabled,或取消选中组合包,卡片会再次从该列表和**更多**页消失。两条路线都在 profile 下一次重新组合时生效,而不是在当前任务中途:启用 HMR 时会重新读取 profile,未启用时则等下一次启动。 + +## 分享模式 + +`ViewerSessionMode` 只有两档,两档都对应一个沙箱:`view` 读取共享会话,并在授予工作区的只读沙箱里驱动它的 loop,绝不写文件;它可以使用读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)和无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`),每个声明路径都按授予范围检查。`write` 在受宿主写入上限约束的可写沙箱里运行,通过有界 `edit` 与 `write` 修改该工作区内的文件。fork 不是一档:分享自带独立的 `allowsFork` 开关,与档位无关。附件上传也不是一档:分享向读者提供的每条会话都接受文件,与档位和分叉开关无关。已退场的 `fork-readonly` 名称不会读回为 `view`:持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。任何环节都不会推断出 `write`,而宿主机无法确定模式的分享仍可读取,但绝不会被驱动。 + +## fork 与其上限 + +fork 创建查看者拥有的新会话,源会话保持不变。Fork 授权检查分享、其 fork 开关、有效期、查看者准入、源身份与请求的边界。`READ_ONLY_FORK_CEILING` 允许读取与检索面——`read`、`read_image`、`glob`、`grep`、`present`、`todo_write`、`skill`、`web_search`、`web_fetch`——,`WRITE_FORK_CEILING` 增加 Host 的有界 `edit` 与 `write`。每个带路径的工具都有受限替代定义:解析它声明的所有路径并按授予根目录检查,再以规范路径委托;`bash`、`pwsh`、`cua`、`plugin_manager`、`schedule_*` 及工具面未列出的名字一律拒绝。 + +每个分叉会话都会记录授权它的分享、Liveshare 服务端断言的可信 viewer,以及它起始的边界,因此 viewer 的列表只显示它自己的分叉会话,被移除的分享会终止它们。只有在分叉会话自身作用域上安装好上限之后它才会被驱动:`ForkRegistry` 会拒绝每个上限未安装的分叉会话的 prompt(`fork-ceiling-uninstalled`),且安装是按分叉会话逐个验证,而非按宿主机验证。若分叉会话作用域拒绝注册受限替换或上限,就拒绝该分叉会话,而不是让它带着部署自身的工具发布。 + +冷态源会话与分叉会话通过 Host 常规 Agent lookup 激活,而非 Remote `session/prepare` 端点。串行 `agent/created` 监听器在排队工作放行前安装作用域执行限制;已经在线的 Agent 仍需检查自身安装。`forkLineage` 是 LiveShare 自有的按查看者 UI 投影,不是会话摘要字段、持久日志事件或权限授予。 + +## 重试、恢复与撤销 + +同一个逻辑 fork 请求可能被发送多次。其身份绑定可信 viewer、分享、源会话与调用方的操作 id;边界是与该身份比较而非折入其中,因此重试会重复它已经产生的分叉会话,同一身份指向另一个边界会冲突而不是悄悄拿到更早的分叉会话,且预留在任何分叉会话存在之前就已持久化。宿主机记录 owner 的 fork 应答返回的分叉会话,重试已记录的操作会重放该应答。owner 的 `SessionForkRequest` 自己生成分叉会话 id,因此在创建分叉会话与记录它之间崩溃的宿主机重试时无法指名该分叉会话,会再创建一个;要关闭这一窗口,需要 Session Controller 的请求接受调用方提供的分叉会话身份。 + +分叉会话工具守卫重新检查分享当前模式、有效期、查看者准入与撤销记录。撤回查看者会取消受影响的分叉会话工作与未决调用。`write` 降级为 `view` 后,写入守卫拒绝后续写入,不授予任何新工具,也不承诺立即取消正在运行的轮次。移除分享撤销已记录分叉会话并停止该会话正在运行的轮次;源执行层在该会话不再有任何分享时释放,并随 Agent 释放。已撤销分叉会话保留日志,源会话所有者可见历史绝不改写。 + +## 约束共享会话 + +每个分享都让它的 viewer 通过正常 prompt 路径驱动*源*会话,任一档位的分享在活跃期间都会安装有界沙箱。`installSourceSessionAccess` 在该会话自身作用域中为部署提供的每个读取与检索面工具注册受限替代,并在任一同会话分享允许写入时再注册有界 `edit` 与 `write` 替代。执行层挂在会话上,但只约束分享查看者发起的轮次:`agent/pre-step` 在 Host 内存中记录一个轮次的已准入输入是否带有 share-viewer principal,守卫与替代定义对该轮次的每次执行读取这一结论。查看者发起的轮次只能运行这些替代定义;所有者自己发起的轮次保留会话原生工具,包括 `bash` 与不受限的文件工具。该结论在轮次准入时确定,不写入会话日志;重启后从下一个准入步骤重新计算。所有者在查看者轮次中追加的输入仍留在该受限轮次内。该会话的所有分享停止后执行层被释放,会话恢复自己的工具;最后一个可写分享降为 `view` 时重装只读层。 + +安装结果报告源会话是否真正受到限制。没有 `read` 工具的部署或拒绝该替代注册的作用域会保留守卫并报告失败;某个工具自己的替代注册失败时,该工具保持拒绝,其余工具面照常运行。读取处理器以 `source-write-unavailable` 拒绝消息:限制未安装时,共享会话中查看者发起的轮次不会用所有者工具驱动。 + +每次操作都会重新读取维持该会话可驱动的分享的 Relay 授权,且读取与其授权的工具调用在同一同步步骤内完成,因此排队超过其准入授权的输入无法凭过期准入行动。执行层按会话安装:同一会话的多个分享共用一个安装;分享收窄或结束时改变该层允许的操作——没有任何活跃分享可写时拒绝有界写入,会话不再有分享时释放执行层。Host 不把正在运行的轮次归属给准入它的查看者,因此结束某个分享的授权会停止该会话正在运行的轮次,而不是只挑出该查看者的工作;内存中的查看者轮次标记只决定该轮次运行哪套工具面。 + +受限文件工具解析其自身参数声明的每个路径,检查获授根目录包含每个目标,再以目标的规范路径委托。无路径的 `glob` 或 `grep` 搜索会话的工作目录,由替代定义补齐并按命名路径一样检查;`present` 调用检查它列出的每个文件;无本地文件副作用的工具原样委托。有界编辑执行同样的包含检查,使用 `fs/edit-intent`,并发布 `editText` 返回的版本;有界写入使用 `fs/write-intent`,并发布 `writeText` 返回的版本。异步意图等待后,Host 解析当前会话沙箱策略并重新检查分享授权。该策略作为 `editText` 或 `writeText` 的第五个参数,位于 `signal` 之后,由沙箱后端据此限制变更。包含关系按路径检查,若本地进程在检查与打开之间把获授范围内的目录换成符号链接,操作可能被改指别处;基于目录句柄锚定操作留待单独变更引入。 + +查看者对源会话的提示词与停止都按标准 Gateway 分派走 `share-viewer` 策略的源会话写入角色:分享档位已确立且其会话受沙箱限制时策略放行,随后由所有者自己的会话控制器停止该会话当前运行的轮次。取消不按查看者比对,因此读者对共享会话的停止也会结束所有者发起的轮次。[Session Controller](../../packages/api/session-controller/README.zh.md) 在 `prompt` 投递前、以及与 `cancel` 同一同步步骤内再次读取 `remoteAccess.allows()`,因此 Gateway 放行调用之后访问权才结束的查看者不会投递或取消任何内容。排队的外部源输入在 `agent/pre-step` 刷新 Liveshare 服务端授权;缺少授权时在模型请求之前拒绝步骤。 + + + +## viewer 状态 + +一次以拒绝结束的 viewer 读取会指明它自己的状态,而不是让所有原因共用一种失败:链接不存在、owner 已停止分享、分享已过期、owner 的机器没有路由、登录已过期、读者尚未申请、申请仍在等待、申请被 owner 拒绝、owner 从未收到通知,每种都有各自的 HTTP 状态与错误码;页面联系不上 Liveshare 服务端时由页面自己陈述。`@deepseek-ai/dsh-experimental-liveshare-protocol` 中的 `VIEWER_STATE_ANSWERS` 就是这份词汇表,每种状态对应的答案由 [共享协议](../../packages/experimental/liveshare-protocol/README.zh.md) 维护。没有任何一种答案会扩大 viewer 能读到的内容:owner 离线、分享已停止、申请被拒,三者都是拒绝。 + +viewer 页面渲染所用的工作区流与会话列表经同一策略过滤:只要工作区还持有共享会话或该 viewer 自己创建的分支就会下发,其成员与手工顺序只保留这些。失去它们的工作区要到该工作区或工作区顺序下次变化时才离开工作区流,届时流替换读者的基线;仅访问变化不会发送帧。因此读者看到的是围绕它被授予的那一个会话的 owner 工作区名称与分组,而不是 owner 其余的工作区。 + +查看流在轮询、替换与取消时均绑定打开它的主体。会话通知、控制基线与工作区成员只暴露源会话及该主体的分叉会话:Gateway 只向读者转发策略在这些会话上允许的 `api-session/*` 通知,Session 与 Workspace 控制器也经同一策略过滤其列表和流;交互审批与提问保留在 Host。被拒绝的读取不创建上游订阅。过期与撤权取消受影响的未决调用和流;120 秒未轮询的流被回收。Worker IPC 响应与取消必须匹配请求 token、分享 ID、Liveshare 服务端 ID 及原始 socket 或 worker generation;迟到响应不能结算替换请求。 + +Liveshare 服务端可选的 `share-access-state` 能力在连接和已提交变更时发布完整、带版本的授权快照,包括其他 Liveshare 服务端实例提交的变更。鉴权 `GET /__liveshare/requests` 返回同一组字段。Host 在连接丢失时清空缓存授权,在同一 generation 内拒绝过期修订,并对异步刷新检查 generation。缺少完整授权时拒绝准入;这不构成立即的分布式停止语义保证。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.liveshareShares` — `LiveshareShares` + +Owner-only Remote surface over the share-management operations. + +The Gateway decides each call through remote access, so a `share-viewer` Peer is refused before a method runs; the owner Peer reaches the one implementation that owns each share-management side effect. `create` takes its Session as the `sessionId` of its single request object, so the Session resource kind's default resolver names that Session for the call without a reader declared here. + +```ts cordis-catalog +/** + * Publish one Session and store the share. + * @param request - Session, title, access, lifetime, and both switches the owner chose. + * @returns The created share's link and stored values. + */ +@Remote('create') create(request: CreateShareRequest): Promise + +/** + * Refresh titles and Liveshare server authority, then list this host's shares. + * @returns Every share this host publishes. + */ +@Remote('list') list(): Promise + +/** + * Stop one share locally, revoke its forks, and delete its row on the Liveshare server when reachable. + * @param request - Share to stop. + * @returns Whether the row on the Liveshare server still needs a retry. + */ +@Remote('stop') stop(request: StopShareRequest): Promise + +/** + * Change one share's tier, fork switch, or both. + * @param request - Share and the switches to change. + * @returns The permissions the share now carries. + */ +@Remote('updatePermissions') updatePermissions(request: UpdateSharePermissionsRequest): Promise + +/** + * Replace one share's exposed plugin endpoints. + * @param request - Share and the selection to store. + * @returns The capability selection the share now carries. + */ +@Remote('setCapabilities') setCapabilities(request: SetShareCapabilitiesRequest): Promise + +/** + * Read the endpoints plugins currently offer. + * @returns The live offer list. + */ +@Remote('capabilities') capabilities(): ShareCapabilityOffers + +/** + * Read the Liveshare server address the owner saved. + * @returns The current Liveshare server state, with both address fields absent while none is saved. + */ +@Remote('relay') relay(): OwnerRelayState + +/** + * Choose the Liveshare server address later shares publish to. + * @param request - Normalized address or null to clear the saved one. + * @returns The Liveshare server state after the change. + */ +@Remote('setRelay') setRelay(request: SetRelayRequest): Promise + +/** + * Report this host's owner identity for one Liveshare server address. + * @param request - Address to report, or the effective one when absent. + * @returns The owner identity status, without the credential. + */ +@Remote('identity') identity(request: OwnerIdentityRequest): Promise + +/** + * Read the Liveshare server's durable audit trail for one share. + * @param request - Share whose audit is read. + * @returns The audit events, or a structured failure when the Liveshare server cannot answer. + */ +@Remote('audit') audit(request: ShareAuditRequest): Promise + +/** + * Check that the effective Liveshare server answers at all. + * @returns The anonymous connectivity result. + */ +@Remote('testRelay') testRelay(): Promise + +/** + * Register one throwaway share to check whether this host may publish. + * @param request - Session the throwaway registration names. + * @returns The Liveshare server's decision about this host's credential. + */ +@Remote('testPublish') testPublish(request: TestPublishRequest): Promise +``` + +Source: [`packages/experimental/liveshare/src/host/share-remote.ts`](../../packages/experimental/liveshare/src/host/share-remote.ts) + diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 70adbab81e..cf8d4c8310 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -642,8 +642,21 @@ type RequestMessage = Message | RequestUserInput ``` ```ts type-equiv -/** A single model request, fully assembled. */ -interface GenerateOptions { +/** Auxiliary request purposes declared by their producer packages. */ +interface LlmRequestPurposeMap {} +``` + +```ts type-equiv +/** Declared auxiliary request purpose; ordinary conversation requests leave it absent. */ +type LlmRequestPurpose = Extract +``` + +```ts type-equiv +/** + * A single model request, fully assembled. + * @typeParam Purpose - declared purpose keys for callers; forwarding adapters may accept `string`. + */ +interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string @@ -684,7 +697,7 @@ interface GenerateOptions { * map the purpose to model-hidden transport metadata. Ordinary conversation * requests leave it unset. */ - purpose?: 'compaction' | 'session-title' + purpose?: Purpose } ``` diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 05accb437c..0feb80a1a1 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -648,8 +648,21 @@ type RequestMessage = Message | RequestUserInput ``` ```ts type-equiv -/** A single model request, fully assembled. */ -interface GenerateOptions { +/** Auxiliary request purposes declared by their producer packages. */ +interface LlmRequestPurposeMap {} +``` + +```ts type-equiv +/** Declared auxiliary request purpose; ordinary conversation requests leave it absent. */ +type LlmRequestPurpose = Extract +``` + +```ts type-equiv +/** + * A single model request, fully assembled. + * @typeParam Purpose - declared purpose keys for callers; forwarding adapters may accept `string`. + */ +interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string @@ -690,7 +703,7 @@ interface GenerateOptions { * map the purpose to model-hidden transport metadata. Ordinary conversation * requests leave it unset. */ - purpose?: 'compaction' | 'session-title' + purpose?: Purpose } ``` diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 53d89173b4..b4cd4bba34 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -68,3 +68,6 @@ /sessions/remote-catalog-and-workspace-opening: en: dd27307e0d7c893c zh: b77d2a749b7b641c +/sessions/session-log-upload-exclusions: + en: cfe3323aade972be + zh: 8423b490c47c5013 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 7cbc91298d..fb3e56ee87 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -817,6 +817,29 @@ The backends that consume this contract are on [persistence.md](persistence.md). The optional `application` selects a registered file handler without changing the system default; `workspacePathApplications({ path })` returns current handlers, names, icons, and default selection after Host path verification. +## Session-log upload exclusions + +`ctx.sessionLogDeepSeek.registerExclusion()` registers a caller-owned predicate over `SessionLogUploadRequest`. Any match omits the log contribution and its acceptance callback before reading Session events. No match preserves normal upload eligibility. The [package README](../../packages/session/session-log-deepseek/README.md#upload-exclusions) defines failure and disposal behavior. + +```ts type-equiv +/** Request identity inspected before preparing a Session-log contribution. */ +interface SessionLogUploadRequest { + /** Live Session whose canonical log would be contributed. */ + readonly sessionId: SessionId + /** Auxiliary request classification; absent for ordinary conversation requests. */ + readonly purpose?: string +} +``` + +```ts type-equiv +/** + * Synchronous, side-effect-free upload exclusion owned by its registering plugin. + * @param request - request identity; the predicate must not mutate it. + * @returns `true` to omit the log field and acceptance callback, or `false` to keep checking eligibility. + */ +type SessionLogUploadExclusion = (request: SessionLogUploadRequest) => boolean +``` + @@ -1035,6 +1058,26 @@ Types: [SessionId](core.md) · [SessionSearchRequest](session-query.md) Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) + + +### `ctx.sessionLogDeepSeek` — `SessionLogDeepSeekService` + +Incremental Session-log contributor with caller-owned upload exclusions. + +```ts cordis-catalog +/** + * Exclude matching requests before reading or preparing their log contribution. + * Any matching rule suppresses both the field and its acceptance callback. With + * no matching rules, normal upload eligibility applies. A throwing rule fails + * this contribution; the request-extension registry omits it and reports the error. + * @param exclude - synchronous, side-effect-free predicate; `true` skips this upload. + * @returns effect disposer that removes this registration when called or when the caller unloads. + */ +registerExclusion(exclude: SessionLogUploadExclusion): () => Promise +``` + +Source: [`packages/session/session-log-deepseek/src/index.ts`](../../packages/session/session-log-deepseek/src/index.ts) + ### `ctx.sessions` — `SessionStore` diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 1f40042967..fb26a5105c 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -821,6 +821,29 @@ interface PluginRecord { 可选的 `application` 指定文件关联应用,不修改系统默认值;`workspacePathApplications({ path })` 在校验 Host 路径后返回当前关联应用、名称、图标和默认项。 +## Session 日志上传排除条件 + +`ctx.sessionLogDeepSeek.registerExclusion()` 注册归调用方所有的判断函数,输入为 `SessionLogUploadRequest`。任意条件匹配时,在读取 Session 事件前省略日志贡献与接受回调。没有条件匹配时保留普通上传资格判断。[包 README](../../packages/session/session-log-deepseek/README.zh.md#upload-exclusions) 定义失败与释放行为。 + +```ts type-equiv +/** Request identity inspected before preparing a Session-log contribution. */ +interface SessionLogUploadRequest { + /** Live Session whose canonical log would be contributed. */ + readonly sessionId: SessionId + /** Auxiliary request classification; absent for ordinary conversation requests. */ + readonly purpose?: string +} +``` + +```ts type-equiv +/** + * Synchronous, side-effect-free upload exclusion owned by its registering plugin. + * @param request - request identity; the predicate must not mutate it. + * @returns `true` to omit the log field and acceptance callback, or `false` to keep checking eligibility. + */ +type SessionLogUploadExclusion = (request: SessionLogUploadRequest) => boolean +``` + @@ -1039,6 +1062,26 @@ Types: [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md) Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) + + +### `ctx.sessionLogDeepSeek` — `SessionLogDeepSeekService` + +Incremental Session-log contributor with caller-owned upload exclusions. + +```ts cordis-catalog +/** + * Exclude matching requests before reading or preparing their log contribution. + * Any matching rule suppresses both the field and its acceptance callback. With + * no matching rules, normal upload eligibility applies. A throwing rule fails + * this contribution; the request-extension registry omits it and reports the error. + * @param exclude - synchronous, side-effect-free predicate; `true` skips this upload. + * @returns effect disposer that removes this registration when called or when the caller unloads. + */ +registerExclusion(exclude: SessionLogUploadExclusion): () => Promise +``` + +Source: [`packages/session/session-log-deepseek/src/index.ts`](../../packages/session/session-log-deepseek/src/index.ts) + ### `ctx.sessions` — `SessionStore` diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 17ae54903e..0a2bb60f40 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -9,8 +9,8 @@ en: f2d2c0d3b8f14ec7 zh: 441ed13ff3f81d2a /web-client-slots/cardinality-and-scope: - en: 5b0ada069239576b - zh: 84e1e6b253d1461e + en: cd17e3e0ff6e62ff + zh: 6e0db2cb16c41b73 /web-client-slots/component-inputs: en: 9cb70abb2e39b958 zh: cd4b6f1705d90e17 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index ee31122d13..fe8589f685 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -65,6 +65,8 @@ The slot declaration fixes two independent axes. `priority` is a shadowing rank for `single`, `list`, and `keyed` cells and an election order for `chain`. Lower values run or render first. Ordinary additive contributions should choose a fresh list `id` or keyed `key`; intentionally reusing a shipped cell replaces its presentation. +The [context-menu plugin](../../packages/client/ui-context-menu/README.md) contributes `useContextMenu` through `provideRoot`; every scope receives the same plugin-owned trigger factory. Its prop and retained handlers retire when the provider unloads. Its root-scoped keyed `contextMenu.icon` Slot renders business-owned icons from action metadata. + ## Component inputs A registered component receives inputs assembled at its binding site. Components derive these types rather than copying their members. @@ -140,7 +142,10 @@ Factory: conversation.content [ConversationContent] │ │ ├─ conversation.chat.text-interaction [list] │ │ └─ conversation.chat.flow [ChatFlow] │ │ └─ conversation.chat.node [ChatNodeSeat dispatch] -│ │ ├─ key=user,steering [UserMessageNodeView] +│ │ ├─ key=user [UserMessageNodeView] +│ │ │ ├─ conversation.chat.user.before [chain] +│ │ │ └─ calls Factory: conversation.chat.user.content +│ │ ├─ key=steering [UserMessageNodeView] │ │ │ └─ calls Factory: conversation.chat.user.content │ │ ├─ key=assistant-step [AssistantNodeView / AssistantMarkdown] │ │ │ ├─ conversation.chat.message.images [MessageImages] diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index ca13d60008..288b4b85f1 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -67,6 +67,8 @@ Slot 声明固定两个相互独立的维度。 +[右键菜单插件](../../packages/client/ui-context-menu/README.zh.md) 通过 `provideRoot` 贡献 `useContextMenu`;每个作用域接收同一个由插件拥有的触发器工厂。提供方卸载时,注入属性与保留的处理器一并失效。其根作用域 keyed Slot `contextMenu.icon` 根据操作元数据渲染业务方提供的图标。 + ## 组件输入 注册组件会在 binding 位置收到组装后的输入。组件应从这些类型推导 props,不要重新抄写成员。 @@ -142,7 +144,10 @@ Factory: conversation.content [ConversationContent] │ │ ├─ conversation.chat.text-interaction [list] │ │ └─ conversation.chat.flow [ChatFlow] │ │ └─ conversation.chat.node [ChatNodeSeat dispatch] -│ │ ├─ key=user,steering [UserMessageNodeView] +│ │ ├─ key=user [UserMessageNodeView] +│ │ │ ├─ conversation.chat.user.before [chain] +│ │ │ └─ calls Factory: conversation.chat.user.content +│ │ ├─ key=steering [UserMessageNodeView] │ │ │ └─ calls Factory: conversation.chat.user.content │ │ ├─ key=assistant-step [AssistantNodeView / AssistantMarkdown] │ │ │ ├─ conversation.chat.message.images [MessageImages] diff --git a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml new file mode 100644 index 0000000000..8249edf3c4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md +/: + en: b0a44570cee3ac45 + zh: f68438befed14c6e +/context-menu-services-in-custom-client-compositions: + en: b2018bf0250be579 + zh: 1d8a243d09f4128f +/context-menu-services-in-custom-client-compositions/change: + en: 3eaef415166d076f + zh: 4040390d2572fc47 +/context-menu-services-in-custom-client-compositions/migration: + en: ebffe957534ac31b + zh: a33d2782f5ad0f5d diff --git a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md new file mode 100644 index 0000000000..082e1da33f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "Custom Client compositions must provide the context-menu and file-action services required by GUI plugins." +--- + +# Context menu services in custom Client compositions + +English | [中文](guide.zh.md) + +## Change + +GUI plugins now inject `contextMenus` to open shared object menus. The open-in-app plugin also injects `fileActions`, `sidebarRight`, `sidebarRightTabs`, and `remote.workspaceFiles`. Custom compositions that mount these GUI plugins without the new services cannot activate them. The shipped Web application bundle includes the providers; existing Session and tab menu Slot names remain available. + +## Migration + +1. In the package manifest that owns a custom composition, add dependencies on `@deepseek-ai/dsh-client-ui-context-menu` and `@deepseek-ai/dsh-client-ui-file-actions` at the same Harness version. +2. Add both package names as plugin rows in the custom Client composition alongside the existing locale, slots, shortcuts, sidebar, and workspace-file providers. The default `@deepseek-ai/dsh-web-app` bundle already contains these rows. +3. Rename `ctx.menus` to `ctx.contextMenus` and `MenuTargetMap` to `ContextMenuTargetMap`; neither old name has an alias. Mount the menu plugin so its `ctx.slots.provideRoot` contribution supplies `useContextMenu` to every Slot. Components call the hook once and spread `getTriggerProps(target, handlers?)` onto each element, including elements created in loops. See the [menu reference](../../../../packages/client/ui-context-menu/README.md) for handler composition and external action registration. +4. Rebuild and launch the custom profile. Confirm that GUI plugins activate, right-clicking a background Session preserves selection, and existing Session/tab Slot contributions still appear. +5. Custom Slot hosts must remove their declarations of `sidebar.workspaces.session.menu.item` and `sidebar.right.tab.menu.item`; the workspace and right-sidebar adapters now own these under `contextMenu.content`. Session rows retain the `[open, setOpen]` hook context, and tab rows receive `{ tab, dismiss }` within the clicked Session’s provider. A custom host replacing these adapters must declare the same children and supply those owners itself. Dockkit embedders using `getTabTriggerProps` own the popup and dismissal; `renderTabMenuItems` is used only by the kit popup. Supply reactive `tabMenuOpen` to suppress close-button tooltips while a custom menu prepares or remains open. + +6. Built-in Session menu items are data contributions. The legacy React Slot retains external entries together before Archive; Slot priority no longer shadows built-in actions. Use `ctx.contextMenus.register` for new actions and optional `commandId` to display an existing command’s current binding. Keep captured-target execution in `run`; no command dispatch is added. +7. Menu contributions use `icon?: ReactNode`; replace `symbol` with a glyph element and wrap icon URLs in ``. Obtain `useContextMenu` at each actual Slot entry. Plain children receive `getTriggerProps` or bound `contextMenuProps`; ToolRow uses `fileContextMenuProps` and `urlContextMenuProps`. Remove menu fields from Chat flow/node owners. The assistant entry supplies an ordinary file adapter to `forClosing()`, while Markdown adapts its target type at `MarkdownDelegateProvider.getTriggerProps`. Keep Session identity and gesture-time path resolution in the business registration. Remove imports of `contextMenuTrigger` and `menuPoint`; their independent utility package is removed. Do not add gesture exports to ui-primitives. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md new file mode 100644 index 0000000000..9281f9bee1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/context-menu-services/guide.zh.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "自定义 Client 组合必须提供 GUI 插件所需的右键菜单与文件动作服务。" +--- + +# 自定义 Client 组合中的右键菜单服务 + +[English](guide.md) | 中文 + +## 变更 + +GUI 插件现在注入 `contextMenus` 以打开共享对象菜单。open-in-app 插件还注入 `fileActions`、`sidebarRight`、`sidebarRightTabs` 和 `remote.workspaceFiles`。自定义组合若挂载这些 GUI 插件却没有新服务,将无法激活它们。随附 Web 应用 bundle(插件包)已包含提供方;现有 Session 和标签页菜单 Slot 名称仍可使用。 + +## 迁移 + +1. 在自定义组合所属的包清单中,添加与 Harness 版本一致的 `@deepseek-ai/dsh-client-ui-context-menu` 和 `@deepseek-ai/dsh-client-ui-file-actions` 依赖。 +2. 在自定义 Client 组合中将两个包名添加为插件行,与已有 locale、slots、shortcuts、侧栏和工作区文件提供方并列。默认 `@deepseek-ai/dsh-web-app` bundle 已包含这些行。 +3. 将 `ctx.menus` 改为 `ctx.contextMenus`,将 `MenuTargetMap` 改为 `ContextMenuTargetMap`,旧名均无兼容别名。挂载菜单插件,由其 `ctx.slots.provideRoot` 贡献向每个 Slot 提供 `useContextMenu`。组件调用一次 hook,将 `getTriggerProps(target, handlers?)` 展开到每个元素,包括循环生成的元素。处理器组合和外部动作注册见[菜单参考](../../../../packages/client/ui-context-menu/README.zh.md)。 +4. 重新构建并启动自定义 profile(配置档)。确认 GUI 插件正常激活、右键后台 Session 不改变选择,且现有 Session/标签页 Slot 贡献仍然显示。 +5. 自定义 Slot 宿主应移除对 `sidebar.workspaces.session.menu.item` 和 `sidebar.right.tab.menu.item` 的声明;现在由工作区和右侧栏适配器在 `contextMenu.content` 下拥有它们。会话行继续接收 `[open, setOpen]` hook context,标签行在所点击会话的 provider 内接收 `{ tab, dismiss }`。替换这些适配器的自定义宿主须自行声明相同的子 Slot 并提供这些 owner。使用 `getTabTriggerProps` 的 Dockkit 嵌入方负责弹窗和关闭,`renderTabMenuItems` 仅供组件自身的弹窗使用。通过响应式 `tabMenuOpen` 在自定义菜单准备或显示时禁用关闭按钮的 Tooltip。 + +6. 内置 Session 菜单项改为数据贡献。旧 React Slot 将外部条目集中显示在归档之前,Slot priority 不再覆盖内置动作。新增动作用 `ctx.contextMenus.register`,可通过 `commandId` 展示已有命令的当前绑定。针对捕获目标的执行仍放在 `run`,不增加命令调度。 +7. 菜单贡献使用 `icon?: ReactNode`:将 `symbol` 改为图标元素,并用 `` 包装图标 URL。在每个实际 Slot 入口获取 `useContextMenu`。普通子组件接收 `getTriggerProps` 或绑定后的 `contextMenuProps`;ToolRow 使用 `fileContextMenuProps` 和 `urlContextMenuProps`。删除 Chat 消息流和节点 owner 的菜单字段。助手入口向 `forClosing()` 传入普通文件适配函数,Markdown 则在 `MarkdownDelegateProvider.getTriggerProps` 处适配目标类型。业务注册保留 Session 归属和触发时路径解析。删除 `contextMenuTrigger` 和 `menuPoint` 导入,其独立工具包已删除。不要向 ui-primitives 增加手势导出。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml new file mode 100644 index 0000000000..f8584a6842 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md +/: + en: 23336f139ca68e47 + zh: 20ef9419a53642d5 +/authorize-the-computer-use-helper: + en: f8335f2dca7a15cf + zh: 469a0a753fea2a93 +/authorize-the-computer-use-helper/change: + en: 1552866718133b10 + zh: 1c760c5c2d5cfc0b +/authorize-the-computer-use-helper/migration: + en: a79e5995415ff456 + zh: 6f818a092e53af2c diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md new file mode 100644 index 0000000000..c913488785 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Computer Use needs permission for its own helper application instead of the terminal that launches DSH." +--- + +# Authorize the Computer Use helper + +English | [中文](guide.zh.md) + +## Change + +macOS Computer Use runs as `DSH Computer Use.app`, launched through LaunchServices. Accessibility and Screen Recording grants for a terminal or Desktop launcher do not grant access to this helper. Source preparation builds and signs the application; installed packages require the prepared application and its `current.json` receipt. A custom `helperPath` pointing at a bare native binary is no longer a supported native launch path. + +## Migration + +1. Computer Use now installs on demand. In **Plugins → Add plugin**, install `@deepseek-ai/dsh-cua-bundle` and enable it; profiles that previously selected the shipped copy need this installation. Restart DSH if requested. In the Computer Use plugin page, grant Accessibility and Screen Recording access to `DSH Computer Use.app` when macOS requests it. +2. Remove an obsolete `helperPath` override or `DSH_CUA_HELPER` value to use automatic preparation. For a custom build, point it to the executable at `DSH Computer Use.app/Contents/MacOS/dsh-cua-native` inside the complete application bundle. +3. Confirm that the permission page identifies `DSH Computer Use.app` and both permissions are allowed before resuming native application tasks. Target-application consent remains required. +4. If a terminal or Desktop launcher was granted these permissions only for Computer Use, revoke those obsolete grants in System Settings. Authorizing the helper does not revoke other applications’ existing permissions; programs the model runs still follow their launcher’s system permissions and the DSH sandbox policy. + +Source builds use ad-hoc signing and may need authorization again after code changes. Release packagers must preserve a stable Developer ID signing identity and notarize their distribution. See the [native build instructions](../../../../packages/experimental/cua-macos/native/README.md#build-and-service). diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md new file mode 100644 index 0000000000..f25a4b805d --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Computer Use 需要给自己的 helper 应用授权,启动 DSH 的终端授权不再适用。" +--- + +# 给 Computer Use helper 授权 + +[English](guide.md) | 中文 + +## 变更 + +macOS Computer Use 通过 LaunchServices 以 `DSH Computer Use.app` 运行。终端或 Desktop 启动器的辅助功能和屏幕录制授权不赋予 helper 权限。源码准备会构建并签名应用;安装版需要预构建的应用及 `current.json` 记录。自定义 `helperPath` 指向裸原生二进制的启动方式不再受支持。 + +## 迁移 + +1. Computer Use 改为按需安装。在“插件 → 添加插件”中安装 `@deepseek-ai/dsh-cua-bundle` 并启用;此前选中过内置副本的 profile 也需完成这次安装。按提示重启 DSH。在 Computer Use 插件页按 macOS 提示,给 `DSH Computer Use.app` 授予辅助功能和屏幕录制权限。 +2. 删除过时的 `helperPath` 覆盖配置或 `DSH_CUA_HELPER` 值,使用自动准备。使用自定义构建时,将其指向完整应用包内的 `DSH Computer Use.app/Contents/MacOS/dsh-cua-native` 可执行文件。 +3. 恢复原生应用任务前,确认权限页显示 `DSH Computer Use.app`,且两项权限均已允许。具体目标应用仍需另行许可。 +4. 若终端或 Desktop 启动器的这两项权限只用于 Computer Use,可在系统设置中撤销这些旧授权。给 helper 授权不会撤销其他应用已有的权限;模型运行的程序仍遵循启动器的系统权限和 DSH 沙箱策略。 + +源码构建使用临时签名,代码变化后可能需要重新授权。正式发布需要保持稳定的 Developer ID 签名身份,并完成分发公证。参见[原生构建说明](../../../../packages/experimental/cua-macos/native/README.zh.md#build-and-service)。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.i18n.yaml new file mode 100644 index 0000000000..881f6d61d6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.md +/: + en: 25674d6a93c97ca5 + zh: ac1563eccab1b284 +/file-downloads-on-web: + en: d8fac1a37dede04d + zh: 8f132360f0553b64 +/file-downloads-on-web/change: + en: d76c6132a5e5fc9d + zh: 691b2264a37a526f +/file-downloads-on-web/migration: + en: a4365e73ae4e2de4 + zh: 8988bc1703fc5b8d diff --git a/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.md new file mode 100644 index 0000000000..dcc26609ac --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Custom file-action menus must offer save-as only on Desktop and browser downloads on Web." +--- + +# File downloads on Web + +English | [中文](guide.zh.md) + +## Change + +The Web file-action service no longer offers save-as through a browser file picker. `canSaveAs` is always false on Web, and `save(source, 'saveAs')` rejects before reading the source. Built-in Web file and image menus offer Download and omit Save as. Desktop still offers both actions; successful saves return the absolute destination `path` instead of an opaque file ID. + +## Migration + +1. Custom Client menus should include Save as only when `ctx.fileActions.canSaveAs` is true. +2. On Web, call `ctx.fileActions.save(source, 'download')`. The browser handles the original content and suggested filename; a `requested` result means download started, not completed. Do not report a saved local path. +3. Replace `savedFile(url)` and `reveal(id)` calls with an action on the completed `saved` result, using its `path` with `session.openWorkspacePath`. Keep original-file context-menu reveal independent of saved destinations. +4. Verify that Web file and image menus expose Download without Save as, including browsers that support `showSaveFilePicker`. Verify that Desktop image menus retain both actions. See the [file-action reference](../../../../packages/client/ui-file-actions/README.md) for save results and feedback ownership. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.zh.md new file mode 100644 index 0000000000..d9e232845a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/web-file-downloads/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "自定义文件动作菜单应仅在 Desktop 提供另存为,在 Web 使用浏览器下载。" +--- + +# Web 文件下载 + +[English](guide.md) | 中文 + +## 变更 + +Web 文件动作服务不再通过浏览器文件选择器提供另存为。Web 的 `canSaveAs` 始终为 false,`save(source, 'saveAs')` 会在读取来源前拒绝。内置 Web 文件和图片菜单提供「下载」,不显示「另存为」。Desktop 仍提供两种操作;保存成功后返回目标绝对路径 `path`,不再返回不透明文件 ID。 + +## 迁移 + +1. 自定义 Client 菜单仅在 `ctx.fileActions.canSaveAs` 为 true 时添加「另存为」。 +2. Web 端调用 `ctx.fileActions.save(source, 'download')`。浏览器处理原始内容和建议文件名;`requested` 结果仅表示下载已开始,不代表完成。不要报告本机保存路径。 +3. 将 `savedFile(url)` 和 `reveal(id)` 调用替换为完成后 `saved` 结果上的操作,使用其中的 `path` 调用 `session.openWorkspacePath`。原文件右键定位应独立于保存目标。 +4. 验证 Web 文件和图片菜单只有「下载」而没有「另存为」,包括支持 `showSaveFilePicker` 的浏览器;验证 Desktop 图片菜单保留两种操作。保存结果与提示的归属参见[文件动作参考](../../../../packages/client/ui-file-actions/README.zh.md)。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.i18n.yaml new file mode 100644 index 0000000000..74990defdf --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.md +/: + en: 6c606440532dfae0 + zh: 9308d72d451e12c7 +/context-menu-types-and-icons: + en: d1237d3842936881 + zh: b32fcacf82bed5f1 +/context-menu-types-and-icons/change: + en: 35faa7bd1cff539f + zh: 0dc2515e0025e3d8 +/context-menu-types-and-icons/migration: + en: e15e76d8d6a95f35 + zh: 4bf2dec8d764e5ae diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.md new file mode 100644 index 0000000000..0f33207f01 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Client plugins must use the prefixed context menu types and hooks and register keyed icon components." +--- + +# Context menu types and icons + +English | [中文](guide.zh.md) + +## Change + +The `@deepseek-ai/dsh-client-ui-context-menu/client` export `MenuTarget` is renamed to `ContextMenuTarget`, matching `ContextMenuTargetMap`. The old name has no compatibility alias. Target fields and runtime behavior are unchanged. Workspace and sidebar injections rename `menuTarget` / `useMenuTarget` to `contextMenuTarget` / `useContextMenuTarget`. Action icons accept `{ key, src? }` instead of React nodes and render through the keyed `contextMenu.icon` Slot. + +## Migration + +1. Replace `MenuTarget` imports and type references with `ContextMenuTarget`. +2. Rename injected `menuTarget` sources and `useMenuTarget` consumers to `contextMenuTarget` and `useContextMenuTarget`. +3. Replace action icon elements with `{ key: 'my-plugin.action' }`; carry a captured image URL in `src` when needed. Register the icon component under that key through `ctx.slots.inject('contextMenu.icon', ...)` and `ctx.slots.register()`. See the [menu package](../../../../packages/client/ui-context-menu/README.md#use-this-package). +4. Rebuild custom Client plugins, confirm that type checks pass, and open their menus to verify icon rendering and plugin-unload cleanup. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.zh.md new file mode 100644 index 0000000000..e6bcb0cd2c --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/context-menu-target/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Client 插件须使用带前缀的右键菜单类型和 hook,并注册 keyed 图标组件。" +--- + +# 右键菜单类型和图标 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-client-ui-context-menu/client` 导出的 `MenuTarget` 更名为 `ContextMenuTarget`,与 `ContextMenuTargetMap` 保持一致。旧名不保留兼容别名,目标字段和运行时行为不变。工作区与侧栏注入的 `menuTarget` / `useMenuTarget` 改为 `contextMenuTarget` / `useContextMenuTarget`。操作图标改用 `{ key, src? }`,通过 keyed Slot `contextMenu.icon` 渲染,不再接收 React 节点。 + +## 迁移 + +1. 将 `MenuTarget` 导入及类型引用替换为 `ContextMenuTarget`。 +2. 将注入源 `menuTarget` 和消费者 `useMenuTarget` 分别改为 `contextMenuTarget`、`useContextMenuTarget`。 +3. 将操作图标元素替换为 `{ key: 'my-plugin.action' }`,需要时通过 `src` 携带已捕获的图片 URL。通过 `ctx.slots.inject('contextMenu.icon', ...)` 和 `ctx.slots.register()` 在该 key 下注册图标组件,参见[菜单包](../../../../packages/client/ui-context-menu/README.zh.md#use-this-package)。 +4. 重新构建自定义 Client 插件,确认类型检查通过,并打开菜单验证图标渲染和插件卸载后的清理。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.i18n.yaml new file mode 100644 index 0000000000..dc8f886672 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.md +/: + en: 670467a1e1ef4871 + zh: a5c49c32981a3d50 +/forge-git-query-permissions-and-content-conversion: + en: 622fa28fae275249 + zh: 75888856615bcfd3 +/forge-git-query-permissions-and-content-conversion/change: + en: d8f544395f65b663 + zh: f785a6d6f9a696b6 +/forge-git-query-permissions-and-content-conversion/migration: + en: db9ae3d66f6bb1e7 + zh: 315bfde302564a7c diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.md new file mode 100644 index 0000000000..76ac171810 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Forge Git queries use Session permissions and disable configured diff helpers." +--- +# Forge Git query permissions and content conversion + +English | [中文](guide.zh.md) + +## Change + +Forge Git queries use the Session's sandbox mode instead of forcing `read-only`. `danger-full-access` works without a sandbox backend; `workspace-write` permits writes within its configured roots. A confined mode still refuses execution when its backend is unavailable. + +On Git 2.31 or newer, local diffs and statistics disable enumerated clean/process filters for each command. Git's built-in newline, ident, and encoding conversions still apply. Materialized LFS files and other filtered files can appear changed when ordinary Git considers them clean. The panel does not request LFS downloads. Older Git or concurrently added filters can still execute helpers under Session permissions. Submodule commit changes remain visible, but internal uncommitted changes are omitted. + +Filter configuration read failures, including truncated output, report `forgePanel/filter-config-failed` before any comparison and offer a retry. They are separate from `forgePanel/diff-too-large`. + +## Migration + +1. Keep a Session in `read-only` when its Git queries require operating-system-enforced write protection. Use the existing permission picker to select the intended Session mode; no configuration keys change. +2. For repositories that require content conversion or submodule working-tree inspection, use an explicit Git command under the intended Session permissions to inspect those changes. The panel's local comparison bypasses clean/process filters on supported Git versions. +3. On a host without a sandbox backend, select Full access and refresh the Forge panel. Its checkout and ordinary local changes should load; switching to a confined mode must still report the unavailable backend. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.zh.md new file mode 100644 index 0000000000..07c4770bf3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/forge-git-permissions/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Forge Git 查询使用 Session 权限并禁用已配置的 diff 辅助程序。" +--- +# Forge Git 查询权限与内容转换 + +[English](guide.md) | 中文 + +## 变更 + +Forge Git 查询使用 Session 的沙箱模式,不再强制 `read-only`。`danger-full-access` 无需沙箱后端;`workspace-write` 允许在其配置的根目录内写入。受限模式的后端不可用时仍拒绝执行。 + +在 Git 2.31 或更新版本中,本地 diff 与统计仅对每次命令禁用已枚举的 clean/process filter。Git 内置的换行、ident 与编码转换仍然生效。已物化的 LFS 文件及其他过滤文件可能显示为变更,即使普通 Git 认为它们未变。面板不请求下载 LFS 对象。旧 Git 或并发新增的过滤器仍可能按 Session 权限执行辅助程序。子模块提交变化仍可见,但内部未提交修改不显示。 + +过滤器配置读取失败(包括输出被截断)时,在任何比较之前报告 `forgePanel/filter-config-failed` 并提供重试,与 `forgePanel/diff-too-large` 区分。 + +## 迁移 + +1. 如果 Git 查询需要操作系统强制禁止写入,请将 Session 保持为 `read-only`。通过现有权限选择器选择所需的 Session 模式;配置键没有变化。 +2. 对于需要内容转换或检查子模块工作区的仓库,请在所需的 Session 权限下显式运行 Git 命令检查这些变化。面板的本地比较在受支持的 Git 版本中绕过 clean/process filter。 +3. 在没有沙箱后端的主机上选择“完全权限”,然后刷新 Forge 面板。检出信息和普通本地变更应可加载;切回受限模式后仍须报告后端不可用。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.i18n.yaml new file mode 100644 index 0000000000..7bae7f2845 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.md +/: + en: 0a5cfa851101f3e9 + zh: 1c8e0e437718d4e2 +/declare-auxiliary-llm-request-purposes: + en: 5d5e1b6cb5b9c95e + zh: 11cf31babb07da28 +/declare-auxiliary-llm-request-purposes/change: + en: 0682a43eb52792fd + zh: 954b186cc66fdb08 +/declare-auxiliary-llm-request-purposes/migration: + en: eae671d1e185ecbf + zh: c042a276a1a7ad17 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.md new file mode 100644 index 0000000000..d71d8f6bef --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.md @@ -0,0 +1,29 @@ +--- +kind: upgrade-guide +description: "Auxiliary LLM request purposes require producer-owned type declarations instead of a shared fixed union." +--- + +# Declare auxiliary LLM request purposes + +English | [中文](guide.zh.md) + +## Change + +`GenerateOptions.purpose` derives from the declaration-mergeable `LlmRequestPurposeMap` exported by `@deepseek-ai/dsh-llm/types`. The shared package no longer declares `compaction` or `session-title`. Plugins that construct auxiliary requests must load the producer's declarations or declare their own purpose. Existing runtime purpose strings keep their meaning. + +## Migration + +1. Import `@deepseek-ai/dsh-compaction-basic` for direct compaction requests, or `@deepseek-ai/dsh-session-title-llm` for title requests, using a type-only import when only the declaration is needed. +2. For a plugin-owned purpose, augment `LlmRequestPurposeMap` from the plugin's public type entry. Map the purpose key to `true`: + + ```ts + import type {} from '@deepseek-ai/dsh-llm/types' + + declare module '@deepseek-ai/dsh-llm/types' { + interface LlmRequestPurposeMap { + 'my-plugin-purpose': true + } + } + ``` + +3. Compile the plugin against the new package declarations. Confirm that its request accepts the declared purpose and rejects an undeclared key. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.zh.md new file mode 100644 index 0000000000..d1fd550142 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/llm-request-purpose-declarations/guide.zh.md @@ -0,0 +1,29 @@ +--- +kind: upgrade-guide +description: "辅助 LLM 请求用途改为生产方拥有的类型声明,不再使用共享固定联合。" +--- + +# 声明辅助 LLM 请求用途 + +[English](guide.md) | 中文 + +## 变更 + +`GenerateOptions.purpose` 由 `@deepseek-ai/dsh-llm/types` 导出的可声明合并的 `LlmRequestPurposeMap` 派生。共享包不再声明 `compaction` 或 `session-title`。构造辅助请求的插件必须加载生产方的声明,或声明自己的用途。现有运行时用途字符串保持原有含义。 + +## 迁移 + +1. 直接构造压缩请求时导入 `@deepseek-ai/dsh-compaction-basic`,构造标题请求时导入 `@deepseek-ai/dsh-session-title-llm`;只需要声明时使用仅类型导入。 +2. 对插件拥有的用途,在插件公开类型入口中扩展 `LlmRequestPurposeMap`,将用途键映射到 `true`: + + ```ts + import type {} from '@deepseek-ai/dsh-llm/types' + + declare module '@deepseek-ai/dsh-llm/types' { + interface LlmRequestPurposeMap { + 'my-plugin-purpose': true + } + } + ``` + +3. 使用新包的声明编译插件,确认请求接受已声明用途,并拒绝未声明的键。 diff --git a/package.json b/package.json index 229ba8880c..9dccd6008b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 24c5ded58e..877006761a 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json index 0e4504e6c1..1bd865aeeb 100644 --- a/packages/api/account-controller/package.json +++ b/packages/api/account-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-account-controller", "description": "Expose safe account operations over authenticated Remote", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index cf412b4449..f67e41c300 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts index eabda54e37..8e5b1c22ca 100644 --- a/packages/api/gateway/src/client/remote-events.ts +++ b/packages/api/gateway/src/client/remote-events.ts @@ -333,7 +333,7 @@ function invalidRemoteEventFrame(): never { } function privateEvents(ctx: Context): PrivateEventContext { - return ctx + return ctx.events } function toError(reason: unknown, message: string): Error { diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index a7e0ca2560..3807baee59 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -866,7 +866,7 @@ describe('Client Typert API', () => { const ctx = await bench(call) const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { - identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + identity: candidate => 'fixtureId' in candidate ? (candidate as Context & { fixtureId?: string }).fixtureId : undefined, resolve: id => id === 'agent-2' ? agentCtx : undefined, }) const assembly = ctx.plugin(Object.assign( @@ -897,7 +897,7 @@ describe('Client Typert API', () => { const ctx = await bench(call) const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { - identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + identity: candidate => 'fixtureId' in candidate ? (candidate as Context & { fixtureId?: string }).fixtureId : undefined, resolve: id => id === 'agent-2' ? agentCtx : undefined, }) const assembly = ctx.plugin(Object.assign( @@ -943,7 +943,7 @@ describe('Client Typert API', () => { const ctx = await bench(call) const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { - identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + identity: candidate => 'fixtureId' in candidate ? (candidate as Context & { fixtureId?: string }).fixtureId : undefined, resolve: id => id === 'agent-remounted' ? agentCtx : undefined, }) const direct = directDescriptor() diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 4272ee90bb..2931b3d159 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -75,14 +75,14 @@ class GoalService extends Service { return { agentId: agent.id, title: request.title, - scope: (this.ctx as MarkedContext).fixtureScope ?? 'root', + scope: 'fixtureScope' in this.ctx ? (this.ctx as MarkedContext).fixtureScope : 'root', } } @RemoteScope('gatewayFixture') rename(request: { readonly title: string }): unknown { this.calls.push('rename') - return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' } + return { title: request.title, scope: 'fixtureScope' in this.ctx ? (this.ctx as MarkedContext).fixtureScope : 'root' } } @Remote diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index 0ac6a2c8b8..c550f1f912 100644 --- a/packages/api/job-controller/package.json +++ b/packages/api/job-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-job-controller", "description": "Job Remote observation stream and the reference-counted client job-output service", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/remote-access/package.json b/packages/api/remote-access/package.json index e0b68f904e..5e81e757c2 100644 --- a/packages/api/remote-access/package.json +++ b/packages/api/remote-access/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remote-access", "description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index cf1766abcd..8735bc4339 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 2c22aff84f..07d0ef5618 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -184,7 +184,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { await client.plugin({ inject: plugin.inject, apply: plugin.apply }) } client.typert.contexts.registerClient('agent', { - identity: candidate => candidate.builtAgentId, + identity: candidate => 'builtAgentId' in candidate ? candidate.builtAgentId : undefined, }) const invalidResult = await client.remote.goals.create(rootAgent.id, { objective: 1 }) diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 7b4e5de954..4a7e14aa2b 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index e65e555a32..e424a9e808 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index a8c0c1c684..84a53b5df6 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-terminal-controller", "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index c3daa0cd9f..8a7363da4f 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index d180209fc8..5170995310 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 77123989f0..8e7071c71d 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 37edd925ea..650a98b1ae 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index ead19a0d41..6bcc5fa3d2 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index acab24ebe4..b89eb3632a 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -340,9 +340,7 @@ async function recompose( diagnostic: failure.entry.fiber?.uid ? reloadDiagnostic(failure) : undefined, })) // Removed entries leave the Loader store before their async disposers finish. - const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [{ - fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED, - }]) + const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber]) const { patches: previous = [], ...includeConfig } = entry.options.config as Include.Config const current = withResolution ? ctx.get('pluginPackages')?.resolution : undefined // A plain-data resolution has no inputs to recompute from. @@ -386,12 +384,15 @@ async function recompose( const prepared = prepareProfilePatches(ctx, patches, parentURL, binName) applying = true const retried = [...new Set([...running, ...withResolution ? [...ctx.loader.entries()].filter(importFailed) : []])] + // Group.update replaces its data array and attempts every row it still lists, including failed imports. + const priorData = new Map(retried.map(row => [row, row.parent.data])) try { await entry.update({ config: { ...includeConfig, patches: prepared } }) } finally { - await resume(ctx, retried) + // Only rows whose Group did not update still need an explicit retry after the awaited update. + await resume(ctx, retried.filter(row => row.parent.data === priorData.get(row))) } - const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await())) + await Promise.allSettled(previousFibers.map(fiber => fiber.await())) await ctx.loader.await() const failures = await inactiveEntries(ctx) const introduced = failures.filter((failure) => { @@ -403,9 +404,6 @@ async function recompose( return !previousFailures.some(previous => sameFailure(previous, failure)) }) if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced, false).trimEnd()) - for (const [index, result] of results.entries()) { - if (result.status === 'rejected' && !previousFibers[index]?.failed) throw result.reason - } ctx.emit('app-boot/config-reload') return { warnings: failures.map(reloadDiagnostic) } } finally { @@ -1224,11 +1222,6 @@ export async function boot( try { ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' ctx.provide('dshHomePath', dshHomePath) - // Fiber.update() discards the restart promise. Observe it before the - // waterfall returns; activation audits still report the failed fiber. - ctx.on('internal/update', (_config, _noSave, next: () => unknown) => { - void Promise.resolve(next()).catch((error: unknown) => { ctx.logger.error(error) }) - }, { global: true, prepend: true }) await ctx.plugin(Loader) await prepare?.(ctx) stage = 'plugin tree failed to load' diff --git a/packages/boot/app-boot/src/startup-diagnostics.ts b/packages/boot/app-boot/src/startup-diagnostics.ts index 7982ccc111..c9fe729935 100644 --- a/packages/boot/app-boot/src/startup-diagnostics.ts +++ b/packages/boot/app-boot/src/startup-diagnostics.ts @@ -90,7 +90,7 @@ export function captureStartupComposition(ctx: Context): StartupCompositionDiagn const impl = store[key] if (impl?.name !== service) continue providers.push({ - entry: impl.fiber.entry?.id ?? 'unknown', state: stateName(impl.fiber.state), + entry: impl.ctx.fiber.entry?.id ?? 'unknown', state: stateName(impl.ctx.fiber.state), visibility: key === scope ? 'same-scope' : 'other-scope', }) } diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 63e0af5ea6..5b0257aac1 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -254,6 +254,11 @@ describe('Loader config interpolation', () => { await provider?.update({ config: { value: 'recovered' } }) await provider?.update({ disabled: false }) await ctx.loader.await() + // rc.11 keeps a failed fiber failed after its dependency recovers; recovery requires an explicit update. + const failed = reader?.fiber + if (failed === undefined) throw new Error('reader entry has no fiber') + await expect(failed.await()).rejects.toThrow('rejected provider') + await failed.update(failed.rawConfig, true) expect(ctx.get('readerResult')).toEqual({ value: 'recovered' }) } finally { await ctx.fiber.dispose() @@ -360,7 +365,7 @@ describe('profile reconciliation settlement', () => { onTestFinished(() => ctx.fiber.dispose()) const entry = [...ctx.loader.entries()].find(row => row.options.id === 'candidate') if (entry === undefined) throw new Error('candidate entry missing') - await entry.update({ config: { fail: true } }) + await expect(entry.update({ config: { fail: true } })).rejects.toThrow('candidate activation failed') await ctx.loader.await() await reconcileProfilePatches(ctx, [], NAME) expect([...ctx.loader.entries()].some(row => row.options.id === 'candidate')).toBe(false) @@ -379,7 +384,7 @@ describe('profile reconciliation settlement', () => { .rejects.toThrow('candidate activation failed') }) - it('reports an activation failure that settles while its entry is being removed', async () => { + it('logs a superseded activation failure without failing removal', async () => { const dir = tmp() const entered = Promise.withResolvers() const release = Promise.withResolvers() @@ -390,11 +395,19 @@ describe('profile reconciliation settlement', () => { }) onTestFinished(async () => { release.resolve(undefined); await ctx.fiber.dispose() }) const entry = [...ctx.loader.entries()].find(row => row.options.id === 'candidate')! - await entry.update({ config: { fail: true } }) + const updating = entry.update({ config: { fail: true } }) await entered.promise - const result = expect(reconcileProfilePatches(ctx, [], NAME)).rejects.toThrow('in-flight failure') + const stopped = Promise.withResolvers() + ctx.on('internal/plugin', (fiber) => { + if (fiber.uid === null && fiber.entry === entry) stopped.resolve(undefined) + }) + const removing = reconcileProfilePatches(ctx, [], NAME) + await stopped.promise release.resolve(undefined) - await result + await Promise.all([updating, removing]) + expect([...ctx.loader.entries()].some(row => row.options.id === 'candidate')).toBe(false) + expect(ctx.logger.buffer.some(message => message.type === 'error' + && message.args.some(value => String(value).includes('in-flight failure')))).toBe(true) }) it('waits for a removed plugin to release its resources', async () => { diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index bb273c3517..04f5f23be1 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/config-editor/package.json b/packages/boot/config-editor/package.json index e4bfc46918..783ce890db 100644 --- a/packages/boot/config-editor/package.json +++ b/packages/boot/config-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-config-editor", "description": "Persist plugin configuration through profile patches and Loader reconciliation", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json index 76f1874831..eeba988bfb 100644 --- a/packages/boot/hmr/package.json +++ b/packages/boot/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hmr", "description": "Coordinated module and profile configuration hot reload", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/hmr/src/index.ts b/packages/boot/hmr/src/index.ts index 709524d741..36c16ba6ac 100644 --- a/packages/boot/hmr/src/index.ts +++ b/packages/boot/hmr/src/index.ts @@ -678,10 +678,12 @@ class Hmr extends Service { activated: Fiber[] = [], ) => { for (const [previousFiber, implementation] of replacements.getActiveImplementations(this.ctx, plugin, fibers)) { - const fiber = previousFiber.fiber.parent.registry.plugin(implementation, previousFiber.config, this.getOuterStack).ctx.fiber + const wrapper = previousFiber.fiber.parent.registry.plugin(implementation, previousFiber.config, this.getOuterStack) + const fiber = wrapper.ctx.fiber if (previousFiber.entry !== undefined) { fiber.entry = previousFiber.entry - previousFiber.entry.fiber = fiber + // Like Loader activation, store the registry wrapper; identity checks compare `.ctx.fiber`. + previousFiber.entry.fiber = wrapper } activated.push(fiber) } @@ -831,7 +833,7 @@ class Hmr extends Service { const owner = (fiber: Fiber): Entry | undefined => { for (let current = fiber; current.uid !== 0; current = current.parent.fiber) { const entry = current.entry - if (entry?.fiber === current && managed.has(entry) && affectedFibers.has(current)) return entry + if (entry?.fiber?.ctx.fiber === current && managed.has(entry) && affectedFibers.has(current)) return entry } return undefined } diff --git a/packages/boot/hmr/tests/coordination.spec.ts b/packages/boot/hmr/tests/coordination.spec.ts index b3660828e9..e0dceccb99 100644 --- a/packages/boot/hmr/tests/coordination.spec.ts +++ b/packages/boot/hmr/tests/coordination.spec.ts @@ -218,7 +218,7 @@ it('rebinds configuration handlers when its provider is reconfigured within a re }) await binding.await() await hmr.runExclusive(async () => { - provider.update({ root: [], ignored: [], debounce: 1 }) + await provider.update({ root: [], ignored: [], debounce: 1 }) await provider.await() await binding.await() }) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 9e99e269d0..2b95a68b06 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: c687e0162da2967d - zh: d7c65e23d84e7cdf + en: ae0905aa47cc971d + zh: b22fc6e831fac136 /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 483285658e267dd5 zh: 846bec2daac59a96 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 4f1f05d774..a06788d42a 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -57,7 +57,7 @@ Official on-demand installs call the same `installBundle` operation as other bun `updateBundle(name, version, options)` replaces an updatable bundle with an exact newer version; an older version is refused. The target is first viewed over the same plan as `listVersions`, and a version that is missing, declares no bundle, or whose `engines.dsh` or DSH peers reject the running DSH stops the update at `step: 'check'` with the profile unchanged. With HMR the update removes the installed version, so its plugins stop, then installs `name@version` with `saveExact` and the current selection from the registry that answered. When that installation fails, is cancelled, or installs but cannot start, the target is removed if it stayed and the removed version is installed again from the registry it was installed from in this process, else over the configured plan as its removal asks, and reported as `restore`; a reinstallation that also fails leaves the package uninstalled. A removal or installation that rejects, for example on a lock timeout, is reported as that step's failure. Without HMR the target is installed over the running version and the result reports `restart-required`. `plugin-manager/update-state` announces `checking`, `removing`, `installing`, and `restoring` under the caller's `requestId`; the target's installation streams its logs and accepts `cancelInstall` under the same id, while the reinstallation carries none, so a cancellation cannot stop it. -`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. +`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. A stopped installation streams that failure under its `requestId` as the installation's only `plugin-manager/install-log` run, named by the `git ls-remote` command line with any URL user information removed; a timed-out check reports no exit code. The answer's `packageResult` stays a plain result, without the command line. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. An HTTP(S) Git URL on a host for which pnpm has no HTTPS-to-SSH fallback, that is, any host `hosted-git-info` does not know, such as a self-hosted GitLab, is probed with `git ls-remote` before pnpm starts. The probe uses the installer's Git configuration and credential helpers with Git's terminal and askpass prompts disabled; on POSIX it also has no controlling terminal, so SSH cannot prompt. When HTTP(S) cannot read the repository and `ssh://git@/` can, pnpm installs that SSH URL with the original fragment, and the profile records it. Otherwise the spec is installed unchanged. Each probe is bounded by `gitTransportTimeoutMs`, 5000 ms by default; cancellation and manager disposal stop it. URLs with credentials or a query skip the probe. @@ -65,7 +65,7 @@ Registries are asked in turn. The plan starts at `options.registry`, else the co The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFICIAL_NPM_REGISTRY` and `NPMMIRROR_REGISTRY` for consumers that identify those public registries. -`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after the Git check or pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, `bundle` names the package a finished run added, and `version` its manifest version, which can differ from the version `inspect` read, for example when pnpm's `minimumReleaseAge` holds back a recent release. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. +`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; a pnpm run that fails without printing streams the diagnostic its log carries, so the details panel is not left empty. `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after the Git check or pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, `bundle` names the package a finished run added, and `version` its manifest version, which can differ from the version `inspect` read, for example when pnpm's `minimumReleaseAge` holds back a recent release. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. `waitForInstall(requestId)` lets a client recover a lost response by waiting for the active installation, including its non-cancellable application phase. It returns the same outcome as the original call, or `null` if the request is not active. Completed results are not retained; `null` establishes neither success nor cancellation. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 3c7eb08c06..db1c756fae 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -57,7 +57,7 @@ kind: "package-reference" `updateBundle(name, version, options)` 把可更新的组合包替换为一个更新的确切版本;更旧的版本会被拒绝。目标版本先按与 `listVersions` 相同的计划查询,版本不存在、未声明组合包,或其 `engines.dsh` 或 DSH peer 拒绝当前 DSH 时,更新停在 `step: 'check'`,profile 保持不变。有 HMR 时,更新先移除已安装版本,使其插件停止,再以 `saveExact` 和当前的选中状态从作答的注册表安装 `name@version`。该安装失败、被取消,或已安装但无法启动时,先移除仍留下的目标版本,再从本进程安装被移除版本所用的注册表重新安装它,没有记录时按配置的计划安装,与移除的做法一致,结果以 `restore` 报告;重新安装也失败时,包处于未安装状态。移除或安装被拒绝(例如锁等待超时)时,报告为该步骤失败。没有 HMR 时,目标版本直接安装在运行中的版本之上,结果报告 `restart-required`。`plugin-manager/update-state` 以调用方的 `requestId` 通告 `checking`、`removing`、`installing` 与 `restoring`;目标版本的安装以同一 id 流式输出日志并接受 `cancelInstall`,重新安装不带 id,因此取消无法中止它。 -`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 +`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。被停止的安装会以调用方的 `requestId` 把该失败转发为这次安装唯一的 `plugin-manager/install-log` 运行,其命令行是去掉 URL 用户信息的 `git ls-remote`;因超时结束的检查不报告退出码。应答中的 `packageResult` 仍是普通结果,不含该命令行。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 HTTP(S) Git URL 指向 pnpm 没有 HTTPS 到 SSH 回退的主机(即 `hosted-git-info` 不认识的任何主机,例如自托管 GitLab)时,在启动 pnpm 前通过 `git ls-remote` 探测。探测使用安装器的 Git 配置与凭据助手,并禁用 Git 的终端与 askpass 提示;在 POSIX 上探测也没有控制终端,因此 SSH 无法提示。HTTP(S) 无法读取仓库而 `ssh://git@/` 可以读取时,pnpm 以该 SSH URL 加上原片段安装,profile 也记录该 URL。否则按原 spec 安装。每次探测受 `gitTransportTimeoutMs` 限制,默认为 5000 毫秒;取消安装或销毁管理器会停止探测。带凭据或查询串的 URL 跳过探测。 @@ -65,7 +65,7 @@ HTTP(S) Git URL 指向 pnpm 没有 HTTPS 到 SSH 回退的主机(即 `hosted-g 可在浏览器使用的 `@deepseek-ai/dsh-plugin-manager/registry` 入口导出 `OFFICIAL_NPM_REGISTRY` 和 `NPMMIRROR_REGISTRY`,供消费方识别这两个公共注册表。 -`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 Git 检查或 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包,`version` 给出它的清单版本,它可能与 `inspect` 读到的版本不同,例如 pnpm 的 `minimumReleaseAge` 暂缓了最近的发布。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 +`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;未打印任何输出就失败的 pnpm 运行转发其日志中的诊断,详情面板因而不会为空。每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 Git 检查或 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包,`version` 给出它的清单版本,它可能与 `inspect` 读到的版本不同,例如 pnpm 的 `minimumReleaseAge` 暂缓了最近的发布。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 `waitForInstall(requestId)` 让客户端在响应丢失后等待活动安装的结果,包括不可取消的应用阶段。它返回与原调用相同的结果,请求不在活动中时返回 `null`。已完成的结果不保留;`null` 不表示成功或已取消。 diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index c5e7b6bf22..cee821053b 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-manager", "description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/plugin-manager/src/github-connection.ts b/packages/boot/plugin-manager/src/github-connection.ts index 99b64fd73c..e2d349c734 100644 --- a/packages/boot/plugin-manager/src/github-connection.ts +++ b/packages/boot/plugin-manager/src/github-connection.ts @@ -4,9 +4,21 @@ import { join } from 'node:path' import { execa } from 'execa' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { classifyInstallFailure } from './install-failure.ts' -import type { ParsedInstallSpec } from './install-spec.ts' +import { HTTP_USER_INFO, type ParsedInstallSpec } from './install-spec.ts' import type { PackageResult } from './types.ts' +/** + * A failed check, with the Git command line the install dialog shows as the installation's only run. + * The returned result itself stays a plain {@link PackageResult}: the command line never joins the wire fields. + */ +export interface GithubConnectionFailure extends PackageResult { + /** + * The `git` command line the check ran, with the user information of an http(s) repository URL removed: + * `git -c credential.helper= ls-remote -- HEAD`. + */ + readonly argv: readonly string[] +} + /** Deadline, diagnostics and cancellation for one read-only GitHub check. */ export interface GithubConnectionOptions { timeoutMs: number @@ -23,11 +35,12 @@ export interface GithubConnectionOptions { * @param spec The parsed installation address; npm packages, local paths and other hosts are not checked. * @param dir The profile directory where installation runs. * @param options The connection deadline, output bound and operation cancellation. - * @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec. + * @returns A failed check carrying the command line it ran (without URL user information) and its bounded output, with a + * complete log at logPath, or undefined for a reachable repository or an unhandled spec. */ export async function checkGithubConnection( spec: ParsedInstallSpec, dir: string, options: GithubConnectionOptions, -): Promise { +): Promise { if (spec.kind !== 'git') return undefined const host = spec.host.toLowerCase().replace(/:.*$/, '') if (host !== 'github.com' && !host.endsWith('.github.com')) return undefined @@ -40,8 +53,12 @@ export async function checkGithubConnection( const logDir = await mkdtemp(join(logRoot, 'github-connection-')) const logPath = join(logDir, 'git.log') const log = await open(logPath, 'ax+', 0o600) + const argv = ['git', '-c', 'credential.helper=', 'ls-remote', '--', repository, 'HEAD'] as const + // The executed command keeps any credentials the spec names; the recorded one drops them before it is displayed. + const recordedArgv = argv.map(argument => argument.replace(HTTP_USER_INFO, '$1')) + const [command, ...args] = argv try { - const result = await execa('git', ['-c', 'credential.helper=', 'ls-remote', '--', repository, 'HEAD'], { + const result = await execa(command, args, { cwd: dir, env: { ...scrubbedParentEnv(), ...options.env, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', @@ -58,9 +75,13 @@ export async function checkGithubConnection( const bytes = Buffer.alloc(Math.min(size, options.outputBytes)) await log.read(bytes, 0, bytes.length, size - bytes.length) const output = bytes.toString('utf8') + // `PackageResult.exitCode` is a status number, so a Git the deadline stopped records the status its failure is read + // as. The run streamed for the same failure reports no exit code, because that process ended without one. return { - exitCode: result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1), output, truncated: size > options.outputBytes, logPath, + argv: recordedArgv, exitCode: result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1), + output, truncated: size > options.outputBytes, logPath, kind: classifyInstallFailure({ log: output, timedOut: result.timedOut }), + ...result.timedOut ? { timedOut: true } : {}, } } finally { await log.close() diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 271e6af0d3..ceb82aac47 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -36,7 +36,7 @@ import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } import { flatten, writePluginEnabled } from './patch.ts' import { incompatiblePlugin, ManagementFailure } from './failure.ts' import { approveBuilds, recordPendingBuilds } from './build-approval.ts' -import { checkGithubConnection } from './github-connection.ts' +import { checkGithubConnection, type GithubConnectionFailure } from './github-connection.ts' import { chooseGitTransport, sshGitSpec } from './git-transport.ts' import { newerVersions, VERSION_FIELDS, VERSION_LIST_FIELDS, versionFacts } from './versions.ts' import type { @@ -522,6 +522,7 @@ export class PluginManager extends TypertRemoteService { catch (error) { platformError = managementError(error) } const error = platformError ?? targetError ?? (enabled ? { code: 'not-bundle' as const } : undefined) bundles.push({ name, official, availability: 'missing', ...catalogMeta, ...target, enabled, installed, optional, + ...offered.listedByDefault === undefined ? {} : { listedByDefault: offered.listedByDefault }, ...platformError?.code === 'unsupported-platform' ? { listedByDefault: false } : {}, removable: enabled, rows: [], overrides: [], ...error === undefined ? {} : { error } }) @@ -857,7 +858,8 @@ export class PluginManager extends TypertRemoteService { /** * Install a package using the same pnpm implementation as dsh plugin. GitHub * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; - * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An + * only network failures or timeouts stop installation, and a stop streams the check's diagnostic + * as the installation's install-log run under its request id, while pnpm owns authentication and transport fallback. An * HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs * find that only SSH can read it. A run * that fails, is cancelled, or adds a package without a bundle patch restores @@ -901,13 +903,23 @@ export class PluginManager extends TypertRemoteService { timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes, signal, ...env, }) this.packageOperations.add(connection) - let connectionFailure: PackageResult | undefined + let connectionFailure: GithubConnectionFailure | undefined try { connectionFailure = await connection } finally { this.packageOperations.delete(connection) } if (stopped()) throw new InstallCancelledError() if (connectionFailure?.kind === 'network' || connectionFailure?.kind === 'timeout') { - result.packageResult = connectionFailure + // The answer keeps a plain PackageResult; the check's command line is only streamed as the run below. + const { argv, ...packageResult } = connectionFailure + result.packageResult = packageResult result.failedAt = 'spec-host' + // pnpm never runs, so the check is the installation's only run: stream its failure into the install log. + // A timed-out Git reports no exit code of its own, unlike the result, which records the status it is read as. + if (requestId !== undefined) { + this.ownerContext.emit('plugin-manager/install-log', { + requestId, jobId: randomUUID(), argv, cwd: this.profile.dir, stream: 'stderr', + text: connectionFailure.output, exitCode: connectionFailure.timedOut === true ? null : connectionFailure.exitCode, + }) + } throw new Error(connectionFailure.output) } const transport = chooseGitTransport(parsed, this.profile.dir, { timeoutMs: this.gitTransportTimeoutMs, signal, ...env }) diff --git a/packages/boot/plugin-manager/src/install-spec.ts b/packages/boot/plugin-manager/src/install-spec.ts index 4b76f1925e..2fb6b64cca 100644 --- a/packages/boot/plugin-manager/src/install-spec.ts +++ b/packages/boot/plugin-manager/src/install-spec.ts @@ -94,7 +94,7 @@ export function parseInstallSpec(raw: string): ParsedInstallSpec { /** A recorded dependency value pnpm resolves outside a registry: a protocol other than a registry alias, or an scp-like git address. */ const NON_REGISTRY_VALUE = /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^[^@/:\s]+@[^:/\s]+:/i /** The user information of an http(s) URL, up to the last `@` before the path, which holds a password or access token when present. */ -const HTTP_USER_INFO = /^((?:git\+)?https?:\/\/)[^/]*@/i +export const HTTP_USER_INFO = /^((?:git\+)?https?:\/\/)[^/]*@/i /** * The spec `pnpm add` accepts for a dependency the profile manifest records. A git address, URL, or other diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index e7b761ad3c..d0ab1dccab 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -470,6 +470,8 @@ export async function runProfilePnpm( if (result.failed && output.length === 0) { const diagnostic = result.shortMessage ?? 'pnpm failed' await log.write(diagnostic) + // The run printed nothing, so this diagnostic is the whole of its captured output; stream it as such. + options.onOutput?.(diagnostic, 'stderr') truncated = Buffer.byteLength(diagnostic) > options.outputBytes output = Buffer.from(diagnostic).subarray(0, options.outputBytes) } diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index efef87b4dc..221607f1e8 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -139,13 +139,18 @@ export type PluginInstallFailureKind = /** Package operation completion, including Git checks and a retrieval path for unabridged diagnostics. */ export interface PackageResult { + /** The status the run is read as having ended with; a run the manager terminated records the coerced status 1. */ exitCode: number output: string truncated: boolean logPath: string /** Present when the run failed: what kind of failure its exit and output describe. */ kind?: PluginInstallFailureKind - /** The manager terminated the run after it printed nothing for its silence bound; `exitCode` still reports how it ended. */ + /** + * The run outlived its bound: the manager terminated it after it printed nothing for its silence bound, or the GitHub + * connection check hit its deadline. Such a run reports no exit status of its own, so `exitCode` records the status + * its failure is read as rather than a status the process returned. + */ timedOut?: boolean /** Present when a compatibility check refused the run: the packages the running DSH version rejects. */ incompatible?: IncompatiblePlugin[] @@ -344,19 +349,19 @@ export interface PluginInstallCancellation { readonly status: 'cancelled' | 'too-late' | 'not-running' } -/** One chunk of a pnpm run's output, as the run produces it. */ +/** One chunk of an installation run's output: a pnpm run, or the GitHub connection check that stopped it. */ export interface PluginInstallLogChunk { /** The installation the run belongs to, when its caller supplied a request id. */ readonly requestId?: PluginInstallRequestId /** The run the chunk belongs to. */ readonly jobId: string - /** The command line the run executes: pnpm's command name, then its arguments. */ + /** The command line the run executes: its command name, then its arguments. */ readonly argv: readonly string[] /** The directory the run executes in: the profile directory. */ readonly cwd: string readonly stream: 'stdout' | 'stderr' readonly text: string - /** Present on the run's last chunk: pnpm's exit code, null when it ended without one. */ + /** Present on the chunk that ends a run: its exit code, null when the run ended without one. */ readonly exitCode?: number | null } @@ -426,7 +431,7 @@ declare module '@deepseek-ai/cordis' { */ 'plugin-manager/changed'(change: PluginChange): void /** - * One chunk of a pnpm run's output, streamed as the run produces it. + * One chunk of an installation run's output, streamed as the run produces it. * @mode emit * @param chunk - the chunk and the run it belongs to. */ diff --git a/packages/boot/plugin-manager/tests/github-connection.spec.ts b/packages/boot/plugin-manager/tests/github-connection.spec.ts index 33dd34e067..e87ac53b8c 100644 --- a/packages/boot/plugin-manager/tests/github-connection.spec.ts +++ b/packages/boot/plugin-manager/tests/github-connection.spec.ts @@ -43,6 +43,9 @@ async function fixture() { return { dir, config, controller, check, env } } +/** The exact command line a check runs for one repository URL. */ +const checkArgv = (repository: string): string[] => ['git', '-c', 'credential.helper=', 'ls-remote', '--', repository, 'HEAD'] + async function proxy(config: string, fail: boolean) { const sockets = new Set() const connected = Promise.withResolvers() @@ -93,7 +96,9 @@ it('reports an immediate transport failure without waiting for the deadline', as const transport = await proxy(config, true) const result = await check('https://github.com/acme/plugin.git') await transport.connected - expect(result).toMatchObject({ exitCode: 128, kind: 'network', truncated: false }) + expect(result).toMatchObject({ + exitCode: 128, kind: 'network', truncated: false, argv: checkArgv('https://github.com/acme/plugin.git'), + }) expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output) await transport.disconnected expect(transport.sockets.size).toBe(0) @@ -106,6 +111,8 @@ it('ends a silent proxy connection and its Git child at the connection deadline' await transport.connected const result = await running expect(result?.kind).toBe('timeout') + // The result records the status the terminated Git is read as; only the streamed run reports no exit code. + expect(result).toMatchObject({ exitCode: 1, timedOut: true, argv: checkArgv('https://github.com/acme/plugin.git') }) expect(result!.output).toContain('connection to github.com timed out after 3000ms') await transport.disconnected expect(transport.sockets.size).toBe(0) @@ -128,12 +135,15 @@ it.each([undefined, 'Git could not start'])('retains a diagnostic when Git write const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, shortMessage } as Awaited>) onTestFinished(() => { run.mockRestore() }) const result = await check('github:acme/plugin') - expect(result).toMatchObject({ exitCode: 1, kind: 'unknown', output: shortMessage ?? 'GitHub connection check failed' }) + expect(result).toMatchObject({ + exitCode: 1, kind: 'unknown', output: shortMessage ?? 'GitHub connection check failed', + argv: checkArgv('https://github.com/acme/plugin'), + }) expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output) }) it('bounds returned diagnostics while retaining Git stderr in the operation log', async () => { - const { check } = await fixture() + const { check, dir } = await fixture() const run = vi.spyOn(subprocess, 'execa').mockImplementation((...args: unknown[]) => { const options = args[2] as { stderr: { file: string } } appendFileSync(options.stderr.file, '0123456789') @@ -141,10 +151,24 @@ it('bounds returned diagnostics while retaining Git stderr in the operation log' }) onTestFinished(() => { run.mockRestore() }) const result = await check('github:acme/plugin', 3000, 4) - expect(result).toMatchObject({ output: '6789', truncated: true }) + expect(result).toMatchObject({ output: '6789', truncated: true, argv: checkArgv('https://github.com/acme/plugin') }) + // The recorded command line is the one Git actually ran. + expect(run).toHaveBeenCalledWith('git', checkArgv('https://github.com/acme/plugin').slice(1), expect.objectContaining({ cwd: dir })) expect(await readFile(result!.logPath, 'utf8')).toBe('0123456789') }) +it('keeps URL user information out of the recorded command line', async () => { + const { check } = await fixture() + const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ + failed: true, exitCode: 128, shortMessage: 'fatal: unable to access the repository', + } as Awaited>) + onTestFinished(() => { run.mockRestore() }) + const result = await check('https://user:token@github.com/acme/plugin.git') + // Git runs the address as typed so its own credentials keep working, while the recorded line is safe to display. + expect(run).toHaveBeenCalledWith('git', checkArgv('https://user:token@github.com/acme/plugin.git').slice(1), expect.anything()) + expect(result?.argv).toEqual(checkArgv('https://github.com/acme/plugin.git')) +}) + it('reports a missing Git executable without classifying it as a network failure', async () => { const { check } = await fixture() const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, code: 'ENOENT', shortMessage: 'spawn git ENOENT' } as Awaited>) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 0f8290a48d..207f34291f 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -127,24 +127,50 @@ it('allows supported bundle activation and permits deselecting an unsupported sa }) it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attributes it to the repository', async (kind) => { - const { manager, dir, connection } = await fixture() + const { ctx, manager, dir, connection } = await fixture() + const chunks: PluginInstallLogChunk[] = [] + ctx.on('plugin-manager/install-log', (chunk) => { chunks.push(chunk) }) const before = readFileSync(join(dir, 'package.json'), 'utf8') - const failure = { exitCode: 1, output: 'GitHub check failed', truncated: false, logPath: join(dir, 'git.log'), kind } - connection.mockResolvedValue(failure) + const argv = ['git', '-c', 'credential.helper=', 'ls-remote', '--', 'https://github.com/acme/dsh-plugin.git', 'HEAD'] + const checked = { + argv, exitCode: 1, output: 'GitHub check failed', truncated: false, logPath: join(dir, 'git.log'), kind, + ...kind === 'timeout' ? { timedOut: true } : {}, + } + connection.mockResolvedValue(checked) const pnpm = vi.spyOn(operations, 'runProfilePnpm') onTestFinished(() => { pnpm.mockRestore() }) - expect(await manager.installBundle('https://github.com/acme/dsh-plugin.git')).toMatchObject({ - application: 'failed', changed: false, stage: 'install', failedAt: 'spec-host', packageResult: failure, + const requestId = '824103ec-bc45-489d-bb85-5b4fe0aefc78' as PluginInstallRequestId + const result = await manager.installBundle('https://github.com/acme/dsh-plugin.git', { requestId }) + expect(result).toMatchObject({ + application: 'failed', changed: false, stage: 'install', failedAt: 'spec-host', + packageResult: { exitCode: 1, output: 'GitHub check failed', truncated: false, logPath: join(dir, 'git.log'), kind }, }) + // The command line belongs to the streamed run only; the answer stays a plain package result. + expect(result.packageResult).not.toHaveProperty('argv') expect(pnpm).not.toHaveBeenCalled() expect(connection).toHaveBeenCalledWith(expect.objectContaining({ host: 'github.com' }), dir, expect.objectContaining({ timeoutMs: 5000 })) expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) + // The check is the installation's only run, so its diagnostic reaches the details panel. A timed-out Git has no exit + // code of its own, so its chunk reports none instead of the status the timeout coerced. + expect(chunks).toEqual([{ + requestId, jobId: expect.any(String) as string, argv, cwd: dir, stream: 'stderr', text: 'GitHub check failed', + exitCode: kind === 'timeout' ? null : 1, + }]) + // Without a request id there is no installation to stream the failed check under. + chunks.length = 0 + expect(await manager.installBundle('https://github.com/acme/dsh-plugin.git')).toMatchObject({ failedAt: 'spec-host' }) + expect(chunks).toEqual([]) }) it('leaves non-network GitHub errors to pnpm and forwards profile Git environment and deadline', async () => { const env = { GIT_CONFIG_GLOBAL: '/application/git.config' } - const { manager, connection } = await fixture('live', false, undefined, { githubConnectionTimeoutMs: 8000 }, { command: 'pnpm', args: [], env }) - connection.mockResolvedValue({ exitCode: 128, output: 'fatal: Authentication failed', truncated: false, logPath: 'git.log', kind: 'unknown' }) + const { ctx, manager, connection } = await fixture('live', false, undefined, { githubConnectionTimeoutMs: 8000 }, { command: 'pnpm', args: [], env }) + const chunks: PluginInstallLogChunk[] = [] + ctx.on('plugin-manager/install-log', (chunk) => { chunks.push(chunk) }) + connection.mockResolvedValue({ + argv: ['git', '-c', 'credential.helper=', 'ls-remote', '--', 'https://github.com/acme/private-plugin', 'HEAD'], + exitCode: 128, output: 'fatal: Authentication failed', truncated: false, logPath: 'git.log', kind: 'unknown', + }) const failure = { exitCode: 1, output: 'pnpm owns authentication', truncated: false, logPath: 'pnpm.log', kind: 'unknown' as const } const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue(failure) onTestFinished(() => { pnpm.mockRestore() }) @@ -154,6 +180,8 @@ it('leaves non-network GitHub errors to pnpm and forwards profile Git environmen expect(pnpm).toHaveBeenCalledOnce() expect(result.failedAt).toBeUndefined() expect(connection).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ timeoutMs: 8000, env })) + // A check failure pnpm may resolve streams no run of its own. + expect(chunks.filter(chunk => chunk.argv[0] === 'git')).toEqual([]) }) it('cancels an active GitHub check before starting pnpm', async () => { @@ -162,7 +190,10 @@ it('cancels an active GitHub check before starting pnpm', async () => { connection.mockImplementation(async (_spec, _dir, options) => { entered.resolve(undefined) await new Promise((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' } + return { + argv: ['git', '-c', 'credential.helper=', 'ls-remote', '--', 'https://github.com/acme/dsh-plugin', 'HEAD'], + exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown', + } }) const pnpm = vi.spyOn(operations, 'runProfilePnpm') onTestFinished(() => { pnpm.mockRestore() }) @@ -244,7 +275,10 @@ it('disposal waits for an active GitHub check to stop', async () => { entered.resolve(undefined) await new Promise((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) }) released.resolve(undefined) - return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' } + return { + argv: ['git', '-c', 'credential.helper=', 'ls-remote', '--', 'https://github.com/acme/addon', 'HEAD'], + exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown', + } }) const pnpm = vi.spyOn(operations, 'runProfilePnpm') onTestFinished(() => { pnpm.mockRestore() }) @@ -1773,9 +1807,13 @@ it('lists absent Official entries offline without resolving their packages or co onTestFinished(() => { resolve.mockRestore(); registry.mockRestore() }) const bundles = await manager.listBundles() for (const entry of OFFICIAL_ON_DEMAND_CATALOG) { - expect(bundles.filter(bundle => bundle.name === entry.packageName)).toEqual([{ + expect(bundles.filter(bundle => bundle.name === entry.packageName)).toHaveLength(1) + expect(bundles.filter(bundle => bundle.name === entry.packageName)).toMatchObject([{ name: entry.packageName, official: true, availability: 'missing', enabled: false, installed: false, optional: false, removable: false, updatable: false, rows: [], overrides: [], meta: entry.meta, + ...entry.listedByDefault === undefined ? {} : { listedByDefault: entry.listedByDefault }, + ...entry.platforms !== undefined && !entry.platforms.includes(process.platform) + ? { listedByDefault: false, error: { code: 'unsupported-platform' } } : {}, installTarget: { spec: `${entry.packageName}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() }, }]) expect(resolve.mock.calls.some(([name]) => name === entry.packageName)).toBe(false) diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index c54260d214..b6a87a6475 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -874,10 +874,16 @@ it.each([ ])('reports launch failures with a complete log: $code', async ({ code, shortMessage, expected }) => { const { context } = fixture() command.run.mockImplementationOnce(() => result(undefined, '', () => {}, { code, ...shortMessage === undefined ? {} : { shortMessage } })) - const outcome = await runProfilePnpm(context, ['root'], { execution: 'service', outputBytes: 4, signal: new AbortController().signal }) + const messages: string[] = [] + const outcome = await runProfilePnpm(context, ['root'], { + execution: 'service', outputBytes: 4, signal: new AbortController().signal, + onOutput: (text) => { messages.push(text) }, + }) expect(outcome.exitCode).toBe(expected) expect(outcome.truncated).toBe(true) expect(readFileSync(outcome.logPath, 'utf8')).toBe(shortMessage ?? 'pnpm failed') + // A run that printed nothing still streams the diagnostic its log carries. + expect(messages).toEqual([shortMessage ?? 'pnpm failed']) }) it('cancels and settles package output when the output consumer fails', async () => { diff --git a/packages/boot/plugin-market/package.json b/packages/boot/plugin-market/package.json index 140fba1cb5..86c97b79b7 100644 --- a/packages/boot/plugin-market/package.json +++ b/packages/boot/plugin-market/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-market", "description": "Configured plugin market indexes for the Web Discover page and the plugin_market agent tool", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json index cfd1264b62..5a67143621 100644 --- a/packages/browser-use/browser-use/package.json +++ b/packages/browser-use/browser-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-browser-use", "description": "Exclusive named browser-use provider registration", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 94846a8a58..d48b0bf711 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 559c32dead..6c6e508732 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 030d3c3a87..dd523037fe 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 0e543f9e13..7be81f2b9c 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 5dcbfd67a2..499aad21e4 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c0ae7077d9..53d2b2971e 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -77,6 +77,10 @@ iconTimeoutMs: 10000 launchWatchMs: 1000 + - id: ui-file-actions + name: '@deepseek-ai/dsh-client-ui-file-actions' + - id: ui-context-menu + name: '@deepseek-ai/dsh-client-ui-context-menu' - id: ui-open-in-app name: '@deepseek-ai/dsh-client-ui-open-in-app' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 864a13e1c6..9ecc295724 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, bash runtime variables, URL line)", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, @@ -191,7 +191,9 @@ "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*", "@deepseek-ai/dsh-harness-dev-context": "workspace:*", "@deepseek-ai/dsh-deliverable-file-references": "workspace:*", - "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*" + "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*", + "@deepseek-ai/dsh-client-ui-file-actions": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 273fd330e9..22f21df125 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport and generation lifecycle", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 6f658f40d9..d91c931e21 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 74387100a1..0ad8192ce6 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Web client graph synchronization and rebuilt-bundle reload transport", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index d3cfb395de..44227c6ebe 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index ecf23dd6e9..d94b2a195b 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -45,6 +46,7 @@ function mount(active = 'en') { usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), ...allowAllAccess, + useContextMenu: contextMenuHook(), useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => key === 'language.title' ? 'Language' : key, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index eb03e31d6c..558dc133e9 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/src/client/entries.ts b/packages/client/modules/src/client/entries.ts index 587d3ffdd0..e33d0055f8 100644 --- a/packages/client/modules/src/client/entries.ts +++ b/packages/client/modules/src/client/entries.ts @@ -236,7 +236,8 @@ export class ClientEntries { } else if (entry.fiber === undefined) { await this.replace(entry, row.id, row.rev, generation) } else if (entry.fiber.state === FAILED) { - entry.fiber.update(entry.options.config) + // A retry with unchanged config is not a configuration change to persist. + await entry.fiber.update(entry.options.config, true) } } catch (error) { failures.push({ id: row.id, message: String(error) }) diff --git a/packages/client/product-analytics/package.json b/packages/client/product-analytics/package.json index 75528b227d..9848ca3c08 100644 --- a/packages/client/product-analytics/package.json +++ b/packages/client/product-analytics/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-product-analytics", "description": "Desktop product event collection and authenticated Host reporting", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index 8cad8c3764..fd54263983 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json index f2c985906b..8ae1eb16a2 100644 --- a/packages/client/shortcuts/package.json +++ b/packages/client/shortcuts/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-shortcuts", "description": "Application keyboard command registry and physical-key routing", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index ee0fab4de1..81ce31353c 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 1a09a46151..18982db48e 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx index 578234c898..83ea649c80 100644 --- a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { useState, type ComponentProps } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -30,6 +31,7 @@ function view(roster: Partial = {}, inMenu = false) { usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, ...allowAllAccess, + useContextMenu: contextMenuHook(), load, startCreatorDraft, onDismiss, } function TestMenu() { diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 1ea42a6af1..59f2bf5842 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' @@ -24,6 +25,7 @@ function view(partial: Partial = {}, startCreatorDraft? usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, ...allowAllAccess, + useContextMenu: contextMenuHook(), useAgentPresetSection: bindSnapshotSelector(store), useDeveloperTools: bindSnapshotSelector(developerTools), t: key => translations.get(key) ?? key } diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index d402973ade..a1a6a8e7c2 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 65b551434d..fe9e9182e5 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-attachment/use-this-package: - en: df47b6abd789dcb5 - zh: 7e17c7f18631a572 + en: 02870b565a595b51 + zh: c31d02d76d78094b /deepseek-ai-dsh-client-ui-attachment/use-this-package/draft-attachments: en: 8246cb3c470e3fc5 zh: 7028cf466a832b99 @@ -30,8 +30,8 @@ en: 07204b7d538b7434 zh: 705cc6b0c9387cce /deepseek-ai-dsh-client-ui-attachment/further-exploration: - en: 733ac3e4df52d6e9 - zh: 98a6a8cac7d66645 + en: 661e8285abb71b5d + zh: 0c421325537cdcda /deepseek-ai-dsh-client-ui-attachment/model-experience: en: 25566605d7b38667 zh: 927d3ea272893296 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 29e7f3aba0..7b3158b50b 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -25,7 +25,7 @@ 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. +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. `MessageImage` and `ImageGallery` require the ordinary `getTriggerProps` factory; they bind each original image once and pass the same `contextMenuProps` to its thumbnail and lightbox. 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. @@ -82,6 +82,8 @@ Read these pages when the attachment surface is not enough. They move from the s ----- +Message images and original previews forward context gestures through the shared gallery to [ui-context-menu](../ui-context-menu/README.md). Original authorized URLs and MIME metadata remain independent from thumbnail presentation. + ## Model Experience diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 80db6ae928..7391553fe2 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -图片拖放回调与输入框的原图读取回调通过 slot 注入从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。图片图库 Factory 也通过声明的注入接口接收回调;自定义 Factory 替换实现必须提供该接口。 +图片拖放回调与输入框的原图读取回调通过 slot 注入从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。图片图库 Factory 也通过声明的注入接口接收回调;自定义 Factory 替换实现必须提供该接口。 `MessageImage` 和 `ImageGallery` 必须接收普通 `getTriggerProps` 函数,每张原图绑定一次,将同一份 `contextMenuProps` 传给缩略图和灯箱。 与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件,工具结果需要图片图库时也要挂载 [`ui-tool`](../ui-tool/README.zh.md)。插件等待这些 slot 的声明,并把组件注册进去。用户会看到混合草稿附件栏、带上传控件的 DeepSeek Web 文件卡、带限制说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片图库,以及支持 Escape、遮罩和关闭按钮的灯箱。 @@ -82,6 +82,8 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪 ----- +消息图片和原图预览通过共享图片库将右键手势转发给 [ui-context-menu](../ui-context-menu/README.zh.md)。原始授权 URL 与 MIME 元数据独立于缩略图展示。 + ## 模型体验 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 6456641085..2f5d2d5d93 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-trajectory", "@deepseek-ai/dsh-client-ui-tool", - "@deepseek-ai/dsh-client-ui-file-drag" + "@deepseek-ai/dsh-client-ui-file-drag", + "@deepseek-ai/dsh-client-ui-context-menu" ], "platform": "web" } @@ -60,7 +61,8 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:*", "clsx": "^2.0.0", "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*", - "@deepseek-ai/dsh-util-file-drag": "workspace:*" + "@deepseek-ai/dsh-util-file-drag": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 5adcfb69a3..7afdb8b245 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,3 +1,4 @@ +import type { ContextMenuBindings } from '@deepseek-ai/dsh-client-ui-context-menu/client' import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, DraggableImage, ImageLightbox, type FileDragActions } from '@deepseek-ai/dsh-client-ui-primitives' @@ -67,12 +68,13 @@ 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, fileDrag }: { +export function MessageImage({ image, load, variant, labels, fileDrag, getTriggerProps }: { image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' | 'thumbnail' fileDrag: FileDragActions labels: MessageImageLabels + getTriggerProps: ContextMenuBindings['getTriggerProps'] }) { const preview = 'preview' in image ? image.preview : undefined const attachment = 'attachment' in image ? image.attachment : undefined @@ -110,6 +112,9 @@ export function MessageImage({ image, load, variant, labels, fileDrag }: { const src = preview?.url ?? loaded const label = ('attachment' in image ? image.label : undefined) ?? preview?.name ?? attachment?.name ?? labels.image + const contextMenuProps = getTriggerProps(src === null ? undefined : { kind: 'image', value: { + url: src, name: label, ...(attachment === undefined ? {} : { mediaType: attachment.mediaType }), + } }) const loadingThumbnail = variant === 'thumbnail' && src === null if (error) return ( - {open && src !== null && } + {open && src !== null && } ) } /** 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, fileDrag }: { +export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels, fileDrag, getTriggerProps }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' @@ -161,6 +168,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail = thumbnail?: boolean fileDrag: FileDragActions labels: MessageImageLabels + getTriggerProps: ContextMenuBindings['getTriggerProps'] }) { if (images.length === 0) return null const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single' @@ -174,6 +182,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail = variant={variant} fileDrag={fileDrag} labels={labels} + getTriggerProps={getTriggerProps} /> ))} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 0a95ddc66d..176940777f 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -12,11 +12,12 @@ type MessageImagesProps = MessageImagesOwnerProps & PropsRenderFactories * @param props - image sources, authorized loader, layout hints, optional labels, and Factory locale seat. * @returns the official gallery; no Session binding is needed by this component. */ -export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag }: +export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag, useContextMenu }: FactoryComponentPropsOf<'conversation.images.content'>) { + const { getTriggerProps } = useContextMenu() const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t]) return + compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} getTriggerProps={getTriggerProps} /> } /** diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 7ef44e1dd8..ef0f496a78 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -1,5 +1,7 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Browser attachment plugin: composer entries and independently replaceable image galleries. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SlotFactoryMap } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -9,6 +11,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-file-drag/client' import { ComposerAttachments, type ComposerAttachmentsInjected } from './ComposerAttachments.tsx' import { MessageImages, MessageImagesContent } from './MessageImages.tsx' +type MessageImagesInjected = SlotFactoryMap['conversation.images.content']['inject'] + /** Slot registry required by the attachment presentation plugin. */ export const inject = ['slots', 'fileDrag'] @@ -19,7 +23,9 @@ export const inject = ['slots', 'fileDrag'] export function apply(ctx: ClientContext): void { ctx.slots.registerFactory({ name: 'conversation.images.content', scope: 'root', locale: 'conversation', - inject: () => ({ fileDrag: ctx.fileDrag.actions }), + inject: (): MessageImagesInjected => ({ + fileDrag: ctx.fileDrag.actions, + }), }, MessageImagesContent) ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({ name: 'conversation.input.attachments', diff --git a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx index 09f0db62cb..98f3d923a3 100644 --- a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx +++ b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom /** Independent domain image positions with one shared official gallery Factory. */ import * as fileDragPlugin from '@deepseek-ai/dsh-client-ui-file-drag/client' +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { act, cleanup, within } from '@testing-library/react' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' @@ -29,6 +30,10 @@ async function bench() { runtime.slots.installLocale(locale) new UiConversation(runtime.ctx, runtime.sessions) await runtime.mount(fileDragPlugin) + runtime.ctx.provide('contextMenus', { open: vi.fn() } as never) + runtime.slots.provideRoot({ props: { + useContextMenu: contextMenuHook((target, point) => { runtime.ctx.contextMenus.open(target, point) }), + } }) await runtime.mount(attachmentPlugin) await runtime.sessions.add({ id: 'image-domains' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-domains')) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index dd6588f8bf..68cf23a7c8 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { useState } from 'react' import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -39,7 +40,8 @@ describe('MessageImage', () => { const load = Object.assign(vi.fn(() => new Promise(() => {})), { peek: vi.fn(() => 'blob:seeded'), }) - const view = render() + const view = render() expect(view.queryByText('图片加载中…')).toBeNull() expect((view.getByAltText('history.png') as HTMLImageElement).src).toContain('blob:seeded') expect(load).toHaveBeenCalledWith(attachment) @@ -47,7 +49,8 @@ describe('MessageImage', () => { it('loads a Gateway-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -62,7 +65,8 @@ describe('MessageImage', () => { it('ignores a click while the thumbnail is still loading', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(view.getByText('图片加载中…')).toBeTruthy() fireEvent.click(frame) @@ -72,7 +76,8 @@ describe('MessageImage', () => { it('falls back to the image label for an unnamed attachment', async () => { const { name: _named, ...unnamed } = attachment const load = vi.fn().mockResolvedValue('blob:unnamed') - const view = render() + const view = render() await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() }) @@ -82,7 +87,8 @@ describe('MessageImage', () => { .mockRejectedValueOnce(new Error('offline')) .mockRejectedValueOnce(new Error('still offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) @@ -94,7 +100,8 @@ describe('MessageImage', () => { it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => { const load = vi.fn().mockResolvedValue('blob:ratio') const tall = render( - , + , ) const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' }) expect(tallFrame.getAttribute('style')).toContain('width: 60px') @@ -103,7 +110,8 @@ describe('MessageImage', () => { expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top') tall.unmount() const wide = render( - , + , ) const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' }) expect(wideFrame.getAttribute('style')).toContain('width: 240px') @@ -112,7 +120,8 @@ describe('MessageImage', () => { expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center') wide.unmount() const small = render( - , + , ) const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' }) expect(smallFrame.getAttribute('style')).toContain('width: 100px') @@ -121,7 +130,8 @@ describe('MessageImage', () => { it('renders a tile at the fixed square without inline sizing', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('data-variant')).toBe('tile') expect(frame.getAttribute('style')).toBeNull() @@ -129,7 +139,9 @@ describe('MessageImage', () => { it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) expect(retry.getAttribute('data-variant')).toBe(variant) }) @@ -140,7 +152,8 @@ describe('MessageImage', () => { ] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => { const pending = Promise.withResolvers() const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry') - const view = render() + const view = render() const loading = view.getByRole('button', { name: statusLabels.loading }) expect(loading.getAttribute('title')).toBe(statusLabels.loading) expect(loading.getAttribute('aria-busy')).toBe('true') @@ -164,7 +177,8 @@ describe('MessageImage', () => { it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => { const { name: _name, ...unnamed } = attachment const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail') - const view = render() + const view = render() fireEvent.click(await view.findByRole('button', { name: labels.loadFailed })) const image = await view.findByAltText('Image 2') expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail') @@ -182,13 +196,15 @@ describe('MessageImage', () => { it('ignores a load settling after unmount', async () => { let resolve: ((url: string) => void) | undefined const load = vi.fn(() => new Promise((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() second.unmount() reject?.(new Error('late failure')) await Promise.resolve() @@ -199,7 +215,7 @@ describe('MessageImage preview arm', () => { it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => { const load = vi.fn() const view = render( - { it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => { const load = vi.fn() const view = render( - , + , ) const img = view.getByAltText('图片') as HTMLImageElement const frame = img.closest('button') as HTMLButtonElement @@ -227,7 +244,8 @@ describe('MessageImage preview arm', () => { it('opens the lightbox from a preview thumbnail', () => { const view = render( - '')} variant="tile" labels={labels} />, + '')} variant="tile" labels={labels} />, ) fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' })) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() @@ -237,10 +255,11 @@ describe('MessageImage preview arm', () => { describe('ImageGallery', () => { it('renders nothing without images and an aligned wrapping group with them', async () => { const load = vi.fn().mockResolvedValue('blob:gallery') - const empty = render() + const empty = render() expect(empty.container.firstChild).toBeNull() const view = render( - { it('renders a lone image large and compact or grouped images as square tiles', () => { const load = vi.fn(() => new Promise(() => {})) - const lone = render() + const lone = render() expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1) lone.unmount() const compact = render( - , + , ) expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1) compact.unmount() const several = render( - , + , ) expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3) }) @@ -277,6 +299,10 @@ describe('ImageGallery', () => { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) + runtime.ctx.provide('contextMenus', { open: vi.fn() } as never) + runtime.slots.provideRoot({ props: { + useContextMenu: contextMenuHook((target, point) => { runtime.ctx.contextMenus.open(target, point) }), + } }) await runtime.mount(attachmentPlugin) const images = [{ preview: { url: 'blob:labels', name: 'preview.png' } }] const loadImage = vi.fn(async () => 'blob:unused') @@ -291,10 +317,14 @@ describe('ImageGallery', () => { }) const view = runtime.renderRoot() const image = view.getByAltText('preview.png') + fireEvent.contextMenu(image, { clientX: 7, clientY: 8 }) + expect(vi.spyOn(runtime.ctx.contextMenus, 'open')).toHaveBeenCalledWith({ kind: 'image', value: { url: 'blob:labels', name: 'preview.png' } }, { x: 7, y: 8 }) const frame = view.getByRole('button', { name: 'preview.png,点击查看原图' }) expect(frame.getAttribute('title')).toBe(labels.open) fireEvent.click(frame) expect(view.getByRole('dialog', { name: labels.lightbox.dialog })).toBeTruthy() + fireEvent.contextMenu(view.getByRole('dialog').querySelector('img')!, { clientX: 13, clientY: 14 }) + expect(vi.spyOn(runtime.ctx.contextMenus, 'open')).toHaveBeenLastCalledWith({ kind: 'image', value: { url: 'blob:labels', name: 'preview.png' } }, { x: 13, y: 14 }) fireEvent.click(view.getByRole('button', { name: labels.lightbox.close })) fireEvent.click(view.getByRole('button', { name: 'Default labels' })) const defaultFrame = view.getByRole('button', { name: 'preview.png, click to view original' }) @@ -318,6 +348,10 @@ describe('ImageGallery', () => { runtime.slots.installLocale(locale) await runtime.declare({ 'conversation.chat.message.images': { kind: 'single', scope: 'session' } }) runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) + runtime.ctx.provide('contextMenus', { open: vi.fn() } as never) + runtime.slots.provideRoot({ props: { + useContextMenu: contextMenuHook((target, point) => { runtime.ctx.contextMenus.open(target, point) }), + } }) await runtime.mount(attachmentPlugin) await runtime.sessions.add({ id: 'message-images-test' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test')) @@ -330,6 +364,8 @@ describe('ImageGallery', () => { expect(view.getByRole('button', { name: 'history.png,点击查看原图' })).toBeTruthy() expect(container.querySelector('[data-align="end"]')).not.toBeNull() expect(loadImage).toHaveBeenCalledWith(attachment) + fireEvent.contextMenu(view.getByAltText('history.png'), { clientX: 9, clientY: 10 }) + expect(vi.spyOn(runtime.ctx.contextMenus, 'open')).toHaveBeenCalledWith({ kind: 'image', value: { url: 'blob:slot-image', name: 'history.png', mediaType: 'image/png' } }, { x: 9, y: 10 }) expect(view.getByRole('button', { name: 'history.png,点击查看原图' }).getAttribute('data-variant')).toBe('single') for (const [compact, thumbnail, variant] of [ [true, false, 'tile'], @@ -346,3 +382,16 @@ describe('ImageGallery', () => { }) const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } + +it('opens image menus from the thumbnail and lightbox using the original source', async () => { + const openImageMenu = vi.fn() + const view = render( 'blob:original')} + variant="single" labels={labels} />) + const image = await view.findByAltText('history.png') + fireEvent.contextMenu(image, { clientX: 18, clientY: 29 }) + expect(openImageMenu).toHaveBeenLastCalledWith({ kind: 'image', value: { url: 'blob:original', name: 'history.png', mediaType: attachment.mediaType } }, { x: 18, y: 29 }) + fireEvent.click(image) + fireEvent.contextMenu(view.getByRole('dialog').querySelector('img')!, { clientX: 31, clientY: 42 }) + expect(openImageMenu).toHaveBeenLastCalledWith({ kind: 'image', value: { url: 'blob:original', name: 'history.png', mediaType: attachment.mediaType } }, { x: 31, y: 42 }) +}) diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index a14099543e..706dc4531b 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -21,6 +21,7 @@ async function bench() { await ctx.fiber.dispose() }) ctx.provide('sessions', sessions) + ctx.provide('contextMenus', { open: vi.fn() } as never) ctx.provide('locale', new LocaleRuntime(ctx)) await ctx.plugin(SlotRegistry).await() ctx.slots.register({ @@ -33,11 +34,13 @@ async function bench() { 'tool.call.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) - ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) + const readFileDrag = vi.fn(() => undefined) + ctx.provide('fileDrag', { actions: fileDrag, read: readFileDrag }) + const register = vi.spyOn(ctx.slots, 'register') const registerFactory = vi.spyOn(ctx.slots, 'registerFactory') const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, fiber, registerFactory } + return { ctx, fiber, register, registerFactory, readFileDrag } } describe('attachment plugin', () => { @@ -46,7 +49,7 @@ describe('attachment plugin', () => { }) it('registers all entries and removes them with the plugin fiber', async () => { - const { ctx, fiber, registerFactory } = await bench() + const { ctx, fiber, register, registerFactory, readFileDrag } = await bench() expect(inject).toEqual(['slots', 'fileDrag']) expect(registerFactory.mock.calls[0]?.[0]).toMatchObject({ name: 'conversation.images.content', scope: 'root', locale: 'conversation', @@ -57,6 +60,10 @@ describe('attachment plugin', () => { expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{ locale: 'conversation', component: ComposerAttachments, }]) + const composer = register.mock.calls.find(([options]) => options.name === 'conversation.input.attachments')! + const injectComposer = composer[0].inject as () => { readFileDrag: () => undefined } + injectComposer().readFileDrag() + expect(readFileDrag).toHaveBeenCalledOnce() for (const name of IMAGE_SLOTS) { expect(ctx.slots.entries(name)).toMatchObject([{ component: MessageImages, options: { priority: 100 } }]) } diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 6d6e4792bc..853d6a70e2 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -43,6 +43,9 @@ }, { "path": "../../util/file-drag" + }, + { + "path": "../ui-context-menu" } ] } diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 37cc217937..468dd1613d 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 9626662137..ea9a48c09a 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -15,14 +15,14 @@ en: 41cd21266011d571 zh: 3172d1a2acbb27b7 /deepseek-ai-dsh-client-ui-chat/reference-previews: - en: a05d85ff4949b526 - zh: 7261ffc98665e9e7 + en: 9386791a9e5efa00 + zh: 7cb9f3bbc42319d6 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: en: cbf1f0a2205046a5 zh: 2e59d0815351349f /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: - en: 41dcd70e14ebe9cc - zh: ededead572b59037 + en: be90c24392fb021b + zh: 992b8745b385f9ab /deepseek-ai-dsh-client-ui-chat/turn-token-usage: en: cfdfe3d99d3f4e09 zh: 9330bad3d3f7a689 @@ -36,8 +36,8 @@ en: 3afad0b4c6bf9ce0 zh: d8179ba4ee9fcd61 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 91e1f5f12ca51211 - zh: b92090966d58b82c + en: dd4e69828096c3a0 + zh: 2a9bc370f44efcd7 /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index deff6574cd..99b01c5566 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -37,13 +37,15 @@ Image drag actions arrive through the image entry’s inject face from [ui-file- Sent generic-file attachments retain the draft's [file-card radius](../../../docs/ui-radius.md#radius-scale). -`ChatView` supplies file and HTTP(S) navigation through the existing `MarkdownDelegateProvider` around its node list, passing `requestOpenFile`, `openExternalLink`, and `fileImages` directly. Its file opener retains Chat's failure feedback and retry handling. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. +`ChatView` supplies file and HTTP(S) navigation through the existing `MarkdownDelegateProvider` around its node list, passing `requestOpenFile`, `openExternalLink`, and `fileImages` directly. Its file opener retains Chat's failure feedback and retry handling. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. [Supported Markdown line references](../ui-primitives/README.md#rendering-agent-output) navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. Native message images use separate user and output positions. The `conversation.chat.user.content` Factory declares `conversation.chat.user.images`, which `UserContent` renders; the Assistant node registration declares `conversation.chat.message.images`, which `AssistantMarkdown` renders. Both receive image sources, the authorized `loadImage`, and layout options through explicit props. Their official `MessageImages` entries call the shared root-scoped `conversation.images.content` Factory. Each position can be replaced independently without duplicating the gallery implementation. The session-scoped `conversation.chat.user.content` Factory renders only the user content stack: text, extra blocks, image/file attachments in input order, and reference summaries. Its [`UserContentInput`](src/client/contract/slots.ts) carries prepared `content`, transform `facts`, display props, and explicit `loadImage`; node-owned injections provide reference actions. It calls the shared `conversation.user.text` Factory for text and its own image Slot for images. Direct callers need a Session binding and supply their own row wrapper. -`UserStyleBubble` and the [source-specific wrappers]() retain the user row, keys, pending/echo markers, timestamps, copy controls, and other message actions. The content Factory reads neither source-specific models nor private Context, and is not a global user-content override entry point. +`UserStyleBubble` and the [source-specific wrappers]() retain the user row, keys, pending/echo markers, timestamps, copy controls, and other message actions. The content Factory reads neither source-specific models nor private Context, and is not a global user-content override entry point. + +The official user and steering renderer declares the session-scoped `conversation.chat.user.before` chain slot at its `user` registration and renders it directly above the bubble with `renderSlotChain`; the `steering` registration shares the renderer without owning the seat. Owner props carry the durable message `source` and row `kind`; each entry’s `select` matches its own sources, and the first non-null result reaches the elected component as `matched`. No match displays no content. Entries own their markup and copy; Chat does not interpret message sources. Standalone Markdown images show contained previews and open the shared image lightbox; local paths resolve against the viewed workspace after settlement. Image file links keep their sidebar activation and show a thumbnail after hover dwell or keyboard focus. Escape dismisses the thumbnail. Failed images retain a localized status and their description; no duplicate-image filtering is applied. @@ -61,6 +63,8 @@ When an Assistant attempt retires without a visible message, Chat hides its alre Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. Every terminal Turn failure renders its inline red-dot row; a quota failure's row states the neutral `message.failure.quota` copy instead of the provider message. The transient notice for a newly appended `QUOTA` or `ACCOUNT_QUOTA` comes from this package's frame-wide entry in `shell.overlay`, which outlives the Chat panel: it offers the one live notice to the `shell.quota-notice` chain and falls back to its own warning Toast, while an entry that claims the code replaces that fallback. Only Sessions this Client has bound and materialized publish; quota failures in Sessions it never opened do not. A newer notice replaces the current one unless a claiming entry retains it with `keepOpen()`: that call returns a release the caller owns and must run on unmount, any live hold keeps the claiming entry mounted and drops later notices, and releasing resumes later notices without replaying the dropped ones. The fallback Toast has no deferral of its own: while the Desktop account's opaque native Platform page covers the document, it still runs underneath and its display timer may elapse unseen, dismissing the notice itself, so only the persistent failure row remains. A release drops only its own hold, so one that runs after a dismissal or a newer hold leaves that newer hold intact. Dismissal and sign-out clear every hold, and dropped notices are not queued while their persistent failure rows still render. History replacement and pagination never publish a notice. Intermediate retries do not create a terminal row; output-token limits use the amber warning dot. A Turn that restart recovery closed as `interrupted` renders a localized stopped row stating that the DSH process exited mid-Turn, without a code chip, and its process summary reads Stopped. +Chat adapts Markdown targets at `MarkdownDelegateProvider.getTriggerProps`. The assistant-node Slot obtains `useContextMenu` directly and passes a file-path adapter to the optional mention service; intermediate flow and node owners carry no menu callbacks. Paths resolve against the originating Session’s current working directory when the gesture begins. Menus retain that captured path and line even if focus changes. Undeclared plugin links keep native behavior. + ----- @@ -182,6 +186,8 @@ The Chat view detects selection and context-menu gestures on business nodes mark `ctx.uiConversation.interactions` dispatches eligible gestures to plugin registrations and supplies their component to the Chat view. The action surface is absolutely positioned inside the existing Chat column, above the composer seat and its dock. Its wrapper preserves the component's outer stroke and shadow; components own internal scrolling. Scrolling the selection outside that visible region temporarily hides the surface; returning restores it. Chat owns viewport visibility, placement, outside pointer presses, Escape, new selections, source invalidation, and disposal; explicit dismissal preserves browser selection and layout changes do not reopen the surface. [Message quotes](../../session/message-quotes/README.md) owns quotation eligibility and insertion. +Markdown files, links, images, reference chips, and tool-file controls forward explicit right-click targets to [ui-context-menu](../ui-context-menu/README.md). File targets retain their working directory and line; ordinary link clicks retain the configured opening preference. + ## Model Experience diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 096bdf6563..35231f8e5e 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -37,13 +37,15 @@ kind: "package-reference" 已发送的普通文件附件保留草稿中的[文件卡片圆角](../../../docs/ui-radius.zh.md#radius-scale)。 -`ChatView` 通过节点列表外围既有的 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航,直接传入 `requestOpenFile`、`openExternalLink` 和 `fileImages`。文件打开动作保留 Chat 的失败反馈与重试处理。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 +`ChatView` 通过节点列表外围既有的 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航,直接传入 `requestOpenFile`、`openExternalLink` 和 `fileImages`。文件打开动作保留 Chat 的失败反馈与重试处理。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。[支持的 Markdown 行号引用](../ui-primitives/README.zh.md#rendering-agent-output)定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 原生消息图片使用独立的用户与输出位置。`conversation.chat.user.content` Factory 声明 `conversation.chat.user.images`,由 `UserContent` 渲染;Assistant 节点注册声明 `conversation.chat.message.images`,由 `AssistantMarkdown` 渲染。两者通过显式 props 接收图片来源、授权 `loadImage` 和布局参数。官方 `MessageImages` 条目调用共享的 root 作用域 `conversation.images.content` Factory。每个位置均可独立替换,无需复制画廊实现。 Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件及引用摘要。[`UserContentInput`](src/client/contract/slots.ts) 携带预处理的 `content`、变换 `facts`、显示 props 和显式 `loadImage`;引用动作由节点自己的 Injection 提供。正文调用共享 `conversation.user.text` Factory,图片调用自身拥有的图片 Slot。直接调用需要 Session 绑定,行包装层由调用方提供。 -`UserStyleBubble` 和[各来源包装层]() 保留用户行、key、pending/echo 标记、时间、复制控件及其他消息操作。内容 Factory 不读取来源专属模型或私有 Context,也不是全局用户内容替换入口。 +`UserStyleBubble` 和[各来源包装层]() 保留用户行、key、pending/echo 标记、时间、复制控件及其他消息操作。内容 Factory 不读取来源专属模型或私有 Context,也不是全局用户内容替换入口。 + +官方 user 与 steering 共用的渲染器在 `user` 注册项上声明 session-scoped `conversation.chat.user.before` chain slot,并在气泡上方直接通过 `renderSlotChain` 渲染;`steering` 注册项复用该渲染器但不拥有该槽位。owner props 携带 Session 记录的持久化消息 `source` 与行 `kind`;各条目的 `select` 匹配自己负责的来源,首个非 null 结果作为 `matched` 交给获选组件。无匹配时不显示内容。条目自行拥有标记和文案;Chat 不解析消息来源。 独立的 Markdown 图片以内嵌预览显示,点击打开共享图片浮层;本地路径在消息落定后基于当前查看的工作区解析。图片文件链接保持点击打开侧栏,鼠标停留或键盘聚焦时显示缩略图,Esc 关闭缩略图。图片加载失败时保留本地化状态与图片说明;不执行重复图片过滤。 @@ -61,6 +63,8 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要表达。每个终止轮次都渲染自己的红点行;欠费失败的行使用中立的 `message.failure.quota` 文案,而不是提供方消息。新追加的 `QUOTA` 或 `ACCOUNT_QUOTA` 产生的瞬时提示来自本包注册在 `shell.overlay` 的全局条目,它不随 Chat 面板消失:该条目把唯一一条实时提示交给 `shell.quota-notice` 链,并在无人接管时回退到自己的警告 Toast,接管该错误码的条目会替换该回退。只有本 Client 已绑定并已物化的 Session 会发布提示;它从未打开的 Session 中的欠费不会发布。更新的提示会替换当前提示,除非认领条目用 `keepOpen()` 保持住当前提示:该调用返回一个由调用方持有、必须在卸载时调用的释放函数,只要存在保持,认领条目就继续挂载,后续提示被丢弃,释放后恢复后续提示但不重放已丢弃的提示。回退 Toast 自身没有延后策略:当 Desktop 账号的不透明原生 Platform 页盖住本页时,它仍会在下方运行,其显示计时器可能在不可见时走完并自行撤下提示,因此只留下持久化失败行。释放只移除自己的保持,因此它在关闭或更新的保持之后执行时不会影响更新的保持。关闭与退出登录会清空所有保持;被丢弃的提示不排队,其对应的持久化失败行仍会渲染。历史替换和分页不会发布提示。中间重试不会创建终止行;输出 token 上限使用琥珀色警告点。重启恢复以 `interrupted` 结束的轮次会渲染本地化的停止行,说明 DSH 进程在轮次中途退出,不显示代码标签,其过程摘要显示“已停止”。 +Chat 在 `MarkdownDelegateProvider.getTriggerProps` 接入处适配 Markdown 目标。助手节点 Slot 直接获取 `useContextMenu`,向可选的文件引用服务传入路径适配函数;消息流和节点中间层不再携带菜单回调。手势发生时按原始 Session 的当前工作目录解析路径,菜单保留捕获的路径和行号,不受焦点变化影响。未声明的插件链接保留原生行为。 + ----- @@ -182,6 +186,8 @@ Chat view 在标记了 `data-text-interaction-source` 的业务节点上检测 `ctx.uiConversation.interactions` 将有效手势分发给插件注册项,并把对应组件交给 Chat view。操作展示位置在已有 Chat 列内绝对定位,限制在 composer seat 及其 dock 上方。外层容器保留组件的外描边与阴影,内部滚动由组件负责。选区滚出该可见范围时暂时隐藏操作,滚回后恢复。Chat 负责视口可见性、定位、外部指针按下、Escape、新选区、来源失效与卸载清理;主动关闭保留浏览器选区,布局变化不会重新打开操作。[Message quotes](../../session/message-quotes/README.zh.md) 负责引用的接管条件和插入。 +Markdown 文件、链接、图片、引用 chip 和工具文件控件将明确的右键目标转发给 [ui-context-menu](../ui-context-menu/README.zh.md)。文件目标保留工作目录和行号;普通链接点击仍遵循配置的打开偏好。 + ## 模型体验 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 223a8dc369..c8a468b48c 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, @@ -42,7 +42,8 @@ "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-workspace", - "@deepseek-ai/dsh-client-ui-file-drag" + "@deepseek-ai/dsh-client-ui-file-drag", + "@deepseek-ai/dsh-client-ui-context-menu" ], "platform": "web" } @@ -97,7 +98,8 @@ "react-dom": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", - "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*" + "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index fbabf95903..d3a6df8b96 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -1,3 +1,4 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Register the Chat Conversation target, renderers, stats, and details surface. */ import type {} from '@deepseek-ai/dsh-client-ui-file-drag/client' import type {} from '@deepseek-ai/dsh-client-product-analytics/client' @@ -25,11 +26,12 @@ import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ChatFlowDataInjected, ChatFlowInjected, ChatNodeInjected, ChatScrollPosition, ChatViewInjected, - QuotaNoticeInjected, QuotaNoticeState, TurnTailOwnerProps, + QuotaNoticeInjected, QuotaNoticeState, } from './contract/slots.ts' import type { ChatSnapshot } from './contract/snapshot.ts' import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts' import { ApprovalCommand } from './chat/ApprovalCommand.tsx' +import { sessionFilePath } from './file-path.ts' import { ChatView } from './chat/ChatView.tsx' import { ChatFlow } from './chat/ChatFlow.tsx' import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' @@ -253,7 +255,6 @@ export function apply(ctx: Context): void { fileDrag: ctx.fileDrag.actions, hooks: { presentation, interactionIds: ctx.uiConversation.interactions }, dispatchInteraction: gesture => ctx.uiConversation.interactions.dispatch(sessionId, gesture), - fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId), // Files open in the right Sidebar, not in a desktop application: the // content stays in the product, beside the conversation that produced // it. A relative path resolves from the Session's working directory @@ -276,6 +277,7 @@ export function apply(ctx: Context): void { else ctx.sidebarRight.openResource(url, { params: { line: options.line } }) await Promise.resolve() }, + resolveFilePath: sessionFilePath(ctx, sessionId), openExternalLink: (url) => { if (linkOpening.getSnapshot() === 'sidebar' && ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { ctx.sidebarRight.openTab('browser', { params: { url } }) diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 455f90f9a2..ad5963c093 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -1,16 +1,28 @@ import { memo, useCallback, useMemo } from 'react' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import type { ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from '../contract/slots.ts' +import type { ChatFileMentions, ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from '../contract/slots.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' -type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace +/** Business readers owned by the assistant renderer's existing Slot registration. */ +export interface AssistantNodeInjected extends PresentationInjected { + /** Resolve against the live Session directory. @param path - Recorded path. @returns Host path. */ + resolveFilePath: (path: string) => string + /** Resolve closing-Turn mentions with the supplied ordinary file gesture callback. */ + fileMentions: (owner: TurnTailOwnerProps, getFileTriggerProps: NonNullable[2]>) => ReturnType +} + +type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace & PropsRenderSlots<'conversation.chat.reasoning.body' | 'conversation.chat.message.images'> /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ node, groupPart, useDisclosure, useGroupAction, useTurnData, turnProcess, openFile, loadImage, fileMentions, - usePresentation, renderSlot, t, + usePresentation, renderSlot, t, sessionId, useContextMenu, resolveFilePath, }: AssistantNodeViewProps) { + const { getTriggerProps } = useContextMenu() + const getFileTriggerProps = useCallback((path: string) => getTriggerProps(() => ({ + kind: 'file', value: { sessionId, path: resolveFilePath(path) }, + })), [getTriggerProps, sessionId, resolveFilePath]) const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn @@ -22,8 +34,8 @@ export const AssistantNodeView = memo(function AssistantNodeView({ return { turn, seq: data.finalNode.seq, openFile } }, [data.finalNode, openFile, tail, turn]) const mentions = useMemo( - () => owner === undefined ? undefined : fileMentions(owner), - [fileMentions, owner], + () => owner === undefined ? undefined : fileMentions(owner, getFileTriggerProps), + [fileMentions, owner, getFileTriggerProps], ) const keepReplies = usePresentation(policy => policy.foldedReplies === 'all') // A kept reply row stays visible while the folded Turn hides its inline reasoning. diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx index 86bcdaa542..d83a3043cc 100644 --- a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx @@ -27,7 +27,7 @@ export const ChatFlow = memo(function ChatFlow({ entries, pendingInputs, lastInputTurn, deferCollapse, useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, useConnectionState, useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction, - cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, + cwd, openFile, inspectCall, forkAt, loadImage, }: ChatFlowSlotProps) { const nodeStore = useChat(snapshot => snapshot.nodes) const running = useSession(snapshot => snapshot.running) @@ -47,7 +47,7 @@ export const ChatFlow = memo(function ChatFlow({ const seatProps = { nodeStore, useChatNode, useChatNodeBottom, useChatNodeProcess, usePresentation, useStore, actions, renderSlot, t, useGroupAction, deferCollapse, - cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, + cwd, openFile, inspectCall, forkAt, loadImage, } const rows = entries.map((entry) => { switch (entry.kind) { diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 6db2f75821..4efdb94142 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -50,7 +50,7 @@ function turnOf(node: ChatNode | undefined): number | undefined { export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation, cwd, openFile, inspectCall, forkAt, - loadImage, fileMentions, useStore, actions, renderSlot, t, useChatNodeBottom, deferCollapse, useGroupAction, + loadImage, useStore, actions, renderSlot, t, useChatNodeBottom, deferCollapse, useGroupAction, }: ChatNodeSeatProps) { const bottom = useChatNodeBottom(nodeKey) const node = useChatNode(nodeKey) @@ -138,11 +138,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ inspectCall, forkAt, loadImage, - fileMentions, turnProcess, }, [ node, groupPart, cwd, openFile, inspectCall, forkAt, - loadImage, fileMentions, turnProcess, + loadImage, turnProcess, ]) if (routedNode === undefined || owner === null) return null // Runtime dispatch owns the correlation: every Node's discriminant is the diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 703fe65449..e265ab635f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -8,7 +8,7 @@ import type { import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import { - Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, + Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, type MarkdownMenuTarget, } from '@deepseek-ai/dsh-client-ui-primitives' import { isAtOrOutside } from '../contract/group-tree.ts' import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' @@ -60,9 +60,22 @@ const MOTION_TAIL_MS = 800 */ export function ChatView({ useSession, useChat, useConversation, useSessions, renderSlot, - sessionId, openFile, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, + sessionId, openFile, openExternalLink, resolveFilePath, useContextMenu, + loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, usePresentation, useProjection, useInteractionIds, dispatchInteraction, t, fileDrag, }: ChatViewSlotProps) { + const { getTriggerProps } = useContextMenu() + const getMarkdownTriggerProps = useCallback((target: MarkdownMenuTarget) => getTriggerProps(() => { + switch (target.kind) { + case 'file': return { kind: 'file', value: { sessionId, path: resolveFilePath(target.path), + ...(target.line === undefined ? {} : { line: target.line }) } } + case 'url': return { kind: 'url', value: { sessionId, url: target.url } } + case 'image': return { kind: 'image', value: { sessionId, url: target.url, + ...(target.name === undefined ? {} : { name: target.name }), + ...(target.mediaType === undefined ? {} : { mediaType: target.mediaType }), + ...(target.path === undefined ? {} : { path: resolveFilePath(target.path) }) } } + } + }), [getTriggerProps, resolveFilePath, sessionId]) const order = useChat(s => s.order) const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries) const entries = useMemo(() => groupedEntries @@ -230,10 +243,15 @@ export function ChatView({ )} - + {renderSlot('conversation.chat.flow', { entries, pendingInputs, lastInputTurn, deferCollapse, - cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, fileMentions, + cwd, openFile: requestOpenFile, inspectCall, forkAt, loadImage, }, { hookContext: flowContext })}
['renderSlotChain'] = () => null + /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, loadImage, renderFactorySlot, actions, pending = false, echo = false, batchStart = false, + node, renderSlotChain, content, loadImage, renderFactorySlot, actions, + pending = false, echo = false, batchStart = false, referenceLabels, skillNames, previewAttachments, facts: inputFacts = EMPTY_TEXT_FACTS, interactive = false, }: { content: readonly unknown[] renderFactorySlot: PropsRenderFactories['renderFactorySlot'] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode + /** Row whose durable source the message-source seat elects above this bubble. */ + node: DecoratedRow + /** Seat renderer; rows that do not own the seat pass {@link NO_MESSAGE_SOURCE}. */ + renderSlotChain: PropsRenderSlots<'conversation.chat.user.before'>['renderSlotChain'] /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean /** Whether this is a local submission echo (invisible marker; the echo renders exactly like its durable replacement). */ @@ -203,6 +223,7 @@ function UserStyleBubble({ data-submission-echo={echo || undefined} data-chat-steering-batch={batchStart ? 'start' : undefined} > + {renderSlotChain('conversation.chat.user.before', { source: node.data.source, kind: node.kind }, { fallback: null })} {renderFactorySlot('conversation.chat.user.content', { content: prepared, loadImage, @@ -227,6 +248,8 @@ export function PendingSteeringBubble({ content, loadImage, renderFactorySlot, t } & Pick): ReactNode { return ( ) { + node, renderFactorySlot, loadImage, renderSlotChain, t, +}: ChatNodeViewProps<'user' | 'steering'> & { + /** Bound by the `user` keyed registration, the only entry that declares the seat. */ + renderSlotChain: PropsRenderSlots<'conversation.chat.user.before'>['renderSlotChain'] +}) { const data = node.data return ( ctx.slots.register( - { name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView)) - ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( - { name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', key: 'user', locale: NS, + children: { 'conversation.chat.user.before': { kind: 'chain', scope: 'session' } }, + }, UserMessageNodeView)) + // Steering rows share the renderer without owning the message-source seat. + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', key: 'steering', locale: NS, + inject: () => ({ renderSlotChain: () => null }), + }, UserMessageNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( @@ -55,7 +61,11 @@ export function registerChatNodeRenderers( 'conversation.chat.reasoning.body': { kind: 'single', scope: 'session' }, 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, }, - inject: () => ({ hooks: { presentation } }), + inject: (sessionId): AssistantNodeInjected => ({ + hooks: { presentation }, + resolveFilePath: sessionFilePath(ctx, sessionId), + fileMentions: (owner, getFileTriggerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId, getFileTriggerProps), + }), }, AssistantNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 948a546689..9479bde696 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -17,7 +17,9 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { ConnectionStateSource } from '@deepseek-ai/dsh-client-connection/client' -import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { FileDragActions, MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client' +import type { ChatKey } from '../locale.ts' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read. import type {} from '@deepseek-ai/dsh-client-ui-session/client' @@ -136,9 +138,12 @@ export interface ChatFileMentions { * Resolve prose links for one closing Turn. * @param owner - closing-Turn identity and file opener. * @param sessionId - viewed Session, including when history is inherited from a fork. + * @param getFileTriggerProps - ordinary gesture binding for a resolved file path. * @returns link resolver when available. */ - forClosing(owner: TurnTailOwnerProps, sessionId: SessionId): MarkdownFileMentions | undefined + forClosing( + owner: TurnTailOwnerProps, sessionId: SessionId, getFileTriggerProps?: (path: string) => ContextMenuTriggerProps, + ): MarkdownFileMentions | undefined } declare module '@deepseek-ai/cordis' { @@ -209,6 +214,20 @@ export interface ChatNodeInjected { } } +/** + * Owner currency of the message-source decoration above one user message bubble. + * + * Entry selectors receive the durable message `source` and row kind. A + * non-null selection elects the component and reaches it as `matched`. + * Entries own their markup and copy; Chat does not interpret the source. + */ +export interface UserBeforeOwnerProps { + /** Durable message source; Chat does not interpret it. */ + readonly source: unknown + /** Chat row kind that owns the decoration site. */ + readonly kind: 'user' | 'steering' +} + /** Stable owner currency delivered to a keyed Chat renderer. */ export interface ChatNodeOwnerProps { /** Renderer-owned Node portion selected by the grouping Definition. */ @@ -219,7 +238,6 @@ export interface ChatNodeOwnerProps { forkAt: (seq: number) => void /** Session-authorized image loader supplied by the Chat view. */ loadImage: MessageImageLoader - fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined /** Turn-process state when this Node belongs to a projected Turn. */ turnProcess?: TurnProcessOwnerProps | undefined } @@ -299,7 +317,9 @@ 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 + readonly fileDrag: FileDragActions + /** Resolve the reference against this Session and its current path base when the gesture begins. */ + resolveFilePath: (path: string) => string /** Dispatch one gesture to the Conversation plugin registry. */ dispatchInteraction: (gesture: TextInteractionGesture) => string | null hooks: { @@ -330,7 +350,6 @@ export interface ChatViewInjected { read: () => ChatScrollPosition | null } forkAt: (seq: number) => void - fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } /** Session-owned node sources shared with the view, plus the flow's connection state. */ @@ -343,7 +362,7 @@ export type ChatFlowDataInjected = Pick & { /** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */ export interface ChatFlowOwnerProps extends Pick { + 'cwd' | 'openFile' | 'inspectCall' | 'forkAt' | 'loadImage'> { readonly entries: readonly RenderEntry[] readonly pendingInputs: readonly (PendingSubmission | InboxState['next-step'][number])[] readonly lastInputTurn: number | undefined @@ -379,7 +398,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { /** Chat target, transcript node, statistics, and details copy. */ - chat: import('../locale.ts').ChatKey + chat: ChatKey } interface SlotFactoryMap { @@ -423,6 +442,17 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { hookContext: ChatNodeHookContext inject: ChatNodeInjected } + /** + * Message-source decoration owned and rendered by the user Node entry. + * Selectors elect one component from the durable source and row kind. + * No match renders no visible content; steering and pending rows do not + * render this slot. + */ + 'conversation.chat.user.before': { + kind: 'chain' + scope: 'session' + owner: UserBeforeOwnerProps + } /** * Renderer for one consecutive group of durable message images. The owner * supplies image references, an authorized loader, and alignment. A diff --git a/packages/client/ui-chat/src/client/file-path.ts b/packages/client/ui-chat/src/client/file-path.ts new file mode 100644 index 0000000000..9c23fdc21d --- /dev/null +++ b/packages/client/ui-chat/src/client/file-path.ts @@ -0,0 +1,19 @@ +/** Gesture-time path resolution for Chat-owned file references. */ +import type { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' + +/** + * Bind path resolution to the originating Session without capturing its current directory. + * @param ctx - Chat plugin context. + * @param sessionId - Original Session identity. + * @returns Resolver using the live working directory, falling back to the workspace root. + */ +export function sessionFilePath(ctx: Context, sessionId: SessionId): (path: string) => string { + return (path) => { + const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd + const current = ctx.sessions.binding(sessionId)?.session.projections.faceOf('workingDirectory').getSnapshot() as string | null | undefined + return resolveWorkspacePath(current ?? cwd, path) + } +} diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 275d76b96b..c785329f0a 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -1,3 +1,4 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Browser Chat target plugin. */ export { apply, inject } from './apply.ts' export type {} from './conversation-nodes/assistant.ts' @@ -39,6 +40,7 @@ export type { CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, PresentationInjected, QuotaNoticeCode, QuotaNoticeHostProps, QuotaNoticeInjected, QuotaNoticeOwnerProps, QuotaNoticeState, ReasoningBodyOwnerProps, ReasoningContentInput, UserContentAttachment, UserContentData, UserContentInput, + UserBeforeOwnerProps, TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, UseDisclosure, UsePresentation, UseGroupAction, } from './contract/slots.ts' export type { 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 9687743249..8ee48b8f65 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -1,4 +1,6 @@ +import type { AssistantNodeInjected } from '../src/client/chat/AssistantNodeView.tsx' import { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client' // @vitest-environment jsdom /** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -100,6 +102,8 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' }, @@ -314,9 +318,15 @@ describe('Chat inject API', () => { it('resolves relative file paths against the Session\'s current working directory', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) + vi.spyOn(b.runtime.ctx.contextMenus, 'open') await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w') await injected.openFile('src/a.ts') expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts') + expect(injected.resolveFilePath('src/a.ts')).toBe('/proj/.agents/worktrees/w/src/a.ts') + await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', null) + expect(injected.resolveFilePath('file.ts')).toBe('/proj/file.ts') + await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w') + // A leading `./` names the same file, so the Sidebar reuses its tab. await injected.openFile('./src/a.ts') expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts') @@ -515,12 +525,16 @@ describe('Chat inject API', () => { expect(flow.injected.hooks.connectionState).toBe(b.runtime.ctx.get('connection')!.state) expect(flow.injected.hooks.presentation).toBe(injected.hooks.presentation) - expect(injected.fileMentions(owner)).toBeUndefined() + const entry = b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'assistant-step')! + const assistant = entry.inject!(ROOT as never) + const fileMentions = assistant.fileMentions as AssistantNodeInjected['fileMentions'] + const getFileTriggerProps = vi.fn(() => ({})) + expect(fileMentions(owner, getFileTriggerProps)).toBeUndefined() const mentions = { resolve: vi.fn() } as never const forClosing = vi.fn(() => mentions) b.runtime.ctx.provide('chatFileMentions', { forClosing } as never) - expect(injected.fileMentions(owner)).toBe(mentions) - expect(forClosing).toHaveBeenCalledWith(owner, ROOT) + expect(fileMentions(owner, getFileTriggerProps)).toBe(mentions) + expect(forClosing).toHaveBeenCalledWith(owner, ROOT, getFileTriggerProps) expect(injected.chatScroll.read()).toBeNull() const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 } 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 ab256ad15b..3e61dce8bf 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { act, fireEvent, render } from '@testing-library/react' @@ -75,6 +76,8 @@ async function bench() { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' }, @@ -152,6 +155,8 @@ describe('Chat apply wiring', () => { const view = b.runtime.slots.entries('conversation.view')[0] const flow = b.runtime.slots.entries('conversation.chat.flow')[0] const assistant = b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'assistant-step') + const user = b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'user') + const steering = b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'steering') const userContent = b.runtime.factoryOf('conversation.chat.user.content') expect(userContent).toMatchObject({ scope: 'session', locale: 'chat', component: UserContent }) expect(userContent.children?.['conversation.chat.user.images']).toMatchObject({ kind: 'single', scope: 'session' }) @@ -163,6 +168,11 @@ describe('Chat apply wiring', () => { expect(view?.children?.['conversation.chat.message.images']).toBeUndefined() expect(view?.children?.['conversation.chat.user.images']).toBeUndefined() expect(flow?.children?.['conversation.chat.node']).toBeDefined() + expect(flow?.children?.['conversation.chat.user.before']).toBeUndefined() + expect(user?.children?.['conversation.chat.user.before']).toMatchObject({ kind: 'chain', scope: 'session' }) + expect(steering).toBeDefined() + expect(steering?.children?.['conversation.chat.user.before']).toBeUndefined() + expect(b.runtime.slots.spec('conversation.chat.user.before')).toMatchObject({ kind: 'chain', scope: 'session' }) expect(flow?.children?.['conversation.chat.message.images']).toBeUndefined() expect(flow?.children?.['conversation.chat.user.images']).toBeUndefined() expect(userContent.children?.['conversation.chat.message.images']).toBeUndefined() @@ -172,6 +182,7 @@ describe('Chat apply wiring', () => { await b.chat.dispose() expect(b.runtime.slots.spec('conversation.chat.flow')).toBeUndefined() expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined() + expect(b.runtime.slots.spec('conversation.chat.user.before')).toBeUndefined() expect(b.runtime.slots.spec('conversation.chat.message.images')).toBeUndefined() expect(b.runtime.slots.spec('conversation.chat.user.images')).toBeUndefined() expect(() => b.runtime.factoryOf('conversation.chat.user.content')).toThrow('no definition') diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index fa922ee1e1..574fe82df9 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ChatConversationViewNode, ConversationNode, @@ -33,6 +34,11 @@ afterEach(() => { const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) const renderUserImages: Parameters>[1] = () => null +const userSlots: PropsRenderSlots<'conversation.chat.user.before'> = { + renderSlot: () => null, + renderSlotChain: (_key, _owner, opts) => opts?.fallback ?? null, + SessionProvider: ({ children }) => <>{children}, +} let renderUserContent: ReturnType beforeEach(() => { renderUserContent = createUserContentFixture() }) const RETRY_ID = 'retry-fixture' as Extract['retryId'] @@ -81,8 +87,9 @@ function MessageItem({ node, t: translate, referenceLabels, skillNames }: Messag } switch (node.kind) { case 'user': + return } {...userSlots} /> case 'steering': - return } /> + return } renderSlotChain={() => null} /> case 'context': return } /> case 'compaction': diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 412e0124e2..2bc8286e93 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import type { ProcessGroupData } from '../src/client/contract/process-groups.ts' import type { InboxState } from '@deepseek-ai/dsh-agent/types' @@ -7,9 +8,10 @@ import type { GlobalStandardProps, PropsRenderFactories, PropsRenderSlots } from import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useEffect, useMemo } from 'react' +import { useMarkdownDelegate, type MarkdownMenuTarget } from '@deepseek-ai/dsh-client-ui-primitives' import type { AssistantMessageNode, ChatFlowHookContext, ChatFlowOwnerProps, ChatFlowSlotProps, - ChatNode, ChatNodeHookContext, ChatNodeOwnerProps, ChatSnapshot, + ChatNode, ChatNodeHookContext, ChatNodeOwnerProps, ChatSnapshot, UserBeforeOwnerProps, ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, LegacyConversationSlice, ModelRetryNode, StartedToolCall, SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, @@ -264,7 +266,10 @@ declare const flowDispatch: PropsRenderSlots<'conversation.chat.flow'>['renderSl type NodeRenderer = typeof nodeDispatch<'conversation.chat.node'> type FlowRenderer = typeof flowDispatch<'conversation.chat.flow'> type ImageRenderer = (owner: MessageImagesOwnerProps) => React.ReactNode -type HarnessStandardProps = ChatViewSlotProps & PropsRenderFactories +declare const userChainDispatch: PropsRenderSlots<'conversation.chat.user.before'>['renderSlotChain'] +type UserChainRenderer = typeof userChainDispatch<'conversation.chat.user.before'> +type UserChainState = { readonly renderer: UserChainRenderer | undefined } +type HarnessStandardProps = ChatViewSlotProps & PropsRenderFactories & Pick, 'fileMentions'> function isImageOwner(owner: object): owner is MessageImagesOwnerProps { return 'images' in owner && Array.isArray(owner.images) @@ -293,10 +298,22 @@ function HarnessChatFlow({ standard, owner, hookContext, renderSlot, useConnecti useGroupHeaderAction: CHAT_FLOW_INJECT.hooks.groupHeaderAction(standard, hookContext), }), [standard, hookContext]) const { renderSlot: _rootRenderSlot, __renders: _rootRenders, ...shared } = standard - return + return } -function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, toolOwners, fallback, renderAssistantSlot }: { +function HarnessUserOrigin({ useRenderer, owner, options }: { + readonly useRenderer: SnapshotSelectorHook + readonly owner: UserBeforeOwnerProps + readonly options: Parameters[2] +}) { + const renderer = useRenderer(value => value.renderer) + return renderer?.('conversation.chat.user.before', owner, options) ?? options?.fallback ?? null +} + +function HarnessChatNode({ + standard, owner, hookContext, usePerformanceUsage, toolOwners, fallback, renderAssistantSlot, renderUserChain, +}: { readonly standard: HarnessStandardProps readonly owner: RoutedChatNodeOwner readonly hookContext: ChatNodeHookContext @@ -304,6 +321,7 @@ function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, to readonly toolOwners: ToolOwner[] readonly fallback: React.ReactNode readonly renderAssistantSlot: React.ComponentProps['renderSlot'] + readonly renderUserChain: PropsRenderSlots<'conversation.chat.user.before'>['renderSlotChain'] }) { const hooks = useMemo(() => ({ useTurnData: CHAT_NODE_INJECT.hooks.turnData(standard, hookContext), @@ -314,9 +332,9 @@ function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, to const nodeProps = { ...shared, ...owner, ...hooks } switch (owner.node.kind) { case 'user': - return + return case 'steering': - return + return null} /> case 'context': return case 'assistant-step': @@ -421,9 +439,11 @@ function makeHarness( const collapseTiming = createSnapshotStore('completion') const foldedReplies = createSnapshotStore('all') const performanceUsage = createSnapshotStore<'compact' | 'detailed'>('detailed') + const originRenderer = createSnapshotStore({ renderer: undefined }) const t = makeTranslate(zh, commonZh) const toolOwners: ToolOwner[] = [] const usePerformanceUsage = bindSnapshotSelector(performanceUsage) + const useOriginRenderer = bindSnapshotSelector(originRenderer) let nodeSlotOverride: NodeRenderer | undefined let imageSlotOverride: ImageRenderer | undefined const renderImages = (name: string, owner: object, options?: { fallback?: React.ReactNode }) => { @@ -438,7 +458,8 @@ function makeHarness( if (nodeSlotOverride !== undefined) return nodeSlotOverride(key, owner, opts) return + toolOwners={toolOwners} fallback={opts.fallback} renderAssistantSlot={renderAssistantSlot} + renderUserChain={renderUserChain} /> } const renderFlowSlot: ChatFlowSlotProps['renderSlot'] = (key: string, owner: object, opts?: { fallback?: React.ReactNode @@ -449,6 +470,10 @@ function makeHarness( } throw new Error(`Undeclared flow child: ${key}`) } + const renderUserChain: PropsRenderSlots<'conversation.chat.user.before'>['renderSlotChain'] = (key, owner, opts) => { + if (key !== 'conversation.chat.user.before') throw new Error(`Undeclared user chain: ${key}`) + return + } const renderFactorySlot: PropsRenderFactories['renderFactorySlot'] = (name, owner) => { if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected Chat Factory: ${name}`) return renderUserContent(owner as UserContentInput, renderImages, props.t) @@ -469,6 +494,7 @@ function makeHarness( const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: HarnessStandardProps = { fileDrag, + resolveFilePath: vi.fn((path: string) => path), renderFactorySlot, usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, @@ -482,6 +508,7 @@ function makeHarness( useTrajectory: (() => { throw new Error('unused') }), useSessions: emptySessions(), useSessionRetainInfo: () => undefined, + useContextMenu: contextMenuHook(), useResource, useSessionStatus: bindSnapshotSelector( createSnapshotStore(new Map()), @@ -561,6 +588,7 @@ function makeHarness( setFoldedReplies: (replies: FoldedReplies) => { foldedReplies.set(replies) }, setNodeRenderer: (renderer: NodeRenderer) => { nodeSlotOverride = renderer }, setImageRenderer: (renderer: ImageRenderer) => { imageSlotOverride = renderer }, + setOriginRenderer: (renderer: UserChainRenderer | undefined) => { originRenderer.set({ renderer }) }, } } @@ -682,6 +710,41 @@ function formatRunDuration(ms: number, t: Parameters[1]): describe('Chat node rendering', () => { + it('adapts Markdown targets with the original Session and resolves file paths when each gesture begins', () => { + const h = makeHarness() + const opened = vi.fn() + let directory = '/workspace' + h.props.useContextMenu = contextMenuHook(opened) + h.props.resolveFilePath = path => `${directory}/${path}` + const targets: MarkdownMenuTarget[] = [ + { kind: 'file', path: 'src/a.ts', line: 24 }, + { kind: 'file', path: 'src/b.ts' }, + { kind: 'url', url: 'https://example.com/page' }, + { kind: 'image', url: 'blob:remote' }, + { kind: 'image', url: 'blob:local', path: 'image.svg', name: 'Image', mediaType: 'image/svg+xml' }, + ] + function References() { + const { getTriggerProps } = useMarkdownDelegate() + return <>{targets.map((target, index) => )} + } + const view = render( } />) + directory = '/workspace/moved' + for (const [index, target] of targets.entries()) { + const button = view.getByRole('button', { name: String(index) }) + button.getBoundingClientRect = () => new DOMRect(10, 20, 30, 40) + fireEvent.contextMenu(button, { clientX: 50, clientY: 60 }) + const { kind, ...value } = target + const expected = { kind, value: { + sessionId: SID, ...value, ...'path' in target ? { path: `${directory}/${target.path}` } : {}, + } } + expect(opened).toHaveBeenLastCalledWith(expected, { x: 50, y: 60 }) + fireEvent.keyDown(button, { key: 'F10', shiftKey: true }) + expect(opened).toHaveBeenLastCalledWith(expected, { x: 10, y: 60 }) + } + expect(opened).toHaveBeenCalledTimes(10) + expect(h.openFile).not.toHaveBeenCalled() + }) + it('opens Markdown references to unmodified files with line navigation', () => { const h = makeHarness({ nodes: [user(1, 'explain'), assistant(2, '[source](src/index.ts#L24-L30)', 1)], @@ -705,13 +768,18 @@ describe('Chat node rendering', () => { ], turnEnds: new Map([[1, 4]]), }) - h.props.fileMentions = owner => ({ + const opened = vi.fn() + let directory = '/workspace' + h.props.useContextMenu = contextMenuHook(opened) + h.props.resolveFilePath = path => `${directory}/${path}` + h.props.fileMentions = (owner, getFileTriggerProps) => ({ resolve: (value) => { if (value !== 'report.html') return undefined return { open: () => { void h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, label: '打开 site/report.html', title: 'site/report.html', + contextMenuProps: getFileTriggerProps('site/report.html'), } }, }) @@ -722,6 +790,17 @@ describe('Chat node rendering', () => { expect(mentions).toHaveLength(1) const mention = view.getByRole('button', { name: '打开 site/report.html' }) expect(mention.getAttribute('title')).toBe('site/report.html') + fireEvent.contextMenu(mention, { clientX: 12, clientY: 34 }) + expect(opened).toHaveBeenLastCalledWith({ + kind: 'file', value: { sessionId: SID, path: '/workspace/site/report.html' }, + }, { x: 12, y: 34 }) + directory = '/workspace/moved' + fireEvent.keyDown(mention, { key: 'F10', shiftKey: true }) + expect(opened).toHaveBeenLastCalledWith({ + kind: 'file', value: { sessionId: SID, path: '/workspace/moved/site/report.html' }, + }, { x: 0, y: 0 }) + expect(opened).toHaveBeenCalledTimes(2) + expect(h.openFile).not.toHaveBeenCalled() fireEvent.click(mention) // The vocabulary was built from the closing message's own owner currency. expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html') @@ -1848,6 +1927,59 @@ describe('ChatView', () => { ]) }) + it('updates the user-before chain without remounting message content or decorating steering', () => { + const h = makeHarness({ nodes: [userInTurn(1, 'question', 1), steering(2, 'direction', 1)] }) + const origin = vi.fn((_key, owner) => {owner.kind}) + const empty = vi.fn(() => null) + const view = render() + const userRow = view.container.querySelector('[data-chat-flow-kind="user"]')! + const steeringRow = view.container.querySelector('[data-chat-flow-kind="steering"]')! + expect(userRow).not.toBeNull() + expect(steeringRow).not.toBeNull() + const bubble = userRow.querySelector('[data-message-bubble]') + const text = within(userRow).getByText('question').firstChild + const copy = within(userRow).getByRole('button', { name: '复制' }) + const actions = copy.closest('[data-clock]') + const steeringBubble = steeringRow.querySelector('[data-message-bubble]') + const steeringText = within(steeringRow).getByText('direction').firstChild + const steeringCopy = within(steeringRow).getByRole('button', { name: '复制' }) + expect(bubble).not.toBeNull() + expect(text?.nodeType).toBe(Node.TEXT_NODE) + expect(actions).not.toBeNull() + expect(steeringBubble).not.toBeNull() + expect(steeringText?.nodeType).toBe(Node.TEXT_NODE) + expect(origin).not.toHaveBeenCalled() + expect(view.container.querySelector('[data-test-origin]')).toBeNull() + + for (const renderer of [origin, empty, undefined]) { + act(() => { h.setOriginRenderer(renderer) }) + + expect(view.container.querySelector('[data-chat-flow-kind="user"]')).toBe(userRow) + expect(userRow.querySelector('[data-message-bubble]')).toBe(bubble) + expect(within(userRow).getByText('question').firstChild).toBe(text) + expect(within(userRow).getByRole('button', { name: '复制' })).toBe(copy) + expect(copy.closest('[data-clock]')).toBe(actions) + expect(view.container.querySelector('[data-chat-flow-kind="steering"]')).toBe(steeringRow) + expect(steeringRow.querySelector('[data-message-bubble]')).toBe(steeringBubble) + expect(within(steeringRow).getByText('direction').firstChild).toBe(steeringText) + expect(within(steeringRow).getByRole('button', { name: '复制' })).toBe(steeringCopy) + expect(steeringRow.querySelector('[data-test-origin]')).toBeNull() + const label = userRow.querySelector('[data-test-origin]') + if (renderer === origin) { + expect(label?.textContent).toBe('user') + expect(label!.compareDocumentPosition(bubble!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + } else { + expect(label).toBeNull() + } + } + expect(origin).toHaveBeenCalledExactlyOnceWith( + 'conversation.chat.user.before', { source: null, kind: 'user' }, { fallback: null }, + ) + expect(empty).toHaveBeenCalledExactlyOnceWith( + 'conversation.chat.user.before', { source: null, kind: 'user' }, { fallback: null }, + ) + }) + it('renders Host-pending steering at the flow tail and hands off to the durable node', withClock(2_000, () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { @@ -4622,3 +4754,21 @@ const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () it('formats Portuguese elapsed minutes with numeric units', () => { expect(formatRunDuration(90_000, key => pt[key])).toBe('1m 30s') }) + +it('does not infer menu targets from links or data attributes inside a plugin flow slot', () => { + const h = makeHarness() + const custom = vi.fn() + const view = render( <> + Plugin link + + + } />) + expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true) + expect(fireEvent.contextMenu(view.getByRole('button', { name: 'Undeclared file' }))).toBe(true) + fireEvent.contextMenu(view.getByRole('button', { name: 'Custom target' })) + expect(custom).toHaveBeenCalledOnce() + expect(h.props.resolveFilePath).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts index e3996ec43d..4cd8dd8651 100644 --- a/packages/client/ui-chat/tests/config.host.spec.ts +++ b/packages/client/ui-chat/tests/config.host.spec.ts @@ -57,15 +57,15 @@ describe('ui-chat Host settings', () => { it('validates folded replies and restores the saved choice', async () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ foldedReplies: 'all' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ foldedReplies: 'all' }) await configuration.update({ foldedReplies: 'final' }) - 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({ foldedReplies: 'final' }) await expect(configuration.update({ foldedReplies: 'none' })).rejects.toThrow() await configuration.fiber.dispose() const restored = await liveConfig(ctx, { Config, apply }, saved) - expect(plainConfig(restored.fiber.config)).toMatchObject({ foldedReplies: 'final' }) + expect(plainConfig(restored.fiber.effectiveConfig)).toMatchObject({ foldedReplies: 'final' }) await restored.fiber.dispose() }) diff --git a/packages/client/ui-chat/tests/context-menu.client.spec.tsx b/packages/client/ui-chat/tests/context-menu.client.spec.tsx new file mode 100644 index 0000000000..545538b18c --- /dev/null +++ b/packages/client/ui-chat/tests/context-menu.client.spec.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { MarkdownDelegateProvider, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +it('leaves undeclared plugin links alone and binds only explicitly rendered Markdown targets', () => { + const open = vi.fn() + const custom = vi.fn() + const view = render( {}}> + Plugin link + + + ) + expect(fireEvent.contextMenu(view.getByRole('link', { name: 'Plugin link' }))).toBe(true) + expect(open).not.toHaveBeenCalled() + const file = view.getByRole('button', { name: 'File' }) + fireEvent.contextMenu(file, { clientX: 40, clientY: 50 }) + expect(open).toHaveBeenLastCalledWith({ kind: 'file', path: 'docs/file.ts', line: 42 }, { x: 40, y: 50 }) + file.getBoundingClientRect = () => new DOMRect(30, 40, 100, 20) + fireEvent.keyDown(file, { key: 'F10', shiftKey: true }) + expect(open).toHaveBeenLastCalledWith({ kind: 'file', path: 'docs/file.ts', line: 42 }, { x: 30, y: 60 }) + expect(document.activeElement).toBe(file) + fireEvent.contextMenu(view.getByRole('button', { name: 'Plugin target' }), { clientX: 70, clientY: 80 }) + expect(custom).toHaveBeenCalledExactlyOnceWith({ kind: 'pluginDocument', id: 'one' }, { x: 70, y: 80 }) + expect(open).toHaveBeenCalledTimes(2) +}) 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 c7f67f3505..5e0f5cd8e3 100644 --- a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx +++ b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx @@ -3,6 +3,7 @@ * that outlives the Chat panel, and history scrollback never does. */ // @vitest-environment jsdom +import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, describe, expect, it } from 'vitest' import { act, screen } from '@testing-library/react' @@ -68,6 +69,8 @@ async function bench() { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.declare({ 'shell.overlay': { kind: 'list', scope: 'root' }, 'conversation.approval.detail': { kind: 'single', scope: 'session' }, diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index f67f18eb92..ca6d67f0f3 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' @@ -50,6 +51,7 @@ function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | ty useSessionRetainInfo: () => undefined, useResource, ...allowAllAccess, + useContextMenu: contextMenuHook(), useTranscriptView: bindSnapshotSelector(source), useCollapseTiming: bindSnapshotSelector(collapseTiming), useFoldedReplies: bindSnapshotSelector(foldedReplies), diff --git a/packages/client/ui-chat/tests/user-content.client.spec.tsx b/packages/client/ui-chat/tests/user-content.client.spec.tsx index 1a93864a41..38a786beee 100644 --- a/packages/client/ui-chat/tests/user-content.client.spec.tsx +++ b/packages/client/ui-chat/tests/user-content.client.spec.tsx @@ -1,3 +1,4 @@ +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' // @vitest-environment jsdom import { useState, type ReactNode } from 'react' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' @@ -54,6 +55,7 @@ async function bench() { const openFile = vi.fn<(path: string) => void>() const openSkill = vi.fn<(name: string) => void>() await runtime.mount({ inject: ['slots'], apply: (ctx) => { + ctx.slots.provideRoot({ props: { useContextMenu: contextMenuHook() } }) const userText = new UserTextRegistry(ctx) for (const rule of [ sessionReferenceRule, fileReferenceRule, folderReferenceRule, skillReferenceRule, @@ -75,7 +77,7 @@ async function bench() { ctx.slots.register({ name: 'conversation.user.text.node', key: 'text' }, PlainUserText) ctx.slots.register({ name: 'conversation.user.text.node', key: 'session' }, SessionReference) ctx.slots.register({ name: 'conversation.user.text.node', key: 'file', locale: 'reference', - inject: () => ({ openFile }) }, FileReference) + inject: () => ({ openFile, resolveFilePath: path => path }) }, FileReference) ctx.slots.register({ name: 'conversation.user.text.node', key: 'folder' }, FolderReference) ctx.slots.register({ name: 'conversation.user.text.node', key: 'skill', inject: () => ({ openSkill }) }, SkillReference) ctx.slots.inject('conversation.chat.user.images', () => ctx.slots.register({ name: 'conversation.chat.user.images' }, function ImageProbe(props) { diff --git a/packages/client/ui-chat/tests/user-origin-slot.client.spec.tsx b/packages/client/ui-chat/tests/user-origin-slot.client.spec.tsx new file mode 100644 index 0000000000..7a522be8ed --- /dev/null +++ b/packages/client/ui-chat/tests/user-origin-slot.client.spec.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +/** + * The official user renderer owns the source-decoration chain above its bubble. + * Steering rows do not render the chain; changing its occupant preserves the + * existing message content and actions. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { + ChatConversationViewNode, SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ChatNodeViewProps, UserContentInput } from '../src/client/contract/slots.ts' +import { UserMessageNodeView } from '../src/client/chat/MessageItem.tsx' +import { zh } from '../src/client/locale.ts' +import { createUserContentFixture } from './user-content-component-fixture.tsx' +import { unusedImageLoader } from './reasoning-component-fixture.tsx' + +afterEach(cleanup) + +const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) +type UserSlots = PropsRenderSlots<'conversation.chat.user.before'> +const userSlots: UserSlots = { + renderSlot: () => null, + renderSlotChain: (_key, _owner, opts) => opts?.fallback ?? null, + SessionProvider: ({ children }) => <>{children}, +} + +/** One durable source, unread by Chat itself. */ +const SHARED = { kind: 'user', principal: { kind: 'share-viewer', shareId: 'share-1', actor: { subjectId: 'subject-42' } } } + +let renderUserContent: ReturnType +beforeEach(() => { renderUserContent = createUserContentFixture() }) + +const renderUserFactory: ChatNodeViewProps['renderFactorySlot'] = (name, input) => { + if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected Chat Factory: ${name}`) + return renderUserContent(input as UserContentInput, () => null, t) +} + +function viewProps(data: UserMessageNode): ChatNodeViewProps<'user'> +function viewProps(data: SteeringMessageNode): ChatNodeViewProps<'steering'> +function viewProps(data: UserMessageNode | SteeringMessageNode): ChatNodeViewProps<'user' | 'steering'> { + const node = { + key: `fixture:${data.kind}:${String(data.seq)}`, + id: String(data.seq), + kind: data.kind, + target: 'chat', + anchorSeq: data.seq, + location: { kind: 'session' }, + visibility: 'visible', + data, + } as ChatConversationViewNode + const props: Partial> & { node: ChatConversationViewNode } = { + node, t, loadImage: unusedImageLoader, renderFactorySlot: renderUserFactory, openFile: vi.fn(), + } + return props as ChatNodeViewProps<'user' | 'steering'> +} + +const userData: UserMessageNode = { + kind: 'user', + seq: 7, + time: 7_000, + content: [{ type: 'text', text: 'hello' }] as never, + source: SHARED, +} + +const steeringData: SteeringMessageNode = { + kind: 'steering', + messageId: 'fixture-steering' as SteeringMessageNode['messageId'], + seq: 8, + time: 8_000, + content: [{ type: 'text', text: 'steer' }] as never, + source: SHARED, +} + +describe('official user renderer origin seat', () => { + it('renders the source chain above the bubble and passes the durable source', () => { + const renderSlotChain = vi.fn(() => 来自会话分享) + const view = render() + const row = view.container.firstElementChild as HTMLElement + const origin = row.querySelector('[data-test-origin]') + const bubble = row.querySelector('[data-message-bubble]') + + expect(renderSlotChain).toHaveBeenCalledExactlyOnceWith( + 'conversation.chat.user.before', { source: SHARED, kind: 'user' }, { fallback: null }, + ) + expect(origin).not.toBeNull() + expect(bubble).not.toBeNull() + expect(row.firstElementChild).toBe(origin) + expect(origin!.compareDocumentPosition(bubble!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + }) + + it('preserves the bubble, text and actions across empty and occupied chain output', () => { + const props = viewProps(userData) + const absent = vi.fn(() => null) + const occupied = vi.fn(() => 来自会话分享) + const empty = vi.fn(() => null) + const view = render() + const row = view.container.firstElementChild + const bubble = view.container.querySelector('[data-message-bubble]') + const text = view.getByText('hello').firstChild + const copy = view.getByRole('button', { name: '复制' }) + const actions = copy.closest('[data-clock]') + expect(bubble).not.toBeNull() + expect(text?.nodeType).toBe(Node.TEXT_NODE) + expect(actions).not.toBeNull() + expect(view.container.querySelector('[data-test-origin]')).toBeNull() + + for (const renderSlotChain of [occupied, empty, absent]) { + view.rerender() + expect(view.container.firstElementChild).toBe(row) + expect(view.container.querySelector('[data-message-bubble]')).toBe(bubble) + expect(view.getByText('hello').firstChild).toBe(text) + expect(view.getByRole('button', { name: '复制' })).toBe(copy) + expect(copy.closest('[data-clock]')).toBe(actions) + expect(view.container.querySelector('[data-test-origin]') !== null).toBe(renderSlotChain === occupied) + } + }) + + it('asks the chain only for the user row beside a steering row', () => { + const renderSlotChain = vi.fn(() => ) + const view = render(<> + + null} /> + ) + const steeringRow = view.container.lastElementChild! + expect(steeringRow.textContent).toContain('steer') + + expect(renderSlotChain).toHaveBeenCalledExactlyOnceWith( + 'conversation.chat.user.before', { source: SHARED, kind: 'user' }, { fallback: null }, + ) + expect(view.container.querySelectorAll('[data-test-origin]')).toHaveLength(1) + expect(steeringRow.querySelector('[data-test-origin]')).toBeNull() + expect(view.container.querySelector('[data-chat-steering-batch]')).toBeNull() + }) +}) diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json index 42e4b6003c..4040aacd08 100644 --- a/packages/client/ui-chat/tsconfig.client.json +++ b/packages/client/ui-chat/tsconfig.client.json @@ -122,6 +122,9 @@ }, { "path": "../../core/tool-executor-ptc" + }, + { + "path": "../ui-context-menu" } ], "exclude": [ diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 9fec901af2..9fedd2d491 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-context-menu/README.i18n.yaml b/packages/client/ui-context-menu/README.i18n.yaml new file mode 100644 index 0000000000..1f54beccbb --- /dev/null +++ b/packages/client/ui-context-menu/README.i18n.yaml @@ -0,0 +1,40 @@ +# 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-context-menu/README.md +/: + en: c7a386986f8aa0d7 + zh: a4bace64648d0a89 +/deepseek-ai-dsh-client-ui-context-menu: + en: f165f99dfbea7ed2 + zh: 0f2835e7726dd0f5 +/deepseek-ai-dsh-client-ui-context-menu/summary: + en: 7d22e21395a76909 + zh: e9f98068a4c5fde7 +/deepseek-ai-dsh-client-ui-context-menu/table-of-contents: + en: d152484eb41ac6b4 + zh: 725b47765ee8f30b +/deepseek-ai-dsh-client-ui-context-menu/use-this-package: + en: c84c65c7d24d51c3 + zh: 98c8f40a326a8f0b +/deepseek-ai-dsh-client-ui-context-menu/use-this-package/register-actions: + en: 11bea09005de2678 + zh: d56e25d2d93022aa +/deepseek-ai-dsh-client-ui-context-menu/understand-the-implementation: + en: 5b7717fad8457145 + zh: 19a06fca8060cf62 +/deepseek-ai-dsh-client-ui-context-menu/further-exploration: + en: 6d538e70a88f070b + zh: f90577fe9c8d1799 +/deepseek-ai-dsh-client-ui-context-menu/model-experience: + en: bb3787b3201a6724 + zh: e51cbb66f42a433d +/deepseek-ai-dsh-client-ui-context-menu/model-experience/kv-cache-effect: + en: e5fe67ca0631f67a + zh: b26e6107f2638589 +/deepseek-ai-dsh-client-ui-context-menu/known-limitations-and-deferred-work: + en: ac1e5554e7f3109f + zh: c2df38df3b9ea4f2 +/deepseek-ai-dsh-client-ui-context-menu/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 6c5f9788c6536776 diff --git a/packages/client/ui-context-menu/README.md b/packages/client/ui-context-menu/README.md new file mode 100644 index 0000000000..dff6ea78cc --- /dev/null +++ b/packages/client/ui-context-menu/README.md @@ -0,0 +1,100 @@ +--- +description: "Add actions to right-click menus without importing a React component" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-context-menu + +English | [中文](README.zh.md) + +## Summary + +Add actions to right-click menus without importing a React component. Targets retain the clicked Session, Workspace, file, directory, URL, image, or pane even when selection changes. Contributions can discover metadata asynchronously and disappear safely when their plugin unloads. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Web application bundle mounts this Client plugin. External Client plugins declare the package in `dsh.client.inject`, import its `/client` types, and inject the `contextMenus` service. `resolutionTimeoutMs` bounds each provider’s preparation (1000ms by default); timed-out providers are aborted and omitted with visible failure feedback. + +The plugin injects `useContextMenu` into every Slot scope through `ctx.slots.provideRoot`. Call `const { getTriggerProps } = useContextMenu()` once in the component and spread `getTriggerProps(target, handlers?)` onto each trigger. The factory is an ordinary function and can run in loops. It handles right-click, Shift+F10, the ContextMenu key, viewport coordinates, and focus without scrolling. Existing handlers run first; `preventDefault()` keeps their ownership and prevents duplicate handling by composed or nested bindings. Repeated, composing, and unrelated key events remain untouched. Pass `undefined` to retain native behavior, or a target resolver to read business state when the gesture begins. The resolved object is captured before focus changes. Plain children receive event props or a factory through props or an existing delegate; no additional React Context is needed. Special editors and canvases can call `ctx.contextMenus.open()` directly. + +### Register actions + +Call `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))` in the contributing plugin. `resolve` receives the captured target and its cancellation signal; return localized actions with stable IDs, optional icons, disabled state, submenus, and execution callbacks. Contributions sort by `order`, then ID. Duplicate contribution IDs within one target family fail registration; duplicate action paths reject that provider’s result. IDs are encoded by segment before publication. + +An action’s optional `commandId` associates it with an existing shortcut command. The overlay subscribes to `ctx.shortcuts.catalog`, including configured, removed, and reset bindings; nested and footer actions use the same presentation. This association never dispatches the command: selection invokes only the action’s captured `run` callback. The menu plugin requires the `shortcuts` service. + +A contribution can declare `observe(target)` sources for permissions, membership, or localized labels. Source updates re-resolve that contribution against the same captured target and replace its callbacks; outdated asynchronous preparations are cancelled. Dismissal and plugin unload release every subscription. + +Extend `ContextMenuTargetMap` through declaration merging on `@deepseek-ai/dsh-client-ui-context-menu/client` for a new target family; `ContextMenuTarget` derives the `{ kind, value }` union from that map. Open a target with `ctx.contextMenus.open(target, { x, y })`. Keep target values as immutable JSON-compatible identifiers and metadata. Execution callbacks capture that value; they must recheck mutable resources before acting. New registrations participate in the next opening. Unregistering removes visible actions and cancels pending metadata. + +`ctx.contextMenus.resolve(target)` gives a separate occurrence for another renderer. Subscribe to its immutable action views, select a published ID, and dispose it when its owner closes. Views contain presentation fields only. Callbacks remain in the registry and execute at most once. Dismissal and replacement cancel metadata, but an action already selected owns its own execution and cancellation. Rejected providers and actions produce a persistent overlay toast; an action’s localized `failureLabel` supplies operation-specific recovery guidance. + +`ctx.contextMenus.notify(text, success, action?)` can attach a localized text action through the shared Toast. The action provides `label`, `run`, and `failureLabel`; clicking dismisses the toast before execution, and rejection reports its failure label. A dismissed or replaced toast cannot execute its action again. + +Shared popups wait for every contribution to settle or time out before appearing. A repeated gesture within four CSS pixels closes the same target; a farther gesture replaces it with a separate fade. `ready` gives custom renderers the same preparation point. Structured action icons align within each divider group; component contributions retain their own layout. Normal dismissal retains the popup for 80ms, replacement for 10ms; opening waits at least 20ms and fades in over 40ms. Reduced motion suppresses the fade. + +Actions carry `icon?: { key: string; src?: string }`. Register the matching glyph component with `ctx.slots.inject('contextMenu.icon', () => ctx.slots.register({ name: 'contextMenu.icon', key: 'my-plugin.action' }, Icon))`. The overlay dispatches `renderSlot` by that key for ordinary, nested, and footer actions; the component receives the captured `icon` metadata, including an optional image URL. Registrations follow the contributing plugin and menu Slot declaration lifetimes. Action views contain no JSX nodes. + +The optional keyed `contextMenu.content` Slot hosts component contributions. Existing Session and tab Slot names remain owned by their feature adapters. Keyboard context keys and Shift+F10 use the focused trigger’s lower edge. `afterContent` places a data action after those rows; ordinary actions render before them. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The registry separates resolution and callback lifetimes from presentation. The shared overlay uses the existing Menu primitive for keyboard movement, focus return, dismissal, submenus, and viewport positioning. [Registry](src/client/registry.ts) owns cancellation and ordering; [target and action types](src/client/types.ts) own the public fields. + +
+ +----- + + +## Further Exploration + +- [Slots](../ui-slots/README.md) — component contributions. +- [File actions](../ui-file-actions/README.md) — original content transfer. +- [Open in app](../ui-open-in-app/README.md) — shipped resource actions. + +----- + + +## Model Experience + +None, as popup presentation and registration do not construct model input. + +#### KV Cache effect + +None; contributed feature actions own any subsequent model interaction. + +## Known Limitations and Deferred Work + + + +- The shipped renderer is the shared Web Menu, including inside Desktop. Native menu rendering is not supplied. +- Component-only legacy actions do not appear in the plain-data resolution API. +- Menus are window-local and transient; they are not persisted in Session data. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-context-menu/README.zh.md b/packages/client/ui-context-menu/README.zh.md new file mode 100644 index 0000000000..78f154feae --- /dev/null +++ b/packages/client/ui-context-menu/README.zh.md @@ -0,0 +1,100 @@ +--- +description: "无需导入 React 组件即可向右键菜单添加操作" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-context-menu + +[English](README.md) | 中文 + +## 概述 + +无需导入 React 组件即可向右键菜单添加操作。即使选中项变化,目标仍保留被点击的会话、工作区、文件、文件夹、链接、图片或窗格。贡献项可以异步查询元数据,并在插件卸载时安全移除。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延后工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +Web 应用 bundle 挂载此客户端插件。外部客户端插件在 `dsh.client.inject` 声明本包,导入 `/client` 类型,并注入 `contextMenus` 服务。`resolutionTimeoutMs` 限制每个提供方的准备时长(默认 1000ms);超时后取消并忽略该提供方,显示失败提示。 + +插件通过 `ctx.slots.provideRoot` 向每个 Slot 作用域注入 `useContextMenu`。组件调用一次 `const { getTriggerProps } = useContextMenu()`,再将 `getTriggerProps(target, handlers?)` 展开到各个触发元素上。该工厂是普通函数,可以在循环中调用;它统一处理右键、Shift+F10、ContextMenu 键、视口坐标及无滚动聚焦。已有处理器先运行,`preventDefault()` 保留其处理权,并防止组合或嵌套绑定重复处理。重复、输入法组合及无关按键保持原状。传入 `undefined` 保留原生行为,也可传入目标解析函数,在手势开始时读取业务状态。解析后的对象会在焦点变化前捕获。普通子组件通过 props 或现有 delegate 接收事件 props 或工厂,无需额外 React Context。特殊编辑器和 Canvas 可直接调用 `ctx.contextMenus.open()`。 + +### 注册操作 + +在贡献插件中调用 `ctx.effect(() => ctx.contextMenus.register({ id, target, order, resolve }))`。`resolve` 接收捕获的目标和取消信号;返回本地化操作,包含稳定 ID、可选图标、禁用状态、子菜单和执行回调。贡献项先按 `order`、再按 ID 排序。同类目标的贡献 ID 重复会导致注册失败;操作路径重复会拒绝该提供方的结果。发布前会逐段编码 ID。 + +动作可通过可选 `commandId` 关联已有快捷键命令。浮层订阅 `ctx.shortcuts.catalog`,跟随已配置、移除和重置的绑定更新;子菜单和底部动作采用相同展示。此关联不会调度命令:选择菜单项只调用该动作捕获的 `run` 回调。菜单插件依赖 `shortcuts` 服务。 + +贡献可通过 `observe(target)` 声明权限、成员状态或本地化标签的数据源。数据源更新时,针对同一捕获目标重新解析该贡献并替换回调;过期的异步准备会被取消。关闭菜单和卸载插件都会释放所有订阅。 + +通过对 `@deepseek-ai/dsh-client-ui-context-menu/client` 的声明合并扩展 `ContextMenuTargetMap`,即可增加目标类别;`ContextMenuTarget` 从该映射派生 `{ kind, value }` 联合类型。通过 `ctx.contextMenus.open(target, { x, y })` 打开目标。目标值使用不可变、可 JSON 序列化的标识与元数据。执行回调捕获该值,并在操作前重新检查可变资源。新注册项在下次打开时生效。注销会移除可见操作并取消待处理的元数据查询。 + +`ctx.contextMenus.resolve(target)` 为其他渲染器提供独立菜单实例。订阅不可变操作视图,选择已发布的 ID,并在所属界面关闭时释放。视图仅包含展示字段。回调保留在注册表中,最多执行一次。关闭和替换菜单会取消元数据查询,已选中操作则自行管理执行与取消。提供方或操作拒绝时由持久的覆盖层提示;操作的本地化 `failureLabel` 提供具体的恢复建议。 + +`ctx.contextMenus.notify(text, success, action?)` 可通过共享 Toast 附加本地化文字操作。操作提供 `label`、`run` 和 `failureLabel`;点击后先关闭提示再执行,失败时显示对应文案。已关闭或被替换的提示不能再次执行其操作。 + +共享弹窗等待全部贡献项完成或超时后才显示。同一目标在四个 CSS 像素以内重复触发会关闭菜单;更远的触发以独立淡入淡出替换菜单。`ready` 向自定义渲染器提供同一个准备完成时点。结构化操作的图标按分隔线组对齐;组件贡献保留自己的布局。普通关闭保留弹窗 80ms,替换保留 10ms;打开至少等待 20ms,再用 40ms 淡入。减少动态效果偏好会禁用淡入淡出。 + +操作携带 `icon?: { key: string; src?: string }`。通过 `ctx.slots.inject('contextMenu.icon', () => ctx.slots.register({ name: 'contextMenu.icon', key: 'my-plugin.action' }, Icon))` 注册匹配的图标组件。覆盖层对普通项、子菜单和底部操作都按该 key 调用 `renderSlot`;组件接收已捕获的 `icon` 元数据,包括可选图片 URL。注册随贡献插件和菜单 Slot 声明的生命周期清理。操作视图不包含 JSX 节点。 + +可选的 keyed Slot `contextMenu.content` 承载组件贡献。现有会话和标签菜单 Slot 名称仍由各自功能适配器维护。键盘菜单键和 Shift+F10 使用焦点触发元素的下边缘定位。`afterContent` 将数据操作放在这些行之后;普通操作排在之前。 + +----- + + +## 理解实现 + +
+实现细节 — 点击展开 + +注册表将查询和回调生命周期与展示分离。共享覆盖层复用现有 Menu 原语,处理键盘移动、焦点返回、关闭、子菜单和视口定位。[注册表](src/client/registry.ts) 管理取消与排序;[目标和操作类型](src/client/types.ts) 定义公开字段。 + +
+ +----- + + +## 进一步探索 + +- [Slots](../ui-slots/README.zh.md) — 组件贡献。 +- [文件操作](../ui-file-actions/README.zh.md) — 原始内容传输。 +- [本地打开](../ui-open-in-app/README.zh.md) — 内置资源操作。 + +----- + + +## 模型体验 + +无,因为弹出菜单展示与注册不会构造模型输入。 + +#### KV 缓存影响 + +无;贡献操作所属功能负责后续可能产生的模型交互。 + +## 已知限制与延后工作 + + + +- 内置渲染器是共享 Web Menu,Desktop 也使用它;未提供原生菜单渲染器。 +- 仅有组件形式的旧操作不会出现在纯数据查询 API 中。 +- 菜单是窗口内的临时状态,不持久化到会话数据。 + + +### 开发备注 + +
+维护者工作背景 — 点击展开 + +无。 + +
diff --git a/packages/client/ui-context-menu/locale/de.json b/packages/client/ui-context-menu/locale/de.json new file mode 100644 index 0000000000..4b83ec446a --- /dev/null +++ b/packages/client/ui-context-menu/locale/de.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Kontextmenüs" + } +} diff --git a/packages/client/ui-context-menu/locale/en.json b/packages/client/ui-context-menu/locale/en.json new file mode 100644 index 0000000000..edcf35ac5e --- /dev/null +++ b/packages/client/ui-context-menu/locale/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Context menus" + } +} diff --git a/packages/client/ui-context-menu/locale/es.json b/packages/client/ui-context-menu/locale/es.json new file mode 100644 index 0000000000..dda4ab5e04 --- /dev/null +++ b/packages/client/ui-context-menu/locale/es.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Menús contextuales" + } +} diff --git a/packages/client/ui-context-menu/locale/fr.json b/packages/client/ui-context-menu/locale/fr.json new file mode 100644 index 0000000000..6e41b74ead --- /dev/null +++ b/packages/client/ui-context-menu/locale/fr.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Menus contextuels" + } +} diff --git a/packages/client/ui-context-menu/locale/ja.json b/packages/client/ui-context-menu/locale/ja.json new file mode 100644 index 0000000000..7b98bda82b --- /dev/null +++ b/packages/client/ui-context-menu/locale/ja.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "コンテキストメニュー" + } +} diff --git a/packages/client/ui-context-menu/locale/pt.json b/packages/client/ui-context-menu/locale/pt.json new file mode 100644 index 0000000000..4a2c18368a --- /dev/null +++ b/packages/client/ui-context-menu/locale/pt.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Menus de contexto" + } +} diff --git a/packages/client/ui-context-menu/locale/ru.json b/packages/client/ui-context-menu/locale/ru.json new file mode 100644 index 0000000000..33b3ad67a4 --- /dev/null +++ b/packages/client/ui-context-menu/locale/ru.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Контекстные меню" + } +} diff --git a/packages/client/ui-context-menu/locale/zh.json b/packages/client/ui-context-menu/locale/zh.json new file mode 100644 index 0000000000..974fc94de9 --- /dev/null +++ b/packages/client/ui-context-menu/locale/zh.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "右键菜单" + } +} diff --git a/packages/client/ui-context-menu/package.json b/packages/client/ui-context-menu/package.json new file mode 100644 index 0000000000..6aacc8717f --- /dev/null +++ b/packages/client/ui-context-menu/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-context-menu", + "description": "Typed, plugin-extensible context menus with captured targets and shared Web presentation", + "version": "0.2.2-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-context-menu" + }, + "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-locale", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-shortcuts" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-locale": "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:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-context-menu/src/client/View.tsx b/packages/client/ui-context-menu/src/client/View.tsx new file mode 100644 index 0000000000..58a9808f3a --- /dev/null +++ b/packages/client/ui-context-menu/src/client/View.tsx @@ -0,0 +1,60 @@ +/** Shared Web renderer for Client menu contributions. */ +import { Menu, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsStore, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { Shortcuts, ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' +import type { createMenuStore } from './store.ts' +import type { MenuActionView } from './types.ts' + +/** + * @param actions - plain presentation. + * @param catalog - effective user shortcuts. + * @param renderSlot - owner-authorized keyed icon renderer. + * @returns Existing Menu entries. + */ +export function menuEntries( + actions: readonly MenuActionView[], catalog: readonly ShortcutCatalogEntry[], + renderSlot: PropsRenderSlots<'contextMenu.icon'>['renderSlot'], +): MenuEntry[] { + return actions.flatMap((action) => { + const shortcut = catalog.find(entry => entry.id === action.commandId) + const item: MenuEntry = { id: action.id, label: action.label, + ...(shortcut === undefined ? {} : { shortcut }), + ...(action.disabled === undefined ? {} : { disabled: action.disabled }), + ...(action.danger === undefined ? {} : { danger: action.danger }), + ...(action.icon === undefined ? {} : { + icon: renderSlot('contextMenu.icon', { icon: action.icon }, { entryKey: action.icon.key }), + }), + ...(action.children === undefined ? {} : { submenu: menuEntries(action.children, catalog, renderSlot) }), + } + return action.separatorBefore ? [{ type: 'separator', id: `${action.id}:separator` }, item] : [item] + }) +} + +/** Shared overlay callbacks. */ +export interface MenuOverlayInjected { + hooks: { catalog: Shortcuts['catalog'] } + close: () => void + returnFocus?: () => HTMLElement | null + select: (id: string) => void +} +type Props = PropsStore> + & PropsRenderSlots<'contextMenu.content' | 'contextMenu.icon'> & InjectFace + +/** @param props - registered state, target-specific legacy rows, and actions. @returns one menu and persistent feedback. */ +export function MenuOverlay({ useStore, actions, renderSlot, close, select, returnFocus, useCatalog }: Props) { + const catalog = useCatalog(value => value) + const { target, point, items, notice, occurrence, closing, replacing } = useStore(s => s) + return <> + new DOMRect(point.x, point.y, 0, 0)} + items={menuEntries(items.filter(item => !item.afterContent), catalog, renderSlot)} + footer={menuEntries(items.filter(item => item.afterContent), catalog, renderSlot)} onClose={close} onSelect={select}> + {target !== null && renderSlot('contextMenu.content', { target, dismiss: close }, { entryKey: target.kind })} + + {notice !== null && { actions.notify(null) }} />} + +} diff --git a/packages/client/ui-context-menu/src/client/index.ts b/packages/client/ui-context-menu/src/client/index.ts new file mode 100644 index 0000000000..ac4fc288be --- /dev/null +++ b/packages/client/ui-context-menu/src/client/index.ts @@ -0,0 +1,155 @@ +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +/** Window-local menu capability and its Web overlay. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import z from '@deepseek-ai/schemastery' +import { MENU_TRANSITION_MS } from '@deepseek-ai/dsh-client-ui-primitives' +import { menuTargetKey } from './target-key.ts' +import { MenuRegistry } from './registry.ts' +import { createMenuStore } from './store.ts' +import { dictionaries } from './locales.ts' +import { MenuOverlay } from './View.tsx' +import type { ContextMenus, MenuResolution, ContextMenuTarget, ContextMenuIcon, MenuPoint, UseContextMenu } from './types.ts' +import { createContextMenuBindings } from './trigger.ts' + +export type { ContextMenus, ContextMenuTarget, ContextMenuTargetMap, MenuPoint, MenuAction, MenuActionView, MenuContribution, MenuResolution } from './types.ts' +export type { ContextMenuBindings, ContextMenuTriggerProps, UseContextMenu } from './types.ts' +export type { ContextMenuIcon } from './types.ts' +export { createMenuStore } from './store.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { /** Extensible context menus for explicit clicked objects. */ contextMenus: ContextMenus } +} +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Menu bindings for explicit objects, available in every Slot scope. */ + useContextMenu: UseContextMenu + } + interface SlotMap { + /** Business-owned glyph or image selected by the action's icon key. */ + 'contextMenu.icon': { kind: 'keyed'; scope: 'root'; inline: true; owner: { icon: ContextMenuIcon } } + /** Optional React rows for existing extension positions; data contributions need no renderer. */ + 'contextMenu.content': { kind: 'keyed'; scope: 'root'; owner: { target: ContextMenuTarget; dismiss: () => void } } + } + interface LocaleNamespaceMap { /** Menu operation feedback. */ contextMenu: 'failed' } +} + +/** Maximum time to prepare each contribution before omitting its pending actions. */ +export interface Config { resolutionTimeoutMs?: number } +/** Validated Client menu preparation budget. */ +export const Config: z = z.object({ resolutionTimeoutMs: z.natural().min(1).default(1000) }) + +/** Required overlay and localization services. */ +export const inject = ['slots', 'locale', 'shortcuts'] +/** @param ctx - plugin-owned Client context. @param config - validated preparation budget. */ +export function apply(ctx: Context, config: Config = Config({})): void { + ctx.effect(() => ctx.locale.register('contextMenu', dictionaries), 'context menu: locale') + const t = ctx.locale.bind('contextMenu') + const handle = createMenuStore() + const instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + const targetSource = createSnapshotStore(null) + let sequence = 0 + let active: { resolution: MenuResolution; unsubscribe: () => void; target: ContextMenuTarget; point: MenuPoint } | undefined + let closeTimer: ReturnType | undefined + let openingTimer: ReturnType | undefined + let finishOpening: (() => void) | undefined + let returnFocus: HTMLElement | null = null + let disposed = false + const notify: ContextMenus['notify'] = (text, success = false, action) => { + if (disposed) return + const id = ++sequence + instance.actions.notify({ id, text, success, ...action === undefined ? {} : { action: { + label: action.label, + onClick: () => { + if (disposed || instance.getSnapshot().notice?.id !== id) return + instance.actions.notify(null) + try { void Promise.resolve(action.run()).catch(() => { notify(action.failureLabel) }) } + catch (_error) { notify(action.failureLabel) } + }, + } } }) + } + const registry = new MenuRegistry((message) => { notify(message ?? t('failed')) }, config.resolutionTimeoutMs) + const cancelOpening = (): void => { + clearTimeout(openingTimer); finishOpening?.(); finishOpening = undefined + active?.unsubscribe(); active?.resolution.dispose(); active = undefined + targetSource.set(null) + } + const dismiss = (replacing: boolean): void => { + cancelOpening() + clearTimeout(closeTimer) + if (instance.getSnapshot().target === null) return + instance.actions.exit(replacing) + closeTimer = setTimeout(() => { instance.actions.close() }, replacing ? MENU_TRANSITION_MS.replace : MENU_TRANSITION_MS.dismiss) + } + const close = (): void => { dismiss(false) } + const contextMenus: ContextMenus = { + active: targetSource, + register: input => registry.register(input), resolve: target => registry.resolve(target), notify, close, + open: (target, point) => { + const previous = active + if (previous !== undefined + && Math.hypot(previous.point.x - point.x, previous.point.y - point.y) <= 4 + && menuTargetKey(previous.target) === menuTargetKey(target)) { close(); return } + const focused = document.activeElement + if (!(focused instanceof Element) || focused.closest('[data-context-menu]') === null) { + returnFocus = focused instanceof HTMLElement ? focused : null + } + dismiss(true) + const resolution = registry.resolve(target) + targetSource.set(target) + let displayed = false + const publish = (): void => { if (displayed) instance.actions.items(resolution.source.getSnapshot()) } + const occurrence = { resolution, unsubscribe: resolution.source.subscribe(publish), target, point } + active = occurrence + const delay = new Promise((resolve) => { + finishOpening = resolve; openingTimer = setTimeout(resolve, MENU_TRANSITION_MS.prepare) + }) + void Promise.all([resolution.ready, delay]).then(() => { + if (active !== occurrence) return + clearTimeout(closeTimer) + instance.actions.open(target, point) + displayed = true + publish() + }) + }, + } + ctx.provide('contextMenus', contextMenus) + const bindings = createContextMenuBindings((target, point) => { contextMenus.open(target, point) }, () => !disposed) + ctx.slots.provideRoot({ props: { useContextMenu: () => bindings } }) + ctx.effect(() => { + const outside = (event: PointerEvent): void => { + if (event.button === 2 || (event.target instanceof Element && event.target.closest('[role="menu"], [data-context-menu-trigger]'))) return + close() + } + const context = (event: MouseEvent): void => { if (!event.defaultPrevented) close() } + const escape = (event: KeyboardEvent): void => { + if (active !== undefined && instance.getSnapshot().target === null && event.key === 'Escape') { + event.preventDefault(); event.stopImmediatePropagation(); close() + } + } + document.addEventListener('pointerdown', outside) + document.addEventListener('contextmenu', context) + window.addEventListener('keydown', escape, true) + return () => { + disposed = true; cancelOpening(); clearTimeout(closeTimer); registry.dispose(); instance.actions.close() + document.removeEventListener('pointerdown', outside) + document.removeEventListener('contextmenu', context) + window.removeEventListener('keydown', escape, true) + } + }, 'context menu: lifetime') + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'context-menu', store, + children: { + 'contextMenu.content': { kind: 'keyed', scope: 'root' }, + 'contextMenu.icon': { kind: 'keyed', scope: 'root', inline: true }, + }, + inject: () => ({ hooks: { catalog: ctx.shortcuts.catalog }, close, returnFocus: () => returnFocus, select: (id: string) => { + const selected = active?.resolution + active?.unsubscribe(); active = undefined + targetSource.set(null); dismiss(false) + selected?.select(id); selected?.dispose() } }), + }, MenuOverlay)) +} diff --git a/packages/client/ui-context-menu/src/client/locales.ts b/packages/client/ui-context-menu/src/client/locales.ts new file mode 100644 index 0000000000..d8b33a422e --- /dev/null +++ b/packages/client/ui-context-menu/src/client/locales.ts @@ -0,0 +1,11 @@ +/** Context-menu operation feedback, separate from plugin display metadata. */ +export const dictionaries = { + zh: { failed: '操作失败,请重试' }, + en: { failed: 'Could not complete the action. Try again.' }, + ja: { failed: '操作に失敗しました。再試行してください。' }, + es: { failed: 'No se pudo completar la acción. Inténtelo de nuevo.' }, + fr: { failed: 'Impossible de terminer l’action. Réessayez.' }, + de: { failed: 'Die Aktion konnte nicht abgeschlossen werden. Versuchen Sie es erneut.' }, + ru: { failed: 'Не удалось выполнить действие. Повторите попытку.' }, + pt: { failed: 'Não foi possível concluir a ação. Tente novamente.' }, +} satisfies Record diff --git a/packages/client/ui-context-menu/src/client/registry.ts b/packages/client/ui-context-menu/src/client/registry.ts new file mode 100644 index 0000000000..3725bb9fed --- /dev/null +++ b/packages/client/ui-context-menu/src/client/registry.ts @@ -0,0 +1,160 @@ +/** Ordered menu resolution with cancellation and contribution-owned action lifetimes. */ +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { MenuAction, MenuActionView, MenuContribution, MenuResolution, ContextMenuTarget, ContextMenuTargetMap } from './types.ts' + +interface Provider { + readonly id: string + readonly order: number + readonly kind: keyof ContextMenuTargetMap + readonly lifetime: AbortController + observe(target: ContextMenuTarget): readonly ObservableSnapshot[] + resolve(target: ContextMenuTarget, signal: AbortSignal): readonly MenuAction[] | Promise +} + +/** React-free registry; each occurrence captures the providers registered at opening. */ +export class MenuRegistry { + private readonly providers = new Map() + private readonly occurrences = new Set<() => void>() + private disposed = false + constructor(private readonly failed: (message?: string) => void, private readonly timeoutMs?: number) {} + + /** + * Register actions for subsequent openings. + * @param input - provider definition. + * @returns removal of this contribution and its pending resolutions. + */ + register(input: MenuContribution): () => void { + if (this.disposed) throw new Error('Menu registry is disposed') + const key = `${encodeURIComponent(input.target)}/${encodeURIComponent(input.id)}` + if (this.providers.has(key)) throw new Error(`Duplicate menu contribution: ${key}`) + const provider: Provider = { + id: input.id, kind: input.target, order: input.order, lifetime: new AbortController(), + observe: target => input.observe?.(target.value as ContextMenuTargetMap[K]) ?? [], + resolve: (target, signal) => input.resolve(target.value as ContextMenuTargetMap[K], signal), + } + this.providers.set(key, provider) + return () => { + if (provider.lifetime.signal.aborted) return + this.providers.delete(key) + provider.lifetime.abort() + } + } + + /** + * Resolve an independently owned occurrence. + * @param target - captured clicked identity. + * @returns presentation, selection, and disposal. + */ + resolve(target: ContextMenuTarget): MenuResolution { + if (this.disposed) throw new Error('Menu registry is disposed') + const lifetime = new AbortController() + const source = createSnapshotStore([]) + const resolved = new Map() + const callbacks = new Map() + const providers = [...this.providers.values()].filter(p => p.kind === target.kind) + .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)) + const refresh = (): void => { + source.set(providers.flatMap(provider => + provider.lifetime.signal.aborted ? [] : resolved.get(provider) ?? [])) + } + const cleanup: (() => void)[] = [] + const dispose = (): void => { + if (lifetime.signal.aborted) return + lifetime.abort() + for (const detach of cleanup) detach() + callbacks.clear() + this.occurrences.delete(dispose) + } + this.occurrences.add(dispose) + const pending: Promise[] = [] + for (const provider of providers) { + let preparation: AbortController | undefined + let actionIds: string[] = [] + let preparing: Promise | undefined + const clear = (): void => { + for (const id of actionIds) callbacks.delete(id) + actionIds = [] + resolved.delete(provider) + refresh() + } + const prepare = (): void => { + preparation?.abort() + const deadline = new AbortController() + preparation = deadline + const signal = AbortSignal.any([lifetime.signal, provider.lifetime.signal, deadline.signal]) + const accept = (actions: readonly MenuAction[]): void => { + if (signal.aborted) return + const next = new Map() + const project = (action: MenuAction, parent: string): MenuActionView => { + const id = `${parent}/${encodeURIComponent(action.id)}` + if (next.has(id)) throw new Error(`Duplicate menu action: ${id}`) + next.set(id, action) + const { run: _run, failureLabel: _failureLabel, children, ...view } = action + return { ...view, id, ...(children === undefined ? {} : { children: children.map(child => project(child, id)) }) } + } + const views = actions.map(action => project(action, encodeURIComponent(provider.id))) + for (const id of actionIds) callbacks.delete(id) + for (const [id, action] of next) callbacks.set(id, { provider, action }) + actionIds = [...next.keys()] + resolved.set(provider, views) + refresh() + } + preparing = undefined + try { + const result = provider.resolve(target, signal) + if (result instanceof Promise) clear() + preparing = result instanceof Promise ? new Promise((settle) => { + const timer = this.timeoutMs === undefined ? undefined : setTimeout(() => { + deadline.abort(); this.failed() + }, this.timeoutMs) + const finish = (): void => { clearTimeout(timer); signal.removeEventListener('abort', finish); settle() } + signal.addEventListener('abort', finish, { once: true }) + void result.then(accept).catch(() => { if (!signal.aborted) this.failed() }).finally(finish) + }) : undefined + if (!(result instanceof Promise)) accept(result) + } catch (_error) { if (!signal.aborted) { clear(); this.failed() } } + } + const subscriptions: (() => void)[] = [] + const withdraw = (): void => { + preparation?.abort() + for (const detach of subscriptions.splice(0)) detach() + clear() + } + provider.lifetime.signal.addEventListener('abort', withdraw, { once: true }) + cleanup.push(() => { + provider.lifetime.signal.removeEventListener('abort', withdraw) + withdraw() + }) + try { + for (const input of provider.observe(target)) subscriptions.push(input.subscribe(prepare)) + prepare() + } catch (_error) { if (!lifetime.signal.aborted && !provider.lifetime.signal.aborted) this.failed() } + pending.push((async () => { + let current: Promise | undefined + do { current = preparing; await current } while (current !== preparing) + })()) + } + return { + source, dispose, ready: Promise.all(pending).then(() => {}), + select: (id) => { + const selected = callbacks.get(id) + if (lifetime.signal.aborted || selected === undefined || selected.provider.lifetime.signal.aborted + || selected.action.disabled || selected.action.children !== undefined) return + const run = selected.action.run + dispose() + try { void Promise.resolve(run?.()).catch(() => { this.failed(selected.action.failureLabel) }) } + catch (_error) { this.failed(selected.action.failureLabel) } + }, + } + } + + /** Cancel every occurrence and reject further registrations or resolutions. */ + dispose(): void { + if (this.disposed) return + this.disposed = true + for (const dispose of this.occurrences) dispose() + for (const provider of this.providers.values()) provider.lifetime.abort() + this.providers.clear() + } +} diff --git a/packages/client/ui-context-menu/src/client/store.ts b/packages/client/ui-context-menu/src/client/store.ts new file mode 100644 index 0000000000..8791201c91 --- /dev/null +++ b/packages/client/ui-context-menu/src/client/store.ts @@ -0,0 +1,41 @@ +/** Single-popup viewing state and feedback retained outside trigger components. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { MenuActionView, MenuPoint, ContextMenuTarget } from './types.ts' + +interface Notice { id: number; text: string; success: boolean; action?: { label: string; onClick: () => void } } +interface MenuState { + occurrence: number + closing: boolean + replacing: boolean + target: ContextMenuTarget | null + point: MenuPoint + items: readonly MenuActionView[] + notice: Notice | null +} +type MenuActions = { + open(draft: MenuState, target: ContextMenuTarget, point: MenuPoint): void + items(draft: MenuState, items: readonly MenuActionView[]): void + exit(draft: MenuState, replacing: boolean): void + close(draft: MenuState): void + notify(draft: MenuState, notice: Notice | null): void +} + +/** + * Create the root popup store. + * @returns the presentation and its mutation actions. + */ +export function createMenuStore(): EngineStoreHandle { + return defineStore({ + init: () => ({ occurrence: 0, closing: false, replacing: false, target: null, point: { x: 0, y: 0 }, items: [], notice: null }), + actions: { + open(draft, target, point) { + draft.occurrence++; draft.closing = false; draft.replacing = false + draft.target = target; draft.point = point; draft.items = [] + }, + items(draft, items) { draft.items = items }, + exit(draft, replacing) { draft.closing = true; draft.replacing = replacing }, + close(draft) { draft.target = null; draft.items = [] }, + notify(draft, notice) { draft.notice = notice }, + }, + }) +} diff --git a/packages/client/ui-context-menu/src/client/target-key.ts b/packages/client/ui-context-menu/src/client/target-key.ts new file mode 100644 index 0000000000..4304d2d617 --- /dev/null +++ b/packages/client/ui-context-menu/src/client/target-key.ts @@ -0,0 +1,14 @@ +/** Stable equality for the JSON-compatible target vocabulary, independent of object insertion order. */ +import type { ContextMenuTarget } from './types.ts' + +/** + * Compare captured JSON targets independently of object key insertion order. + * @param target - captured immutable target. + * @returns canonical JSON identity. + */ +export function menuTargetKey(target: ContextMenuTarget): string { + return JSON.stringify(target, (_key: string, value: unknown): unknown => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return value + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) + }) +} diff --git a/packages/client/ui-context-menu/src/client/trigger.ts b/packages/client/ui-context-menu/src/client/trigger.ts new file mode 100644 index 0000000000..04cd530e1a --- /dev/null +++ b/packages/client/ui-context-menu/src/client/trigger.ts @@ -0,0 +1,43 @@ +/** DOM gesture bindings owned by one menu plugin instance. */ +import type { SyntheticEvent } from 'react' +import type { ContextMenuTriggerProps, MenuPoint, ContextMenuTarget } from './types.ts' + +/** + * Create stable bindings for the plugin's injected hook. + * @param open - Open against the supplied captured target and viewport position. + * @param active - Whether the plugin still owns its service. + * @returns Ordinary trigger-prop factory; no React subscription or context is created. + */ +export function createContextMenuBindings( + open: (target: Target, point: MenuPoint) => void, active: () => boolean, +): { getTriggerProps: (target: Target | (() => Target) | undefined, handlers?: ContextMenuTriggerProps) => ContextMenuTriggerProps } { + return { + getTriggerProps(target, handlers = {}) { + if (target === undefined) return handlers + const show = (event: SyntheticEvent, x: number, y: number): void => { + if (event.defaultPrevented || !active()) return + const captured = typeof target === 'function' ? target() : target + event.preventDefault() + event.stopPropagation() + event.currentTarget.focus({ preventScroll: true }) + open(captured, { x, y }) + } + return { + ...handlers, + tabIndex: handlers.tabIndex ?? 0, + onContextMenu(event) { + handlers.onContextMenu?.(event) + const rect = event.clientX === 0 && event.clientY === 0 ? event.currentTarget.getBoundingClientRect() : undefined + show(event, rect?.left ?? event.clientX, rect?.bottom ?? event.clientY) + }, + onKeyDown(event) { + handlers.onKeyDown?.(event) + if (event.defaultPrevented || event.repeat || event.nativeEvent.isComposing || event.ctrlKey || event.altKey || event.metaKey + || !(event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey)) return + const rect = event.currentTarget.getBoundingClientRect() + show(event, rect.left, rect.bottom) + }, + } + }, + } +} diff --git a/packages/client/ui-context-menu/src/client/types.ts b/packages/client/ui-context-menu/src/client/types.ts new file mode 100644 index 0000000000..d32a788fb4 --- /dev/null +++ b/packages/client/ui-context-menu/src/client/types.ts @@ -0,0 +1,143 @@ +/** Client menu contributions and explicit gesture targets. */ +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { PaneId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { HTMLAttributes } from 'react' + +/** DOM handlers bound to an explicit object; existing handlers can prevent menu activation. */ +export type ContextMenuTriggerProps = Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'> + +/** Functions supplied by the globally injected useContextMenu hook. */ +export interface ContextMenuBindings { + /** + * Bind an object or deferred business target to one element. This is an ordinary function, safe in loops. + * @param target - Object captured when the gesture runs; undefined retains native behavior. + * @param handlers - Existing handlers run first and can cancel with preventDefault(). + * @returns Combined pointer and keyboard handlers plus a keyboard focus stop. + */ + getTriggerProps: ( + target: ContextMenuTarget | (() => ContextMenuTarget) | undefined, handlers?: ContextMenuTriggerProps, + ) => ContextMenuTriggerProps +} + +/** Read menu gesture bindings owned by the current menu plugin lifetime. */ +export type UseContextMenu = () => ContextMenuBindings + +/** External plugins extend this map with their own target families. */ +export interface ContextMenuTargetMap { + session: { readonly sessionId: SessionId; readonly displayTitle: string } + file: { readonly sessionId: SessionId; readonly path: string; readonly line?: number; readonly paneId?: PaneId } + directory: { readonly sessionId: SessionId; readonly path: string } + url: { readonly sessionId: SessionId; readonly url: string } + image: { + readonly sessionId?: SessionId + readonly url: string + readonly name?: string + readonly mediaType?: string + readonly path?: string + } + tab: { readonly sessionId: SessionId; readonly paneId: PaneId; readonly tab: TabRecord } +} + +/** Captured target; changing selection never changes an open menu's target. */ +export type ContextMenuTarget = { + [K in keyof ContextMenuTargetMap]: { readonly kind: K; readonly value: ContextMenuTargetMap[K] } +}[keyof ContextMenuTargetMap] +/** Pointer position in CSS pixels; keyboard gestures use the trigger's bottom edge. */ +export interface MenuPoint { readonly x: number; readonly y: number } +/** Keyed icon renderer and optional image URL captured with the action. */ +export interface ContextMenuIcon { + readonly key: string + readonly src?: string +} +/** Client presentation fields; callbacks stay in the registry. */ +export interface MenuActionView { + readonly id: string + readonly label: string + /** Command identity for live shortcut display only; selection still invokes this action’s run callback. */ + readonly commandId?: ShortcutCommandId + readonly disabled?: boolean + readonly icon?: ContextMenuIcon + readonly danger?: boolean + readonly separatorBefore?: boolean + /** Render after legacy component contributions in the shared Web popup. */ + readonly afterContent?: boolean + readonly children?: readonly MenuActionView[] +} +/** A contribution can decline, return actions immediately, or resolve metadata asynchronously. */ +export interface MenuAction extends Omit { + readonly children?: readonly MenuAction[] + /** Localized operation and recovery guidance when execution rejects. */ + readonly failureLabel?: string + /** Execute against the captured target. Reject for a visible failure; cancellation resolves normally. */ + readonly run?: () => void | Promise +} +/** A namespaced contribution, ordered within its target family. */ +export interface MenuContribution { + readonly id: string + readonly target: K + readonly order: number + /** + * Reactive inputs that re-resolve actions for the same captured target. + * @param target - clicked object. + * @returns sources owned by the contributing feature; unsubscribed on dismissal or unload. + */ + observe?(target: ContextMenuTargetMap[K]): readonly ObservableSnapshot[] + /** + * @param target - clicked object. + * @param signal - aborted on dismissal, plugin unload, or a newer observed preparation. + * @returns available actions. + */ + resolve(target: ContextMenuTargetMap[K], signal: AbortSignal): readonly MenuAction[] | Promise +} +/** Independent menu occurrence used by custom renderers and embedded menus. */ +export interface MenuResolution { + /** Read a new immutable presentation after each provider settles, updates, or is withdrawn. */ + readonly source: ObservableSnapshot + /** Settles when all opening contributions have completed or been withdrawn. */ + readonly ready: Promise + /** + * Select once; disposed or removed contributions cannot execute. + * @param id - resolved action id. + */ + select(id: string): void + /** Cancel metadata queries and discard callbacks. */ + dispose(): void +} +/** Client capability used equally by built-in and external plugins. */ +export interface ContextMenus { + /** Captured target of the shared popup; null after dismissal. */ + readonly active: ObservableSnapshot + /** + * Register for subsequent openings; unloading withdraws actions and aborts their queries. Duplicate ids within a target fail. + * @param contribution - target provider. + * @returns unregister. + */ + register(contribution: MenuContribution): () => void + /** + * Open the single shared popup. + * @param target - captured object. + * @param point - viewport position. + */ + open(target: ContextMenuTarget, point: MenuPoint): void + /** + * Resolve an embedded menu without opening the shared popup. + * @param target - captured object. + * @returns owned occurrence. + */ + resolve(target: ContextMenuTarget): MenuResolution + /** Close the shared popup and cancel its metadata queries. */ + close(): void + /** + * Show localized feedback outside the originating surface. + * @param text - localized message. + * @param success - successful action. + * @param action - optional text action retained by this notice; rejects with localized failure feedback. + */ + notify(text: string, success?: boolean, action?: { + readonly label: string + readonly run: () => void | Promise + readonly failureLabel: string + }): void +} diff --git a/packages/client/ui-context-menu/src/index.ts b/packages/client/ui-context-menu/src/index.ts new file mode 100644 index 0000000000..77258040c7 --- /dev/null +++ b/packages/client/ui-context-menu/src/index.ts @@ -0,0 +1,4 @@ +/** Host Loader entry; menus belong to the Client. */ + +/** Register the browser plugin without Host behavior. */ +export function apply(): void {} diff --git a/packages/client/ui-context-menu/tests/overlay.client.spec.tsx b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx new file mode 100644 index 0000000000..54194a811b --- /dev/null +++ b/packages/client/ui-context-menu/tests/overlay.client.spec.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { bindSnapshotSelector, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' +import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts' +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { createMenuStore } from '../src/client/store.ts' +import { MenuOverlay } from '../src/client/View.tsx' +import { apply, inject } from '../src/client/index.ts' +import type { ContextMenuTarget } from '../src/client/types.ts' + +afterEach(() => { cleanup(); vi.useRealTimers() }) + +it('renders legacy Session actions before data contributions and restores More focus after dismissal', async () => { + const store = createMenuStore().create(), run = vi.fn() + const close = () => { store.actions.close() } + const useStore = bindSnapshotSelector(store) + const view = render(<> + + select([])} useStore={useStore} actions={store.actions} close={close} select={run} + renderSlot={(_key, owner: object) => { + const { target, dismiss } = owner as { target: ContextMenuTarget; dismiss: () => void } + return { + if (target.kind === 'session') run(target.value.sessionId) + dismiss() + }}>Export + }} /> + ) + const trigger = view.getByRole('button', { name: 'More' }) + trigger.focus(); fireEvent.click(trigger) + await waitFor(() =>{ expect(view.getByRole('menuitem', { name: 'Export' })).toBe(document.activeElement) }) + fireEvent.click(view.getByRole('menuitem', { name: 'Export' })) + await waitFor(() =>{ expect(document.activeElement).toBe(trigger) }) + expect(run).toHaveBeenCalledExactlyOnceWith('clicked') + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(trigger) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(view.queryByRole('menu')).toBeNull() +}) + +it('renders disabled, grouped and nested data actions and keeps feedback after menu dismissal', () => { + vi.useFakeTimers() + const store = createMenuStore().create(), select = vi.fn() + store.actions.open({ kind: 'session', value: { sessionId: SessionId('clicked'), displayTitle: 'Clicked' } }, { x: 20, y: 30 }) + store.actions.items([ + { id: 'plain', label: 'Open' }, + { id: 'blocked', label: 'Unavailable', disabled: true, danger: true, separatorBefore: true }, + { id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original' }, { id: 'other', label: 'Other', separatorBefore: true }] }, + { id: 'footer', label: 'Folder', afterContent: true }, + ]) + const view = render( select([])} useStore={bindSnapshotSelector(store)} actions={store.actions} + close={() => { store.actions.close() }} select={select} renderSlot={() => null} />) + expect(view.getByRole('menuitem', { name: 'Unavailable' }).hasAttribute('disabled')).toBe(true) + fireEvent.click(view.getByRole('menuitem', { name: 'Open' })) + expect(select).toHaveBeenCalledWith('plain') + fireEvent.click(view.getByRole('menuitem', { name: 'Export' })) + expect(view.getAllByRole('separator')).toHaveLength(2) + fireEvent.click(view.getByRole('menuitem', { name: 'Original' })) + expect(select).toHaveBeenCalledWith('child') + act(() => { store.actions.exit(true) }) + expect(document.querySelector('[data-menu-phase="replace"]')).not.toBeNull() + act(() => { store.actions.exit(false) }) + expect(document.querySelector('[data-menu-phase="dismiss"]')).not.toBeNull() + const reveal = vi.fn() + act(() => { store.actions.close(); store.actions.notify({ id: 1, text: 'Saved', success: true, + action: { label: 'Show in Finder', onClick: reveal } }) }) + expect(view.queryByRole('menu')).toBeNull() + expect(view.getByRole('alert').textContent).toBe('SavedShow in Finder') + fireEvent.click(view.getByRole('button', { name: 'Show in Finder' })) + expect(reveal).toHaveBeenCalledOnce() + act(() => { store.actions.notify({ id: 2, text: 'Copy failed', success: false }) }) + expect(view.getByRole('alert').textContent).toBe('Copy failed') + expect(view.queryByRole('button', { name: 'Show in Finder' })).toBeNull() + act(() => { vi.advanceTimersByTime(4000) }) + expect(view.queryByRole('alert')).toBeNull() +}) + +function PluginIcon({ icon }: PropsRuntime<'contextMenu.icon'>) { + return +} + +it('dispatches captured icon keys in rows, submenus and footers and retires renderer registrations on unload', async () => { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, ({ renderSlot }) => + <>{renderSlot('shell.overlay', {})}) + const menuPlugin = await runtime.mount({ inject, apply }) + const run = vi.fn() + const contribution = await runtime.mount({ inject: ['contextMenus'], apply(ctx) { + ctx.effect(() => ctx.contextMenus.register({ id: 'icons', target: 'session', order: 0, resolve: () => [ + { id: 'row', label: 'Open', icon: { key: 'example.icon', src: '/captured.png' }, run }, + { id: 'parent', label: 'Export', children: [{ id: 'child', label: 'Original', icon: { key: 'example.icon' } }] }, + { id: 'footer', label: 'Footer', afterContent: true, icon: { key: 'example.icon' } }, + ] })) + } }) + const icons = await runtime.mount({ inject: ['slots'], apply(ctx) { + ctx.slots.inject('contextMenu.icon', function* () { + yield ctx.slots.register({ name: 'contextMenu.icon', key: 'example.icon' }, PluginIcon) + yield ctx.slots.register({ name: 'contextMenu.icon', key: 'example.unused' }, () => Unused icon) + }) + } }) + const view = runtime.renderRoot() + const open = () => { runtime.ctx.contextMenus.open({ kind: 'session', value: { + sessionId: SessionId('icons'), displayTitle: 'Icons', + } }, { x: 20, y: 30 }) } + act(open) + await waitFor(() => { expect(view.getAllByTestId('plugin-icon')).toHaveLength(2) }) + expect(view.getByRole('menuitem', { name: 'Open' }).querySelector('img')?.getAttribute('src')).toBe('/captured.png') + expect(view.queryByText('Unused icon')).toBeNull() + fireEvent.click(view.getByRole('menuitem', { name: 'Export' })) + expect(view.getByRole('menuitem', { name: 'Original' }).querySelector('img')).not.toBeNull() + await icons.dispose() + expect(view.queryAllByTestId('plugin-icon')).toHaveLength(0) + fireEvent.click(view.getByRole('menuitem', { name: 'Open' })) + expect(run).toHaveBeenCalledOnce() + await contribution.dispose() + await menuPlugin.dispose() + expect(runtime.slots.spec('contextMenu.icon')).toBeUndefined() +}) + +it('tracks configured shortcuts for nested and footer actions without invoking command dispatch', () => { + const shortcuts = new ShortcutRegistry('desktop', 'macos') + const commandId = 'session.rename' as ShortcutCommandId + const resolveCommand = vi.fn(() => ({ status: 'pass' as const })) + const remove = shortcuts.register({ id: commandId, label: () => 'Rename', aliases: [], + defaults: { 'desktop:macos': { code: 'KeyG', modifiers: ['primary', 'alt'] } }, + regions: ['page'], modals: [], resolve: resolveCommand }) + const store = createMenuStore().create() + store.actions.open({ kind: 'session', value: { sessionId: SessionId('captured'), displayTitle: 'Captured' } }, { x: 30, y: 40 }) + store.actions.items([ + { id: 'rename', label: 'Rename', commandId }, + { id: 'missing', label: 'Unknown command', commandId: 'absent' as ShortcutCommandId }, + { id: 'parent', label: 'Submenu', children: [{ id: 'nested', label: 'Nested', commandId }] }, + { id: 'footer', label: 'Footer', commandId, afterContent: true }, + ]) + const select = vi.fn() + const view = render( null} />) + const item = view.getByRole('menuitem', { name: 'Rename' }) + expect(item.getAttribute('aria-keyshortcuts')).toBe('Alt+Meta+G') + expect(view.getByRole('menuitem', { name: 'Unknown command' }).getAttribute('aria-keyshortcuts')).toBeNull() + act(() => { shortcuts.configure({ ...shortcuts.config.getSnapshot(), revision: 'custom' as never, + document: { schemaVersion: 2, profiles: { 'desktop:macos': { 'session.rename': { code: 'KeyR', modifiers: ['primary', 'shift'] } } } } }) }) + expect(item.getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+R') + expect(view.getByRole('menuitem', { name: 'Footer' }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+R') + fireEvent.click(view.getByRole('menuitem', { name: 'Submenu' })) + expect(view.getByRole('menuitem', { name: 'Nested' }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+R') + act(() => { shortcuts.configure({ ...shortcuts.config.getSnapshot(), revision: 'unbound' as never, + document: { schemaVersion: 2, profiles: { 'desktop:macos': { 'session.rename': null } } } }) }) + expect(item.getAttribute('aria-keyshortcuts')).toBeNull() + expect(item.querySelector('kbd')).toBeNull() + act(remove) + expect(item.getAttribute('aria-keyshortcuts')).toBeNull() + fireEvent.click(item) + expect(select).toHaveBeenCalledWith('rename') + expect(resolveCommand).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-context-menu/tests/plugin.client.spec.ts b/packages/client/ui-context-menu/tests/plugin.client.spec.ts new file mode 100644 index 0000000000..afd3d97350 --- /dev/null +++ b/packages/client/ui-context-menu/tests/plugin.client.spec.ts @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import type { MenuAction } from '../src/client/types.ts' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { Context } from '@deepseek-ai/cordis' +import { afterEach, expect, it, vi } from 'vitest' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { MenuOverlayInjected } from '../src/client/View.tsx' +import { apply, inject } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' +import type { createMenuStore } from '../src/client/store.ts' + +afterEach(() => { vi.useRealTimers() }) + +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { pluginDocument: { readonly id: string } } +} + +it('lets an external contribution open another menu and withdraws its actions when its fiber unloads', async () => { + const ctx = new Context() + ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + let overlay!: MenuOverlayInjected + let store!: ReturnType['create']> + ctx.provide('slots', { provideRoot: () => () => {}, + inject: (_name: string, register: Parameters[1]) => ctx.effect(register), + register: (entry: { inject: () => MenuOverlayInjected; store: ReturnType }) => { + overlay = entry.inject(); store = entry.store.create(); return () => {} + }, + } as never) + ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const menuFiber = ctx.plugin({ inject, apply }) + await menuFiber.await() + expect(hostApply).not.toThrow() + const run = vi.fn() + const contribution = ctx.plugin({ inject: ['contextMenus'], apply: (plugin: Context) => { + plugin.effect(() => plugin.contextMenus.register({ + target: 'pluginDocument', id: 'example.document', order: 0, + resolve: ({ id }) => [{ id: 'inspect', label: id, run: () => { + run(id) + plugin.contextMenus.open({ kind: 'pluginDocument', value: { id: 'next' } }, { x: 3, y: 4 }) + } }], + })) + } }) + await contribution.await() + try { + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'clicked' } }, { x: 1, y: 2 }) + await vi.waitFor(() => { expect(store.getSnapshot().items.length).toBeGreaterThan(0) }) + overlay.select('example.document/inspect') + expect(run).toHaveBeenCalledExactlyOnceWith('clicked') + expect(ctx.contextMenus.active.getSnapshot()).toEqual({ kind: 'pluginDocument', value: { id: 'next' } }) + const occurrence = ctx.contextMenus.resolve({ kind: 'pluginDocument', value: { id: 'retained' } }) + expect(occurrence.source.getSnapshot().map(action => action.label)).toEqual(['retained']) + await contribution.dispose() + expect(occurrence.source.getSnapshot()).toEqual([]) + occurrence.select('example.document/inspect') + expect(run).toHaveBeenCalledOnce() + occurrence.dispose() + ctx.contextMenus.notify('Saved', true) + expect(store.getSnapshot().notice).toMatchObject({ text: 'Saved', success: true }) + ctx.contextMenus.notify('Unavailable') + expect(store.getSnapshot().notice).toMatchObject({ text: 'Unavailable', success: false }) + const reveal = vi.fn(async () => {}) + ctx.contextMenus.notify('Saved', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' }) + const previousAction = store.getSnapshot().notice!.action! + expect(previousAction.label).toBe('Show in folder') + previousAction.onClick() + previousAction.onClick() + expect(reveal).toHaveBeenCalledOnce() + expect(store.getSnapshot().notice).toBeNull() + ctx.contextMenus.notify('Saved again', true, { label: 'Show in folder', run: reveal, failureLabel: 'Reveal failed' }) + const replaced = store.getSnapshot().notice!.action! + ctx.contextMenus.notify('New notice') + replaced.onClick() + expect(reveal).toHaveBeenCalledOnce() + for (const fail of [() => { throw new Error('denied') }, async () => { throw new Error('offline') }]) { + ctx.contextMenus.notify('Saved', true, { label: 'Show in folder', run: fail, failureLabel: 'Reveal failed' }) + store.getSnapshot().notice!.action!.onClick() + await vi.waitFor(() => { expect(store.getSnapshot().notice).toMatchObject({ text: 'Reveal failed', success: false }) }) + } + const removeFailure = ctx.contextMenus.register({ target: 'pluginDocument', id: 'failure', order: 0, + resolve: () => { throw new Error('Metadata unavailable') } }) + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'failed' } }, { x: 0, y: 0 }) + expect(store.getSnapshot().notice?.text).toBe('failed') + removeFailure() + const removeAction = ctx.contextMenus.register({ target: 'pluginDocument', id: 'action', order: 0, + resolve: () => [{ id: 'copy', label: 'Copy', failureLabel: 'Copy failed', run: () => { throw new Error('Denied') } }] }) + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'failed-action' } }, { x: 0, y: 0 }) + overlay.select('action/copy') + expect(store.getSnapshot().notice?.text).toBe('Copy failed') + removeAction() + } finally { await contribution.dispose(); await menuFiber.dispose() } +}) + +it('waits for metadata, toggles nearby repeated gestures, replaces occurrences, and cancels pending openings', async () => { + vi.useFakeTimers() + const ctx = new Context() + ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + let store!: ReturnType['create']> + ctx.provide('slots', { provideRoot: () => () => {}, + inject: (_name: string, register: Parameters[1]) => ctx.effect(register), + register: (entry: { store: ReturnType }) => { store = entry.store.create(); return () => {} }, + } as never) + ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const fiber = ctx.plugin({ inject, apply }) + await fiber.await() + let finish!: (actions: readonly MenuAction[]) => void + ctx.contextMenus.register({ target: 'pluginDocument', id: 'slow', order: 0, + resolve: () => new Promise((resolve) => { finish = resolve }) }) + const target = { kind: 'pluginDocument' as const, value: { id: 'file' } } + try { + ctx.contextMenus.open(target, { x: 20, y: 30 }) + await vi.advanceTimersByTimeAsync(100) + expect(store.getSnapshot().target).toBeNull() + finish([{ id: 'open', label: 'Open' }]) + await vi.advanceTimersByTimeAsync(0) + expect(store.getSnapshot().items.map(item => item.label)).toEqual(['Open']) + const first = store.getSnapshot().occurrence + ctx.contextMenus.open(target, { x: 22, y: 31 }) + expect(ctx.contextMenus.active.getSnapshot()).toBeNull() + expect(store.getSnapshot().closing).toBe(true) + await vi.advanceTimersByTimeAsync(80) + expect(store.getSnapshot().target).toBeNull() + ctx.contextMenus.open(target, { x: 80, y: 30 }) + finish([{ id: 'second', label: 'Second' }]) + await vi.advanceTimersByTimeAsync(40) + expect(store.getSnapshot().occurrence).toBe(first + 1) + ctx.contextMenus.open(target, { x: 180, y: 30 }) + expect(store.getSnapshot().replacing).toBe(true) + document.body.dispatchEvent(new Event('pointerdown', { bubbles: true })) + finish([{ id: 'late', label: 'Late' }]) + await vi.advanceTimersByTimeAsync(100) + expect(store.getSnapshot().target).toBeNull() + ctx.contextMenus.open(target, { x: 20, y: 30 }) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + finish([{ id: 'escape', label: 'Escape' }]) + await vi.advanceTimersByTimeAsync(100) + expect(store.getSnapshot().target).toBeNull() + } finally { await fiber.dispose() } +}) + +it('keeps trigger focus through replacement and ignores non-dismissal events and late feedback', async () => { + vi.useFakeTimers() + const ctx = new Context() + ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + let overlay!: MenuOverlayInjected + ctx.provide('slots', { provideRoot: () => () => {}, + inject: (_name: string, register: Parameters[1]) => ctx.effect(register), + register: (entry: { inject: () => MenuOverlayInjected }) => { overlay = entry.inject(); return () => {} }, + } as never) + ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const fiber = ctx.plugin({ inject, apply }) + await fiber.await() + const notify = ctx.contextMenus.notify.bind(ctx.contextMenus) + ctx.contextMenus.register({ target: 'pluginDocument', id: 'plain', order: 0, resolve: () => [{ id: 'copy', label: 'Copy' }] }) + const trigger = document.createElement('button'), menu = document.createElement('button') + menu.setAttribute('data-context-menu', '') + menu.setAttribute('role', 'menu') + document.body.append(trigger, menu) + try { + trigger.focus() + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'first' } }, { x: 10, y: 10 }) + await vi.advanceTimersByTimeAsync(40) + expect(overlay.returnFocus?.()).toBe(trigger) + menu.focus() + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'second' } }, { x: 80, y: 80 }) + await vi.advanceTimersByTimeAsync(40) + expect(overlay.returnFocus?.()).toBe(trigger) + menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 2 })) + menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + expect(ctx.contextMenus.active.getSnapshot()).not.toBeNull() + document.dispatchEvent(new MouseEvent('contextmenu', { cancelable: true })) + expect(ctx.contextMenus.active.getSnapshot()).toBeNull() + const handled = new MouseEvent('contextmenu', { cancelable: true }); handled.preventDefault() + document.dispatchEvent(handled) + document.dispatchEvent(new Event('pointerdown')) + expect(ctx.contextMenus.active.getSnapshot()).toBeNull() + const active = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null) + ctx.contextMenus.open({ kind: 'pluginDocument', value: { id: 'third' } }, { x: 80, y: 80 }) + active.mockRestore() + expect(overlay.returnFocus?.()).toBeNull() + } finally { trigger.remove(); menu.remove(); await fiber.dispose() } + notify('late') +}) + +it('leaves unbound keyboard gestures native', async () => { + const ctx = new Context() + ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + ctx.provide('slots', { provideRoot: () => () => {}, inject: () => () => {} } as never) + ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + const fiber = ctx.plugin({ inject, apply }) + await fiber.await() + try { + const event = new KeyboardEvent('keydown', { key: 'ContextMenu', bubbles: true, cancelable: true }) + document.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + } finally { await fiber.dispose() } +}) diff --git a/packages/client/ui-context-menu/tests/registry.client.spec.ts b/packages/client/ui-context-menu/tests/registry.client.spec.ts new file mode 100644 index 0000000000..c533f3b49d --- /dev/null +++ b/packages/client/ui-context-menu/tests/registry.client.spec.ts @@ -0,0 +1,218 @@ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { describe, expect, it, vi } from 'vitest' +import { MenuRegistry } from '../src/client/registry.ts' +import type { MenuAction } from '@deepseek-ai/dsh-client-ui-context-menu/client' + +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { extension: { readonly id: string } } +} +const target = { kind: 'extension' as const, value: { id: 'clicked' } } + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +describe('menu contribution lifecycle', () => { + it('orders independently resolved contributions and executes the captured target once', async () => { + const failure = vi.fn(), run = vi.fn() + const registry = new MenuRegistry(failure) + const waiting = deferred() + registry.register({ id: 'later', target: 'extension', order: 20, resolve: () => waiting.promise }) + registry.register({ id: 'first', target: 'extension', order: 10, resolve: ({ id }) => [{ id: 'open', label: id, run: () => { run(id) } }] }) + const menu = registry.resolve(target) + expect(menu.source.getSnapshot().map(x => x.id)).toEqual(['first/open']) + waiting.resolve([{ id: 'open', label: 'Later' }]); await waiting.promise + expect(menu.source.getSnapshot().map(x => x.id)).toEqual(['first/open', 'later/open']) + expect(JSON.parse(JSON.stringify(menu.source.getSnapshot()))).toEqual(menu.source.getSnapshot()) + menu.select('first/open'); menu.select('first/open') + expect(run).toHaveBeenCalledExactlyOnceWith('clicked') + expect(failure).not.toHaveBeenCalled() + registry.dispose() + }) + + it.each(['menu', 'provider', 'registry'] as const)('aborts pending metadata when its %s retires', async (retire) => { + const failure = vi.fn(), run = vi.fn() + const registry = new MenuRegistry(failure) + const waiting = deferred() + let signal!: AbortSignal + const remove = registry.register({ id: 'slow', target: 'extension', order: 0, resolve: (_target, lifetime) => { signal = lifetime; return waiting.promise } }) + const menu = registry.resolve(target) + if (retire === 'menu') menu.dispose() + if (retire === 'provider') remove() + if (retire === 'registry') registry.dispose() + expect(signal.aborted).toBe(true) + waiting.resolve([{ id: 'late', label: 'Late', run }]); await waiting.promise + expect(menu.source.getSnapshot()).toEqual([]) + menu.select('slow/late') + expect(run).not.toHaveBeenCalled() + expect(failure).not.toHaveBeenCalled() + menu.dispose(); remove(); registry.dispose() + }) + + it('withdraws resolved actions on unload and takes new contributions on the next opening', () => { + const registry = new MenuRegistry(vi.fn()), run = vi.fn() + const remove = registry.register({ id: 'old', target: 'extension', order: 0, resolve: () => [{ id: 'a', label: 'A', run }] }) + const first = registry.resolve(target) + remove(); remove() + expect(first.source.getSnapshot()).toEqual([]) + first.select('old/a'); expect(run).not.toHaveBeenCalled() + registry.register({ id: 'new', target: 'extension', order: 0, resolve: () => [{ id: 'b', label: 'B' }] }) + expect(first.source.getSnapshot()).toEqual([]) + const next = registry.resolve(target) + expect(next.source.getSnapshot()[0]?.id).toBe('new/b') + first.dispose(); next.dispose(); registry.dispose() + expect(() => registry.resolve(target)).toThrow('disposed') + }) + + it('contains provider failure and rejects duplicate contributions without corrupting other actions', async () => { + const failed = vi.fn(), run = vi.fn(), registry = new MenuRegistry(failed) + const definition = { id: 'good', target: 'extension' as const, order: 0, resolve: () => [{ id: 'a', label: 'A', run }] } + registry.register(definition) + expect(() => registry.register(definition)).toThrow('Duplicate menu contribution') + registry.register({ id: 'bad', target: 'extension', order: 1, resolve: () => [{ id: 'a', label: 'A' }, { id: 'a', label: 'B' }] }) + registry.register({ id: 'rejected', target: 'extension', order: 2, resolve: () => Promise.reject(new Error('unavailable')) }) + const menu = registry.resolve(target) + await Promise.resolve(); await Promise.resolve() + expect(failed).toHaveBeenCalledTimes(2) + expect(menu.source.getSnapshot().map(x => x.id)).toEqual(['good/a']) + menu.select('bad/a'); expect(run).not.toHaveBeenCalled() + menu.select('good/a'); expect(run).toHaveBeenCalledOnce() + registry.dispose() + }) + + it('ignores disabled, submenu and unknown selections and reports an action rejection', async () => { + const failed = vi.fn(), run = vi.fn(), registry = new MenuRegistry(failed) + registry.register({ id: 'actions', target: 'extension', order: 0, resolve: () => [ + { id: 'disabled', label: 'Disabled', disabled: true, run }, + { id: 'parent', label: 'Parent', children: [{ id: 'child', label: 'Child', run: () => Promise.reject(new Error('failed')) }], run }, + ] }) + const menu = registry.resolve(target) + menu.select('missing'); menu.select('actions/disabled'); menu.select('actions/parent') + expect(run).not.toHaveBeenCalled() + menu.select('actions/parent/child'); await Promise.resolve(); await Promise.resolve() + expect(failed).toHaveBeenCalledOnce() + registry.dispose() + }) + + it('orders equal-priority providers by identity and contains synchronous action failures', () => { + const failed = vi.fn(), registry = new MenuRegistry(failed) + registry.register({ id: 'z', target: 'extension', order: 1, resolve: () => [{ id: 'none', label: 'No action' }] }) + registry.register({ id: 'a', target: 'extension', order: 1, resolve: () => [{ id: 'throw', label: 'Fail', + failureLabel: 'Could not copy. Try again.', run: () => { throw new Error('clipboard denied') } }] }) + const first = registry.resolve(target) + expect(first.source.getSnapshot().map(item => item.id)).toEqual(['a/throw', 'z/none']) + first.select('a/throw') + expect(failed).toHaveBeenCalledWith('Could not copy. Try again.') + const second = registry.resolve(target) + second.select('z/none') + registry.dispose() + expect(() => registry.register({ id: 'late', target: 'extension', order: 0, resolve: () => [] })).toThrow('disposed') + }) + + it('does not report cancelled metadata failures, including unload during resolution', async () => { + const failed = vi.fn(), registry = new MenuRegistry(failed) + let remove = () => {} + remove = registry.register({ id: 'self-removing', target: 'extension', order: 0, resolve: () => { + remove(); throw new Error('owner unloaded') + } }) + const waiting = deferred() + registry.register({ id: 'late-rejection', target: 'extension', order: 1, resolve: () => waiting.promise }) + const menu = registry.resolve(target) + menu.dispose() + waiting.reject(new Error('cancelled')) + await Promise.resolve(); await Promise.resolve() + expect(failed).not.toHaveBeenCalled() + registry.dispose() + }) +}) + +it('omits a provider that misses its deadline and ignores its late result', async () => { + vi.useFakeTimers() + const failed = vi.fn(), registry = new MenuRegistry(failed, 50) + const waiting = deferred() + let signal!: AbortSignal + registry.register({ id: 'slow', target: 'extension', order: 0, resolve: (_target, value) => { signal = value; return waiting.promise } }) + registry.register({ id: 'ready', target: 'extension', order: 1, resolve: () => [{ id: 'copy', label: 'Copy' }] }) + const menu = registry.resolve(target) + try { + await vi.advanceTimersByTimeAsync(50) + await menu.ready + expect(signal.aborted).toBe(true) + expect(menu.source.getSnapshot().map(item => item.label)).toEqual(['Copy']) + expect(failed).toHaveBeenCalledOnce() + waiting.resolve([{ id: 'late', label: 'Late' }]) + await waiting.promise + expect(menu.source.getSnapshot().map(item => item.label)).toEqual(['Copy']) + } finally { registry.dispose(); vi.useRealTimers() } +}) + +it('replaces observed actions and callbacks, cancels older preparation, and releases every subscription', async () => { + const input = createSnapshotStore(0) + const original = input.subscribe.bind(input) + const subscribe = vi.spyOn(input, 'subscribe') + const detach = vi.fn() + subscribe.mockImplementation((listener) => { + const off = original(listener) + return () => { detach(); off() } + }) + const failed = vi.fn(), registry = new MenuRegistry(failed), run = vi.fn() + const old = deferred(), current = deferred() + const signals: AbortSignal[] = [] + const resolve = vi.fn((_target: { id: string }, signal: AbortSignal) => { + signals.push(signal) + return input.getSnapshot() === 0 ? old.promise : current.promise + }) + const remove = registry.register({ id: 'live', target: 'extension', order: 0, observe: () => [input], resolve }) + const menu = registry.resolve(target) + try { + input.set(1) + expect(signals[0]?.aborted).toBe(true) + old.resolve([{ id: 'old', label: 'Old', run }]) + await old.promise + expect(menu.source.getSnapshot()).toEqual([]) + current.resolve([{ id: 'new', label: 'New', run }]) + await menu.ready + expect(menu.source.getSnapshot().map(item => item.id)).toEqual(['live/new']) + menu.select('live/old') + expect(run).not.toHaveBeenCalled() + remove() + expect(detach).toHaveBeenCalledOnce() + expect(menu.source.getSnapshot()).toEqual([]) + input.set(2) + expect(resolve).toHaveBeenCalledTimes(2) + expect(signals.every(signal => signal.aborted)).toBe(true) + expect(failed).not.toHaveBeenCalled() + } finally { menu.dispose(); registry.dispose() } + expect(detach).toHaveBeenCalledOnce() +}) + +it('withdraws invalid reactive updates and contains observation failure without retaining stale callbacks', () => { + const input = createSnapshotStore(false), failed = vi.fn(), run = vi.fn() + const registry = new MenuRegistry(failed) + registry.register({ id: 'live', target: 'extension', order: 0, observe: () => [input], resolve: () => { + if (input.getSnapshot()) throw new Error('No access') + return [{ id: 'run', label: 'Run', run }] + } }) + registry.register({ id: 'broken', target: 'extension', order: 1, observe: () => { throw new Error('Unavailable') }, resolve: () => [] }) + const menu = registry.resolve(target) + input.set(true) + expect(menu.source.getSnapshot()).toEqual([]) + menu.select('live/run') + expect(run).not.toHaveBeenCalled() + expect(failed).toHaveBeenCalledTimes(2) + menu.dispose(); registry.dispose() +}) + +it('does not report an observation failure after its provider unloads', () => { + const failed = vi.fn(), registry = new MenuRegistry(failed) + let remove = () => {} + remove = registry.register({ id: 'removed', target: 'extension', order: 0, + observe: () => { remove(); throw new Error('Unloaded') }, resolve: () => [] }) + const menu = registry.resolve(target) + expect(menu.source.getSnapshot()).toEqual([]) + expect(failed).not.toHaveBeenCalled() + menu.dispose(); registry.dispose() +}) diff --git a/packages/client/ui-context-menu/tests/target-key.client.spec.ts b/packages/client/ui-context-menu/tests/target-key.client.spec.ts new file mode 100644 index 0000000000..0f6830f205 --- /dev/null +++ b/packages/client/ui-context-menu/tests/target-key.client.spec.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { menuTargetKey } from '../src/client/target-key.ts' +import type { ContextMenuTarget } from '../src/client/types.ts' + +declare module '@deepseek-ai/dsh-client-ui-context-menu/client' { + interface ContextMenuTargetMap { nested: { id: string; data: { a: number; b: readonly (string | null)[] } } } +} + +it('compares JSON targets independently of object key insertion order while preserving arrays', () => { + const first: ContextMenuTarget = { kind: 'nested', value: { id: 'a', data: { a: 1, b: ['x', null] } } } + const second: ContextMenuTarget = { value: { data: { b: ['x', null], a: 1 }, id: 'a' }, kind: 'nested' } + expect(menuTargetKey(first)).toBe(menuTargetKey(second)) + expect(menuTargetKey(first)).not.toBe(menuTargetKey({ kind: 'nested', value: { id: 'a', data: { a: 1, b: [null, 'x'] } } })) +}) diff --git a/packages/client/ui-context-menu/tests/trigger.client.spec.tsx b/packages/client/ui-context-menu/tests/trigger.client.spec.tsx new file mode 100644 index 0000000000..d83a906a3b --- /dev/null +++ b/packages/client/ui-context-menu/tests/trigger.client.spec.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { act, cleanup, createEvent, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { createPortal } from 'react-dom' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { apply, inject } from '../src/client/index.ts' +import { createContextMenuBindings } from '../src/client/trigger.ts' +import type { ContextMenuTriggerProps, ContextMenuTarget } from '../src/client/types.ts' + +afterEach(cleanup) +const target = (id: string): ContextMenuTarget => ({ kind: 'session', value: { sessionId: SessionId(id), displayTitle: id } }) + +it('binds targets in loops, captures deferred identity before focus changes it, and composes existing handlers', () => { + const open = vi.fn(), previous = vi.fn(), clicked = vi.fn() + let selected = 'first' + const { getTriggerProps } = createContextMenuBindings(open, () => true) + const view = render(<> + {['one', 'two'].map(id => )} + + ) + for (const id of ['one', 'two']) { + fireEvent.contextMenu(view.getByRole('button', { name: id }), { clientX: 12, clientY: 23 }) + expect(open).toHaveBeenLastCalledWith(target(id), { x: 12, y: 23 }) + } + expect(previous).toHaveBeenCalledTimes(2) + const button = view.getByRole('button', { name: 'Captured' }) + button.getBoundingClientRect = () => new DOMRect(30, 40, 50, 20) + const focus = vi.spyOn(button, 'focus') + fireEvent.keyDown(button, { key: 'F10', shiftKey: true }) + expect(open).toHaveBeenLastCalledWith(target('first'), { x: 30, y: 60 }) + expect(focus).toHaveBeenCalledWith({ preventScroll: true }) + expect(button.tabIndex).toBe(-1) + expect(clicked).not.toHaveBeenCalled() + fireEvent.contextMenu(button) + expect(open).toHaveBeenLastCalledWith(target('changed'), { x: 30, y: 60 }) +}) + +it('honors prevented and nested gestures, portals, repeats, composition, and missing targets', () => { + const open = vi.fn(), outer = vi.fn(), existing = vi.fn() + const bindings = createContextMenuBindings(open, () => true) + const parent = createContextMenuBindings(outer, () => true) + const inner = bindings.getTriggerProps(target('inner')) + const composed = bindings.getTriggerProps(target('second'), inner) + const view = render(
+ + + + {createPortal(, document.body)} +
) + const button = view.getByRole('button', { name: 'Inner' }) + fireEvent.contextMenu(button, { clientX: 1, clientY: 2 }) + fireEvent.keyDown(button, { key: 'ContextMenu' }) + expect(open).toHaveBeenCalledTimes(2) + expect(open).toHaveBeenLastCalledWith(target('inner'), { x: 0, y: 0 }) + fireEvent.contextMenu(view.getByRole('button', { name: 'Blocked' })) + fireEvent.keyDown(view.getByRole('button', { name: 'Blocked' }), { key: 'ContextMenu' }) + for (const extra of [{ repeat: true }, { isComposing: true }, { ctrlKey: true }, { altKey: true }, { metaKey: true }, { key: 'Enter' }]) { + fireEvent.keyDown(button, { key: 'ContextMenu', ...extra }) + } + const prevented = createEvent.contextMenu(button) + prevented.preventDefault(); fireEvent(button, prevented) + expect(open).toHaveBeenCalledTimes(2) + fireEvent.contextMenu(view.getByRole('button', { name: 'Portal' }), { clientX: 4, clientY: 5 }) + expect(open).toHaveBeenCalledTimes(3) + expect(outer).not.toHaveBeenCalled() + fireEvent.keyDown(view.getByRole('button', { name: 'Native' }), { key: 'a' }) + expect(existing).toHaveBeenCalledOnce() +}) + +it('injects the hook through the root Slot and withdraws both its prop and retained handlers on unload', async () => { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) } as never) + runtime.ctx.provide('locale', { register: () => () => {}, bind: () => (key: string) => key } as never) + let retained: ContextMenuTriggerProps = {} + let received = false + await runtime.root.declare({}, ({ useContextMenu }) => { + received = typeof useContextMenu === 'function' + if (!received) return null + retained = useContextMenu().getTriggerProps(target('injected')) + return + }) + const plugin = await runtime.mount({ inject, apply }) + const view = runtime.renderRoot() + expect(received).toBe(true) + act(() => { fireEvent.contextMenu(view.getByRole('button')) }) + expect(runtime.ctx.contextMenus.active.getSnapshot()).toEqual(target('injected')) + const source = runtime.ctx.contextMenus.active + await plugin.dispose() + expect(received).toBe(false) + expect(source.getSnapshot()).toBeNull() + const detached = render() + expect(fireEvent.contextMenu(detached.getByRole('button'))).toBe(true) + expect(fireEvent.keyDown(detached.getByRole('button'), { key: 'ContextMenu' })).toBe(true) + expect(source.getSnapshot()).toBeNull() +}) diff --git a/packages/client/ui-context-menu/tsconfig.json b/packages/client/ui-context-menu/tsconfig.json new file mode 100644 index 0000000000..e12f61a4c8 --- /dev/null +++ b/packages/client/ui-context-menu/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../core/session" + }, + { + "path": "../locale/tsconfig.client.json" + }, + { + "path": "../store" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-dockkit" + }, + { + "path": "../ui-layout" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../shortcuts/tsconfig.client.json" + } + ] +} diff --git a/packages/client/ui-context-menu/tsdown.config.ts b/packages/client/ui-context-menu/tsdown.config.ts new file mode 100644 index 0000000000..7aa03cebc1 --- /dev/null +++ b/packages/client/ui-context-menu/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-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 8f74c9dfc6..0f945d3708 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,11 +21,11 @@ en: cc4dafd1aae02468 zh: be6b67724b7ac4a3 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 08cf047cee6419bd - zh: 1f464f059753eacb + en: 9b596b668979544a + zh: 159f24dfc8715c92 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: - en: bb1244310f5c0382 - zh: 824b84077432b559 + en: 59cd63057dfb6eeb + zh: b2e2c86e810b7bb8 /deepseek-ai-dsh-client-ui-conversation/model-experience: en: e0a05b73ba3e4318 zh: dc85ab8433bb4456 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 1fdfca0f3c..2002f10c1b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -68,6 +68,8 @@ The existing semantic `DraftSnapshot` retains quote text and clipboard-coordinat ## Shell and standard props +Below 768px, Web view tabs align with the header's 20px left inset. Desktop and wider Web windows retain the additional 8px tab inset. + 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. @@ -182,6 +184,8 @@ The selector must be a pure function of the owner currency. Its non-null return `conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls and the context meter while preserving the editor and submit action. Closing the activity restores those controls with context details closed. The empty hero dock remains collapsed when it has no content. The occupant releases expansion on unmount and owns any activity-specific feedback. +Session title context gestures use the shared [context menu](../ui-context-menu/README.md) with the clicked breadcrumb’s Session. The reusable image gallery receives `getTriggerProps` from its Factory’s injected `useContextMenu` hook. + ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index dd01ddf45b..b1d1c5fbd3 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -68,6 +68,8 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS ## Shell 与标准 props +宽度小于 768px 时,Web 的 View 标签与页头的 20px 左内边距对齐。桌面客户端和较宽的 Web 窗口保留标签额外的 8px 左内边距。 + 共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。 在粗指针设备上,composer 的草稿字号至少为 16px,以避免获得焦点时浏览器自动放大;更大的字号偏好仍然生效。 @@ -182,6 +184,8 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match `conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件,上下文详情保持关闭。首页输入框下方没有内容时,该区域保持收起。占用者在卸载时释放展开状态,并拥有活动专属反馈。 +会话标题右键使用共享[右键菜单](../ui-context-menu/README.zh.md),目标是被点击面包屑所属会话。可复用图片库从其 Factory 注入的 `useContextMenu` hook 获取 `getTriggerProps`。 + ## 模型体验 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 78e1b39d8a..f168e7f6c8 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-ui-workspace" + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-ui-context-menu" ], "platform": "web" } @@ -97,7 +98,8 @@ "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", - "@deepseek-ai/dsh-message-quotes": "workspace:*" + "@deepseek-ai/dsh-message-quotes": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "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 b3c0d3bcd0..3328e1e5da 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,3 +1,4 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Registers the target-neutral Conversation assembly, shell, input, and docks. */ import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context } from '@deepseek-ai/cordis' diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index caec4849b4..369f562a7b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -475,6 +475,7 @@ export interface ConversationViewActionsOwnerProps { /** Business callbacks injected into the strict Session header. */ export interface ConversationSessionHeaderInjected { + /** Open the clicked title's actions. */ /** Package-owned View roster source bound only for the Conversation header. */ readonly hooks: { readonly conversationViews: ObservableSnapshot diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index d4f06accc5..7a39200c30 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,3 +1,4 @@ +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Browser Conversation assemble core, React adapter, shell, input, and text projection plugin. */ export type {} from './user-text/session-reference-rule.ts' 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 ac6cb69f70..bf1a1e8d02 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -224,6 +224,12 @@ padding-left: 8px; } +@media (max-width: 767px) { + :global(html:not([data-platform])) .tabs { + padding-left: 0; + } +} + .viewTabs { display: flex; flex-wrap: wrap; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index deaddcf769..14602cbca0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -107,8 +107,9 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum */ export function ConversationSessionHeader({ sessionId, hideChrome, useSessions, useConversationViews, useStore, - renderSlot, open, selectView, setPinned, t, useEntryPins, useEntryActions, + renderSlot, open, useContextMenu, selectView, setPinned, t, useEntryPins, useEntryActions, }: ConversationSessionHeaderProps) { + const { getTriggerProps } = useContextMenu() const tabs = useConversationViews(value => value) const selectedId = useStore(s => s.view) const active = resolveActiveView(tabs, selectedId) @@ -176,7 +177,7 @@ export function ConversationSessionHeader({ ...last ? {} : { openTitle: () => { open(summary.id) } }, } return ( - + {index > 0 && /} {lineage ? summary.subagent 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 523fcffaae..d968bdc4d8 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client' import { render, cleanup } from '@testing-library/react' import { $getRoot, $isTextNode, PASTE_COMMAND } from 'lexical' import { createUserTextFixture } from './user-text-component-fixture.client.tsx' @@ -103,6 +104,8 @@ async function bench(renderQuote = false) { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, 'quote-probe': { kind: 'single', scope: 'session' }, 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 a7b0bfdbdf..154d31ec9f 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client' import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages, @@ -25,6 +26,8 @@ async function bench(options: { declareConversation?: boolean } = {}) { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) if (options.declareConversation !== false) { await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index e4c2a2c037..c8e96efdbc 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { apply as applyMenus, inject as injectMenus } from '@deepseek-ai/dsh-client-ui-context-menu/client' /** Conversation assembly acceptance independent of Tool presentation. */ import './control-row-dom.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -86,6 +87,8 @@ async function bench(opts?: { blank?: boolean }) { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, @@ -109,6 +112,8 @@ describe('resident composer', () => { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...inject], apply }) runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe) @@ -136,6 +141,8 @@ describe('resident composer', () => { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) await runtime.workspaces.update((draft) => { draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never }) @@ -203,6 +210,8 @@ describe('prompt rejection through the assembled composer', () => { const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('shortcuts', { catalog: createSnapshotStore([]) }) + await runtime.mount({ inject: [...injectMenus], apply: applyMenus }) const prompt = vi.fn(async () => ({ ok: false, error: new RemoteError('session/agent-busy', 'prompt rejected before acceptance', { reason: 'busy' }), diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index f4b80c891c..3106eeb495 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -47,6 +48,7 @@ function mount() { useResource, useWorkspaces: emptyWorkspaces(), ...allowAllAccess, + useContextMenu: contextMenuHook(), useBusyEnter: bindSnapshotSelector(policy.busyEnter), setBusyEnter, t: makeTranslate(en), 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 aa9a983654..4be70c648d 100644 --- a/packages/client/ui-conversation/tests/header-styles.client.spec.ts +++ b/packages/client/ui-conversation/tests/header-styles.client.spec.ts @@ -5,6 +5,12 @@ import { describe, expect, it } from 'vitest' const css = readFileSync(new URL('../src/client/skeleton/ConversationRoot.module.css', import.meta.url), 'utf8') describe('conversation header geometry', () => { + it('removes the extra tab inset only in narrow Web windows', () => { + const narrow = /@media \(max-width: 767px\) \{([\s\S]*?)\n\}/.exec(css)?.[1] + expect(narrow).toContain(':global(html:not([data-platform])) .tabs') + expect(narrow).toContain('padding-left: 0') + }) + 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') 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 a657abf806..da4c7dc61b 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' // InputBar behavior over the editor + submit-machine wiring: Enter-send // semantics (IME guard, Shift newline, busy Enter policy, Ctrl/Meta steering, // repeat suppression), running semantics (input stays free; continuable @@ -195,6 +196,7 @@ function bench(over?: BenchOptions) { items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })), ...allowAllAccess, + useContextMenu: contextMenuHook(), useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint), useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 6a18cede9a..517848498a 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' /** * Impact-matrix projection tests (row by row): what each * phase projects onto the InputBar — enter routing, visuals (command chip / @@ -67,6 +68,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled baselinesReady: true, recentWorkspaceId: undefined, })), ...allowAllAccess, + useContextMenu: contextMenuHook(), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 380097b5d1..2129c605d2 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' /** * Scenario-chain integration (scenarios A/C/D/H/I): the real per-session * InputTriggerController pipeline over a real session scope (Client Sessions over @@ -169,6 +170,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo baselinesReady: true, recentWorkspaceId: undefined, })), ...allowAllAccess, + useContextMenu: contextMenuHook(), useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), 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 4583814093..44878bbc18 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' /** * QueueDock rendering and operations: authoritative rows, inline editing, * collapse state, removal, QueueDock Steer, failure notices, and live retirement. @@ -118,6 +119,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial ), useWorkspaces: (() => { throw new Error('unused') }) as never, ...allowAllAccess, + useContextMenu: contextMenuHook(), useProjection: (() => undefined) as never, useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'], diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 2f7a1fd979..5f07662274 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import type { GlobalStandardProps, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createContext, useContext, type ReactNode } from 'react' @@ -235,7 +236,7 @@ function mount( useTrajectory={useTrajectory} useSessions={props.useSessions} usePanelInfo={props.usePanelInfo} - useResource={useResource} + useContextMenu={contextMenuHook()} useResource={useResource} useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} @@ -265,7 +266,7 @@ function mount( useTrajectory={useTrajectory} useSessions={props.useSessions} usePanelInfo={props.usePanelInfo} - useResource={useResource} + useContextMenu={contextMenuHook()} useResource={useResource} useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} @@ -289,7 +290,7 @@ function mount( children} - useResource={useResource} + useContextMenu={contextMenuHook()} useResource={useResource} useSession={useSession} useConversation={useConversation} useSessions={props.useSessions} @@ -353,6 +354,7 @@ function mount( useTrajectory, useSessions: bindSnapshotSelector(sessions), usePanelInfo: selector => selector({ activePanelId: null }), + useContextMenu: contextMenuHook(), useResource, useSessionStatus, useSessionRetainInfo: () => undefined, @@ -386,6 +388,7 @@ function mount( useSessions: bindSnapshotSelector(sessions), useSessionStatus, useSessionRetainInfo: () => undefined, + useContextMenu: contextMenuHook(), useResource, useWorkspaces: bindSnapshotSelector(workspaces), ...allowAllAccess, 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 f4970b398d..c5383f3091 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 @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { createElement, type ComponentProps, type ReactNode } from 'react' import { onTestFinished } from 'vitest' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, contextMenuHook, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' 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' @@ -62,7 +62,7 @@ export function createUserTextFixture(actions: UserTextFixtureActions = {}): (in 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) + case 'file': return createElement(FileReference, { ...owner, openFile, t, useContextMenu: contextMenuHook(), resolveFilePath: (path: string) => path } as ComponentProps) case 'folder': return createElement(FolderReference, owner as ComponentProps) case 'skill': return createElement(SkillReference, { ...owner, openSkill } as ComponentProps) case 'command': return createElement(GoalCommandReference, owner as ComponentProps) diff --git a/packages/client/ui-conversation/tsconfig.client.json b/packages/client/ui-conversation/tsconfig.client.json index 0339eebbe6..0187cf5d72 100644 --- a/packages/client/ui-conversation/tsconfig.client.json +++ b/packages/client/ui-conversation/tsconfig.client.json @@ -104,6 +104,9 @@ }, { "path": "../product-analytics/tsconfig.client.json" + }, + { + "path": "../ui-context-menu" } ], "exclude": [ diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ff6839eee5..675bcff105 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -33,8 +33,8 @@ en: 7ee5ee783902662c zh: fdf2c244fb22b8ab /deepseek-ai-dsh-client-ui-deliverables/further-exploration: - en: 476b59e8671db982 - zh: b3d0495a03e8940a + en: 98eda99fafcb04b9 + zh: e6a21254c0383f48 /deepseek-ai-dsh-client-ui-deliverables/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 6727c12776..0632574d83 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -86,6 +86,8 @@ Read these pages when the deliverables surface is not enough. They move from the ----- +Delivery cards and known inline file mentions expose a [context menu](../ui-context-menu/README.md) for their declared Session file, preserving the original path and optional line. + ## Model Experience diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 45484627dd..2951888e72 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -86,6 +86,8 @@ Node 半部提供经过身份验证的原生文件打开;共用指导由[模 ----- +交付卡片和已知行内文件引用为其声明的会话文件提供[右键菜单](../ui-context-menu/README.zh.md),保留原始路径和可选行号。 + ## 模型体验 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 32cd775ef3..4172b1f4c2 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Changed-files card with per-file comparison tabs, delivery cards, and clickable final-response file references for Web", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, @@ -37,7 +37,8 @@ "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-tool", - "@deepseek-ai/dsh-client-ui-settings" + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-context-menu" ], "platform": "web" } @@ -80,7 +81,8 @@ "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", - "@deepseek-ai/dsh-client-ui-settings": "workspace:*" + "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-context-menu": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 544e7a82b2..89cfa24afa 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -66,11 +66,12 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail * @returns the closing turn's file rows. */ export function Deliverables({ - matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost, + matched, openFile, useContextMenu, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost, useChangesDiff, loadChangesDiff, useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot, }: Pick & { matched: DeliverablesMatch -} & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { +} & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { + const { getTriggerProps } = useContextMenu() const [expanded, setExpanded] = useState(false) const showCodeDiff = useShowCodeDiff(value => value) const cwd = useSessions(state => state.byId[sessionId]?.cwd) @@ -109,6 +110,7 @@ export function Deliverables({ phase={states[presentedFileUrl(sessionId, file.seq, file.index)]} host={host === 'error' ? null : host} t={t} onPreview={() => { openFile(file.path) }} + contextMenuProps={getTriggerProps({ kind: 'file', value: { sessionId, path: file.path } })} actions={renderSlot('deliverables.file.actions', { actionUrl: presentedFileUrl(sessionId, file.seq, file.index), available: host !== null && host !== 'error' && host.available, diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index b40bfa40a0..6e71d25d15 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,3 +1,4 @@ +import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client' /** File identity, Sidebar preview, and contributed native actions for one delivery. */ import type { ReactNode } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' @@ -19,12 +20,13 @@ function cardDescription(description: string | undefined, fallback: string): str * @param props - durable file metadata, Sidebar preview, Host capabilities, gesture status, and localized copy. * @returns the file card and its anchored action menu. */ -export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions, t }: { +export function PresentedFileCard({ file, cwd, phase, host, onPreview, contextMenuProps, actions, t }: { file: PresentedPath cwd: string | undefined phase: PresentedOpenPhase | undefined host: PresentedHost | null onPreview: () => void + contextMenuProps: ContextMenuTriggerProps actions: ReactNode } & PropsLocale) { const reveal = host?.fileManager ?? 'directory' @@ -36,7 +38,8 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions, : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) return
) + fireEvent.contextMenu(trigger.getByRole('button'), { clientX: 41, clientY: 52 }) + trigger.unmount() + expect(vi.spyOn(ctx.contextMenus, 'open')).toHaveBeenLastCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'out/report.docx' } }, { x: 41, y: 52 }) mention?.open() } } @@ -1014,6 +1025,8 @@ describe('presented files', () => { const props = openProps() props.openPresented.mockResolvedValue({ kind: 'opened' }) const view = render() + fireEvent.contextMenu(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' }), { clientX: 18, clientY: 29 }) + expect(props.openMenu).toHaveBeenCalledWith({ kind: 'file', value: { sessionId: 'child-session', path: 'report-0.docx' } }, { x: 18, y: 29 }) expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) const expand = view.getByRole('button', { name: 'Show all 8 delivered files' }) expect(expand.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 192fb447de..0b009d5915 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -2,20 +2,25 @@ /** Explicit file actions preserve their destination, availability, and independent failure state. */ import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { contextMenuHook, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { PresentedFileCard } from '../src/client/PresentedFileCard.tsx' import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) -const props = () => ({ - cwd: undefined, - file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, - host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, - phase: undefined, - onPreview: vi.fn(), - actions: , - t: makeTranslate(en), -}) +const props = () => { + const onMenu = vi.fn() + return { + cwd: undefined, + file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, + host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, + phase: undefined, + onPreview: vi.fn(), + onMenu, contextMenuProps: contextMenuHook((_target, point) => { onMenu(point) })() + .getTriggerProps({ kind: 'image', value: { url: 'unused' } }), + actions: , + t: makeTranslate(en), + } +} it('localizes reveal failures and restores the description when progress clears', () => { const p = props() const view = render() @@ -66,6 +71,8 @@ it('renders the supplied action independently from the card preview', () => { fireEvent.click(view.getByRole('button', { name: 'Native file action' })) expect(native).toHaveBeenCalledOnce() expect(p.onPreview).not.toHaveBeenCalled() + fireEvent.contextMenu(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' }), { clientX: 26, clientY: 37 }) + expect(p.onMenu).toHaveBeenCalledWith({ x: 26, y: 37 }) fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) expect(p.onPreview).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json index 4fe248edd6..bd78605122 100644 --- a/packages/client/ui-deliverables/tsconfig.client.json +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -68,6 +68,9 @@ }, { "path": "../../util/workspace-path" + }, + { + "path": "../ui-context-menu" } ] } diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 6a89ebbb8c..035f2bfe7b 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 4083b08a91..46a287905c 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 6bff57dde1..0fadf55cad 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -18,8 +18,8 @@ en: 718f52cfce426009 zh: e8282e2e3713d242 /deepseek-ai-dsh-client-ui-dockkit/embedding-it: - en: 87fab28e0608044e - zh: 2d5f80a367f1852b + en: 53bbabeb910c6f77 + zh: 51fb4f8fb78cfd02 /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: en: cf2d00ce2b7d5719 zh: 5510420f8872e23a diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 12f5b74938..a446fdedd6 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -60,6 +60,8 @@ Everything host-specific arrives through props: `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. +The split control wrapper exposes `data-dockkit-split-control` for embedder styling, independently of its element tag. + A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end. `DockLayout`, used by the Sidebar, keeps each tab in a stable DOM cell across selection, pane moves and floating. It accepts one docked pane or two horizontal panes; CSS Grid resolves their widths. `keepMounted(tab)` retains visited bodies and `active` controls Session visibility without reparenting content. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 06b1a593f3..d94e4ee9f3 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -60,6 +60,8 @@ kind: "package-reference" `dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。 +分栏控件的包裹元素通过 `data-dockkit-split-control` 提供宿主样式入口,不依赖具体元素标签。 + tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。 Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间,把每个 tab 保留在稳定的 DOM 容器中。它接受一个停靠格或左右两个格,由 CSS Grid 计算宽度。`keepMounted(tab)` 保留已访问正文,`active` 控制 Session 可见性,不改变内容的父子关系。 diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index 20b15c7b52..c626d2c657 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-dockkit", "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)", - "version": "0.2.1-rc.4", + "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 846a4b75e8..36ec07ca0b 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -9,9 +9,9 @@ * drop target changes nothing. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { ReactNode } from 'react' +import type { ReactNode, HTMLAttributes } from 'react' import type { DockIntents, DockLabels, TabMenuExtras, TabRenderer } from '../contract/adapter.ts' -import type { LayoutState, PaneId, SplitId, TabId } from '../contract/types.ts' +import type { LayoutState, PaneId, SplitId, TabId, TabRecord } from '../contract/types.ts' import { clampSizes, FLOAT_DEFAULT_SIZE, MIN_PANE_FRACTION } from '../engine/constraints.ts' import { getSplit, topRightPaneId } from '../engine/tree.ts' import type { DropTarget, HalvesFit } from '../engine/geometry.ts' @@ -63,7 +63,11 @@ export interface DockSurfaceProps { * slot, and nothing outside that embedder is expected to supply it. */ readonly renderTabTitle?: TabRenderer - /** Extra items for a tab's context menu; omit for the kit's own item only. */ + /** Whether the embedder popup is preparing or visible; suppresses tab tooltips. */ + readonly tabMenuOpen?: boolean | undefined + /** Override the kit popup; the embedder owns rendering and dismissal. */ + readonly getTabTriggerProps?: ((tab: TabRecord, paneId: PaneId, handlers?: Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) => Pick, 'onContextMenu' | 'onKeyDown' | 'tabIndex'>) | undefined + /** Extra rows for the kit popup; ignored when getTabTriggerProps is supplied. */ readonly renderTabMenuItems?: TabMenuExtras /** * Surface-wide controls, drawn at the far end of the top-right pane's tab @@ -169,7 +173,8 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean { /** The split tree and the gestures over it. */ function Surface({ - state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, + state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, + getTabTriggerProps, tabMenuOpen, chrome, onRoom, draw, dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false, }: DockSurfaceProps & { readonly draw: (callbacks: PaneCallbacks, preview: SizePreview | undefined) => ReactNode }): ReactNode { @@ -291,6 +296,7 @@ function Surface({ renderTab, renderTabTitle, renderTabMenuItems, + getTabTriggerProps, tabMenuOpen, chromePaneId: topRightPaneId(state), chrome, } diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index 632a763ff3..a7d2e721ea 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -22,6 +22,7 @@ * `canAddTab` allows), the pane's split control, and the embedder's chrome keep * their width and their place at the strip's end. */ +import type { KeyboardEvent } from 'react' import { Fragment, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode, RefObject } from 'react' import clsx from 'clsx' @@ -303,6 +304,13 @@ export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): Re // A pane's lone unclosable chip is a label, not a choice: there is // no other tab to select against and nothing to do to it. const quiet = !closable && pane.tabs.length === 1 + const onKeyDown = (event: KeyboardEvent) => { + if (event.target !== event.currentTarget) return + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return + const next = chipToFocus(event.key, pane.tabs, tabId) + if (next !== undefined) { event.preventDefault(); focusChip(next); return } + if (selects(event.key)) { event.preventDefault(); activate(tabId) } + } return ( {(index > 0 || stripIndex === index) && ( @@ -350,30 +358,18 @@ export function TabStrip({ state, pane, callbacks, offsets }: TabStripProps): Re event.stopPropagation() activate(tabId) }} - onKeyDown={(event) => { - // Keys on the chip's nested close control are that control's. - if (event.target !== event.currentTarget) return - if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return - const next = chipToFocus(event.key, pane.tabs, tabId) - if (next !== undefined) { - event.preventDefault() - focusChip(next) - return - } - if (selects(event.key)) { - event.preventDefault() - activate(tabId) - } - }} - onContextMenu={(event) => { - event.preventDefault() - const anchor = event.currentTarget - setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor }) + {...callbacks.getTabTriggerProps?.(tab, pane.id, { onKeyDown, tabIndex: selected ? 0 : -1 }) ?? { + onKeyDown, + onContextMenu: (event) => { + event.preventDefault(); event.stopPropagation() + const anchor = event.currentTarget + setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor }) + }, }} > {callbacks.renderTabTitle?.(tab) ?? tab.title} {closable && ( - + diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 1fd68864bb..d3c24aad2b 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -34,6 +34,11 @@ max-width: 360px; } +/* Legacy slots can render no rows; keep empty context popups out of layout. */ +.list[data-context-menu]:not(:has([role='menuitem'])) { + display: none; +} + /* Portal mode: fixed in the viewport, coordinates supplied inline from the * anchor rect (side/align resolved in JS, the in-place offset rules above * don't apply). Portaled lists must layer above modal overlays (z 1000) — @@ -87,6 +92,12 @@ border-top: 0.5px solid var(--dsw-alias-border-l2); } +.list[data-context-menu] .footer:has(> .separator:first-child) { + margin-top: 0; + padding-top: 0; + border-top: none; +} + .itemWrap { position: relative; } @@ -153,7 +164,7 @@ } .compactList .separator { - margin: 2px; + margin: 2px 6px; } .compactList .label { @@ -258,11 +269,11 @@ color: var(--dsw-alias-label-tertiary); } -/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. l2, not - * l1, for the same reason as .footer: l1 is near-invisible on this surface. */ +/* Separator endpoints align with the row content. The l2 stroke stays visible + * on the menu surface. */ .separator { height: 0.5px; - margin: 3px 2px; + margin: 3px 8px; background: var(--dsw-alias-border-l2); } @@ -274,6 +285,11 @@ display: none; } +/* A legacy outlet following structured rows is not the beginning of the menu. */ +.list[data-context-menu] .viewport > .itemWrap ~ [data-slot] .itemWrap:first-child > .separator:first-child { + display: block; +} + /* The submenu aligns with the parent's bottom edge. Its hit bridge covers * the menu inset and the 6px gap so pointer movement keeps it open. */ .submenu { @@ -293,3 +309,7 @@ left: -10px; width: 10px; } + +.list[data-menu-phase='dismiss'], +.list[data-menu-phase='replace'] { pointer-events: none; } +.list[data-context-menu].scrollable { max-height: calc(100vh - 24px); } diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 68b3a5766e..753de26f95 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode, SyntheticEvent } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' @@ -24,7 +24,7 @@ export interface MenuItem { /** Destructive row: error-colored text/icon and danger hover fill. */ danger?: boolean /** Nested card opened to the right on hover/focus. */ - submenu?: readonly MenuItem[] + submenu?: readonly MenuEntry[] } /** Hairline between item groups (not selectable). */ @@ -115,6 +115,25 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { return 'type' in entry && entry.type === 'label' } +/** Mark icon columns within each separator-delimited data group. */ +function iconColumns(entries: readonly MenuEntry[], contextual: boolean): ReadonlySet { + const aligned = new Set() + if (!contextual) return aligned + let group: MenuEntry[] = [] + const finish = (): void => { + if (group.some(entry => !isLabel(entry) && !isSeparator(entry) && entry.icon !== undefined)) { + for (const entry of group) aligned.add(entry) + } + group = [] + } + for (const entry of entries) { + if (isSeparator(entry)) finish() + else group.push(entry) + } + finish() + return aligned +} + /** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } @@ -125,6 +144,9 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * anchor's first button, and selecting a row does the same — the rows unmount * with the list. Only a keyboard on the trigger or inside the list is * intercepted; Tab presses elsewhere on the page stay the browser's. + * @param props.contextual - cursor positioning, context dismissal, and per-divider icon alignment. + * @param props.exiting - retain an inert popup during dismissal or replacement fade. + * @param props.returnFocus - original control captured before asynchronous menu preparation. * @param props.autoFocus - focus the first item on open; the arrow keys walk the list either way. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). @@ -155,7 +177,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * frame, and scroll/resize; return null to skip placement for that frame. * @param props.minWidthFromAnchor - in portal mode, keep the menu at least as wide as its anchor, bounded by viewport margins. * @param props.footer - rows pinned below the scrolling items area, separated - * by a hairline; they stay visible while the items above scroll. + * by a hairline; contextual menus reuse an explicit leading separator. These rows stay visible while the items above scroll. * @param props.children - component rows rendered after `items` in the same * list, each a `role="menuitem"` button such as {@link MenuItemButton}; they * share the keyboard walk, the submenu exclusivity, and the post-selection @@ -170,8 +192,14 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * document.body outside the owner's DOM subtree. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName }: { +export function Menu({ open, anchor, items = [], children, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, minWidthFromAnchor = false, footer, className, listClassName, contextual = false, exiting, returnFocus }: { open: boolean + /** Pointer-positioned popup: cursor clearance, grouped icons, and context gestures. */ + contextual?: boolean + /** Resolve the original control for focus restoration and inert/hidden dismissal. */ + returnFocus?: (() => HTMLElement | null) | undefined + /** Retain a dismissed context popup during its short fade. */ + exiting?: 'dismiss' | 'replace' | undefined autoFocus?: boolean anchor: ReactNode items?: readonly MenuEntry[] @@ -195,6 +223,17 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI }) { const rootRef = useRef(null) const listRef = useRef(null) + const menuId = useId() + useLayoutEffect(() => { + const trigger = returnFocus?.() + if (!open || !contextual || trigger == null) return + const previous = trigger.getAttribute('aria-controls') + trigger.setAttribute('aria-controls', [previous, menuId].filter(Boolean).join(' ')) + return () => { + if (previous === null) trigger.removeAttribute('aria-controls') + else trigger.setAttribute('aria-controls', previous) + } + }, [open, contextual, returnFocus, menuId]) /** Index the arrow walk last focused, the resume point when focus left the rows. */ const walkIndex = useRef(null) /** @@ -213,7 +252,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI * @param navigation - whether explicit keyboard traversal should retain its focus indicator. */ const refocusAnchor = (navigation = false): void => { - const trigger = triggerRef.current + const trigger = returnFocus?.() ?? triggerRef.current const target = trigger !== null && document.contains(trigger) && !(trigger as HTMLButtonElement).disabled ? trigger : rootRef.current?.querySelector('button:not(:disabled)') if (target == null) return @@ -231,15 +270,19 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI const refocusAfterSelection = (): void => { const navigation = selectingWithTab.current queueMicrotask(() => { - if (openRef.current) return + if (openRef.current && exitingRef.current === undefined) return const active = document.activeElement if (active === null || active === document.body || listRef.current?.contains(active) === true) refocusAnchor(navigation) }) } + const exitingRef = useRef(exiting) + exitingRef.current = exiting const openRef = useRef(open) openRef.current = open const [openSubmenuId, setOpenSubmenuId] = useState(null) const [fixedPos, setFixedPos] = useState(null) + const close = useRef(onClose) + close.current = onClose const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose) // Portal mode: fixed-position the list from the anchor rect before paint; @@ -250,6 +293,11 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI useLayoutEffect(() => { if (!open || !portal) { setFixedPos(null); return } const place = () => { + const anchorElement = returnFocus?.() ?? rootRef.current + if (anchorElement?.closest('[inert], [hidden]') != null) { + if (exitingRef.current === undefined) close.current() + return + } let r: DOMRect | null if (getAnchorRect !== undefined) { r = getAnchorRect() @@ -284,7 +332,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) - if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, contextual ? MARGIN : overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN) setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y }) } @@ -305,49 +353,49 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, portal, align, side, getAnchorRect, minWidthFromAnchor]) + }, [open, portal, align, side, getAnchorRect, minWidthFromAnchor, contextual, returnFocus]) // Opening remembers where the keyboard was, so closing can hand it back to // that control — an anchor wrapping several (a split button) cannot be asked // for it by position. Declared before the autoFocus effect so the capture // sees the trigger, not the row autoFocus is about to focus. useEffect(() => { - if (!open) { - triggerRef.current = null - return - } + if (!open) return const active = document.activeElement - triggerRef.current = active instanceof HTMLElement && rootRef.current?.contains(active) === true ? active : null + triggerRef.current = active instanceof HTMLElement && (getAnchorRect !== undefined || rootRef.current?.contains(active) === true) + ? active : null }, [open]) useEffect(() => { - if (!open || !autoFocus) return + if (!open || exiting !== undefined || (portal && fixedPos === null) || !autoFocus + || listRef.current?.contains(document.activeElement)) return const first = listRef.current?.querySelector('button:not(:disabled)') walkIndex.current = first === undefined || first === null ? null : 0 if (first != null) focusWithoutRing(first) - }, [open, autoFocus]) + }, [open, autoFocus, items.length, footer?.length, fixedPos, portal, exiting]) useEffect(() => { - if (!open) { + if (!open || exiting !== undefined) { setOpenSubmenuId(null) walkIndex.current = null return } const composition = observeComposition(document) const onPointerDown = (e: PointerEvent) => { - if (!(e.target instanceof Node)) return + if (!(e.target instanceof Node) || (contextual && e.button === 2)) return // The portaled list is outside the anchor subtree; check both. if (rootRef.current?.contains(e.target) === true) return if (listRef.current?.contains(e.target) === true) return onClose() } const onKeyDown = (e: KeyboardEvent) => { - if (composition.guards(e) || isBehindModal(rootRef.current) || e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey) return + if (composition.guards(e) || isBehindModal(triggerRef.current ?? rootRef.current) + || e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey) return // Where the keyboard is, computed once: the menu owns it when it holds a // row or sits on its anchor region. const focused = document.activeElement const insideList = listRef.current?.contains(focused) === true - const anchored = rootRef.current?.contains(focused) === true || insideList + const anchored = focused === returnFocus?.() || rootRef.current?.contains(focused) === true || insideList if (e.key === 'Escape' && !e.shiftKey) { e.preventDefault() if (e.repeat) return @@ -433,7 +481,11 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI document.removeEventListener('keydown', onEscape, true) window.removeEventListener('blur', onWindowBlur) } - }, [open, onClose, autoFocus]) + }, [open, onClose, autoFocus, contextual, exiting]) + + useLayoutEffect(() => { + if (exiting === 'dismiss' && listRef.current?.contains(document.activeElement)) refocusAnchor() + }, [exiting]) // A close from selection/Escape/outside click outruns a pending grace close; // left armed it would shut a list reopened inside the grace window. Its own @@ -447,7 +499,9 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI // scroll clip would crop it, so only submenu-free menus get the height cap. const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) - const renderEntry = (entry: MenuEntry) => { + const itemColumns = iconColumns(items, contextual) + const footerColumns = iconColumns(footer ?? [], contextual) + const renderEntry = (entry: MenuEntry, columns: ReadonlySet) => { if (isSeparator(entry)) { return
} @@ -456,6 +510,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id + const subColumns = iconColumns(entry.submenu ?? [], contextual) const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true return (
- {entry.icon !== undefined && {entry.icon}} + {(entry.icon !== undefined || columns.has(entry)) && } {entry.label} {entry.shortcut !== undefined && } {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} @@ -489,21 +544,23 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI {subOpen && entry.submenu !== undefined && ( - {entry.submenu.map(sub => ( - - ))} + {entry.submenu.map(sub => isSeparator(sub) + ?
+ : isLabel(sub) ?
{sub.text}
: ( + + ))} )}
@@ -526,11 +583,14 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI // already at the final position (with getAnchorRect returning null the // list simply stays hidden). const list = open && ( - { + event.preventDefault(); event.stopPropagation(); onClose() + } : undefined} onMouseOver={collapseSubmenuFrom} onFocus={collapseSubmenuFrom} >
- {items.map(renderEntry)} + {items.map(entry => renderEntry(entry, itemColumns))} {children}
{footer !== undefined && footer.length > 0 && (
- {footer.map(renderEntry)} + {footer.map(entry => renderEntry(entry, footerColumns))}
)}
diff --git a/packages/client/ui-primitives/src/MenuSurface.module.css b/packages/client/ui-primitives/src/MenuSurface.module.css index 57d9308455..896264095d 100644 --- a/packages/client/ui-primitives/src/MenuSurface.module.css +++ b/packages/client/ui-primitives/src/MenuSurface.module.css @@ -47,3 +47,19 @@ background: var(--dsw-alias-bg-base); pointer-events: none; } + +/* Opacity on the container would prevent its material from blurring the page. + * Enter animations release their compositing layers so nested menus can blur too. */ +.surface[data-menu-phase='enter'] > *, +.backing[data-menu-phase='enter'] { animation: dsh-context-menu-in var(--dsh-menu-enter) ease-out; } +.surface[data-menu-phase='dismiss'] > *, +.backing[data-menu-phase='dismiss'], +.surface[data-menu-phase='replace'] > *, +.backing[data-menu-phase='replace'] { animation: dsh-context-menu-out var(--dsh-menu-dismiss) ease-in both; } +.surface[data-menu-phase='replace'] > *, +.backing[data-menu-phase='replace'] { animation-duration: var(--dsh-menu-replace); } +@keyframes dsh-context-menu-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes dsh-context-menu-out { from { opacity: 1; } to { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { + .surface[data-menu-phase] > *, .backing[data-menu-phase] { animation-duration: 0ms; } +} diff --git a/packages/client/ui-primitives/src/MenuSurface.tsx b/packages/client/ui-primitives/src/MenuSurface.tsx index 1bdefd0d6a..8e54773170 100644 --- a/packages/client/ui-primitives/src/MenuSurface.tsx +++ b/packages/client/ui-primitives/src/MenuSurface.tsx @@ -4,10 +4,15 @@ import { createPortal } from 'react-dom' import clsx from 'clsx' import css from './MenuSurface.module.css' +/** Context-menu transition durations in milliseconds, shared with its lifecycle owner. */ +export const MENU_TRANSITION_MS = Object.freeze({ enter: 40, dismiss: 80, replace: 10, prepare: 20 }) + /** Menu containers preserve native div props and refs. */ export interface MenuSurfaceProps extends ComponentPropsWithoutRef<'div'> { /** Match the shared compact menu's smaller outer radius. */ compact?: boolean + /** Fade direct content, material, and native backing without isolating the material's backdrop. */ + phase?: 'enter' | 'dismiss' | 'replace' | undefined } /** @@ -18,7 +23,7 @@ export interface MenuSurfaceProps extends ComponentPropsWithoutRef<'div'> { * @returns Menu content plus a backing portal removed with the menu. */ export const MenuSurface = forwardRef(function MenuSurface({ - compact = false, className, style, children, ...props + compact = false, phase, className, style, children, ...props }, ref) { const id = useId() const backingRef = useRef(null) @@ -28,17 +33,20 @@ export const MenuSurface = forwardRef(function // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The portal ref is attached before layout effects run. document.body.appendChild(backingRef.current!) }, []) - const anchorStyle: CSSProperties & { '--dsh-menu-anchor': string } = { + const anchorStyle: CSSProperties & Record<`--dsh-menu-${string}`, string> = { '--dsh-menu-anchor': `--dsh-menu-${id.replaceAll(':', '')}`, + '--dsh-menu-enter': `${MENU_TRANSITION_MS.enter}ms`, + '--dsh-menu-dismiss': `${MENU_TRANSITION_MS.dismiss}ms`, + '--dsh-menu-replace': `${MENU_TRANSITION_MS.replace}ms`, } return <> -
{createPortal( -