diff --git a/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml new file mode 100644 index 0000000000..71c2bd9db9 --- /dev/null +++ b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md +2026-09-08-web-explicit-file-delivery.md: ff2ddeb59ded05b70006dce217df2966eab9d4f2 +2026-09-08-web-explicit-file-delivery.zh.md: 85b09ac82c83365b1c198168b78aa7ac84ef216f diff --git a/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md new file mode 100644 index 0000000000..ff2ddeb59d --- /dev/null +++ b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md @@ -0,0 +1,38 @@ +# Agent Note: Web delivers explicit file snapshots + +Status: implemented +Archived: 2026-09-08 + +English | [中文](2026-09-08-web-explicit-file-delivery.zh.md) + +## Problem + +Workspace links read live paths, so edits or deletion can invalidate a final deliverable. Files created through shell commands also lack first-party editor mutation records. Delivery needs an explicit operation and saved bytes without expanding Session ZIP exports. + +## Decision + +The [present tool](../../../../packages/fs/tool-present/README.md) owns execution, immutable snapshots, delivery types, and the durable event. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) owns authenticated snapshot actions and browser rendering, with type-only imports from the tool’s `./types` entry. The `standard`, `ptc`, and `cordis` presets mount the tool package; `minimal` retains its two-tool training configuration. The existing attachment service saves immutable bytes; successful final `tools/result` notifications append `deliverables/presented` to the calling Session. Native and nested calls use the same recorder. A later enclosing program failure does not undo a completed nested delivery. Blocked tool results publish none. + +Download and native-open requests authorize a reference by the viewed Session, event sequence, and file index. The event stores no Session ID, so forked history uses the child's own log. The existing produced-file row keeps its names and behavior. Session ZIP retains delivery events but does not collect their attachment bytes. + +Card and closing-mention gestures open a verified private copy with the existing native-command utility. A POST expresses the desktop side effect; GET remains a byte read. Each gesture receives a new copy so application edits cannot corrupt the immutable attachment or alter later opens. Successful copies survive until plugin disposal for applications that read lazily; failed copies are removed immediately, and disposal awaits cancelled work before cleanup. + +## Alternatives considered + +**A Host tool subpath in the UI package** couples preset installation to browser packaging and requires extra published entries. An ordinary tool package preserves shared filesystem and tool error classes through the repository’s peer dependency rules. + +**Live workspace links** cannot preserve a delivered version after edits or deletion. Opening the attachment store’s own path instead would expose immutable saved bytes to application writes. + +**Generic artifact fields throughout tools, dispatch, and Session** would broaden unrelated APIs for one Web feature. A plugin-owned event uses existing extension points and avoids parent-result forwarding. + +**Tool text as the durable index** is unreliable because post-processing and spill can replace ordinary or nested result text. Each plugin instance retains its own completed snapshots by execution identity and publishes them only on a successful final result. Same-name scoped replacements cannot create or duplicate another instance’s delivery records. + +**Descriptor-bound filesystem extensions** would change multiple capability providers. This feature uses existing bounded reads with containment and before/after version checks. Those checks reject ordinary concurrent changes but do not guarantee atomic confinement against swap-and-restore; stronger filesystem guarantees belong to the filesystem provider. + +## Consequences + +The implementation adds no artifact service or attachment format. Unreferenced snapshots can remain after partial failure; attachment retention remains service-owned. A downstream build must understand the new required event to read the log. The generated Session event inventory records that requirement without changing released format generations. + +The delivery event is required-on-read because it is the authorization index for saved bytes, not only display metadata. Skipping it would allow an older reader to reconstruct or fork a Session without its completed deliveries. Unsupported readers refuse that loss instead of silently dropping the references. + +Focused tests cover snapshot bytes, invalid inputs, blocked results, HTTP integrity, native-open copy isolation, retry and disposal, turn isolation, and fork-addressed actions. The recorded Web scenario covers nested completion followed by an enclosing failure, source deletion, reload, native-open gestures without browser downloads, and ZIP exclusion. diff --git a/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md new file mode 100644 index 0000000000..85b09ac82c --- /dev/null +++ b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md @@ -0,0 +1,38 @@ +# Agent Note: Web 显式交付文件快照 + +Status: implemented +Archived: 2026-09-08 + +[English](2026-09-08-web-explicit-file-delivery.md) | 中文 + +## 问题 + +工作区链接读取当前路径,因此编辑或删除会使最终交付文件失效。通过 shell 命令创建的文件也没有第一方编辑器修改记录。交付需要显式操作和保存的字节,同时不扩大 Session ZIP 导出内容。 + +## 决策 + +[present 工具](../../../../packages/fs/tool-present/README.zh.md)拥有执行、不可变快照、交付类型和持久事件。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)拥有认证快照操作和浏览器渲染,仅从工具的 `./types` 入口导入类型。`standard`、`ptc` 与 `cordis` preset 挂载工具包;`minimal` 保留双工具训练配置。现有 attachment 服务保存不可变字节;成功的最终 `tools/result` 通知将 `deliverables/presented` 追加到调用方 Session。原生与嵌套调用使用同一个记录器。外层程序随后失败不会撤销已完成的嵌套交付。被阻止的工具结果不发布交付。 + +下载与原生打开请求通过当前查看的 Session、事件序号与文件索引授权引用。事件不保存 Session ID,因此 fork 历史使用子 Session 自己的日志。现有产出文件行保留其名称和行为。Session ZIP 保留交付事件,但不收集其中引用的 attachment 字节。 + +卡片和收尾引用操作通过现有 native-command 工具,在默认应用中打开经过校验的私有副本。POST 表达桌面副作用;GET 仍仅读取字节。每次操作创建新副本,避免应用内编辑损坏不可变 attachment 或改变后续打开的内容。成功副本保留到插件释放,以支持延迟读取的应用;失败副本立即删除,释放时先等待取消的操作结束再清理。 + +## 已考虑的替代方案 + +**在 UI 包中提供 Host 工具子路径**会将 preset 安装与浏览器打包耦合,并要求额外发布入口。普通工具包通过仓库 peer dependency 规则保留共享的文件系统和工具错误类。 + +**实时工作区链接**无法在编辑或删除后保留已交付版本。直接打开 attachment 存储路径则会使不可变保存字节暴露于应用写入。 + +**在工具、dispatch 和 Session 中增加通用 artifact 字段**会为单个 Web 功能扩大无关 API。插件拥有的事件使用现有扩展点,并省去父调用结果转发。 + +**将工具文本作为持久索引**并不可靠,因为后处理与 spill 可以替换普通或嵌套结果文本。每个插件实例按执行对象保留自身已完成的快照,仅在最终结果成功时发布。同名作用域替代工具不能创建或重复其他实例的交付记录。 + +**基于文件描述符的文件系统扩展**会修改多个能力提供方。本功能使用现有有界读取,并检查路径包含关系及读取前后的版本。这些校验会拒绝普通并发变化,但不保证对替换后复原提供原子路径限制;更强的文件系统保证属于文件系统提供方。 + +## 影响 + +实现不增加 artifact 服务或 attachment 格式。部分失败后可能留下无引用快照;attachment 保留策略仍由服务拥有。下游构建必须理解新必需事件才能读取日志。生成的 Session 事件清单记录该要求,不修改已发布的格式代际。 + +交付事件要求读取端识别,因为它是保存字节的授权索引,不只是显示元数据。跳过事件会让旧读取端在重建或分叉 Session 时丢失已完成的交付。不支持该事件的读取端拒绝读取,避免静默丢弃引用。 + +定向测试覆盖快照字节、无效输入、被阻止的结果、HTTP 完整性、原生打开的副本隔离、重试与释放、turn 隔离及使用 fork 地址的操作。录制 Web 场景覆盖嵌套调用完成后外层失败、源文件删除、重新加载、不触发浏览器下载的原生打开操作和 ZIP 排除。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index f540320056..cded9f6c7e 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -1381,6 +1381,9 @@ "feature/2026-09-01-web-superellipse-corner-smoothing.i18n.yaml": "sha256:50afdbe5b5e19889918af6d86ab3218c05205be35938b6d33d158c60777e3b58", "feature/2026-09-01-web-superellipse-corner-smoothing.md": "sha256:b1445101c49e74bbcb4f607af850cd6df105d4034828d0dd47081e8079148f15", "feature/2026-09-01-web-superellipse-corner-smoothing.zh.md": "sha256:1a278c417c0d7de3b4c3c35061b419303b4a1a0707831c283d8f862ab9b6fd23", + "feature/2026-09-08-web-explicit-file-delivery.i18n.yaml": "sha256:99daae539cc8fd7376ce0265538bee21e1e33f3c0d77c8cc4e011f94b4e9568a", + "feature/2026-09-08-web-explicit-file-delivery.md": "sha256:bb416b1e8be081e6cb6af17792eb1a442172ff114c3a3577e5ef06a77eb57093", + "feature/2026-09-08-web-explicit-file-delivery.zh.md": "sha256:00a642380e1f6ac9d5cd840e021f4e3e4ae68a289cb6344eb3dcd73a6fd81f4d", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 3c883f7d6c..d03d20b03b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 5533c38d635d04c8799658fc1f6bcec8543d1dab -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 05800082034f0d1bd5034fc5bb17f09356d84dd9 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7792e24a5869be6b7bae7787a6a481f3075ba740 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 857bfaec80da962fac0403f2239e0a5e71954194 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 5533c38d63..7792e24a58 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -66,7 +66,7 @@ The Python client launches the packaged `dsh` command with the selected profile ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and single shell-tool catalog and retains shell state across calls. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 0580008203..857bfaec80 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -66,7 +66,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread` ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与单个 shell 工具目录,并跨调用保留 shell 状态。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index d757a5efd0..06c136e20b 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md -2026-07-14-provider-routed-llm-adapters.md: 4cc3cc3cdceeefdfea864ccf3465e528e32159c7 -2026-07-14-provider-routed-llm-adapters.zh.md: 098a6af7c951c6dee8a1b4f948bc69725e8d189c +2026-07-14-provider-routed-llm-adapters.md: 24d6e7e439dc74a158801b647ddac96169730af1 +2026-07-14-provider-routed-llm-adapters.zh.md: f740468ed67830dabf5769eca206402d91c90aac diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index 4cc3cc3cdc..24d6e7e439 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -30,7 +30,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek` `dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step extension point. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback. -The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog. +The plugin registers configured provider names against one `PiAiAdapter` in one atomic call. Each immutable request snapshot combines the effective profiles and their serviceable model descriptors. Catalog-external models require an explicit or inferable protocol and endpoint. Stored catalog errors remain visible and repairable under the [settings catalog recovery decision](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md), while writes validate changed providers and requests reject the selected failed model before network I/O. The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its registered API implementation, including OpenAI Responses instead of Chat Completions where the descriptor says `openai-responses`. Harness temperature, maximum tokens, signal, session id, and the profile's common stream options flow through directly. Profile headers merge with the mandatory Harness attribution headers, with Harness attribution winning its reserved names. The adapter no longer maintains DeepSeek-specific payload rewrites or a provider-protocol matrix. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 098a6af7c9..f740468ed6 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -30,7 +30,7 @@ Status: implemented `dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤扩展点上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 -插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。 +插件通过一次原子调用,将已配置的提供方名称注册到同一个 `PiAiAdapter`。每个不可变请求快照组合有效 profile 与可服务模型的描述符。目录外模型需要显式指定或可推断的协议与端点。根据[设置目录恢复决策](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md),已存储的目录错误保持可见、可修复,写入仍会校验已修改提供方,请求则在网络 I/O 前拒绝所选错误模型。 适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。Harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 Harness 强制归因 headers 合并;发生保留名称冲突时,以 Harness 归因为准。适配器不再维护 DeepSeek 专用 payload 重写或提供方协议矩阵。 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index 7d108bbf7d..f633a831d3 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md -2026-07-22-slot-type-chain-implementation.md: 0e493a19e232acf6f289714e53e0cf64b3ffc4b0 -2026-07-22-slot-type-chain-implementation.zh.md: 5e5c2ec808cec6949396a333c6a86e267bb4268b +2026-07-22-slot-type-chain-implementation.md: 98585a42f71844594da4ada19d7438a7d8321702 +2026-07-22-slot-type-chain-implementation.zh.md: d1093537ccbc5ee5ff2dc18c234233ffca39879d diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index 0e493a19e2..98585a42f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -12,6 +12,8 @@ The page is composed at runtime from independently loaded plugins, so the UI nee ## Decision +Global main-panel selection and its root lifetime are defined by the [global main-panels decision](2026-09-08-global-main-panels.md). + One sentence: **the ui-renderer renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.** ### 'root' is the only a-priori slot @@ -25,7 +27,7 @@ ctx.slots.register({ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' }, - 'conversation': { kind: 'single', scope: 'session' }, + 'main': { kind: 'keyed', scope: 'root' }, }, store: createLayoutStore, // StoreHandle or factory (below) inject: injectFrame, // business face (below) diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 5e5c2ec808..d1093537cc 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -12,6 +12,8 @@ Status: implemented ## 决策 +全局主面板选择及其 root 生命周期由[全局主面板决策](2026-09-08-global-main-panels.zh.md)定义。 + 一句话:**ui-renderer 只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。** ### 'root' 是唯一的先验 slot @@ -25,7 +27,7 @@ ctx.slots.register({ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' }, - 'conversation': { kind: 'single', scope: 'session' }, + 'main': { kind: 'keyed', scope: 'root' }, }, store: createLayoutStore, // StoreHandle or factory (below) inject: injectFrame, // business face (below) diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index 9ccf82b4a5..e8b41ca73b 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: eb5eb14f28a6459bd388caa2ea106ec01d1ebbe6 -2026-08-31-released-session-format-migrations.zh.md: b06bfac059541e9c397ff46fe7d169690c5b1213 +2026-08-31-released-session-format-migrations.md: 286737730198ad895de2f03a48f9c74848839582 +2026-08-31-released-session-format-migrations.zh.md: 228b09e03916c1fa447398edb45508a7a6ab61e5 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index eb5eb14f28..2867377301 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -94,6 +94,8 @@ The JSONL provider scans frame boundaries once, reuses one Zstandard decoder, pa Current encoding is record based. The provider serializes about 1 MiB of plaintext per main-thread slice, streams it through one Zstandard context with source-error propagation, writes compressed output in 4 MiB batches to an exclusively created same-directory temporary file, and syncs it before publication. A process-wide scheduler admits at most two full verification Workers and hands a released permit directly to the oldest waiter. +The shipped `lib/worker.cjs` bundles its JavaScript workspace dependencies so each fresh verifier avoids resolving and compiling their runtime module graph. This is safe because the Worker communicates through plain request/result messages and shares no service or class identity with its host. The Host build applies the existing TypeScript and Typert transforms; the Client pass skips this Node-only package instead of replacing its worker with untransformed source. Native add-ons remain external. Verification, scheduler admission, termination, and durable publication still complete before writable open returns. The built-worker smoke copies the package manifest and worker into an isolated temporary package with ambient module paths removed, accepts a valid generation, and rejects an incorrect event count. + Preparation forwards cancellation through source reads and observes it at the existing approximately 500 ms Decode yield boundary. Once `publish()` starts, encode, Worker verification, and publication do not receive caller cancellation and run to settlement; write open checks its caller signal again afterward. A published generation is never rolled back. The Stage pipeline ends at one prepared current artifact. [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index b06bfac059..228b09e039 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -94,6 +94,8 @@ JSONL provider 只扫描一次 frame boundary,复用一个 Zstandard decoder Current encode 以单条 record 为单位。Provider 在主线程每个 slice 序列化约 1 MiB plaintext,通过一个会传播 source error 的 Zstandard context 流式压缩,以 4 MiB batch 写入同目录排他创建的临时文件,并在 publication 前 sync。进程级 scheduler 最多允许两个完整 verification Worker 并行,并把释放的 permit 直接交给最早的 waiter。 +发布的 `lib/worker.cjs` 将 JavaScript workspace 依赖一起打包,使每个新 verifier 无需解析并编译它们的运行时模块图。Worker 只通过普通 request/result 消息通信,与 host 不共享 service 或 class identity,因此可以这样处理。Host build 应用现有 TypeScript 与 Typert 转换;Client pass 跳过这个 Node-only package,不会用未经转换的源代码覆盖 worker。Native add-on 保持 external。Verification、scheduler admission、termination 与 durable publication 仍在 writable open 返回前完成。Built-worker 冒烟测试把 package manifest 与 worker 复制到隔离的临时 package,移除环境中的模块搜索路径,接受有效 generation,并拒绝错误的 event count。 + Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms Decode yield 边界观察它。`publish()` 一旦开始,encode、Worker verification 与 publication 不接收 caller cancellation,并运行到终态;write open 会在之后再次检查 caller signal。已经发布的 generation 绝不会回滚。 Stage pipeline 终止于一份 prepared current artifact。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。 diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml new file mode 100644 index 0000000000..697c410f3a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md +2026-09-01-parent-owned-subagent-catalog.md: 5926af77219680a48af1e5fb062ddc11a91a2280 +2026-09-01-parent-owned-subagent-catalog.zh.md: ddc77a665207169cfc048b9f2cfbd4f912cb93dc diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md new file mode 100644 index 0000000000..5926af7721 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md @@ -0,0 +1,49 @@ +# Agent Note: Parent-owned subagent catalog events + +Status: implemented + +English | [中文](2026-09-01-parent-owned-subagent-catalog.zh.md) + +## Problem + +Direct-child discovery once reconstructed a catalog from the global Session corpus and each selected child's log. Creation already knows the direct parent, child id, mode, and label, so repository-wide enumeration and child-log reads duplicated an owned fact and made browser refresh cost depend on unrelated Sessions. + +The child descriptor remains necessary for recovery and composition, but it cannot be the discovery source because a reader must find and open the child before it can read the descriptor. Forks add a separate requirement: a seeded copy of a parent log must not inherit the original Session's children. + +## Decision + +The parent Session's required `subagent/catalog` events are the persistent authority for direct-child discovery. Each event is one successful creation fact containing `childId`, `childCreatedAt`, mode, and the mode-discriminated label. Remote one-shot runs without a local Session remain outside this catalog. Invalid own facts, including unsupported payload versions, reject projection restoration because silently dropping a required fact would return an incomplete catalog. + +Creation publishes only successful facts. A one-shot run appends the catalog event after its provider returns a local child and before the run reaches its caller. A continuable run admits the initial prompt, appends the catalog event, then returns the child id. If admission or catalog append fails, creation fails and releases the activation; there is no compensating catalog event or rollback protocol. + +The child header and `subagent/descriptor` remain authoritative for recovery and composition. An Activation and the exact parent relationship remain authoritative for authorization and delivery. Mode and label are snapshotted once and the same detached values reach the parent catalog fact and child descriptor. + +The registered `subagentCatalog` projection materializes the parent facts. It delegates storage, append, iteration, and checkpoint validation to [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.md), which stores facts in a persistent stack of 64-entry chunks, so an append copies at most the head chunk in bounded O(1) work. Materialization visits chunks from oldest to newest and preserves parent catalog event order in O(D) time for D facts. Concurrent creation is ordered by successful catalog append, independent of child timestamps and ids. A projection checkpoint clones the state once in O(D); projection-cache writes remain asynchronous and use the existing mandatory creation, turn-end, and disposal points. + +The utility owns chunk layout and its shared capacity constant; the catalog owns event validation, fork filtering, and row conversion. Catalog projection state version 2 stores generic chunk values, so the projection registry rebuilds incompatible caches from Session events. Session event payloads and public catalog rows retain their formats. + +Fork isolation uses the exact `Session.inheritedEventCount` supplied to projection initialization. The fold ignores `subagent/catalog` events below that offset. The state stores the inherited offset but not each event seq because acceptance is decided during folding. + +Headless snapshot collection assigns sibling fixture roles by their parent catalog order, regardless of child creation timestamps: provider startup can publish an older Session after a newer one. The collection preserves each log verbatim. + +Snapshot normalizers zero `childCreatedAt` because it originates from the process clock. Event order and source-event references remain intact: adjacent facts can come from sequential creation, so adjacency does not establish commutativity. + +Current-writer snapshot expectations include catalog facts even when replay input retains a historical Session generation. The comparison preserves the catalog and its source-event references; historical replay files remain unchanged. + +## Alternatives considered + +**A flat immutable array.** Appending with `[...facts, fact]` copies D facts, so creation is O(D). Mutating a shared array would violate projection state ownership and checkpoint safety. + +**A node-per-fact linked list.** It provides O(1) append and O(D) read, but persisted projection checkpoints form JSON nested D levels deep. Sixty-four-entry chunks preserve the asymptotic costs while reducing nesting. + +**Separate host-state observation output.** Returning internal projection states duplicates the existing observation result mechanism and copies states unrelated to child discovery. A catalog view supplies the direct-child list through the existing typed projection map. + +**A durable SQLite child index.** An index would create another write path, reconciliation protocol, schema, and corruption surface for a fact already ordered in the parent Session log. + +**A compensating failure event.** Recording catalog membership before initial prompt admission requires a second operation, pairing rules, rollback cleanup, and client reconciliation. Delaying the success fact until admission completes removes that protocol. + +## Consequences + +Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection. + +Backends that do not know the required event refuse the log under the existing Session event mechanism. Pre-release format policy requires no fallback scan for old logs. diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md new file mode 100644 index 0000000000..ddc77a6652 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md @@ -0,0 +1,49 @@ +# Agent Note: parent 自有的 subagent 目录事件 + +Status: implemented + +[English](2026-09-01-parent-owned-subagent-catalog.md) | 中文 + +## 问题 + +直接 child discovery 曾从全局 Session 语料与每个入选 child 的日志重建目录。创建过程已经知道直接 parent、child id、mode 与 label,因此仓库范围枚举和 child 日志读取重复推导了已有归属的事实,并让浏览器刷新成本取决于无关 Session。 + +child descriptor 对恢复与 composition 仍然必要,但它不能作为 discovery 来源,因为读取方必须先找到并打开 child 才能读取 descriptor。fork 还有独立要求:从 parent 日志播种的副本不能继承原 Session 的 child。 + +## 决策 + +parent Session 的 required `subagent/catalog` 事件是直接 child discovery 的持久化权威。每个事件都是一条成功创建事实,包含 `childId`、`childCreatedAt`、mode 与按 mode 区分的 label。没有本地 Session 的远程 one-shot run 不进入该目录。无效的自身 fact(包括不支持的 payload 版本)会使 projection 恢复失败,因为静默丢弃 required fact 会返回不完整的目录。 + +创建只发布成功事实。one-shot run 在 provider 返回本地 child 后、run 到达调用方前追加目录事件。continuable run 先准入初始 prompt,再追加目录事件,最后返回 child id。准入或目录追加失败时,创建失败并释放 activation;不存在补偿目录事件或 rollback 协议。 + +child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威。Activation 与精确 parent 关系继续拥有授权与投递权威。mode 与 label 只快照一次,同一份分离值写入 parent catalog fact 与 child descriptor。 + +注册的 `subagentCatalog` projection 物化 parent fact。它将存储、追加、迭代和检查点校验交给 [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.zh.md),后者以每块 64 项的持久 stack 保存事实,因此 append 最多复制 head chunk,以有界 O(1) 工作完成。materialization 从旧到新访问 chunk,对 D 条事实以 O(D) 时间保留父目录事件顺序。并发创建按目录成功追加的顺序排列,与 child 时间戳和 id 无关。projection checkpoint 以 O(D) 克隆 state;projection-cache 继续异步写入,并使用既有创建、turn-end 与 disposal 强制点。 + +工具库拥有分块布局及其共享容量常量;目录拥有事件校验、fork 过滤和目录行转换。目录 projection state 版本 2 保存通用块值,因此 projection registry 从 Session 事件重建不兼容的缓存。Session 事件载荷和公开目录行保持各自格式。 + +fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。 + +Headless 快照采集按父目录顺序分配同父子级的 fixture 角色,不依赖子级创建时间戳:provider 启动可能在较新的 Session 之后发布较旧的 Session。采集过程原样保留每份日志。 + +snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clock。事件顺序与来源事件引用保持不变:相邻 fact 也可能来自顺序创建,因此相邻关系不能证明可交换性。 + +即使 replay 输入保留历史 Session generation,当前 writer 的快照预期也包含 catalog 事实。比较保留 catalog 及其来源事件引用;历史 replay 文件保持不变。 + +## 考虑过的替代方案 + +**扁平不可变数组。** 用 `[...facts, fact]` append 会复制 D 个 fact,因此创建是 O(D)。修改共享数组会违反 projection state ownership 与 checkpoint 安全。 + +**每 fact 一个 node 的 linked list。** 它提供 O(1) append 与 O(D) read,但持久 projection checkpoint 会形成 D 层 JSON 嵌套。每块 64 项保留渐进复杂度,同时降低嵌套深度。 + +**独立的 host state 观察输出。** 返回内部 projection state 会重复已有观察结果机制,并复制与子级发现无关的状态。目录视图通过既有的类型化 projection map 提供直接子级列表。 + +**持久 SQLite child index。** index 会为 parent Session 日志中已有顺序的 fact 增加另一套写路径、reconciliation protocol、schema 与 corruption surface。 + +**补偿失败事件。** 在初始 prompt 准入前记录 catalog membership 会引入第二种 operation、配对规则、rollback 清理与 client reconciliation。把成功事实推迟到准入完成后即可删除该协议。 + +## 后果 + +Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。 + +不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。pre-release format policy 不要求为旧日志保留 fallback scan。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml new file mode 100644 index 0000000000..f5b5d88586 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md +2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3 +2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89 diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md new file mode 100644 index 0000000000..75be68ac1b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md @@ -0,0 +1,37 @@ +# Agent Note: Global main panels without default UI additions + +Status: implemented + +English | [中文](2026-09-08-global-main-panels.zh.md) + +## Problem + +Plugins need application-wide views that do not belong to a Session. A Session-scoped Conversation view cannot provide that lifetime, and replacing the Conversation's single slot removes the ordinary conversation surface. Adding this extension must not add navigation controls or reserved space to the default application. + +## Decision + +The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding. + +The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel. + +One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame. + +`uiWorkspace.openSession(id)` selects the Session before returning the main area to the Conversation, including when the same Session is selected again. `openWorkspace` and `forkSession` use the layout's `beginNavigation()` abort signal and their own service lifetime to commit only the latest navigation. The Workspace preparation callback moves drafts synchronously only while the request remains current. Supersession prevents a late UI commit, not Session creation. Panel navigation neither cancels the retained Session nor writes a Session event. + +DOM focus is not navigation selection. Search and directory-picker controls can receive focus while the global panel and its selected sidebar row remain visible; opening a Session changes the main selection. + +## Alternatives considered + +**Session-scoped main views.** Their lifetime and standard props bind application-wide state to whichever Session happens to be current. + +**A second navigation stack.** Back buttons and saved return destinations are unnecessary when New Session and workspace Session rows already provide explicit destinations. + +**React title slots.** Navigation entries use the same plain label for visible text and accessibility; a separate title registration is outside that presentation. + +**Flat selection and layout state with shallow comparison.** Separating the two stored objects preserves reference equality directly and avoids allocating and comparing a fresh layout projection on every panel selection. + +## Consequences + +The default sidebar snapshots remain unchanged. Extension panels have no right Sidebar, and selecting a different global panel does not change layout preferences. Switching between a Conversation with a visible right Sidebar and a global panel still changes the required column widths; this is not a promise of zero browser layout work. + +Panel selection is transient and resets on reload. Plugin disposal removes its contributions; removing the selected main entry returns the main area to the Conversation. Tests register real temporary panels and cover row interaction, focus, independent stored references, invalid ids, superseded asynchronous navigation, declaration lifetimes, and the empty default sidebar. The [Slots reference](../../../../docs/subsystems/slots.md) owns the composition API. diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md new file mode 100644 index 0000000000..343f183367 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 不增加默认界面的全局主面板 + +Status: implemented + +[English](2026-09-08-global-main-panels.md) | 中文 + +## 问题 + +插件需要不属于任何会话的应用级视图。会话作用域的 Conversation 视图无法提供这种生命周期,而替换 Conversation 的 single slot 又会移除普通会话界面。增加此扩展不能在默认应用中增加导航控件或预留空间。 + +## 决策 + +布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。 + +侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。 + +渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。 + +`uiWorkspace.openSession(id)` 先选中会话,再将中央区域切回 Conversation,包括再次选中同一个会话的情况。`openWorkspace` 和 `forkSession` 使用布局的 `beginNavigation()` abort signal 与自身 service 生命周期,只提交最新导航。工作区准备回调仅在请求仍有效时同步搬移草稿。请求过期会阻止晚到的 UI 提交,但不阻止会话创建。面板导航既不取消保留的会话,也不写入会话事件。 + +DOM 焦点不是导航选中态。搜索和目录选择控件可以获得焦点,同时保留全局面板及其侧栏行的选中态;打开会话才改变中央区域的选中态。 + +## 考虑过的替代方案 + +**会话作用域的主视图。** 其生命周期和标准 props 会把应用级状态绑定到恰好处于当前态的会话。 + +**另一套导航栈。** 新会话和工作区会话行已经提供明确目标,不需要返回按钮或保存返回目的地。 + +**React 标题 slot。** 导航条目的可见文字与无障碍名称使用同一个普通标签;独立的标题注册不属于这一呈现方式。 + +**平铺选中态和布局状态,再做浅比较。** 将两者存为独立对象可以直接保持引用相等,避免每次选择面板都分配并比较新的布局投影。 + +## 后果 + +默认侧栏快照保持不变。扩展面板没有右侧 Sidebar,选择另一个全局面板不会改变布局偏好。在显示右侧 Sidebar 的 Conversation 与全局面板之间切换时,所需列宽仍会变化;这并不保证浏览器完全不计算布局。 + +面板选中态是瞬时状态,刷新后重置。插件 dispose(资源释放)会移除其贡献;移除当前选中的主面板条目会使中央区域回到 Conversation。测试注册真实临时面板,覆盖行交互、焦点、存储引用的独立性、无效 id、过期异步导航、声明生命周期和默认空侧栏。[Slots 参考](../../../../docs/subsystems/slots.zh.md)拥有组合 API 的说明。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml new file mode 100644 index 0000000000..43f6f3b14c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md +2026-09-07-pi-ai-settings-catalog-recovery.md: 21fe532a491775ff875f6b9bcb000d917a95e13c +2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 80bd0758e13b767671f4cec52ee3832c9250651f diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md new file mode 100644 index 0000000000..21fe532a49 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md @@ -0,0 +1,39 @@ +# Agent Note: Repairable pi-ai settings after catalog changes + +Status: implemented + +English | [中文](2026-09-07-pi-ai-settings-catalog-recovery.zh.md) + +## Problem + +An installed pi-ai catalog can change the validity of unchanged user settings. OpenRouter models outside the catalog can inherit a protocol while all shipped models agree; adding a second protocol removes that inference. Removing a catalog model also invalidates an override keyed by its former id. Rejecting the entire settings namespace at registration makes unrelated providers disappear and removes the controls needed to repair the configuration. + +## Decision + +The pi-ai consumer uses the existing settings `validate` callback. During namespace registration it tolerates catalog diagnostics; after registration it strictly checks changed providers against the current resolved section. Settings invokes this callback before persistence for update, replacement, and path mutation. External reload uses the same strict check and retains the last accepted section on failure. The settings service and its public API remain unchanged. + +Initial profile resolution retains catalog diagnostics, while schema and self-contained profile constraints still reject loading. Writes strictly resolve each new or changed provider, comparing effective provider values against the committed snapshot. Unchanged failed providers do not block another provider's edit, and deletion remains possible. Editing a provider-wide setting validates all models it affects. + +Profile resolution keeps valid models beside per-model errors. A missing override retains its diagnostic without disabling the remaining catalog. A route-level catalog failure retains its provider and editable settings but supplies no callable models. When route-wide validation aborts catalog resolution, the incomplete catalog and its collected per-model diagnostics are discarded; model requests on that route report the route-level error. The adapter checks the selected model's recorded failure before credentials or network I/O and reports `INVALID_CONFIG`. No protocol is guessed and no user configuration is rewritten during loading. Immutable snapshots still keep an in-flight request on its captured configuration. + +`LlmConfigurableProvider.error` carries the first available model diagnostic for the provider row, falling back to the route error. A provider-construction failure does not overwrite a collected model diagnostic, preserving the specific correction for a missing protocol. The configurable-provider directory publishes diagnostic changes so configuration repair refreshes the browser without re-registering the adapter. Failed model ids remain in settings, while the model selector receives serviceable entries. Models settings displays the diagnostic and retains edit/delete controls. Both add actions require their owning settings namespace; the ordinary add menu filters out unavailable namespaces. + +This extends the [provider-routed adapter decision](../architecture/2026-07-14-provider-routed-llm-adapters.md): provider ownership and request snapshots remain unchanged, while catalog validity does not determine whether settings can be managed. That note remains active for routing, ownership, and replay rationale. + +## Alternatives considered + +**Reject catalog errors at registration.** This prevents users from repairing an otherwise parseable configuration and lets an unused stale model disable unrelated providers. + +**Relax save validation too.** A newly entered model with no inferable protocol can be rejected immediately with the offending provider and model named. Accepting it creates an avoidable request-time failure. + +**Strictly revalidate the entire namespace on every save.** An unrelated provider's old error would block adding a healthy provider or repairing providers independently. + +**Assign OpenRouter a fixed route protocol.** A route override replaces every model's protocol and can change working catalog entries that intentionally use another API. + +## Consequences + +Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. Models settings does not explain namespace registration failures; those errors require inspecting the configuration and startup diagnostics. No settings API, storage format, or session event is added; configurable-provider entries gain one optional diagnostic field. + +## Testing + +Adapter tests cover mixed valid/invalid models, deleted override referents, independent provider edits, route deletion, pre-network failure, and repair. A file-watcher regression verifies that invalid external edits retain the last accepted profiles and a repaired file takes effect. The assembled Web expectation boots with stale OpenRouter settings, preserves zai and both add controls, rejects an invalid save without changing the file, and repairs the route by removing the stale model. Existing snapshot tests continue to own request freezing and replay behavior. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md new file mode 100644 index 0000000000..80bd0758e1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md @@ -0,0 +1,39 @@ +# Agent Note: pi-ai 目录变化后可修复的设置 + +Status: implemented + +[English](2026-09-07-pi-ai-settings-catalog-recovery.md) | 中文 + +## Problem + +已安装的 pi-ai 目录可能改变未修改用户设置的有效性。OpenRouter 的目录外模型可以在所有内置模型协议一致时继承协议;加入第二种协议会使这种推断失效。删除目录模型也会使按其旧 ID 声明的覆盖失效。注册时拒绝整个 settings 命名空间,会让无关提供方消失,也移除了修复配置所需的控件。 + +## Decision + +pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期间容忍目录诊断;注册完成后,将变化的提供方与当前已解析分节比较并严格校验。Settings 在更新、整体替换和路径修改的持久化之前调用此回调。外部重载使用相同的严格校验,失败时保留最后一次接受的分节。Settings 服务及其公共 API 保持不变。 + +首次解析 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。 + +Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。路由级校验中止目录解析时,不完整的目录及其已收集的逐模型诊断会被丢弃,该路由上的模型请求统一报告路由级错误。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。 + +`LlmConfigurableProvider.error` 优先为提供方行携带首个模型诊断,无模型诊断时返回路由错误。提供方构造失败不会覆盖已收集的模型诊断,从而保留缺少协议时的具体修复提示。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。 + +本决策扩展了[按提供方路由的适配器决策](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):提供方所有权与请求快照不变,目录有效性不决定设置是否可管理。旧记录仍保留为路由、所有权与回放设计的依据。 + +## Alternatives considered + +**在注册时拒绝目录错误。** 这会阻止用户修复结构可解析的配置,并让未使用的过期模型禁用无关提供方。 + +**同时放宽保存校验。** 对无法推断协议的新模型,可以立即拒绝并点名提供方与模型。接受它只会制造可避免的请求时错误。 + +**每次保存都严格重校验整个命名空间。** 无关提供方的旧错误会阻止添加正常提供方,或逐个修复提供方。 + +**为 OpenRouter 指定固定路由协议。** 路由覆盖会替换所有模型的协议,可能改变有意使用其他 API 的正常目录条目。 + +## Consequences + +依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。模型设置页不展示命名空间注册失败的原因,此类错误需要检查配置与启动诊断。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。 + +## Testing + +适配器测试覆盖有效与错误模型混合、覆盖目标被删除、独立提供方编辑、路由删除、联网前失败及修复。文件监听回归验证非法外部编辑保留最后一次接受的 profile,而修复后的文件能够生效。完整 Web 期望测试从过期 OpenRouter 设置启动,保留 zai 与两个添加控件,在不改变文件的前提下拒绝无效保存,并通过删除过期模型修复路由。已有快照测试继续负责请求冻结与回放行为。 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 55a907edf1..97110cbb0c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: df334bc0bd9d011ea2e2f4ee938fb29c4c4bfe2c -2026-07-31-even-out-shipped-tool-rosters.zh.md: ddee380f836bf50a733541b2d3ea6dd8a74126a2 +2026-07-31-even-out-shipped-tool-rosters.md: caadb4601a56e9180ccb8d1eae571208941cbcba +2026-07-31-even-out-shipped-tool-rosters.zh.md: 9503baf9f1f8f2567c3ccd81db685c74db189430 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index df334bc0bd..caadb4601a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,7 +12,7 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-reminder`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md). Two later decisions narrow that roster: the [session-search decision](../../archived/feature/2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, and the [single-editor decision](../../archived/simplification/2026-08-10-default-presets-single-editor.md) keeps `tool-str-replace-editor` out of the general-purpose presets while retaining it in `minimal`. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-reminder`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md). Later decisions narrow that roster: the [session-search decision](../../archived/feature/2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, the [single-editor decision](../../archived/simplification/2026-08-10-default-presets-single-editor.md) removes `tool-str-replace-editor` from general-purpose presets, and the [persistent-shell-only decision](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md) removes it from the minimal compositions. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index ddee380f83..9503baf9f1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-reminder`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.zh.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md)。之后有两项决策收窄这份清单:[session-search 决策](../../archived/feature/2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一编辑器决策](../../archived/simplification/2026-08-10-default-presets-single-editor.md)让通用 preset 不提供 `tool-str-replace-editor`,但在 `minimal` 中保留它。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-reminder`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.zh.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md)。后续决策收窄了这份清单:[session-search 决策](../../archived/feature/2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一 editor 决策](../../archived/simplification/2026-08-10-default-presets-single-editor.md)从通用 preset 移除 `tool-str-replace-editor`,[仅持久 shell 决策](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)则从极简组合移除它。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index 5a8b133ed6..98786c2a8b 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 9e4d476c36ab6f920481b1281058d888710a2ef5 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 761c384f1656ce66d027eb2b13c9dbc4f20f3686 +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 8cfa4aa71e33317947d0661641df1e6e814f5e90 +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: f1f916bdde92fd104c3d879ff058b2e0474eef38 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index 9e4d476c36..8cfa4aa71e 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -1,4 +1,4 @@ -# Agent Note: Minimal profiles use the bare two-tool runtime +# Agent Note: Minimal profiles use a bare runtime Status: implemented @@ -12,23 +12,23 @@ The two launch paths also have different configuration owners. Web mounts a per- ## Decision -The shipped Web minimal preset exposes persistent `bash` and `str_replace_editor`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows, plus the same editor. Both mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. +The shipped Web minimal preset exposes persistent `bash`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows. Both mount no context-compaction or filesystem provider and suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions. The [persistent-shell-only decision](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md) removes the editor and its otherwise-unused `fs-local` provider from both compositions. The Web preset's persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. -The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, `fs-local`, that shell's tool consumer, the editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. The persistent shell consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. [docs/architecture.md](../../../../docs/architecture.md) owns this bundle placement and its separation from `dsh-base`. +The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, that shell's tool consumer, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-local`, `fs-sandbox`, `fs-observation-policy`, or a filesystem tool. The persistent shell consumes the profile's danger-full-access sandbox policy. [docs/architecture.md](../../../../docs/architecture.md) owns this bundle placement and its separation from `dsh-base`. `DSH_SYSTEM_PROMPT` selects the standalone persona, and `DSH_CONTEXT_WINDOW` supplies fallback capacity for a model without exact catalog metadata. The SDK client's JSON-RPC `initialize` request is the sole runtime model selection. [`minimal.py`](../../../../python/sdk/examples/minimal.py) may read `DSH_MODEL` only as the command's default `model` argument; an explicit `--model` needs no matching child environment value. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. ## Verification -The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services. +The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that no scoped filesystem or compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and persistent Bash. It then executes persistent Bash against the real scoped services. -The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. +The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact single-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. ## Alternatives considered **Keep `compaction-basic` mounted with a high threshold.** Rejected because even an inert-for-short-tests provider permits history replacement in longer sessions and leaves the minimal composition dependent on model-capacity metadata and the token meter. -**Keep `fs-sandbox` in danger-full-access mode.** Rejected because the sandboxed provider still makes confinement and escalation part of the editor capability. The target runtime requires the bare local provider, whose lack of `sandboxMode` is composition truth. +**Keep `fs-local` after removing the editor.** Rejected because neither minimal composition has another filesystem consumer. Retaining the provider would increase the runtime roster without adding a model-visible capability. **Use one Cordis leaf for Web and Python SDK startup.** Rejected because a Web preset contributes agent-scoped services to an existing multi-session host, while the Python SDK must launch a complete process containing the JSON-RPC server and its process-wide dependencies. @@ -36,4 +36,4 @@ The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a ## Consequences -Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The editor can address any absolute path visible to the runtime process, independently of the persistent shell's sandbox policy. The two launch paths share their model-facing tool, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile. +Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The two launch paths share their single-tool, no-filesystem, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile. diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index 761c384f16..f1f916bdde 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -1,4 +1,4 @@ -# Agent Note: minimal profile 使用裸双工具运行时 +# Agent Note: minimal profile 使用裸运行时 Status: implemented @@ -12,23 +12,23 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` ## 决策 -随附 Web minimal preset 对外提供持久 `bash` 与 `str_replace_editor`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`,并提供相同 editor。两者都不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 +随附 Web minimal preset 对外提供持久 `bash`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`。两者都不挂载上下文压缩或文件系统提供方,并为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献。[仅持久 shell 决策](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)从两份组合中移除了编辑器及其原本除此之外无人使用的 `fs-local` 提供方。Web preset 的 persona 继续采用较早的 [minimal preset 组合决策](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 -独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、`fs-local`、该 shell 的工具消费方、editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 shell 消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[docs/architecture.md](../../../../docs/architecture.zh.md) 负责该组合包的位置及其与 `dsh-base` 的分离。 +独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、该 shell 的工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-local`、`fs-sandbox`、`fs-observation-policy` 或文件系统工具。持久 shell 消费该 profile 的 danger-full-access 沙箱策略。[docs/architecture.md](../../../../docs/architecture.zh.md) 负责该组合包的位置及其与 `dsh-base` 的分离。 `DSH_SYSTEM_PROMPT` 选择独立组合的 persona,`DSH_CONTEXT_WINDOW` 为没有确切目录元数据的模型提供后备容量。SDK 客户端的 JSON-RPC `initialize` 请求是唯一运行时模型选择。[`minimal.py`](../../../../python/sdk/examples/minimal.py)可以只把 `DSH_MODEL` 读作命令的默认 `model` 参数;显式 `--model` 不需要匹配的子进程环境值。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 ## 验证 -Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。 +Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言不存在作用域文件系统或压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与持久 Bash。随后,它通过真实作用域服务执行持久 Bash。 -SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确双工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 +SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确单工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 ## 考虑过的替代方案 **以较高阈值保留 `compaction-basic`。** 不予采用,因为即便提供方在短测试中未触发,较长会话仍允许替换历史记录,而且 minimal 组合仍会依赖模型容量元数据与 token meter。 -**在 danger-full-access 模式下保留 `fs-sandbox`。** 不予采用,因为沙箱提供方仍会使限权与提权成为编辑器能力的一部分。目标运行时要求裸本地提供方,而其不具备 `sandboxMode` 正是组合事实。 +**移除编辑器后保留 `fs-local`。** 不予采用,因为两份 minimal 组合都没有其他文件系统消费方。保留该提供方只会扩大运行时清单,不会新增模型可见能力。 **为 Web 与 Python SDK 启动使用同一个 Cordis leaf。** 不予采用,因为 Web preset 向现有多会话宿主贡献 agent 作用域服务,而 Python SDK 必须启动包含 JSON-RPC 服务器及其进程级依赖的完整进程。 @@ -36,4 +36,4 @@ SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境 ## 后果 -Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。编辑器可以访问运行时进程可见的任何绝对路径,且不受持久 shell 沙箱策略影响。两条启动路径共享面向模型的工具、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。 +Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。两条启动路径共享单工具、无文件系统、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml new file mode 100644 index 0000000000..27af3c5438 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +2026-09-08-present-workspace-source-files.md: 6239c9bd920849f3c8cf4fdee2e1ded4b758b01d +2026-09-08-present-workspace-source-files.zh.md: 7aa5bebc97558b9b0cb74406303d038483c39d94 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md new file mode 100644 index 0000000000..6239c9bd92 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -0,0 +1,37 @@ +# Agent Note: Present declares workspace source files + +Status: implemented + +English | [中文](2026-09-08-present-workspace-source-files.zh.md) + +## Problem + +Users need to open and edit the files produced in their workspace, including shell-created files that have no editor mutation records. Preserving an independent delivered version adds content storage, copy verification, temporary-file retention, and a second editing destination to this workflow. + +## Decision + +The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. + +The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. + +An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The existing produced-file row retains its separate text-preview behavior. + +## Alternatives considered + +**Immutable attachment snapshots and editable temporary copies** preserve delivered versions after source edits or deletion, but make desktop edits diverge from workspace files and introduce retention work without a current product requirement. This decision supersedes the [snapshot-delivery design](../../archived/feature/2026-09-08-web-explicit-file-delivery.md). Neither a download endpoint nor a fallback copy remains; both require an explicit future product decision. + +**Opening attachment-store files directly** lets editors mutate immutable objects. A future persistent delivery system needs an owned editing and retention policy, such as copy-on-write, before exposing saved versions to applications. + +**Generic artifact fields or a Host tool subpath inside the UI package** broaden unrelated APIs or couple preset installation to browser packaging. A tool-owned event and ordinary package preserve existing extension points and publication rules. + +**Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text. + +**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees. + +## Consequences + +The Session log persists declarations but no attachment references or file contents from `present`. Session ZIP exports contain these declarations; transferring the log does not transfer workspace files. The event remains required-on-read because silently losing delivery declarations would alter reconstructed or forked history. Released Session format generations remain unchanged. + +The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names, types, and descriptions without stale byte-size metadata. No artifact service or speculative storage fallback is introduced. + +Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md new file mode 100644 index 0000000000..7aa5bebc97 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -0,0 +1,37 @@ +# Agent Note:Present 声明交付工作区源文件 + +Status: implemented + +[English](2026-09-08-present-workspace-source-files.md) | 中文 + +## 问题 + +用户需要打开并编辑工作区中产出的文件,包括没有编辑器修改记录的 shell 产出文件。保存独立交付版本会为这一流程增加内容存储、副本校验、临时文件保留,以及第二个编辑目标。 + +## 决策 + +[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 + +工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 + +经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为。 + +## 考虑过的替代方案 + +**不可变附件快照和可编辑临时副本**可在源文件编辑或删除后保留交付版本,但会使桌面编辑与工作区文件分离,并在缺少当前产品需求时引入保留工作。本决策取代[快照交付设计](../../archived/feature/2026-09-08-web-explicit-file-delivery.md)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。 + +**直接打开附件存储文件**会让编辑器修改不可变对象。未来持久化交付系统需要先明确编辑和保留策略,例如写时复制,再将保存版本暴露给应用。 + +**通用 artifact 字段或 UI 包内的 Host 工具子路径**会扩展无关 API,或将 preset 安装与浏览器打包耦合。工具拥有的事件与普通包保留现有扩展点和发布规则。 + +**以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。 + +**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。 + +## 影响 + +Session 日志持久化声明,不保存来自 `present` 的附件引用或文件内容。Session ZIP 导出包含这些声明;转移日志不会转移工作区文件。该事件仍要求读取端识别,因为静默丢失交付声明会改变重建或 fork 的历史。已发布 Session 格式代际保持不变。 + +仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称、类型和说明,不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。 + +定向测试覆盖不读取内容的声明、无效输入、被阻止的结果、源路径身份、编辑后的当前字节、缺失文件、工作区越界、fork 相对路径、重试、取消与释放。录制的 Web 场景覆盖嵌套成功后外层失败、源文件编辑、重新加载、删除错误、卡片与正文打开且无浏览器下载,以及不包含交付内容的 Session 导出。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 91c1bc8481..95ff31f9f1 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: b81f386f0d0e820b0361c775fb4c45a9e633d04b -2026-07-30-generated-third-party-notices.zh.md: d3b3e1686bfdf8901902f995205c1dbdd6dfbf38 +2026-07-30-generated-third-party-notices.md: 41ac0c75ca55c81f2055c867bd029ee7e4a350f9 +2026-07-30-generated-third-party-notices.zh.md: 4097831828287e7180cf37d713fa4694ae028b31 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index b81f386f0d..41ac0c75ca 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -20,7 +20,7 @@ One trigger gap is accepted rather than worked around: lefthook inspects only fi The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail. -**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the root source-run scripts execute through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). +**Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook. The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. Source execution can mount any plugin package from a user's `cordis.yml`; `@modelcontextprotocol/sdk` and the OpenTelemetry packages therefore reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index d3b3e1686b..4097831828 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -20,7 +20,7 @@ Status: implemented 文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。 -**分层依据是声明方所在区域,而非 manifest 字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一 manifest——即根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而根目录的源码运行脚本通过 `tsx` 执行,根本没有任何 manifest 把它声明为运行时依赖,只能由生成器显式标记。 +**分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行。 运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。从源码运行时,用户可以通过 `cordis.yml` 挂载任何插件包;因此,`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 5edb76739c..692a6fe140 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: c403964d8de1c158e5949b5e112a038832709874 -2026-08-10-npm-release-sequences.zh.md: d03da4c4485c4807497dfb28b61ab342bb4c8d12 +2026-08-10-npm-release-sequences.md: c039db2c7463f93f6f8847ae0ae6100df650f2a5 +2026-08-10-npm-release-sequences.zh.md: 1f14c03beb57d3a574305b348379b1542836083c diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index c403964d8d..c039db2c74 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -123,6 +123,8 @@ A dsh verification installs the vendored family's pack output too. The harness p The verification also packs the Landlock entry, which `dsh-sandbox-local` declares as a plain dependency, and omits optional dependencies. The platform packages behind those optional entries need a musl toolchain and one build per architecture, so a job on one runner cannot produce them; a consumer that cannot install them must still start, which is what optional means here. The verification therefore reads a directory by its contents rather than a pack order, because a directory can hold tarballs packed only to satisfy a cross-sequence dependency. +The installed-consumer probe captures npm's HTTP diagnostics and includes them when installation fails. Registry response codes and cache status remain visible even when npm reports a failed peer manifest fetch as `ERESOLVE` with an undefined version. + ### Repository changes this carried | Item | Content | diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index d03da4c448..1f14c03beb 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -123,6 +123,8 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven 验证还会打一份 Landlock entry 的 tarball——`dsh-sandbox-local` 把它声明为普通 `dependencies`——同时略去可选依赖。那些可选项背后的平台包需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起,这正是「可选」在这里的含义。因此验证按目录内容读取 tarball,而不是读发布顺序:一个目录可能只装着为满足跨序列依赖而打出来的包,任何发布顺序都不描述它。 +已安装消费方探针会捕获 npm 的 HTTP 诊断信息,并在安装失败时输出。即使 npm 将 peer manifest 获取失败报告为版本未定义的 `ERESOLVE`,日志中仍能看到 registry 响应码和缓存状态。 + ### 本次带出的仓库改动 | 项 | 内容 | diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index d44c9594bb..d24d629c9f 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md -2026-08-26-published-dependency-faces.md: 25e9f2ce139a7cd4efb64dbe71d49d8c9f88c24b -2026-08-26-published-dependency-faces.zh.md: ccc198b164b7450b6840862faf23b546c99fa2a6 +2026-08-26-published-dependency-faces.md: 1a94ccedcf6f614c7853c96c329e88d037167460 +2026-08-26-published-dependency-faces.zh.md: 4ac3f461018c21047af6359ca5e27d050da5a2c4 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index 25e9f2ce13..1a94ccedcf 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -28,7 +28,7 @@ A workspace package reached by a runtime value import from the Host entry closur An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each export-table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports covered by neither the package list nor an export table; namespace, dynamic, and side-effect imports remain unbounded unless the complete exact entry is package-classified. -Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Ordinary third-party packages imported by the Host runtime belong in `dependencies`; other third-party relationships keep their declared section. Workspace references use `workspace:^`. +Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, and existing metadata-only peers belong only in `devDependencies`. Host runtime imports, including additional Node entries, follow the Host classification. [Browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) governs ordinary third-party declarations; it partially supersedes their preservation in this decision. Workspace references use `workspace:^`. Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index ccc198b164..4ac3f46101 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -28,7 +28,7 @@ Host 入口闭包中的运行期 value import 所到达的 workspace 包,只 constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。每个导出表的 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝既没有包级分类、也没有导出级分类的导出;除非完整的精确入口已按包分类,否则 namespace、dynamic 和 side-effect import 仍无法限定范围。 -Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时导入的普通第三方包属于 `dependencies`;其他第三方关系保持原区段。Workspace 引用使用 `workspace:^`。 +Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject` 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时 import(包括额外 Node 入口)遵循 Host 分类。[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)规定普通第三方声明,部分取代本决策对它们原区段的保留。Workspace 引用使用 `workspace:^`。 部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。 diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml new file mode 100644 index 0000000000..7fc8e364f2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md +2026-09-08-browser-third-party-build-inputs.md: 97e61f3b99955cb402f9e622b2d40e3ee2953d79 +2026-09-08-browser-third-party-build-inputs.zh.md: e23c4e9d4615c7c5bc788beafcf6b94699f211ce diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md new file mode 100644 index 0000000000..97e61f3b99 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md @@ -0,0 +1,35 @@ +# Agent Note: Browser third-party libraries as build inputs + +Status: implemented + +English | [中文](2026-09-08-browser-third-party-build-inputs.zh.md) + +## Problem + +Prebuilt browser plugins distribute their third-party implementations inside JavaScript, but production npm dependencies still make installers download those libraries separately and resolve their peers. When React is declared only for development, installers can select a different React version for those extra dependencies than the browser artifact uses. Both `use-sync-external-store@1.2.0` and `@tanstack/react-virtual@3.14.9` support the current React 18; this problem does not require a React upgrade. + +## Decision + +Browser-only third-party dependencies belong in `devDependencies`, including implementations inlined into dynamic plugins, static browser-library inputs, and React shared by the Web shell. This partially supersedes the preservation of ordinary third-party declarations in [published dependency faces](2026-08-26-published-dependency-faces.md); that note continues to govern package selection, Host value dependencies, and Cordis identity. + +The dependency classifier collects build inputs from source imports and JSX. Third-party libraries reachable by the Host runtime take precedence as `dependencies`; additional Node build entries must also be checked. Type-only source references do not create Host runtime dependencies. Existing configuration-metadata and shared-Host-export classifications remain unchanged. + +Npm sections do not select browser bundling behavior. Dynamic plugins inline private libraries and obtain React and other shared modules from the platform table; static browser libraries retain bare imports and styles for the final Vite build. Static packages are Web-shell build inputs, not independently installed libraries with every rebundling dependency provided. Source builds need development dependencies; installed published Web artifacts do not. + +License classification follows distributed content. Dependency resolution through the real browser build configurations covers dynamic plugins and the Web shell; resolved third-party implementations remain [runtime disclosures](2026-07-30-generated-third-party-notices.md) even when manifests declare them for development. Test tools, erased type imports, and build tools do not become distributed code merely by appearing in `devDependencies`. + +## Alternatives considered + +**Declare another production React or override peer resolution.** This retains an extra installed graph that the browser does not use, without making that installed copy the browser's shared instance. + +**Inline every static-library dependency early.** This changes Vite's third-party chunks, caching, and CSS handling; dependency classification does not require those build changes. + +**Move every third-party dependency of a Client-bearing package.** Dual-face packages still load Host libraries, including `fflate` for ZIP output and `zod` for RPC validation; those installation relationships must remain. + +**Classify license disclosures directly by manifest section.** Distributing browser code and having an installer download a same-named package are different facts; that distinction cannot remove license checks on distributed code. + +## Consequences + +Production dependencies do not install third-party libraries a second time solely for browser implementations. React and React DOM retain one build version, and plugins consume the Web shell's shared instance. Classification tests constrain browser dev-only inputs, Host precedence, and idempotent repair; publication checks reject React installation leaks, while browser artifact verification independently covers module loading. + +Classification remains source-based. License resolution likewise must not depend on existing `lib/` files or write build outputs; its tests cover real resolution, type erasure, asset references, and missing dependencies. Developers independently consuming static packages or public types supply the corresponding build dependencies themselves; this decision adds no standalone browser-library support promise. diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md new file mode 100644 index 0000000000..e23c4e9d46 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 浏览器第三方库作为构建输入 + +Status: implemented + +[English](2026-09-08-browser-third-party-build-inputs.md) | 中文 + +## 问题 + +预构建浏览器插件的第三方实现已经随 JavaScript 分发,但生产 NPM 依赖仍让安装器另外下载这些库并解析其对等依赖(peer dependency)。React 只在构建期声明时,安装器可以为这些额外依赖选择与浏览器产物不同的 React 版本。`use-sync-external-store@1.2.0` 与 `@tanstack/react-virtual@3.14.9` 都支持当前 React 18;问题不要求升级 React。 + +## 决策 + +浏览器专用第三方依赖属于 `devDependencies`,包括动态插件内联的实现、静态浏览器库的输入,以及由 Web 壳提供共享实例的 React。该规则部分取代[已发布依赖分类](2026-08-26-published-dependency-faces.zh.md)对普通第三方声明的保留策略;该文档继续规定包的选择范围、Host 值依赖与 Cordis 实例身份。 + +依赖分类器从源码导入和 JSX 收集构建输入。Host 运行时可达的第三方库优先归入 `dependencies`;额外 Node 构建入口也必须纳入检查。源码中的纯类型引用不产生 Host 运行时依赖。配置元数据与共享 Host 导出的既有分类保持不变。 + +NPM 字段不决定浏览器打包方式。动态插件继续内联私有库,并从平台模块表读取 React 等共享模块;静态浏览器库继续保留裸导入及样式,由最终 Vite 构建处理。静态包是 Web 壳的构建输入,不承诺独立安装后具备二次打包所需的全部依赖。源码构建需要开发依赖;安装已发布的 Web 产物不需要它们。 + +许可证分类以交付内容为准。真实浏览器构建配置的依赖解析同时覆盖动态插件与 Web 壳;解析到的第三方实现仍计入[运行时披露](2026-07-30-generated-third-party-notices.zh.md),即使清单将其列为开发依赖。测试工具、纯类型导入和构建工具不会仅因处于 `devDependencies` 而算作分发代码。 + +## 考虑过的替代方案 + +**为生产安装额外声明 React,或强制覆盖对等依赖。** 这保留了浏览器不使用的额外安装图,且并未使安装副本成为浏览器共享实例。 + +**把静态库的所有依赖提前内联。** 这会改变 Vite 对第三方分块、缓存和 CSS 的处理;依赖分类不需要改变这些构建行为。 + +**把带 Client 的包的全部第三方依赖移走。** 双面包仍可能在 Host 加载库,例如 ZIP 输出所用的 `fflate` 和 RPC 校验所用的 `zod`;这些安装关系必须保留。 + +**按清单字段直接划分许可证披露。** 浏览器代码的交付与安装器是否下载同名包不是同一事实,不能因此取消对分发代码的许可证检查。 + +## 结果 + +生产依赖不再为纯浏览器实现重复安装第三方库。React 与 React DOM 保持同一构建版本,插件继续消费 Web 壳的共享实例。分类测试同时约束浏览器 dev-only、Host 优先和修复幂等;发布检查拒绝 React 安装泄漏,浏览器产物验证独立覆盖实际模块加载。 + +分类检查保留源码执行方式。许可证解析也不能依赖已有 `lib/` 或写入构建输出;其测试覆盖真实解析、纯类型擦除、资源引用与缺失依赖。独立消费静态包或公开类型的开发者需要自己提供相应构建依赖;本决策不增加独立浏览器库的支持承诺。 diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml new file mode 100644 index 0000000000..b85c97a4b8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md +2026-09-03-minimal-profiles-persistent-shell-only.md: d58210cb890e4264b02d591f213045b592ca6bfc +2026-09-03-minimal-profiles-persistent-shell-only.zh.md: 481ef479c2dc01ae503f621df976529aeb6bdbe0 diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md new file mode 100644 index 0000000000..d58210cb89 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md @@ -0,0 +1,35 @@ +# Agent Note: Minimal profiles expose only a persistent shell + +Status: implemented + +English | [中文](2026-09-03-minimal-profiles-persistent-shell-only.zh.md) + +## Problem + +The shipped Web `minimal` preset and standalone `sdk-minimal` profile exposed `str_replace_editor` beside their persistent shell. The editor added a second file-mutation interface and its complete schema to every minimal model request, although the shell already provides file inspection and mutation. It also required a dedicated `fs-local` service that no other row in either minimal composition consumed. + +Using one persistent shell gives the model a consistent file-operation interface and keeps the harness composition aligned with that interface. Leaving the editor mounted but hidden through a presentation filter would preserve an inactive capability that could reappear when presentation configuration changes. + +## Decision + +The shipped minimal compositions expose exactly one platform-selected persistent shell: `bash` on Linux and macOS, or `pwsh` on Windows. Neither composition mounts `@deepseek-ai/dsh-tool-str-replace-editor`, a filesystem tool, or the `fs-local` service that supported the editor. The fixed complete persona, absence of runtime context and compaction, shell timeout, and launch-specific host services remain unchanged. + +The standalone editor package remains available for explicit custom compositions. A trusted user-authored preset or higher profile patch must insert the editor into the Cordis tree with a filesystem provider in the same service scope; the shipped `minimal` and `sdk-minimal` defaults never insert it. The [Python SDK guide](../../../../docs/user/guide/python-sdk.md#opt-in-to-str_replace_editor) provides an executable patch example. + +The shared [persistent Bash consumer](../../../../packages/shell/tool-bash-persistent/README.md#model-experience) uses the one-shot shell's command-status wording while retaining its persistent state. Settled commands append `[Command finished with exit code N]`, including success; timeout output includes `[Command timed out or OOM]` and the shell-reset notice. Trailing newlines are removed before the status trailer. Both minimal Bash descriptions state that network access depends on the task environment. Explicit compositions using this consumer share its output behavior; the persistent PowerShell description and output remain unchanged. + +Exact composition tests assert the single tool and the absence of a preset-local filesystem service. The `sdk-minimal` bundle test and built config dump assert that its row and dependency allowlists contain neither `fs-local` nor `dsh-tool-str-replace-editor`. Web and packaged-Python model-visible snapshots pin the one-tool schema roster. SDK profile smoke tests execute the guide's editor patch and verify file creation and viewing. + +This decision partially supersedes the tool selection in [the bare minimal runtime](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) and the minimal exception in [the base editor decision](2026-09-05-base-default-file-editor.md). Those notes retain authority for prompt ownership, no-compaction behavior, and base-backed file editing. [The application architecture](../../../../docs/architecture.md) owns profile launch and bundle layering. + +## Alternatives considered + +**Keep the editor row and hide its schema.** Rejected because a presentation or restriction layer would leave the capability in the minimal composition and make its absence depend on another setting. + +**Remove the editor package from the distribution.** Rejected because explicit custom compositions remain valid consumers. The requirement concerns the two shipped minimal defaults. + +**Keep the editor only in `sdk-minimal`.** Rejected because the two minimal paths would present different tool contracts to the same model class, and the packaged SDK path would retain the schema cost and unused filesystem service. + +## Consequences + +Minimal agents inspect and modify files through their persistent shell. Their model requests carry one tool schema, and their compositions own no filesystem service. The editor package and explicit editor compositions remain available. Web and SDK replay fixtures pin the persistent Bash status trailers alongside shell state and file effects. diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md new file mode 100644 index 0000000000..481ef479c2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 极简 profile 只提供持久 shell + +Status: implemented + +[English](2026-09-03-minimal-profiles-persistent-shell-only.md) | 中文 + +## 问题 + +随附 Web `minimal` preset 与独立 `sdk-minimal` profile 在持久 shell 之外还提供 `str_replace_editor`。Shell 已经可以检查和修改文件,editor 仍会为每个极简模型请求增加第二种文件修改接口及其完整 schema。它还要求挂载一个专用 `fs-local` 服务,而两份极简组合中的其他配置项都不使用该服务。 + +只使用持久 shell 可以为模型提供一致的文件操作接口,并让 harness 组合与这一接口保持一致。如果保留 editor 的挂载,只通过呈现层过滤隐藏它,那么呈现配置变化时,该能力仍可能重新出现。 + +## 决策 + +随附的极简组合只提供一个按平台选择的持久 shell:Linux 与 macOS 使用 `bash`,Windows 使用 `pwsh`。两份组合都不挂载 `@deepseek-ai/dsh-tool-str-replace-editor`、文件系统工具或支撑 editor 的 `fs-local` 服务。固定的 complete persona、运行时上下文与 compaction 的缺失、shell 超时和各启动路径的宿主服务保持不变。 + +独立 editor 包仍可用于显式自定义组合。受信任的用户自定义 preset 或更高优先级的 profile patch 必须将 editor 插入 Cordis tree,并在同一服务作用域内提供文件系统后端;随附的 `minimal` 与 `sdk-minimal` 默认组合不会插入它。[Python SDK 指南](../../../../docs/user/guide/python-sdk.zh.md#opt-in-to-str_replace_editor)提供可执行的 patch 示例。 + +共享的[持久 Bash 消费方](../../../../packages/shell/tool-bash-persistent/README.zh.md#model-experience)保留跨调用状态,并采用单次 shell 的命令状态文案。完成的命令追加 `[Command finished with exit code N]`,成功时也包含该标记;超时输出包含 `[Command timed out or OOM]` 和 shell 重置说明。追加状态标记前会移除输出末尾的全部换行。两份极简 Bash 描述都说明网络访问取决于任务环境。使用该消费方的显式组合共享相同的输出行为;持久 PowerShell 的描述与输出保持不变。 + +精确组合测试会断言单工具清单以及 preset 内不存在文件系统服务。`sdk-minimal` bundle 测试与构建后配置转储会断言配置项和依赖 allowlist 都不含 `fs-local` 或 `dsh-tool-str-replace-editor`。Web 与打包 Python 的模型可见快照会固定单工具 schema 清单。SDK profile 冒烟测试会执行指南中的 editor patch,并验证文件创建和查看。 + +本决策部分取代[极简裸运行时](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)中的工具选择,以及[base 编辑器决策](2026-09-05-base-default-file-editor.zh.md)中的极简例外。这些 Agent Note 继续负责提示词所有权、无 compaction 行为和基于 base 的文件编辑。[应用架构](../../../../docs/architecture.zh.md)负责 profile 启动与 bundle 分层。 + +## 考虑过的替代方案 + +**保留 editor 配置项并隐藏其 schema。** 不予采用,因为呈现层或限制层会让该能力继续留在极简组合中,并使其缺失依赖另一项设置。 + +**从发行物中删除 editor 包。** 不予采用,因为显式自定义组合仍是有效消费方。本需求只涉及两份随附的极简默认组合。 + +**只在 `sdk-minimal` 中保留 editor。** 不予采用,因为两条极简路径会向同类模型提供不同的工具约定,而且打包 SDK 路径仍会承担 schema 成本和未被其他配置项使用的文件系统服务。 + +## 后果 + +极简 agent 通过持久 shell 检查和修改文件。模型请求只携带一个工具 schema,组合不拥有文件系统服务。editor 包和显式 editor 组合仍然可用。Web 与 SDK 回放快照会同时固定持久 Bash 的状态标记、shell 状态和文件操作结果。 diff --git a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml index 672c293dc5..a4c4a9902a 100644 --- a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md -2026-09-05-base-default-file-editor.md: 68a86769a99d697f9c7c767e9ba59b0cf61669b1 -2026-09-05-base-default-file-editor.zh.md: e8302b609a8647b1a0b90923361da06e859868ab +2026-09-05-base-default-file-editor.md: b87d7986e7beeab30ff5414908239ba95022d231 +2026-09-05-base-default-file-editor.zh.md: db3c70d0a28527391073ddc34a72e9faabe1c3be diff --git a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md index 68a86769a9..b87d7986e7 100644 --- a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md +++ b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md @@ -12,7 +12,7 @@ The shared base selects both `read`/`write`/`edit` and `str_replace_editor`, whi The [base patch](../../../../packages/bundle/base/cordis.patch.yml) selects `read`, `write`, and `edit` for file editing. It does not insert `tool-str-replace-editor`; SDK and Web application patches therefore need no disabling override. The editor package remains available to compositions that insert it explicitly. -[Web minimal](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) inserts its own `str-replace-editor` row in the agent scope. The standalone [sdk-minimal bundle](../../../../packages/bundle/sdk-minimal/cordis.patch.yml) inserts its own row without inheriting base. Both minimal compositions retain their editor. +Web minimal and the standalone `sdk-minimal` bundle own their tool selection independently of base. The [persistent-shell-only decision](2026-09-03-minimal-profiles-persistent-shell-only.md) owns their single-tool defaults. This refines the shared tool defaults in [one dsh launcher](../architecture/2026-08-22-single-dsh-application-launcher.md). That note remains active for launch ownership, shared services, and patch precedence; no active note is fully superseded. @@ -20,7 +20,7 @@ This refines the shared tool defaults in [one dsh launcher](../architecture/2026 **Disable the editor separately in each application.** This leaves overlapping defaults in base and requires each consumer to opt out. The base owns the shared choice directly. -**Delete the tool package or remove it from minimal.** The dedicated minimal compositions use this interface for file operations. Keeping the package and their explicit rows preserves that behavior. +**Delete the tool package.** Explicit custom compositions still use this interface. Base default selection does not remove the package or constrain independently owned minimal defaults. ## Consequences @@ -28,4 +28,4 @@ Base-backed SDK, headless, ACP, and custom profiles omit the editor schema by de ## Verification -The [SDK process tests](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) capture actual model requests for default file tools, explicit editor insertion, and the standalone minimal roster. The [headless process test](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) checks the shared default through its application. [Web minimal snapshots](../../../../apps/web/tests/minimal-preset.snapshot.ts) exercise the editor through the minimal preset. The [headless](../../../../snapshots/session/headless.snapshot.ts), [SDK](../../../../snapshots/sdk/sdk.snapshot.ts), and [ACP](../../../../snapshots/acp/acp.snapshot.ts) recorded sessions pin the assembled model-visible outputs, including the SDK fixture that explicitly inserts the editor. +The [SDK process tests](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) capture actual model requests for default file tools, explicit editor insertion, and the standalone minimal roster. The [headless process test](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) checks the shared default through its application. The [headless](../../../../snapshots/session/headless.snapshot.ts), [SDK](../../../../snapshots/sdk/sdk.snapshot.ts), and [ACP](../../../../snapshots/acp/acp.snapshot.ts) recorded sessions pin the assembled model-visible outputs, including the SDK fixture that explicitly inserts the editor. diff --git a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md index e8302b609a..db3c70d0a2 100644 --- a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md @@ -12,7 +12,7 @@ Status: implemented [base patch](../../../../packages/bundle/base/cordis.patch.yml) 选择 `read`、`write` 和 `edit` 负责文件编辑。它不插入 `tool-str-replace-editor`;因此 SDK 与 Web 应用 patch 无需禁用覆盖。编辑器包仍可供显式插入它的组合使用。 -[Web minimal](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) 在 agent 作用域插入自己的 `str-replace-editor` 配置项。独立的 [sdk-minimal bundle](../../../../packages/bundle/sdk-minimal/cordis.patch.yml) 不继承 base,自行插入配置项。两种极简组合都保留其编辑器。 +Web minimal 与独立 `sdk-minimal` bundle 各自负责工具选择,不依赖 base。[仅持久 shell 决策](2026-09-03-minimal-profiles-persistent-shell-only.zh.md)负责它们的单工具默认值。 本决策细化了[统一 dsh 启动器](../architecture/2026-08-22-single-dsh-application-launcher.zh.md)中的共享工具默认值。该文档对启动所有权、共享服务和 patch 优先级仍然有效;没有被完全取代的活跃 Agent Note。 @@ -20,7 +20,7 @@ Status: implemented **在每个应用中分别禁用编辑器。** 这会在 base 中保留重叠的默认接口,并要求各消费方主动退出。共享选择由 base 直接负责。 -**删除工具包或从 minimal 移除它。** 专用的极简组合通过此接口完成文件操作。保留包及其显式配置项可以保留这一行为。 +**删除工具包。** 显式自定义组合仍使用此接口。base 的默认选择不删除包,也不约束独立负责的极简默认值。 ## Consequences @@ -28,4 +28,4 @@ Status: implemented ## Verification -[SDK 进程测试](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) 捕获默认文件工具、显式插入编辑器与独立极简工具清单的实际模型请求。[headless 进程测试](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) 通过所属应用检查共享默认值。[Web minimal 快照](../../../../apps/web/tests/minimal-preset.snapshot.ts) 通过极简 preset 执行编辑器。[headless](../../../../snapshots/session/headless.snapshot.ts)、[SDK](../../../../snapshots/sdk/sdk.snapshot.ts) 与 [ACP](../../../../snapshots/acp/acp.snapshot.ts) 录制会话固定组装后模型可见的输出,包括显式插入编辑器的 SDK fixture。 +[SDK 进程测试](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) 捕获默认文件工具、显式插入编辑器与独立极简工具清单的实际模型请求。[headless 进程测试](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) 通过所属应用检查共享默认值。[headless](../../../../snapshots/session/headless.snapshot.ts)、[SDK](../../../../snapshots/sdk/sdk.snapshot.ts) 与 [ACP](../../../../snapshots/acp/acp.snapshot.ts) 录制会话固定组装后模型可见的输出,包括显式插入编辑器的 SDK fixture。 diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml index 413d0f32b5..a1a94872ba 100644 --- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md -2026-09-04-session-open-performance-gate.md: d5447f93666a5cb27af2073a4a66f99d0c39f6ee -2026-09-04-session-open-performance-gate.zh.md: 657f77fb76cf84afc36301d22d9a7798c559075b +2026-09-04-session-open-performance-gate.md: d69e7424c5b34bc9f50d4a1d355279b65b7dfd8a +2026-09-04-session-open-performance-gate.zh.md: cce4ae3921d70d43dde823d64dca5a0976344100 diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md index d5447f9366..d69e7424c5 100644 --- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md +++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md @@ -56,6 +56,8 @@ The pre-stack implementation keeps V0 as its current format, so first open does The [standard two-CPU run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34023970384/job/101461539961) at `ca3ffe95dac2c55eefeb16ed9b61067bbd19ee90` uses Node 24.20.0 x64 and Ubuntu image `20260831.293.1`. Its five current-generation `open` samples are 49.2, 47.4, 49.1, 48.6, and 48.1 ms: median 48.6 ms, maximum 49.2 ms. The rounded 50 ms CI expectation gives a 63 ms limit without reapplying the 2× machine scale. The log identifies two available CPUs but not their model; it does not isolate hardware from the Node-version change. This is endpoint-specific runner calibration, not evidence of an application optimization or a new reference-machine measurement. Every other benchmark passes its existing budget. Deterministic controls reject the observed median at the historical 30 ms limit, accept it at 63 ms, reject a synthetic 75 ms reopen median, and reject a synthetic 4,000 ms first-open duration at its unchanged 550 ms limit. These controls verify budget enforcement, not a measured new regression. +A cold-verifier packaging change removes runtime workspace-module loading without changing these budgets or the measured endpoint. On macOS arm64, Node 24.18.0, the same 127,400-event fixture at `ac48359b195558806ee5a2286697074fd1a52815` takes 164.2, 162.4, 159.7, 149.3, and 167.7 ms for first writable resume (median 162.4 ms). Bundling the verifier through the workspace build gives 119.8, 120.9, 121.9, 122.3, and 121.8 ms (median 121.8 ms, 25% lower). Retained heap stays at 5.4 MB; median peak RSS changes from 144.9 to 143.7 MB. Reopen medians are 27.5 and 27.1 ms, and all 16 Session cases, including the 128 MB completion checks, pass. A CPU profile attributes part of the old verifier cost to module resolution and compilation. The isolated-package built-worker test fails on the original worker because its workspace imports cannot resolve, and passes with the bundled worker, including rejection of an incorrect event count. These local results do not establish Linux runner timing; the existing 450 ms CI gate remains the acceptance check. + The calibrated source budgets are: | Measurement | Reference expectation | CI budget | diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md index 657f77fb76..cce4ae3921 100644 --- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md @@ -56,6 +56,8 @@ Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 `ca3ffe95dac2c55eefeb16ed9b61067bbd19ee90` 上的[标准双 CPU 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34023970384/job/101461539961)使用 Node 24.20.0 x64 和 Ubuntu 镜像 `20260831.293.1`。当前 generation `open` 的五次样本为 49.2、47.4、49.1、48.6 和 48.1 ms:中位数 48.6 ms,最大值 49.2 ms。取整后的 50 ms CI 预期值给出 63 ms 上限,不重复乘以 2 倍机器系数。日志标明两个可用 CPU,但未记录型号;它无法区分硬件变化与 Node 版本变化的影响。这是端点专属的运行器校准,不是应用优化或参考机器新测量的证据。其他每项 benchmark 均通过既有预算。确定性正反例在历史 30 ms 上限下拒绝实测中位数,在 63 ms 下接受它,拒绝合成的 75 ms reopen 中位数,并以未改变的 550 ms 上限拒绝合成的 4,000 ms 首次打开耗时。这些正反例验证预算执行,不代表测得新的退化。 +一次冷 verifier 打包调整移除了运行时 workspace 模块加载,未改变这些预算或测量终点。在 macOS arm64、Node 24.18.0 上,`ac48359b195558806ee5a2286697074fd1a52815` 对同一份 127,400-event fixture 的首次 writable resume 耗时为 164.2、162.4、159.7、149.3、167.7 ms(中位数 162.4 ms)。通过 workspace build 打包 verifier 后为 119.8、120.9、121.9、122.3、121.8 ms(中位数 121.8 ms,降低 25%)。Retained heap 保持 5.4 MB;peak RSS 中位数从 144.9 变为 143.7 MB。Reopen 中位数为 27.5 和 27.1 ms,包含 128 MB completion check 的全部 16 项 Session 用例通过。CPU profile 将旧 verifier 的部分成本归因于模块解析和编译。隔离 package 的 built-worker 测试在旧 worker 上因无法解析 workspace import 而失败,在打包后的 worker 上通过,同时验证错误的 event count 会被拒绝。这些本地结果不能证明 Linux runner 耗时;现有 450 ms CI gate 仍是验收检查。 + 校准后的源码预算如下: | 测量项 | 参考机预期 | CI 预算 | diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml index dfa20e45f0..ab338ea560 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md -2026-09-06-backend-continuation-performance.md: f4316e790cf62f5027a0f7bfb2d3148cc79e7536 -2026-09-06-backend-continuation-performance.zh.md: 0fb36ba5375c61e907791b31d96f09062b7f62a3 +2026-09-06-backend-continuation-performance.md: 62d8af10015cc2da7b399aae3c9d197f75931753 +2026-09-06-backend-continuation-performance.zh.md: 7c8904fa019b27362e2cdb0e50697921913e5d09 diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md index f4316e790c..62d8af1001 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md @@ -23,6 +23,8 @@ The shared history has 800 completed two-step turns, four tool calls per turn, a The tool execution pipeline, request preparation, Session projections required by those services, persistence, and catalog observations remain production code. Only the model adapter and bounded tool body are synthetic. The adapter retains a request counter, not request objects, so the fixture cannot manufacture a growing retention cost. Sequential input means each idle interval belongs to the one request delivered by this worker; it does not generalize idle to a per-message completion API under concurrent input. +The SDK fixture explicitly inserts `fs-local` and `str_replace_editor` through its profile patch. This preserves the calibrated file-view workload independently of the [minimal profile's shell-only defaults](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md). File reads, timing endpoints, and budgets remain the same. + Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations. The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage. diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md index 0fb36ba537..7c8904fa01 100644 --- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md @@ -23,6 +23,8 @@ Status: implemented 工具执行管线、请求准备、这些服务所需的 Session 投影、持久化和目录观察均保留生产代码。只有模型适配器和有界工具体是合成的。适配器只保留请求计数,不保留请求对象,因此 fixture(测试前置数据)不会制造不断增长的保留成本。顺序输入使每个空闲区间对应此 worker 提交的唯一请求;这不代表并发输入时可以把空闲状态推广为逐消息完成 API。 +SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor`。这使经校准的文件查看负载不依赖[极简 profile 只提供 shell 的默认组合](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)。文件读取、计时终点和预算保持不变。 + 五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。 父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml index de9078ed58..e14bbcc662 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md -2026-09-08-ci-readiness-and-completion.md: 8ea0a5892d78eda16e657334dba2af58b6648d09 -2026-09-08-ci-readiness-and-completion.zh.md: 62a4c64081369a20a576805fb8a465bffff2922d +2026-09-08-ci-readiness-and-completion.md: 01d2d7f91a0ef10e161772d3c398261acc472238 +2026-09-08-ci-readiness-and-completion.zh.md: 584d8ea00c012e197cb75d9fbce03eb2e1fb03a1 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md index 8ea0a5892d..01d2d7f91a 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md @@ -10,6 +10,8 @@ The [empty master PR run](https://github.com/deepseek-harness/deepseek-harness/a Another [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437) reports a null publint child status and an LSP initialization-marker timeout. Their helpers impose five- and three-second limits inside the lane's 90-second test budget. These cases verify publication contents and cancellation behavior rather than cold-start latency. +The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228) exhausts a one-second registry poll after transport failure. Disconnect cleanup includes cancellation, output draining, persistence, and owner disposal; registry removal alone does not establish complete teardown. + ## Decision The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first. @@ -18,6 +20,8 @@ The [desktop transaction test](../../../../apps/desktop/tests/project-manager.sp The [publint runner tests](../../../../scripts/publint-all.spec.ts) pass the active test budget to their child and check launch errors and termination signals before interpreting its exit code. The [LSP instance test](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts) uses the same budget for its fixture marker, observes the actual pending `didOpen` write before aborting, and captures the query's rejection before waiting for readiness. Its [server fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts) publishes the marker after pausing stdin. Teardown captures the instance list, Context, and directory before its first await. +The [ACP disconnect tests](../../../../packages/acp/acp/tests/dispose.spec.ts) await the real session handle disposer for both EOF and transport failure. A barrier holds disposal pending while the test checks ownership, then releases it before awaiting completion and checking both registries. Neither case invokes plugin disposal to trigger the behavior under test. The independent teardown hook releases the barrier before disposing the captured Context, including when the test body times out. + The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) owns lifecycle cleanup budgets. The [persistent PowerShell decision](2026-09-07-pwsh-ci-observable-completion.md) owns exact versus inferred terminal readiness; a one-shot process's completion promise has different semantics. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md index 62a4c64081..584d8ea00c 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md @@ -10,6 +10,8 @@ Status: implemented 另一次 [Windows coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437)报告了 publint 子进程退出状态为 null,以及 LSP 初始化标记等待超时。对应 helper 在通道的 90 秒测试预算内另设五秒和三秒限制。这些用例验证发布内容与取消行为,不衡量冷启动延迟。 +[ACP coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228)在传输失败后耗尽一秒的注册表轮询期限。断连清理包含取消、输出排空、持久化和 owner 处置;仅从注册表移除不能证明完整拆卸已经结束。 + ## 决策 [Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。 @@ -18,6 +20,8 @@ Status: implemented [publint runner 测试](../../../../scripts/publint-all.spec.ts)将当前测试预算传给子进程,并在解释退出码前检查启动错误和终止信号。[LSP 实例测试](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts)用同一预算等待 fixture 标记,在取消前观察实际尚未完成的 `didOpen` 写入,并在等待就绪前接住查询的 rejection。[服务器 fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts)在暂停 stdin 后发布标记。Teardown 在首次 await 前捕获实例列表、Context 和目录。 +[ACP 断连测试](../../../../packages/acp/acp/tests/dispose.spec.ts)在 EOF 和传输失败两种情况下等待真实 Session handle 的 disposer。屏障阻塞处置,供测试检查所有权,然后释放屏障,等待完成并检查两个注册表。两个用例都不调用插件处置来触发待验证行为。独立的 teardown hook 在处置捕获的 Context 前释放屏障,包括测试体超时的情况。 + [子 Agent 拆卸决策](2026-09-07-subagent-teardown-test-budgets.zh.md)负责生命周期清理预算。[持久 PowerShell 决策](2026-09-07-pwsh-ci-observable-completion.zh.md)负责精确与推断的终端就绪状态;一次性进程的完成 Promise 具有不同语义。 ## 考虑过的替代方案 diff --git a/.github/workflows/node-addon-system.yml b/.github/workflows/node-addon-system.yml index 1097042f7d..a763a2a8d2 100644 --- a/.github/workflows/node-addon-system.yml +++ b/.github/workflows/node-addon-system.yml @@ -1,7 +1,8 @@ # CI for the node-addon-system packages under native/system. A separate # workflow from ci.yml keeps the native OS/architecture matrix independent of -# the harness Node matrix. Release assembly and publication use the companion -# Node Addon System Release workflow. +# the harness Node matrix. Each platform job builds once and tests those bytes +# under every supported Node release. Release assembly and publication use the +# companion Node Addon System Release workflow. name: Node Addon System on: @@ -46,14 +47,11 @@ jobs: runs-on: ubuntu-24.04 outputs: ci: ${{ steps.matrix.outputs.ci }} - compatibility: ${{ steps.matrix.outputs.compatibility }} steps: - uses: actions/checkout@v4 - id: matrix - run: | - echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT" - echo "compatibility=$(node ./scripts/github-matrix.mjs compatibility)" >> "$GITHUB_OUTPUT" + run: echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT" native: name: ${{ matrix.platform }} @@ -112,68 +110,59 @@ jobs: env: NALR_REQUIRE_LANDLOCK: ${{ runner.os == 'Linux' && '1' || '0' }} - - name: Verify platform payload rules + - name: Verify platform payload rules (Node 24) run: pnpm test:packaging - - name: Flock behavior (built addon) + - name: Flock behavior (Node 24, built addon) run: | pnpm build:test-oracle pnpm test:flock - - name: Upload this platform's built addon and entry - uses: actions/upload-artifact@v4 - with: - name: system-compat-${{ matrix.platform }} - path: | - native/system/packages/*/bin/** - native/system/packages/entry/lib/** - if-no-files-found: error - - - name: Upload independent syscall test oracle - uses: actions/upload-artifact@v4 - with: - name: system-oracle-${{ matrix.platform }} - path: native/system/test/bin/** - if-no-files-found: error - - compatibility: - name: ${{ matrix.platform }} / Node ${{ matrix.node }} (same binary) - needs: [matrix, native] - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.matrix.outputs.compatibility) }} - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node }} - - - name: Download the original platform build - uses: actions/download-artifact@v4 - with: - name: system-compat-${{ matrix.platform }} - path: native/system/packages - - - name: Download independent syscall test oracle - uses: actions/download-artifact@v4 - with: - name: system-oracle-${{ matrix.platform }} - path: native/system/test/bin - - - name: Restore oracle executable permissions - run: find ./test/bin -type f -name flock-oracle -exec chmod +x {} + - - - name: Test without rebuilding or installing dependencies - run: | - node ./test/link-platform.mjs - node --test ./test/flock.test.js ./test/package-matrix.test.js - - - name: Test the same musl addon without a compiler + - name: Test the same musl addon on Node 24 without a compiler if: runner.os == 'Linux' run: >- docker run --rm -v "$PWD:$PWD" -w "$PWD" - node:${{ matrix.node }}-alpine + node:24-alpine + node --test ./test/flock.test.js ./test/package-matrix.test.js + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Test the same binaries on Node 20 + run: node --test ./test/flock.test.js ./test/package-matrix.test.js + + - name: Test the same musl addon on Node 20 without a compiler + if: runner.os == 'Linux' + run: >- + docker run --rm -v "$PWD:$PWD" -w "$PWD" + node:20-alpine + node --test ./test/flock.test.js ./test/package-matrix.test.js + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Test the same binaries on Node 22 + run: node --test ./test/flock.test.js ./test/package-matrix.test.js + + - name: Test the same musl addon on Node 22 without a compiler + if: runner.os == 'Linux' + run: >- + docker run --rm -v "$PWD:$PWD" -w "$PWD" + node:22-alpine + node --test ./test/flock.test.js ./test/package-matrix.test.js + + - uses: actions/setup-node@v4 + with: + node-version: 26 + + - name: Test the same binaries on Node 26 + run: node --test ./test/flock.test.js ./test/package-matrix.test.js + + - name: Test the same musl addon on Node 26 without a compiler + if: runner.os == 'Linux' + run: >- + docker run --rm -v "$PWD:$PWD" -w "$PWD" + node:26-alpine node --test ./test/flock.test.js ./test/package-matrix.test.js diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 43c4263136..9004b1906f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. +External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. | Package | License | | --- | --- | @@ -55,7 +55,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | -| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | @@ -88,7 +87,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | -| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`mime-types`](https://github.com/jshttp/mime-types) | MIT | | [`negotiator`](https://github.com/jshttp/negotiator) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | @@ -138,7 +136,7 @@ The installed SDK 0.3.263 declares the following optional platform packages. Eac ## Development-only npm dependencies -External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. +External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. | Package | License | | --- | --- | @@ -155,6 +153,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -190,6 +189,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT | | [`oxlint`](https://github.com/oxc-project/oxc) | MIT | | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | diff --git a/apps/cli/package.json b/apps/cli/package.json index d56d9c5e29..5e87c205c3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-present": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 1c72034bf2..ae963e570a 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 42fb2855e97465417ca284944102d3c6a610416d -README.zh.md: 786774dae50a2dfcadbf3fd260b4680277c0d39e +README.md: 99ef8310cd1316e9ee4ade55fcac00766ae1e821 +README.zh.md: aa446d539a5b666e7745f459b5a014b22e72f90e diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 42fb2855e9..99ef8310cd 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -90,11 +90,11 @@ The production Web runner needs built package and frontend artifacts (`pnpm run Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. -The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its local filesystem and sandbox-policy root but intentionally omits instruction discovery and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. +The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root but intentionally omits filesystem tools, instruction discovery, and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. +`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only the platform-selected persistent shell. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 786774dae5..aa446d539a 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -92,11 +92,11 @@ dsh web --help 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 -基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为本地文件系统与沙箱策略根目录,但刻意省略指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。 +基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录,但刻意省略文件系统工具、指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合按平台选择的持久 shell。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ee1d6f3c04..ef606d49af 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1081,7 +1081,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['pty', '@deepseek-ai/dsh-terminal'], ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'], - ['fs-local', '@deepseek-ai/dsh-fs-local'], ['timer', '@deepseek-ai/cordis-plugin-timer'], ['llm', '@deepseek-ai/dsh-llm'], ['session', '@deepseek-ai/dsh-session'], @@ -1099,7 +1098,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['agent-loop', '@deepseek-ai/dsh-agent-loop'], ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], - ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], ]) expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index 7b41f6017e..44d31a36ab 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -22,6 +22,7 @@ {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} +{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Delegated write probe"}} {"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index c2d9145aaf..b027378207 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -12,16 +12,17 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":19,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":20,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":23,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":25,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":28,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"PARENT_RECEIVED_CHILD_RESULT","usage":{"inputTokens":30,"outputTokens":15}} diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 6db08281f3..c879e5b8b9 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -189,8 +189,25 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) - it('boots the standalone minimal profile through its generated manifest', async () => { + it.each([ + { label: 'boots the standalone minimal profile through its generated manifest', editorEnabled: false }, + { label: 'executes the documented editor opt-in patch with sdk-minimal', editorEnabled: true }, + ])('$label', async ({ editorEnabled }) => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) + const editorPatch = join(root, 'editor.patch.yml') + if (editorEnabled) { + const guide = await readFile(join(repoRoot, 'docs/user/guide/python-sdk.md'), 'utf8') + const yaml = guide.split('')[1] + ?.match(/```yaml\n([\s\S]*?)```/)?.[1] + expect(yaml).toBeDefined() + await writeFile(editorPatch, yaml!) + } + const editorFile = join(root, 'editor.txt') + const editorContent = 'sdk-minimal editor opt-in\n' + const editorCalls = editorEnabled ? [ + { command: 'create', path: editorFile, file_text: editorContent }, + { command: 'view', path: editorFile }, + ] : [] const modelRequests: Record[] = [] const modelServer = createServer((request, response) => { let body = '' @@ -200,8 +217,21 @@ describe('Python SDK dsh profile keyless smoke', () => { modelRequests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') - response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') - response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + const toolCall = editorCalls[modelRequests.length - 1] + if (toolCall) { + response.write(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index: 0, + id: `editor-${toolCall.command}`, + type: 'function', + function: { name: 'str_replace_editor', arguments: JSON.stringify(toolCall) }, + }] } }] })}\n\n`) + } else { + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + } + response.write(`data: ${JSON.stringify({ + choices: [{ delta: {}, finish_reason: toolCall ? 'tool_calls' : 'stop' }], + usage: { prompt_tokens: 3, completion_tokens: 1 }, + })}\n\n`) response.end('data: [DONE]\n\n') }) }) @@ -214,6 +244,7 @@ describe('Python SDK dsh profile keyless smoke', () => { binScript, '--profile', 'sdk-minimal', + ...(editorEnabled ? ['--patch', editorPatch] : []), ], { cwd: repoRoot, env: { @@ -251,11 +282,14 @@ describe('Python SDK dsh profile keyless smoke', () => { method: 'session/prompt', params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, })}\n`) - await waitForLine(lines, (value) => { + const turnEnd = await waitForLine(lines, (value) => { const params = value.params as Record | undefined const event = params?.event as Record | undefined return params?.sessionId === 'minimal' && event?.type === 'turn/end' }, () => stderr) + expect(turnEnd).toMatchObject({ + params: { event: { data: { reason: { kind: 'completed' } } } }, + }) const profile = JSON.parse( await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'), @@ -266,13 +300,27 @@ describe('Python SDK dsh profile keyless smoke', () => { }) expect(modelRequests[0]?.tools).toEqual(expect.any(Array)) const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] - expect(tools.map(tool => tool.function?.name).sort()).toEqual( - [process.platform === 'win32' ? 'pwsh' : 'bash', 'str_replace_editor'].sort(), - ) + expect(tools.map(tool => tool.function?.name)).toEqual([ + process.platform === 'win32' ? 'pwsh' : 'bash', + ...(editorEnabled ? ['str_replace_editor'] : []), + ]) + expect(modelRequests).toHaveLength(editorEnabled ? 3 : 1) + if (editorEnabled) { + expect(await readFile(editorFile, 'utf8')).toBe(editorContent) + expect(modelRequests[2]?.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + role: 'tool', + tool_call_id: 'editor-view', + content: expect.stringContaining(editorContent.trim()) as unknown, + }), + ])) + } child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) await waitForLine(lines, value => value.id === 3, () => stderr) const exit = await child + expect(exit.timedOut, stderr).toBe(false) + expect(exit.signal, stderr).toBeUndefined() expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) } finally { child.kill('SIGKILL') diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 3ac7af61ce..eacd602971 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -32,8 +32,7 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.' const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. -* You don't have access to the internet via this tool. -* You do have access to a mirror of common linux and python packages via apt and pip. +* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available. * State is persistent across command calls and discussions with the user. * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. * Please avoid commands that may produce a very large amount of output. @@ -63,6 +62,8 @@ async function bootWeb( // back on the next run, so a stored document from any other build decides // this test's boot. Same reason the settings row above is pinned. { id: 'storage-json', config: { root: storageRoot } }, + // Fixed Session IDs must stay inside this boot's temporary profile root. + { id: 'session-persistence-jsonl', config: { root: join(dirname(settingsFile), 'sessions') } }, // Host rows with side effects outside this process: a bound port, a served // asset tree, a telemetry exporter. `api-gateway` and `directory-picker` // stay ENABLED on purpose — the api-proxy is the host row that injects @@ -189,9 +190,7 @@ describe('the shipped Web composition', () => { it('leaves the global tool layer empty', () => { // Every model-facing tool belongs to a preset, `ask_user_question` // included: a tool in the global layer reaches EVERY agent regardless of - // which preset composed it, so a two-tool benchmark surface would really - // present three. A regression here means an agent-plane row came back to - // the host composition. + // which preset composed it, expanding that preset's tool list. expect(toolNames(ctx)).toEqual([]) }) @@ -243,7 +242,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill', + 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'ralph', 'read', 'read_image', 'send_message', 'skill', 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write', ]) @@ -287,7 +286,7 @@ describe('the shipped Web composition', () => { } }) - it('composes the exact RL prompt and two tools from `minimal`', async () => { + it('composes the exact RL prompt and persistent shell from `minimal`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-minimal'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), @@ -297,11 +296,13 @@ describe('the shipped Web composition', () => { expect(assembly.sections).toEqual([ { name: 'deployment:persona-prefix', text: MINIMAL_PROMPT }, ]) - expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor']) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash']) expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) - expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters)) - .toContain('Absolute path') expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() + // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs. + expect(ctx.agentPresets.serviceFor(handle.agent, 'fs')).toBeUndefined() + expect(ctx.get('fs')?.sandboxMode).toBeDefined() + expect(handle.agent.ctx.get('fs')?.sandboxMode).toBe(ctx.get('fs')?.sandboxMode) expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeUndefined() expect(handle.agent.ctx.get('compaction')).toBeUndefined() } finally { @@ -319,7 +320,7 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(ctx, minimal.agent)).toEqual(['bash']) expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) await minimal.dispose() @@ -470,7 +471,7 @@ describe('the shipped Web composition', () => { // stays the preset's choice — minimal mounts no `tool-skill`, so its // tool table has no loader even though the global layer is readable. expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash']) } finally { await handle.dispose() } @@ -849,7 +850,7 @@ describe('authoring a preset on the shipped composition', () => { try { // The same tools the shipped `minimal` composes, from a directory copied // through the service into a root outside the installed harness. - expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(authorCtx, handle.agent)).toEqual(['bash']) } finally { await handle.dispose() } @@ -884,9 +885,9 @@ describe('the default preset as a user setting', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), }) try { - // `mount()` with no id resolves the effective default. Two tools, not + // `mount()` with no id resolves the effective default. One tool, not // `standard`'s catalog: the setting decided the composition. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash']) } finally { await handle.dispose() } @@ -911,7 +912,7 @@ describe('a session keeps the preset it was created with', () => { try { // The api-proxy guard reads exactly this: the header records what the // session runs, so naming anything else is a caller error rather than a - // switch. Its history was produced under `minimal`'s two tools. + // switch. Its history was produced under `minimal`'s single tool. expect(handle.agent.session.header.agentPreset).toBe('minimal') } finally { await handle.dispose() @@ -973,7 +974,7 @@ describe('a composition that configures its own preset roots', () => { setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined), }) try { - expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash']) } finally { await handle.dispose() } diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index db33227043..fe7b5ec14e 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -150,7 +150,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') expect(metadata).toContain('name: 我的模式') - expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。') + expect(metadata).toContain('description: 仅提供持久 shell 的单工具编码 Agent。') expect(metadata).not.toContain('order:') }, 60_000) diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index 37dc43d9a6..f9daab0c26 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -43,7 +43,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 + - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img @@ -65,7 +65,7 @@ - list: - listitem: - 'button "设为默认: 我的模式"': - - text: 我的模式 自定义 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 + - text: 我的模式 自定义 仅提供持久 shell 的单工具编码 Agent。 - code: my-agent - 'button "查看路径: 我的模式"': - img diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index 7f8537f392..cfae761ba3 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -43,7 +43,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 + - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index e6c83143d7..7efe280eb5 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -43,7 +43,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 + - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index c89bfd50a5..7c05a3332a 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -3,6 +3,6 @@ - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program." - - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor." + - menuitem "Minimal mode Single-tool coding agent with a persistent shell." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." - menuitem "Refusing mode Resolves, then refuses to start." diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md new file mode 100644 index 0000000000..6a1a3e0511 --- /dev/null +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -0,0 +1,42 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "插件": + - img + - text: 插件 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: openrouter + - button "编辑 openrouter": 编辑 + - button "删除 openrouter": 删除 + - alert: "llm-pi-ai: provider \"openrouter\" model \"111\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks" + - listitem: + - text: zai + - button "编辑 zai": 编辑 + - button "删除 zai": 删除 + - listitem: + - text: acme-gateway 自定义 + - button "编辑 acme-gateway": 编辑 + - button "删除 acme-gateway": 删除 + - alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks" + - button "添加提供方": + - img + - text: 添加提供方 + - button "添加自定义提供方": + - img + - text: 添加自定义提供方 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 30d9f922be..f42337be37 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, @@ -72,10 +72,17 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher')) + const input = page.locator('[data-composer-input]').first() + onTestFinished(async () => { + await input.press('Escape') + await writeComposerDraft(page, input, '') + await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' }) + }) const launcher = page.getByRole('button', { name: 'Commands' }) await launcher.click() const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) - await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) + await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE) expect(snapshot).toContain('text: Commands') @@ -84,9 +91,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const launchedBox = await menu.boundingBox() await page.locator('[data-composer-input]').first().press('Escape') await expect.poll(() => menu.count()).toBe(0) - const input = page.locator('[data-composer-input]').first() await writeComposerDraft(page, input, '/') - await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) + await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const typedBox = await menu.boundingBox() expect(launchedBox).not.toBeNull() expect(typedBox).not.toBeNull() @@ -114,7 +121,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const launcher = zhPage.getByRole('button', { name: '指令' }) await launcher.click() const menu = zhPage.getByRole('listbox', { name: '触发候选建议' }) - await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) + await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE) expect(zhTripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 3df0703839..fa69b3644e 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -99,6 +99,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { } scaffold = await launchWebScaffold({ replayFixture: FIXTURE, + // Throughput snapshots need a nonzero interval between replayed chunks. + paceMs: 1, ...(overridePath === undefined ? {} : { replayOverride: overridePath }), ...(overridePath === undefined ? {} : { compareReplaySession: false }), ...(retryPolicy === undefined ? {} : { replayRetryPolicy: retryPolicy }), diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 1b34c986b0..22ab1bc476 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -76,7 +76,7 @@ describe('minimal agent preset', () => { if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed') }) - it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => { + it('sends the exact RL prompt and shell schema, then executes the persistent shell', async () => { const requestHeader = agentHandle.agent.session.requestHeader() if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') const systemPrompt = systemPromptText(agentHandle.agent.session) @@ -84,9 +84,7 @@ describe('minimal agent preset', () => { expect(agentHandle.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false) - const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs') - expect(presetFileSystem).toBeDefined() - expect(presetFileSystem?.sandboxMode).toBeUndefined() + expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')).toBeUndefined() expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compaction')).toBeUndefined() const stateDir = join(scaffold.workspaceCwd, 'persistent-state') @@ -106,16 +104,6 @@ describe('minimal agent preset', () => { arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' }, agent: agentHandle.agent, }) - const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt') - await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n') - const editor = await scaffold.ctx.tools.execute({ - signal, - callId: ToolCallId('minimal-editor-smoke'), - name: 'str_replace_editor', - arguments: { command: 'view', path: seedPath }, - agent: agentHandle.agent, - }) - const text = (result: typeof bash): string => result.content .filter(block => block.type === 'text') .map(block => block.text) @@ -128,18 +116,14 @@ describe('minimal agent preset', () => { tools: requestHeader.tools?.map(tool => tool.name), goalCommand: scaffold.ctx.commands.find(agentHandle.agent, 'goal') !== undefined, bash: text(bash), - editor: text(editor), }).toMatchInlineSnapshot(` { - "bash": "PERSISTED:{{cwd}}/persistent-state", - "editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines): - 1 MINIMAL_EDITOR_OK - 2", + "bash": "PERSISTED:{{cwd}}/persistent-state + [Command finished with exit code 0]", "goalCommand": false, "prompt": "You are a helpful software engineer assistant.", "tools": [ "bash", - "str_replace_editor", ], } `) @@ -178,7 +162,7 @@ describe('minimal agent preset', () => { const call = row.locator('xpath=..') await call.getByText('IN', { exact: true }).waitFor() await call.getByText('OUT', { exact: true }).waitFor() - await call.getByText('MINIMAL_BASH_CARD_OK', { exact: true }).waitFor() + await call.getByText('MINIMAL_BASH_CARD_OK\n[Command finished with exit code 0]', { exact: true }).waitFor() await call.getByText(/"command": "printf 'MINIMAL_BASH_CARD_OK/).waitFor() const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/models-settings-recovery.e2e.ts b/apps/web/tests/models-settings-recovery.e2e.ts new file mode 100644 index 0000000000..1087f8be3d --- /dev/null +++ b/apps/web/tests/models-settings-recovery.e2e.ts @@ -0,0 +1,94 @@ +/** Stored catalog drift remains repairable through the assembled Models settings page. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, + watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts' + +const EXPECTED = fileURLToPath(new URL('./expected/models-settings-recovery/stored-error.expected.md', import.meta.url)) +const FAILURE = 'llm-pi-ai: provider "openrouter" model "111" needs an api; ' + + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks' +const CUSTOM_FAILURE = 'llm-pi-ai: provider "acme-gateway" model "custom-model" needs an api; ' + + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks' + +describe('web e2e: repairs a stored provider after catalog drift', () => { + let home: string + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'dsh-models-recovery-')) + await writeFile(join(home, 'settings.yaml'), [ + 'llm-pi-ai:', ' providers:', ' openrouter:', ' models:', + ' - id: "111"', ' zai: {}', ' acme-gateway:', + ' baseURL: https://gateway.example/v1', ' models:', ' - id: "custom-model"', '', + ].join('\n')) + scaffold = await launchWebScaffold({ harnessHome: home }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await dialog.getByText(FAILURE, { exact: true }).waitFor() + }, 120_000) + + afterAll(async () => { + try { + await browser?.close() + } finally { + try { + await scaffold?.close() + } finally { + if (home !== undefined) await rm(home, { recursive: true, force: true }) + } + } + }) + + it('shows the failed provider beside healthy providers and keeps both add actions usable', async () => { + onTestFailed(() => saveFailureShot(page, 'models-settings-recovery')) + const dialog = page.getByRole('dialog', { name: '设置' }) + expect(await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '编辑 zai', exact: true }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '编辑 acme-gateway', exact: true }).count()).toBe(1) + expect(await dialog.getByText(CUSTOM_FAILURE, { exact: true }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '添加提供方', exact: true }).isEnabled()).toBe(true) + expect(await dialog.getByRole('button', { name: '添加自定义提供方', exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) + + await dialog.getByRole('button', { name: '添加提供方', exact: true }).click() + await dialog.getByLabel('提供方', { exact: true }).selectOption('minimax-cn') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor() + expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toContain('minimax-cn: {}') + expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(1) + }) + + it('rejects an invalid edit without persisting and accepts removal of the obsolete model', async () => { + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).click() + await dialog.getByText('自定义设置', { exact: true }).click() + await dialog.getByLabel('API 地址', { exact: true }).fill('https://gateway.example/v1') + const before = await readFile(join(home, 'settings.yaml'), 'utf8') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(() => dialog.getByText(FAILURE, { exact: true }).count()).toBe(2) + expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toBe(before) + await dialog.getByRole('button', { name: '删除模型 1', exact: true }).click() + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 openrouter。', { exact: true }).waitFor() + expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(0) + const repaired = await readFile(join(home, 'settings.yaml'), 'utf8') + expect(repaired).not.toContain('111') + expect(repaired).toContain('baseURL: https://gateway.example/v1') + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts new file mode 100644 index 0000000000..52aee329fa --- /dev/null +++ b/apps/web/tests/present.e2e.ts @@ -0,0 +1,186 @@ +/** Recorded source-file delivery, edits, reload, deletion, and Session ZIP behavior. */ +import { readFile, unlink, mkdir, mkdtemp, writeFile, rm, realpath } from 'node:fs/promises' +import { join, delimiter } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { unzipSync, strFromU8 } from 'fflate' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { tmpdir, release } from 'node:os' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-tool-present/types' +import { + acknowledgeReloadConnectionLoss, assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, + watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url)) +const FIXTURE = join(DIR, 'session.v3.jsonl') +const MODE = webSnapshotMode() +const PROMPT = 'Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. ' + + 'Use bash to run exactly `printf "DELIVERED_REPORT\\n" > report.txt; printf "DELIVERED_NOTE\\n" > 说明.txt`. ' + + 'Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string "AFTER_PRESENT" (not an Error object) from that same run_code program. ' + + 'Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph.' + +// The recorded Bash scenario and executable opener fixture require a POSIX host outside WSL. +describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes('microsoft'))('web e2e: explicit file delivery', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let sessionId: SessionId + let cwd: string + let disposeApproval: (() => void) | undefined + const events: SessionEvent[] = [] + let nativeRoot: string | undefined + let openLog: string + const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string }) + const downloads: string[] = [] + + beforeAll(async () => { + nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-present-native-')) + openLog = join(nativeRoot, 'opened.jsonl') + await writeFile(openLog, '') + // Exercise the built Host through its actual OS command, replacing only the desktop application. + const command = process.platform === 'darwin' ? 'open' : 'xdg-open' + await writeFile(join(nativeRoot, command), `#!${process.execPath} +const fs = require('node:fs'); +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n'); +`, { mode: 0o700 }) + vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) + await mkdir(DIR, { recursive: true }) + scaffold = await launchWebScaffold({ + agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), + }) + disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true }) + scaffold.ctx.on('session/event', (_session, event) => { events.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + page.on('download', (download) => { downloads.push(download.suggestedFilename()) }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + try { + await browser?.close() + } finally { + disposeApproval?.() + try { + await scaffold?.close() + } finally { + vi.unstubAllEnvs() + if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true }) + } + } + }) + + it('declares nested deliveries even when the enclosing program subsequently fails', async () => { + if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill(PROMPT) + await input.press('Enter') + sessionId = await settled + const workspace = scaffold.ctx.agents.get(sessionId)?.session.header.cwd + if (workspace === undefined) throw new Error('present Session has no workspace') + cwd = workspace + if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) + await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 }) + await assertFinalWorkspaceSnapshot(DIR, cwd) + expect(events.filter(event => event.type === 'deliverables/presented').flatMap(event => event.data.files.map(file => file.path))) + .toEqual(['report.txt', '说明.txt']) + for (const event of events) { + if (event.type === 'deliverables/presented') { + expect(event.data.files).toEqual([ + { path: 'report.txt', description: 'delivered report' }, + { path: '说明.txt', description: 'delivered note' }, + ]) + } + } + expect(events.some(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'present' && event.data.isError)).toBe(true) + expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError)).toBe(true) + }, 200_000) + + it('opens current source files after edits and reload, and reports deletion without downloading', async () => { + await writeFile(join(cwd, 'report.txt'), 'EDITED_REPORT\n') + await writeFile(join(cwd, '说明.txt'), 'EDITED_NOTE\n') + for (const reload of [false, true]) { + if (reload) { + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 }) + } + const row = page.locator('[data-presented-files-row]') + await row.waitFor() + expect(await row.getByRole('button').count()).toBe(2) + for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { + const count = (await opened()).length + const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await row.getByRole('button', { name: `Open ${name} in default app`, exact: true }).click() + expect((await response).status()).toBe(204) + await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect(await opened()).toHaveLength(count + 1) + expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes }) + } + } + const count = (await opened()).length + const openedResponse = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await page.locator('code').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click() + await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect((await openedResponse).status()).toBe(204) + expect(await opened()).toHaveLength(count + 1) + expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) + expect(downloads).toEqual([]) + const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) + expect(response.status()).toBe(200) + const entries = unzipSync(await response.body()) + expect(Object.keys(entries)).toHaveLength(1) + const exported = strFromU8(Object.values(entries)[0]!) + expect(exported).toContain('deliverables/presented') + const declarations = exported.trim().split('\n').map(line => JSON.parse(line) as SessionEvent) + .filter(event => event.type === 'deliverables/presented') + expect(declarations).toHaveLength(1) + expect(declarations[0]!.data.files).toEqual([ + { path: 'report.txt', description: 'delivered report' }, + { path: '说明.txt', description: 'delivered note' }, + ]) + expect(exported).not.toContain('EDITED_REPORT') + if (MODE !== 'record') { + const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) + await page.locator('[data-turn-process]').click() + const failed = page.locator('[data-tool="present"][data-state="error"]') + const delivered = page.locator('[data-tool="present"][data-state="ok"]') + expect(await failed.count()).toBe(1) + expect(await delivered.count()).toBe(1) + expect(await failed.innerText()).toContain('Delivery failed') + expect(await delivered.innerText()).toContain('Delivered') + await page.locator('[data-turn-process]').click() + await page.setViewportSize({ width: 480, height: 900 }) + const row = page.locator('[data-presented-files-row]') + await row.scrollIntoViewIfNeeded() + for (const card of await row.getByRole('button').all()) { + const bounds = await card.boundingBox() + expect(bounds).not.toBeNull() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(480) + } + } + const beforeDelete = (await opened()).length + await unlink(join(cwd, 'report.txt')) + const missing = page.waitForResponse(response => response.url().includes('/api/present.open?')) + await page.locator('[data-presented-files-row]').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click() + expect((await missing).status()).toBe(404) + await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor() + expect(await opened()).toHaveLength(beforeDelete) + expect(downloads).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index e617c9aead..8ef2af7048 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import { join } from 'node:path' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -378,6 +378,10 @@ describe('web e2e: settings modal and General preferences', () => { it('steps the content font size, applies it to body, and persists across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-font-size')) + onTestFinished(async () => { + await page.keyboard.press('Escape') + await page.getByRole('dialog', { name: '设置', exact: true }).waitFor({ state: 'hidden' }) + }) const readFontSize = async (target: Page = page): Promise => await target.evaluate( () => document.body.style.getPropertyValue('--dsh-content-font-size'), ) @@ -392,6 +396,24 @@ describe('web e2e: settings modal and General preferences', () => { probe.remove() return size }) + // The displayed value is optimistic; wait for the write before the next step. + const stepFontSize = async (button: Locator, px: number): Promise => { + const [response] = await Promise.all([ + page.waitForResponse((reply) => { + if (new URL(reply.url()).pathname !== '/api/settings/mutate' || reply.request().method() !== 'POST') return false + const request = reply.request().postDataJSON() as { payload: { args: { ns: string } } } + return request.payload.args.ns === 'ui-theme' + }), + button.click(), + ]) + expect(await response.finished()).toBeNull() + const envelope = await response.json() as { result: { ok: boolean } } + expect(envelope.result.ok).toBe(true) + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(new RegExp(`ui-theme:\n(?:\\s+\\w+: .*\n)*?\\s+fontSize: ${px}`)) + await page.getByRole('dialog', { name: '设置' }).getByText(String(px), { exact: true }).waitFor({ timeout: 5_000 }) + await expect.poll(readFontSize, { timeout: 5_000 }).toBe(`${px}px`) + } expect(await readFontSize()).toBe('14px') expect(await readSecondaryFontSize()).toBe('13px') await page.getByRole('button', { name: '设置', exact: true }).click() @@ -400,17 +422,12 @@ describe('web e2e: settings modal and General preferences', () => { // The stepper reveals its arrows on hover; the up arrow steps 14 → 15 → 16. await dialog.getByText('14', { exact: true }).hover() const increase = dialog.getByRole('button', { name: '增大字号' }) - await increase.click() - await dialog.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) + await stepFontSize(increase, 15) // 15 is the piecewise boundary: the secondary tier holds at 13px (−2) // where the ≤14 branch would have given 14px (−1). await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('13px') - await increase.click() - await dialog.getByText('16', { exact: true }).waitFor({ timeout: 5_000 }) - await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px') + await stepFontSize(increase, 16) await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('14px') - await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) - .toMatch(/ui-theme:\n(?:\s+\w+: .*\n)*?\s+fontSize: 16/) await page.keyboard.press('Escape') // Reload: the boot script embeds the durable size and ThemeRuntime seeds @@ -429,11 +446,8 @@ describe('web e2e: settings modal and General preferences', () => { await restored.waitFor({ timeout: 10_000 }) await restored.getByText('16', { exact: true }).hover() const decrease = restored.getByRole('button', { name: '减小字号' }) - await decrease.click() - await restored.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) - await decrease.click() - await restored.getByText('14', { exact: true }).waitFor({ timeout: 5_000 }) - await expect.poll(readFontSize, { timeout: 5_000 }).toBe('14px') + await stepFontSize(decrease, 15) + await stepFontSize(decrease, 14) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 61acc027fd..fc0ef1694c 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -45,6 +45,7 @@ const EXPECTED_TOOLS = [ 'job_list', 'job_output', 'list_agents', + 'present', 'ralph', 'read', 'read_image', diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 9457407c0a..0a176a9af0 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -64,7 +64,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { /** Boot scaffold + page, materializing the sidecar before the replay row installs. */ async function launch( buildOverride?: (sidecarHome: string) => ReplayOverrideDoc, - paceMs?: number, + // Throughput snapshots require a nonzero interval between replayed chunks. + paceMs = 1, ): Promise { sessionEvents = [] let overridePath: string | undefined @@ -80,7 +81,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { replayFixture: FIXTURE, ...(overridePath === undefined ? {} : { replayOverride: overridePath }), compareReplaySession: overridePath === undefined, - ...(paceMs === undefined ? {} : { paceMs }), + paceMs, }, ) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1e3fbcd850..2a25226d5f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -43,6 +43,7 @@ "tests/plugin-config.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", + "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", "tests/github-ready-review.e2e.ts", "tests/streaming-fence-highlight.e2e.ts", @@ -61,6 +62,7 @@ "tests/rail-search-expand.e2e.ts", "tests/conversation-column-overflow.e2e.ts", "tests/ptc-round.e2e.ts", + "tests/present.e2e.ts", "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index dff22a99ec..0e15b4f088 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -46,15 +46,21 @@ function rejectStandaloneServe(): Plugin { */ function emitPreviewPage(): Plugin { let bootstrapFile: string | undefined + let write = true return { name: 'dsh-emit-preview-page', + configResolved(config) { + write = config.build.write + }, generateBundle(_options, bundle) { + if (!write) return for (const item of Object.values(bundle)) { if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName } if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle') }, async closeBundle() { + if (!write) return // A build that failed before generateBundle has no page to splice. if (bootstrapFile === undefined) return const page = await readFile(src('./dist/index.html'), 'utf8') diff --git a/benchmarks/agent-continuation/README.i18n.yaml b/benchmarks/agent-continuation/README.i18n.yaml index f2e65cc938..68bb7e50e6 100644 --- a/benchmarks/agent-continuation/README.i18n.yaml +++ b/benchmarks/agent-continuation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write benchmarks/agent-continuation/README.md -README.md: 75f804fc18deb90f1db40093cd52e5df10886b46 -README.zh.md: 047c4988beaebe96e0bd561674e5314139608c41 +README.md: cb3d99d749922e08d493f0b58dab04c63356024a +README.zh.md: 30331aa886d75f8582c6e386cbb022bb487dbe9f diff --git a/benchmarks/agent-continuation/README.md b/benchmarks/agent-continuation/README.md index 75f804fc18..cb3d99d749 100644 --- a/benchmarks/agent-continuation/README.md +++ b/benchmarks/agent-continuation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) ## Summary -Measure long-history request processing, cold tool-heavy continuation, and repeated discovery of inactive fork children without network services or recorded user data. The SDK variant drives 100 turns and 800 real file reads through the shipped sdk-minimal profile; other cases isolate backend service costs. No case renders a browser. +Measure long-history request processing, cold tool-heavy continuation, and repeated discovery of inactive fork children without network services or recorded user data. The SDK variant drives 100 turns and 800 real file reads through the shipped sdk-minimal profile with an explicit editor patch; other cases isolate backend service costs. No case renders a browser. ## Table of Contents diff --git a/benchmarks/agent-continuation/README.zh.md b/benchmarks/agent-continuation/README.zh.md index 047c4988be..30331aa886 100644 --- a/benchmarks/agent-continuation/README.zh.md +++ b/benchmarks/agent-continuation/README.zh.md @@ -4,7 +4,7 @@ ## Summary -在不使用网络服务或录制用户数据的情况下,测量长历史请求处理、冷工具密集续聊和重复发现非活动 fork 子会话。SDK 变体通过已发布 sdk-minimal profile 执行 100 个轮次和 800 次真实文件读取;其他用例隔离后端服务成本。所有用例均不渲染浏览器。 +在不使用网络服务或录制用户数据的情况下,测量长历史请求处理、冷工具密集续聊和重复发现非活动 fork 子会话。SDK 变体通过已发布 sdk-minimal profile 和显式 editor patch 执行 100 个轮次和 800 次真实文件读取;其他用例隔离后端服务成本。所有用例均不渲染浏览器。 ## Table of Contents diff --git a/benchmarks/agent-continuation/profile-continuation.worker.ts b/benchmarks/agent-continuation/profile-continuation.worker.ts index 4e0fade29b..531fc2f50d 100644 --- a/benchmarks/agent-continuation/profile-continuation.worker.ts +++ b/benchmarks/agent-continuation/profile-continuation.worker.ts @@ -1,4 +1,4 @@ -/** End-to-end SDK continuation through the built dsh sdk-minimal profile and real file tools. */ +/** End-to-end SDK continuation through built dsh sdk-minimal with an explicitly mounted file editor. */ import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' @@ -27,7 +27,10 @@ async function run(root: string): Promise { await writeFile(patch, [ '- id: llm-deepseek', ' disabled: true', '- id: sessions', ' config:', ' root: ' + JSON.stringify(join(root, 'profile-sessions')), ' compression: zstd', - '- insert:', ' - id: benchmark-model', ' name: ' + JSON.stringify(join(import.meta.dirname, 'profile-adapter.js')), + '- insert:', + ' - id: fs-local', " name: '@deepseek-ai/dsh-fs-local'", + ' - id: str-replace-editor', " name: '@deepseek-ai/dsh-tool-str-replace-editor'", + ' - id: benchmark-model', ' name: ' + JSON.stringify(join(import.meta.dirname, 'profile-adapter.js')), '', ].join('\n')) const env: NodeJS.ProcessEnv = { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4254334b41..919cd841d9 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: cb928dbe63e6dfebe2c3a48fdca2b0fd5750180a -config-catalog.zh.md: db47d7ff19691b7de78ed349ba99c535cf2e487a +config-catalog.md: 79ea53c3a55467fb26ada6400e82c416e5f0472f +config-catalog.zh.md: bca6b34f5246b1465cc92117e68e708bf5367bac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cb928dbe63..79ea53c3a5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1389,7 +1389,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable @@ -2762,7 +2762,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -2891,6 +2891,22 @@ export interface Config { Source: [`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts) + + +## `@deepseek-ai/dsh-tool-present` + +Requires: `tools` · `fs` · `sessionProjections` + +```ts config-catalog +/** Per-call delivery limit. */ +export interface Config { + /** Maximum number of files in one call. */ + maxFiles: number +} +``` + +Source: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts) + ## `@deepseek-ai/dsh-tool-pwsh` @@ -3463,7 +3479,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@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-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-deliverables` — requires `systemPrompt` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@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-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) @@ -3561,6 +3577,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base` ([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-chunked-list` ([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts)) - `@deepseek-ai/dsh-client-store` ([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-dockkit` ([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index db47d7ff19..bca6b34f52 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1392,7 +1392,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable @@ -2765,7 +2765,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts) +来源:[`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -2894,6 +2894,22 @@ export interface Config { 来源:[`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts) + + +## `@deepseek-ai/dsh-tool-present` + +依赖: `tools` · `fs` · `sessionProjections` + +```ts config-catalog +/** Per-call delivery limit. */ +export interface Config { + /** Maximum number of files in one call. */ + maxFiles: number +} +``` + +来源: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts) + ## `@deepseek-ai/dsh-tool-pwsh` @@ -3466,7 +3482,7 @@ export interface Config { - `@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-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-deliverables` — 需要 `systemPrompt`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@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-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) @@ -3563,6 +3579,7 @@ export interface Config { - `@deepseek-ai/dsh-atomic-write`([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base`([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-chunked-list`([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts)) - `@deepseek-ai/dsh-client-store`([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime`([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-dockkit`([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 39758901ca..6bad710968 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: a31a18e423750a33b1644831deeba1505ebb0815 -event-producer-consumer.zh.md: cd7fde41aaafb03258cf8ec867e0aa1a2c8f06e4 +event-producer-consumer.md: 843c567ea1f9342231f9db22a9d431c351bbdaf3 +event-producer-consumer.zh.md: 0b8803c591e88d52dfe478d49f32d4ada37ea38d diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a31a18e423..843c567ea1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -57,10 +57,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -68,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/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/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` | | `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), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index cd7fde41aa..0b8803c591 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -70,7 +70,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/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/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` | | `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), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index c6ed924269..08ab4f66e9 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 7b28bef1df9ce985133b475fdee1a13c6b2d724d -module-graph.zh.md: d66bb0792d8446c8a6282595fc0f42a016b3d9ef +module-graph.md: bd414dedc39f249580a2d0b89ba9c4d00d1493d1 +module-graph.zh.md: 80096e1fa14e769ba2d9ade73bf4c19b97d7f2ca diff --git a/docs/module-graph.md b/docs/module-graph.md index 7b28bef1df..bd414dedc3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_chunked_list["chunked-list"] pkg_deque["deque"] pkg_home_paths["home-paths"] pkg_http_proxy["http-proxy"] @@ -55,6 +56,7 @@ flowchart TD pkg_fs_sandbox["fs-sandbox"] pkg_tool_fs["tool-fs"] pkg_tool_fs_search["tool-fs-search"] + pkg_tool_present["tool-present"] pkg_tool_str_replace_editor["tool-str-replace-editor"] end subgraph group_skill["packages/skill"] @@ -744,6 +746,12 @@ flowchart TD pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools + pkg_tool_present --> pkg_agent + pkg_tool_present --> pkg_fs + pkg_tool_present --> pkg_llm + pkg_tool_present --> pkg_session + pkg_tool_present --> pkg_session_projection + pkg_tool_present --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy @@ -1186,6 +1194,7 @@ flowchart TD | --- | --- | --- | | [`atomic-write`](../packages/util/atomic-write) | `util` | — | | [`brand`](../packages/util/brand) | `util` | — | +| [`chunked-list`](../packages/util/chunked-list) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | | [`http-proxy`](../packages/util/http-proxy) | `util` | — | @@ -1383,6 +1392,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index d66bb0792d..80096e1fa1 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -12,6 +12,7 @@ flowchart TD subgraph group_util["packages/util"] pkg_atomic_write["atomic-write"] pkg_brand["brand"] + pkg_chunked_list["chunked-list"] pkg_deque["deque"] pkg_home_paths["home-paths"] pkg_http_proxy["http-proxy"] @@ -57,6 +58,7 @@ flowchart TD pkg_fs_sandbox["fs-sandbox"] pkg_tool_fs["tool-fs"] pkg_tool_fs_search["tool-fs-search"] + pkg_tool_present["tool-present"] pkg_tool_str_replace_editor["tool-str-replace-editor"] end subgraph group_skill["packages/skill"] @@ -746,6 +748,12 @@ flowchart TD pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_timeout pkg_tool_fs_search --> pkg_tools + pkg_tool_present --> pkg_agent + pkg_tool_present --> pkg_fs + pkg_tool_present --> pkg_llm + pkg_tool_present --> pkg_session + pkg_tool_present --> pkg_session_projection + pkg_tool_present --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_sandbox pkg_tool_str_replace_editor --> pkg_sandbox_policy @@ -1188,6 +1196,7 @@ flowchart TD | --- | --- | --- | | [`atomic-write`](../packages/util/atomic-write) | `util` | — | | [`brand`](../packages/util/brand) | `util` | — | +| [`chunked-list`](../packages/util/chunked-list) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | | [`http-proxy`](../packages/util/http-proxy) | `util` | — | @@ -1385,6 +1394,7 @@ flowchart TD | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 1e60882ff3..8049e2d430 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: b239204810f92d866d309214678a22901e6f3ef6 -persistence-catalog.zh.md: 86d81a5a64322c98ea288c0aa0289550153eedf0 +persistence-catalog.md: a9e1221a564a4ad1af1353278f1215bb33fb9c4f +persistence-catalog.zh.md: 2b74b03b4b783f848385e66e15c40d73f50a788b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b239204810..a9e1221a56 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -396,6 +396,21 @@ Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streami Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts) +### `deliverables/*` + + + +#### `deliverables/presented` — log-only + +```ts persistence-catalog +/** Declared workspace files from a successful final present result, including nested calls. */ +'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } +``` + +Types: [ToolCallId](subsystems/core.md) + +Source: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts) + ### `feedback/*` @@ -761,6 +776,20 @@ Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/ ### `subagent/*` + + +#### `subagent/catalog` — log-only + +```ts persistence-catalog +/** + * A direct child's complete discovery fact. + * @param data - versioned parent-owned catalog entry. + */ +'subagent/catalog': SubagentCatalogEvent +``` + +Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) + #### `subagent/descriptor` — log-only diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 86d81a5a64..2b74b03b4b 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -398,6 +398,21 @@ export type SessionEvent = { 来源:[`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts) +### `deliverables/*` + + + +#### `deliverables/presented` — 仅日志 + +```ts persistence-catalog +/** Declared workspace files from a successful final present result, including nested calls. */ +'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } +``` + +类型: [ToolCallId](subsystems/core.zh.md) + +来源: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts) + ### `feedback/*` @@ -763,6 +778,20 @@ export type SessionEvent = { ### `subagent/*` + + +#### `subagent/catalog` — log-only + +```ts persistence-catalog +/** + * A direct child's complete discovery fact. + * @param data - versioned parent-owned catalog entry. + */ +'subagent/catalog': SubagentCatalogEvent +``` + +来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) + #### `subagent/descriptor` — log-only diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index d59028fa8b..ff5cdfed88 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 35a4db80d17205f91342523c8413054743fb7481 -llm-streaming.zh.md: 5e3ddc55ab2972e3e2a318baf1e37e0013d324e8 +llm-streaming.md: cadfda5553ca5d5e0df78f40b6a7a75ef7cc71a6 +llm-streaming.zh.md: 5f73d544459268ce83a30218b710dd7c29f7a614 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 35a4db80d1..cadfda5553 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -492,6 +492,8 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ + error?: string } ``` diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 5e3ddc55ab..5f73d54445 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -498,6 +498,8 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ + error?: string } ``` diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 1b16e2a532..98c8a3a7b2 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: db4103a282287d8ea163d66b19efec27e0a0630f -sidebar-right.zh.md: 1cb0b1e67e99acd7cb6aa12272cd57a5fdd207e0 +sidebar-right.md: 59eda91862282d240d49764d53c17f531ff4bf21 +sidebar-right.zh.md: 3ecb5e9ef496471316414b6aeb0c683a78188025 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index db4103a282..59eda91862 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -8,7 +8,7 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re ## Position and ownership -One docking surface exists per Session, held in a session-scoped slot store and drawn by the `rightbar` seat; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab. +One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab. A tab type is two registrations that share one `kind`: a static definition in `ctx.sidebarRightTabs` saying which addresses the type opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 1cb0b1e67e..3ecb5e9ef4 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -8,7 +8,7 @@ ## 定位与归属 -每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar` 席位绘制;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。 +每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。 一个 tab 类型是共用一个 `kind` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明该类型打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 49925a9aa5..d829845174 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: 136607b035c4a40c8ab96d6bf933e6b5c8a925bf -slots.zh.md: b3a54048f5df20ad3ef271aea868ce15d5e18bce +slots.md: e659a0ccff548dcf1d9aa6f422d1773a838a62ef +slots.zh.md: 92d1af68f8b67637342055d3c11aea29194048ad diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 136607b035..e659a0ccff 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -82,6 +82,7 @@ The shipped adapters add these standard props. They are available according to t |---|---|---| | every scope | `useSessions`, `useSessionPendingInteraction` | `ui-session` | | every scope | `useWorkspaces` | `ui-workspace` | +| every scope | `usePanelInfo` | `ui-layout` | | `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` | | `session-maybe` | optional `sessionId`, `useSession`, `useProjection` results | `ui-session` | | `session` | `useConversation`, `useInput`, `inputActions` | `ui-conversation` | @@ -112,6 +113,7 @@ root ├─ sidebar │ ├─ sidebar.brand.mark │ ├─ sidebar.brand.name +│ ├─ sidebar.panellist │ ├─ sidebar.footer.action │ ├─ sidebar.workspaces │ │ └─ sidebar.workspaces.directoryFlow @@ -127,43 +129,45 @@ root │ ├─ settings.models.footer │ └─ settings.plugins.tab │ └─ settings.plugin.item -├─ conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.node -│ │ │ ├─ conversation.chat.assistant-actions -│ │ │ ├─ conversation.chat.commandview -│ │ │ ├─ conversation.chat.turnTail -│ │ │ └─ tool.call.toolview -│ │ │ ├─ tool.call.images -│ │ │ └─ tool.view.cordis -│ │ ├─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ └─ conversation.approval.detail -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset +├─ main +│ └─ main.conversation +│ ├─ conversation.session +│ │ └─ conversation.view +│ │ ├─ conversation.chat.node +│ │ │ ├─ conversation.chat.assistant-actions +│ │ │ ├─ conversation.chat.commandview +│ │ │ ├─ conversation.chat.turnTail +│ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images +│ │ │ └─ tool.view.cordis +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images +│ ├─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner +│ ├─ conversation.composer +│ │ └─ conversation.approval.detail +│ ├─ conversation.composer.bar +│ │ ├─ conversation.input.attachments +│ │ ├─ conversation.input.plan +│ │ └─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.input.dock +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ ├─ conversation.input.right +│ ├─ conversation.hero.brand.mark +│ ├─ conversation.hero.workspace +│ │ └─ conversation.hero.workspace.directoryFlow +│ └─ conversation.hero.agentPreset ├─ rightbar -│ ├─ sidebar.right.pane.tab -│ │ └─ sidebar.right.tab.guide -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item +│ └─ rightbar.session +│ ├─ sidebar.right.pane.tab +│ │ └─ sidebar.right.tab.guide +│ ├─ sidebar.right.pane.tab.title +│ └─ sidebar.right.tab.menu.item └─ shell.overlay ``` diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index b3a54048f5..92d1af68f8 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -82,6 +82,7 @@ Slot 声明固定两个相互独立的维度。 |---|---|---| | 所有 scope | `useSessions`、`useSessionPendingInteraction` | `ui-session` | | 所有 scope | `useWorkspaces` | `ui-workspace` | +| 所有作用域 | `usePanelInfo` | `ui-layout` | | `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` | | `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection` | `ui-session` | | `session` | `useConversation`、`useInput`、`inputActions` | `ui-conversation` | @@ -112,6 +113,7 @@ root ├─ sidebar │ ├─ sidebar.brand.mark │ ├─ sidebar.brand.name +│ ├─ sidebar.panellist │ ├─ sidebar.footer.action │ ├─ sidebar.workspaces │ │ └─ sidebar.workspaces.directoryFlow @@ -127,43 +129,45 @@ root │ ├─ settings.models.footer │ └─ settings.plugins.tab │ └─ settings.plugin.item -├─ conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.node -│ │ │ ├─ conversation.chat.assistant-actions -│ │ │ ├─ conversation.chat.commandview -│ │ │ ├─ conversation.chat.turnTail -│ │ │ └─ tool.call.toolview -│ │ │ ├─ tool.call.images -│ │ │ └─ tool.view.cordis -│ │ ├─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ └─ conversation.approval.detail -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset +├─ main +│ └─ main.conversation +│ ├─ conversation.session +│ │ └─ conversation.view +│ │ ├─ conversation.chat.node +│ │ │ ├─ conversation.chat.assistant-actions +│ │ │ ├─ conversation.chat.commandview +│ │ │ ├─ conversation.chat.turnTail +│ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images +│ │ │ └─ tool.view.cordis +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images +│ ├─ conversation.session.header +│ │ ├─ conversation.session.header.lineage +│ │ ├─ conversation.session.header.actions +│ │ ├─ conversation.session.header.utilities +│ │ └─ conversation.session.header.corner +│ ├─ conversation.composer +│ │ └─ conversation.approval.detail +│ ├─ conversation.composer.bar +│ │ ├─ conversation.input.attachments +│ │ ├─ conversation.input.plan +│ │ └─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.input.dock +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ ├─ conversation.input.right +│ ├─ conversation.hero.brand.mark +│ ├─ conversation.hero.workspace +│ │ └─ conversation.hero.workspace.directoryFlow +│ └─ conversation.hero.agentPreset ├─ rightbar -│ ├─ sidebar.right.pane.tab -│ │ └─ sidebar.right.tab.guide -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item +│ └─ rightbar.session +│ ├─ sidebar.right.pane.tab +│ │ └─ sidebar.right.tab.guide +│ ├─ sidebar.right.pane.tab.title +│ └─ sidebar.right.tab.menu.item └─ shell.overlay ``` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index f64a9d5c91..4750a413d5 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: 55e8f8af23cb71c1103c017c09e9dfd2ef365b75 -subagent.zh.md: 71a71b33771ff83ccaa6802358b8534c11930181 +subagent.md: 5f927f4c0bef57c0fda0bcb39280a5ac2012343e +subagent.zh.md: ce827f154989404825e322682c4af6ba83182253 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 55e8f8af23..5f927f4c0b 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -8,6 +8,8 @@ Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subag Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) +The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. + ## Two kinds of capability, discovered two ways A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a one-shot run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. Those flags describe only the one-shot [`start()`](#the-provider-contract-subagentprovider) path, where the provider composes the child. **Continuable** children are composed by the continuation manager itself, so they are gated by one optional method whose presence IS the capability, with TS narrowing as the discovery mechanism: [`SubagentProvider.prepareContinuable`](#the-provider-contract-subagentprovider). @@ -672,6 +674,8 @@ list(): string[] * fulfills; a rejection therefore has no run for the caller to dispose and * emits no run lifecycle events. Post-publication turn and infrastructure * failures settle through the returned run. + * A catalog append failure disposes the run and handles its result rejection; + * the caller receives the catalog error even if disposal also fails. * @param name - the provider to use. * @param request - child label, prompt, parent, signal, and optional capabilities. * @returns the published holder-owned run. diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 71a71b3377..ce827f1549 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -8,6 +8,8 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) +`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。 + ## 两类能力,两种发现方式 提供方通过一个静态描述符公布其**启动时**功能,服务会在单次 run 存在之前即行检查;如果请求依赖提供方不具备的功能,会被明确拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。这些 flag 仅描述单次 [`start()`](#the-provider-contract-subagentprovider) 路径,即由提供方组合子 agent 的路径。**可继续**子 agent 由继续执行管理器自行组合,因此它们由唯一一个可选方法把关,方法存在即为能力,并以 TypeScript 的类型收窄作为发现机制:[`SubagentProvider.prepareContinuable`](#the-provider-contract-subagentprovider)。 @@ -676,6 +678,8 @@ list(): string[] * fulfills; a rejection therefore has no run for the caller to dispose and * emits no run lifecycle events. Post-publication turn and infrastructure * failures settle through the returned run. + * A catalog append failure disposes the run and handles its result rejection; + * the caller receives the catalog error even if disposal also fails. * @param name - the provider to use. * @param request - child label, prompt, parent, signal, and optional capabilities. * @returns the published holder-owned run. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 16319731c8..49f6610bd2 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: c85201523e66408e6aa536de1f141df650055265 -tool-catalog.zh.md: ce5f5b57f6ac6f6a3db6a1503967fc7ceb7c48fd +tool-catalog.md: c2ea95ff460b65fbba789738b16b0c67a7c91948 +tool-catalog.zh.md: 41fc1eaa3c6a0acf17bde4b69c97413b64872930 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c85201523e..c2ea95ff46 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,6 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`, `cordis_inspect_list`, `cordis_inspect_query`, `cordis_inspect_self`, `cordis_run`, `cordis_stop`, `cordis_undefine` | `ctx.tools`, `ctx.dynamicCordisRunner` | `tool/call`, `tool/result`, `process-local dynamic package lifecycle` | - | Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | @@ -218,6 +219,49 @@ Source: [`packages/shell/tool-bash/src/index.ts`](../packages/shell/tool-bash/sr The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. + + +## `@deepseek-ai/dsh-tool-present` + +### `present` + +Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool. + +```json +{ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Path of an existing file inside the workspace." + }, + "description": { + "type": "string", + "description": "Brief description for the user." + } + }, + "required": [ + "path" + ] + } + } + }, + "required": [ + "files" + ] +} +``` + +Source: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts) + +Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. + ## `@deepseek-ai/dsh-tool-pwsh` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index ce5f5b57f6..41fc1eaa3c 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -23,6 +23,7 @@ | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`、`cordis_inspect_list`、`cordis_inspect_query`、`cordis_inspect_self`、`cordis_run`、`cordis_stop`、`cordis_undefine` | `ctx.tools`、`ctx.dynamicCordisRunner` | `tool/call`、`tool/result`、`process-local dynamic package lifecycle` | - | 不在任何随产品发布的树中,需要显式选择启用;动态 Package 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。该工具集注入 `@deepseek-ai/dsh-cordis-host-runner` 提供的 `ctx.dynamicCordisRunner`,后者拥有定义注册表和 vm 沙箱;组合缺少它时这些工具不会激活。运行中的 Package 在停止、undefine 或 DSH 重启前可以注册**额外的**模型可见工具;发生这类工具集变化时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | @@ -222,6 +223,49 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类 bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 + + +## `@deepseek-ai/dsh-tool-present` + +### `present` + +声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。 + +```json +{ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Path of an existing file inside the workspace." + }, + "description": { + "type": "string", + "description": "Brief description for the user." + } + }, + "required": [ + "path" + ] + } + } + }, + "required": [ + "files" + ] +} +``` + +来源: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts) + +交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 + ## `@deepseek-ai/dsh-tool-pwsh` diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 04933034ac..476fb26576 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md -python-sdk.md: 88cf32f60c285a4ce7c09e424532a6d9b7b00890 -python-sdk.zh.md: e443ec811a3b42034c75162b759408f1acd3486f +python-sdk.md: 6fde258d9f4f3e8ba1fd41537129ba5b8f02fd58 +python-sdk.zh.md: f50fd6d2bd37e3dc203b519e07d0df4098cab0c7 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 88cf32f60c..6fde258d9f 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -129,19 +129,35 @@ The first command initializes the shipped standalone profile. The second forward Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition. + +### Opt in to `str_replace_editor` + +The bundled runtime includes `str_replace_editor`, but `sdk-minimal` omits it from the default Cordis tree. To use it, save this configuration as `editor.patch.yml`; `insert` adds both the editor and the filesystem provider that the minimal profile lacks: + +```yaml +- insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' +``` + +Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSeekHarness(profile="sdk-minimal", ...)`, or put the patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent configuration. On the next runtime launch, model requests include `str_replace_editor` beside the persistent shell. The local filesystem provider uses the runtime working directory for relative paths; like the minimal shell, it does not confine access to that directory. For the standard `sdk` profile, insert only the editor row so it uses the existing filesystem provider and policies. + ## Understand the minimal profile | Property | Value | |---|---| | System prompt | `DSH_SYSTEM_PROMPT`, falling back to `You are a helpful software engineer assistant.` | | Model in `minimal.py` | `--model`, then `DSH_MODEL`, then `deepseek-v4-flash` | -| Model-facing tools | Persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor` | +| Model-facing tool | Persistent `bash` on Linux/macOS or `pwsh` on Windows | | Shell timeout | 300 seconds | -| Editor output limit | 16,000 characters | | Runtime context and compaction | Absent | | Session persistence | Uncompressed JSONL under `/sessions` | -The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so the platform-selected persistent shell and editor can modify any path visible to the runtime; use a disposable checkout or container. +The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while filesystem tools, settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container. The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index e443ec811a..f50fd6d2bd 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -129,19 +129,35 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle 另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。 + +### 显式启用 `str_replace_editor` + +随附运行时包含 `str_replace_editor`,但 `sdk-minimal` 的默认 Cordis tree 不挂载它。要使用该工具,请将以下配置保存为 `editor.patch.yml`;`insert` 会添加 editor,以及极简 profile 缺少的文件系统后端: + +```yaml +- insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' +``` + +构造 `DeepSeekHarness(profile="sdk-minimal", ...)` 时传入 `patches=("/absolute/path/to/editor.patch.yml",)`,或将 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` 以持久保存配置。下次运行时启动后,模型请求会在持久 shell 之外包含 `str_replace_editor`。本地文件系统后端以运行时工作目录解析相对路径;与极简 shell 一样,它不会将访问限制在该目录内。对于标准 `sdk` profile,只插入 editor 配置项,让它使用已有的文件系统后端与策略。 + ## 理解极简 profile | 属性 | 值 | |---|---| | 系统提示词 | `DSH_SYSTEM_PROMPT`,未设置时为 `You are a helpful software engineer assistant.` | | `minimal.py` 的模型 | `--model`,然后是 `DSH_MODEL`,最后是 `deepseek-v4-flash` | -| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh`,以及 `str_replace_editor` | +| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh` | | Shell 超时 | 300 秒 | -| Editor 输出上限 | 16,000 字符 | | 运行时上下文与 compaction | 不存在 | | 会话持久化 | `/sessions` 下的未压缩 JSONL | -该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 与 editor 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 +该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文件系统工具、settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。 diff --git a/lefthook.yml b/lefthook.yml index 1c2a693c88..237202e7e0 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,7 +28,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 4aa32f078c..325255fb81 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -7,10 +7,14 @@ import { makeBridgeHarness, type BridgeHarness } from './harness.ts' describe('ACP connection ownership', () => { let harness: BridgeHarness | undefined + let releaseBlockedDisposal: (() => void) | undefined afterEach(async () => { - await harness?.dispose() + const bridge = harness harness = undefined + releaseBlockedDisposal?.() + releaseBlockedDisposal = undefined + await bridge?.dispose() }) it('disposal cancels a running prompt and awaits agent teardown', async () => { @@ -198,34 +202,58 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.list()).toHaveLength(0) }) - it('a client disconnect disposes every owned session without root-context disposal', async () => { + it.each([ + ['a client disconnect', 'closeClientTransport'], + ['a failed client transport', 'abortClientTransport'], + ] as const)('%s disposes its session without plugin disposal', async (_name, disconnect) => { harness = await makeBridgeHarness({ script: ['hang'] }) - await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(SessionId(sessionId))! - void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) - await vi.waitFor(() => { expect(agent.status).toBe('running') }) - - await harness.closeClientTransport() - await harness.acpFiber.dispose() - expect(agent.status).toBe('idle') - expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() - expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() - }) - - it('a failed client transport still disposes every owned session', async () => { - harness = await makeBridgeHarness({ script: ['hang'] }) - await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(SessionId(sessionId))! - void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) - await vi.waitFor(() => { expect(agent.status).toBe('running') }) - - await harness.abortClientTransport() - await vi.waitFor(() => { - expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true) + const bridge = harness + const create = bridge.ctx.agents.create.bind(bridge.ctx.agents) + const disposalStarted = Promise.withResolvers() + const releaseDisposal = Promise.withResolvers() + releaseBlockedDisposal = () => { releaseDisposal.resolve(undefined) } + const disposalCompleted = Promise.withResolvers() + const createSpy = vi.spyOn(bridge.ctx.agents, 'create').mockImplementation(async (options) => { + const handle = await create(options) + const dispose = handle.dispose.bind(handle) + handle.dispose = () => { + const completion = (async () => { + disposalStarted.resolve(undefined) + await releaseDisposal.promise + await dispose() + return undefined + })() + disposalCompleted.resolve(completion) + return completion + } + return handle }) - expect(agent.status).toBe('idle') + const running = Promise.withResolvers() + let stopListening: (() => void) | undefined + try { + await bridge.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await bridge.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = bridge.ctx.agents.get(SessionId(sessionId))! + stopListening = bridge.ctx.on('agent/status', ({ agent: changed, status }) => { + if (changed === agent && status === 'running') running.resolve(undefined) + }) + void bridge.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) + await running.promise + + await bridge[disconnect]() + await disposalStarted.promise + expect(bridge.ctx.agents.get(SessionId(sessionId))).toBe(agent) + expect(bridge.ctx.sessions.get(SessionId(sessionId))).toBe(agent.session) + releaseDisposal.resolve(undefined) + await disposalCompleted.promise + expect(agent.status).toBe('idle') + expect(bridge.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + expect(bridge.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() + } finally { + releaseDisposal.resolve(undefined) + stopListening?.() + createSpy.mockRestore() + } }) it('disconnect and plugin disposal share one quiescence boundary', async () => { diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 33721a6727..54dd7ed873 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 7b78e910b0cb54f0c64efb7816b428fe9f0d131c -README.zh.md: b25bb58ac031792b0f6ee0b81dfff2713668c5c4 +README.md: 186d3184b344789187e0096b49dd93c191c2fde4 +README.zh.md: fcc42c39c4489afa484844a036a5f85ef72d1e7e diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 7b78e910b0..186d3184b3 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -49,7 +49,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The opt-in [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path. -Default file editing uses `read`, `write`, and `edit`. The optional `str_replace_editor` tool is selected independently by Web minimal and `sdk-minimal`. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: +Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: ```yaml - insert: diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index b25bb58ac0..fcc42c39c4 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -49,7 +49,7 @@ kind: "package-bundle" 开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(provider 与模型可配置,你还可以在设置中启用额外 provider)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整权威日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。需主动开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。 -默认文件编辑使用 `read`、`write` 和 `edit`。Web minimal 与 `sdk-minimal` 各自选择可选的 `str_replace_editor` 工具。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: +默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: ```yaml - insert: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 3d32022816..4f3407903a 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -95,6 +95,7 @@ "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-present": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index cac1c4c770..b4894c1932 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/sdk-minimal/README.md -README.md: 3be71c7e319f70bddda716c92ade14336db0a065 -README.zh.md: dff652f53d0a4fd6d50c216e564c46f81f4a188b +README.md: e67b1eb0637f870f47bd97ecff7133242e2f2210 +README.zh.md: 077d3ac0f603ce6781476b4850306d8f8486f82a diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 3be71c7e31..e67b1eb063 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -1,5 +1,5 @@ --- -description: "Standalone two-tool SDK profile for users who need a minimal cross-platform coding agent without the shared base bundle." +description: "Standalone single-tool SDK profile for users who need a minimal cross-platform coding agent without the shared base bundle." kind: "package-bundle" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. The profile advertises a platform-selected persistent shell and `str_replace_editor`, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and deliberately excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell and editor modify any path available to the process, so use it only with an isolated workspace. +Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. The profile advertises only a platform-selected persistent shell, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and deliberately excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, filesystem tools, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell modify any path available to the process, so use it only with an isolated workspace. ## Table of Contents @@ -46,7 +46,7 @@ The profile mounts exactly one persistent shell stack: Bash on Linux and macOS,
Implementation internals — click to expand -The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, local subprocess and unrestricted filesystem providers, a platform-selected persistent shell PTY, the string-replace editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change. +The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, local subprocess execution, a platform-selected persistent shell PTY, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change. ### Source map @@ -77,11 +77,11 @@ The bundle's single insert is the complete application tree: SDK stdio startup a #### What the model sees -The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. +The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tool is owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows; runtime context, filesystem tools, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. #### Token effect -One stable persona plus the two tool schemas. Tool results and ordinary conversation history grow with the session. +One stable persona plus one tool schema. Tool results and ordinary conversation history grow with the session. #### KV Cache effect diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index dff652f53d..077d3ac0f6 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -1,5 +1,5 @@ --- -description: "供需要不含共享 base bundle 的极简跨平台 coding agent 的用户使用的独立双工具 SDK profile。" +description: "供需要不含共享 base bundle 的极简跨平台 coding agent 的用户使用的独立单工具 SDK profile。" kind: "package-bundle" --- @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 只公布按平台选择的持久 shell 与 `str_replace_editor`,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并刻意排除 `dsh-base`、Web、settings、托管凭据、遥测、compaction、workspace 指令、skills、jobs 与 subagent。其 danger-full-access 策略允许 shell 与编辑器修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。 +当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 只公布按平台选择的持久 shell,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并刻意排除 `dsh-base`、Web、settings、托管凭据、遥测、compaction、文件系统工具、workspace 指令、skills、jobs 与 subagent。其 danger-full-access 策略允许 shell 修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。 ## 目录 @@ -46,7 +46,7 @@ dsh --profile sdk-minimal
实现细节——点击展开 -该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、本地子进程与不受限文件系统提供方、按平台选择的持久 shell PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。 +该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、本地子进程执行、按平台选择的持久 shell PTY,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。 ### 源码地图 @@ -77,11 +77,11 @@ dsh --profile sdk-minimal #### 模型看到的内容 -系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`,外加 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 +系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的唯一工具是 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`;运行时上下文、文件系统工具、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 #### Token 影响 -一个稳定 persona 加两个工具 schema。工具结果与普通对话历史随会话增长。 +一个稳定 persona 加一个工具 schema。工具结果与普通对话历史随会话增长。 #### KV Cache 影响 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index f19486e7d0..c96f07d550 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -63,13 +63,6 @@ shellDialect: pwsh timeoutMs: 300000 - # The editor uses the bare local filesystem; persistent Bash still consumes - # the shared danger-full-access sandbox policy above. - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - # Keep the minimal agent kernel explicit: unlike dsh-base, this profile # owns each service row and omits every optional producer it does not use. - id: timer @@ -135,8 +128,7 @@ description: |- Run commands in a bash shell * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. - * You don't have access to the internet via this tool. - * You do have access to a mirror of common linux and python packages via apt and pip. + * Network access depends on the task environment. Prefer configured mirrors/proxies when they are available. * State is persistent across command calls and discussions with the user. * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. * Please avoid commands that may produce a very large amount of output. @@ -156,11 +148,6 @@ * Please avoid commands that may produce a very large amount of output. * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. - - id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - - id: sessions name: '@deepseek-ai/dsh-session-persistence-jsonl' config: diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 66984a7129..d95375faed 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", - "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", + "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" @@ -38,7 +38,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -61,7 +60,6 @@ "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", - "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^" }, "peerDependencies": { diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index d74cba198b..39867f9d21 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -39,7 +39,6 @@ describe('dsh-sdk-minimal bundle', () => { ['pty', '@deepseek-ai/dsh-terminal'], ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'], - ['fs-local', '@deepseek-ai/dsh-fs-local'], ['timer', '@deepseek-ai/cordis-plugin-timer'], ['llm', '@deepseek-ai/dsh-llm'], ['session', '@deepseek-ai/dsh-session'], @@ -57,7 +56,6 @@ describe('dsh-sdk-minimal bundle', () => { ['agent-loop', '@deepseek-ai/dsh-agent-loop'], ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], - ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], ]) expect(rows.find(row => row.id === 'sdk-app-startup')?.config).toEqual({ profile: 'sdk-minimal' }) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 0d1633b902..91e57ed5e9 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -60,9 +60,9 @@ Npm sections describe installation and development relationships; each build fac 1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract. 2. **A package under `packages/client/` is always covered; `dsh.client` marks a Client/Host package outside that directory.** Explicit include/exclude entries handle exceptions. Every covered package's Host entry is scanned, while a `./client` export alone does not select dependency policy. -3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`. Configuration-only entries that Knip cannot infer from imports are listed in the dependency policy and projected into `knip.json` by `--fix`. +3. **Browser and type relationships are development-only.** Workspace and third-party Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, and metadata-only peers belong only in `devDependencies`. Configuration-only entries absent from imports are listed in the dependency policy; Node companion value imports follow the Host rules below. 4. **Host value imports require classified exports.** A workspace value reached from the package's Host entry belongs only in `dependencies` when its exact module specifier and runtime export appear in `safeHostDependencyExports`. Exports whose identity or module state must be shared appear in `peerRequiredHostExports` and keep the whole package edge in matching `peerDependencies` and `devDependencies`. The verifier rejects unclassified exports before `--fix` writes manifests. -5. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter. +5. **Only Host runtime libraries require installation dependencies.** Browser-only third-party implementations belong in `devDependencies`, whether inlined into `lib/client.js` or retained as bare imports in a static browser library. Static libraries are Web-shell build inputs; the final Vite build merges their imports and styles. Distributed browser code remains subject to runtime license disclosure ([decision](../../.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md)). 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index f10adf664e..d65a1c82f0 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -12,6 +12,7 @@ import { createLanguageRowStore } from '../src/client/settings-store.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -41,7 +42,7 @@ function mount(active = 'en') { const props: LanguageRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/store/README.i18n.yaml b/packages/client/store/README.i18n.yaml index d6af2417f1..657b1a396d 100644 --- a/packages/client/store/README.i18n.yaml +++ b/packages/client/store/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/store/README.md -README.md: 9d675c1189ae194b913293c89904b4c4f39c879e -README.zh.md: 3ff170b71c320688e50c02c20479ee1d94061a87 +README.md: 0d4525ab6eb2a58bdea89673b96f9aaf5e0e2cf8 +README.zh.md: b66a2e31675c82d933526d40ed9eba20bc5077d9 diff --git a/packages/client/store/README.md b/packages/client/store/README.md index 9d675c1189..0d4525ab6e 100644 --- a/packages/client/store/README.md +++ b/packages/client/store/README.md @@ -32,6 +32,7 @@ None; the stores neither assemble nor send model requests. - **Persistence is browser-local** — persisted stores use JSON in `localStorage`; non-browser runtimes disable persistence, and the package provides no cross-device synchronization. +- **Web-shell build input** — the static ESM retains third-party imports for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). diff --git a/packages/client/store/README.zh.md b/packages/client/store/README.zh.md index 3ff170b71c..b66a2e3167 100644 --- a/packages/client/store/README.zh.md +++ b/packages/client/store/README.zh.md @@ -32,6 +32,7 @@ kind: "package-library" - **持久化仅限浏览器本地**——持久化 store 使用 `localStorage` 中的 JSON;非浏览器运行时会禁用持久化,本包也不提供跨设备同步。 +- **Web 壳构建输入**——静态 ESM 为 Vite 保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 172439a947..6bb0c0a212 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -22,15 +22,13 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { - "immer": "^10.1.1", - "zustand": "~4.4.7" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "zustand": "~4.4.7", + "immer": "^10.1.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index a4dbdd949b..f14ef62142 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -39,7 +39,7 @@ export const en: Record = { 'Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', presetMinimalDescription: - 'Two-tool coding agent with persistent bash and str_replace_editor.', + 'Single-tool coding agent with a persistent shell.', presetCordisName: 'Creator mode', presetCordisDescription: 'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.', @@ -98,7 +98,7 @@ export const zh: Record = { presetPtcName: 'PTC 模式', presetPtcDescription: '功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。', presetMinimalName: '极简模式', - presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。', + presetMinimalDescription: '仅提供持久 shell 的单工具编码 Agent。', presetCordisName: '创造模式', presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。', duplicate: '复制', diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 7679b48621..9adf41af13 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -42,9 +42,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1", @@ -58,7 +55,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^" + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", 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 7da3a878e9..3e86094c74 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -12,6 +12,7 @@ import { MessageImages } from '../src/client/MessageImages.tsx' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -272,7 +273,7 @@ describe('ImageGallery', () => { sessionId: 'message-images-test' as MessageImagesProps['sessionId'], useSession, useSessions, - useResource, + usePanelInfo, useResource, useSessionPendingInteraction, useWorkspaces, useProjection: () => undefined, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4985ac3796..8ea1e5d5d6 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: b5a837f927b483a1f72e8282032fce29683bc237 -README.zh.md: f6b8018434251e46875bfd5cc0b0a242cdf2a439 +README.md: 7c9b38a1abc6289c9c0f7b8335d2c4a796e32840 +README.zh.md: 80198a89fea2586d350dfc8bbeaf8a122739122c diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b5a837f927..7c9b38a1ab 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -10,6 +10,8 @@ English | [中文](README.zh.md) Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Compact display folds completed-turn process rows while keeping the final answer and independently useful context visible; packed historical Assistant runs remain collapsed. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests. +File-mention providers receive the viewed Session ID with the closing-turn owner, so links into inherited history can address the fork itself. + ## Table of Contents - [System prompt row](#system-prompt-row) diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index f6b8018434..80198a89fe 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -10,6 +10,8 @@ kind: "package-reference" 使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript 与 steering 提交会立即显示并保留在原区域,在权威 Session 记录到达时原子地消失,而 queued 提交始终不进入 Chat。本包不组装或修改模型请求。 +文件引用提供方同时接收当前查看的 Session ID 与收尾 turn 的属主信息,因此继承历史中的链接可以指向 fork 自身。 + ## 目录 - [系统提示词行](#system-prompt-row) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 2d8de50561..0fc9e82a22 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -116,7 +116,7 @@ export function apply(ctx: Context): void { chatNode: key => chat.getSnapshot().nodes.source(key), chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key), }, - fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner), + 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, or an absolute one inside the session's diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 3fb8bcfcee..b5c3a45b99 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -1,6 +1,6 @@ /** Chat-owned Slot declarations and composed component props. */ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, @@ -53,9 +53,10 @@ 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. * @returns link resolver when available. */ - forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined + forClosing(owner: TurnTailOwnerProps, sessionId: SessionId): MarkdownFileMentions | undefined } declare module '@deepseek-ai/cordis' { 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 590cc5602c..71a7b5afe9 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -16,6 +16,7 @@ import { apply as applyChat, inject as injectChat, type ChatViewInjected, } from '@deepseek-ai/dsh-client-ui-chat/client' import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createChatStore } from '../src/client/stores.ts' usePinnedBrowserLanguages('zh-CN') @@ -57,7 +58,11 @@ async function bench() { ) new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => ROOT), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(ROOT) + runtime.sessions.open(ROOT) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const session = sessionFakeFor() await runtime.sessions.add({ @@ -69,7 +74,7 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) await runtime.mount({ inject: [...injectChat], apply: applyChat }) @@ -177,7 +182,7 @@ describe('Chat inject API', () => { const forClosing = vi.fn(() => mentions) b.runtime.ctx.provide('chatFileMentions', { forClosing } as never) expect(injected.fileMentions(owner)).toBe(mentions) - expect(forClosing).toHaveBeenCalledWith(owner) + expect(forClosing).toHaveBeenCalledWith(owner, ROOT) 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 fc1cf43bc5..ac364e4cb7 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -9,6 +9,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { apply as applyConversation, inject as injectConversation, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -44,7 +45,11 @@ async function bench() { runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) new TestRemote(runtime.ctx, { session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })) }, @@ -53,7 +58,7 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, 'conversation.approval.detail': { kind: 'single', scope: 'session' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) @@ -120,7 +125,8 @@ describe('Chat apply wiring', () => { await b.chat.dispose() expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0) expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined() - expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation']) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1) expect(b.runtime.ctx.get('uiConversation')).toBeDefined() await b.runtime.dispose() }) 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 7b72d280dc..fc8fd9c15e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -364,6 +364,7 @@ function makeHarness( // ChatView never invokes it (pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: ChatViewSlotProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, useSession: bindSnapshotSelector(session.source), useChat: bindSnapshotSelector(chatSource.source), 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 5aa8e31047..7004e75a37 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 @@ -35,6 +35,7 @@ function mount(mode: 'normal' | 'compact' = 'compact') { const source = createSnapshotStore(mode) const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) const props: TranscriptViewRowProps = { + usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), useSessionPendingInteraction: noPendingInteraction(), useWorkspaces: emptyWorkspaces(), diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index bc7b069162..40ce5a3bff 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -64,7 +61,8 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b28c9069b7..a1eea2e97a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: edfcb83193d4513fba846f673d3572c35e8c0d1e -README.zh.md: f92e4c77ba3d3a2ceb96758e5817e447ba2ec1ac +README.md: 19a210bb200dae359920e85ff944b68242f45b4b +README.zh.md: c5a56871eb99607c924b2b2e048de72d918e6a8b diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index edfcb83193..19a210bb20 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,7 +36,9 @@ Target packages declaration-merge their snapshot and Location data maps, then re ## Shell and standard props -The package registers the optional-Session `conversation` shell, strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. +Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. + +The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f92e4c77ba..c5a56871eb 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,7 +36,9 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, ## Shell 与标准 props -本包注册 optional-Session `conversation` shell、strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 +工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在请求仍有效时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 + +本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 8902063d3c..987aa9574f 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -46,13 +46,7 @@ }, "license": "MIT", "dependencies": { - "clsx": "^2.0.0", - "@deepseek-ai/schemastery": "workspace:^", - "@lexical/history": "^0.49.0", - "@lexical/plain-text": "^0.49.0", - "@lexical/text": "^0.49.0", - "@lexical/utils": "^0.49.0", - "lexical": "^0.49.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" @@ -93,7 +87,13 @@ "react-dom": "^18.2.0", "@types/react-dom": "~18.3.0", "zod": "^4.4.3", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "lexical": "^0.49.0", + "clsx": "^2.0.0", + "@lexical/utils": "^0.49.0", + "@lexical/text": "^0.49.0", + "@lexical/plain-text": "^0.49.0", + "@lexical/history": "^0.49.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 65b8374919..7eba82cfc0 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -28,6 +28,7 @@ import { queueDockEntry } from './queue/QueueDock.tsx' import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx' import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' +import { ConversationPanel } from './skeleton/ConversationPanel.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' @@ -84,9 +85,11 @@ const ABSENT_FILE_UPLOADS = { } interface WorkspaceNavigation { - connectWorkspace( + openSession(sessionId: SessionId): void + openWorkspace( workspaceId: Parameters[0], - ): Promise + beforeOpen: (sessionId: SessionId) => void, + ): Promise } /** Resolve the session-scoped Conversation action face, failing loud. */ @@ -214,7 +217,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) const registerConversationRoot = () => slots.register({ - name: 'conversation', + name: 'main.conversation', locale: NS, children: { 'conversation.session': { kind: 'single', scope: 'session' }, @@ -230,8 +233,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), }, - selectWorkspace: async (workspaceId) => { - const nextId = await workspaceNavigation.connectWorkspace(workspaceId) + selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => { if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft @@ -251,8 +253,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { } } } - sessions.open(nextId) - }, + }), }), }, ConversationRoot) @@ -284,7 +285,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { store: conversationStore, inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionHeaderInjected => ({ hooks: { conversationViews }, - open: (id) => { sessions.open(id) }, + open: (id) => { workspaceNavigation.openSession(id) }, selectView: (view) => { activateView(sessionId, view) actions.setView(view) @@ -384,7 +385,12 @@ export function apply(ctx: Context, config: Config = Config({})): void { }, }, InputBar) - slots.inject('conversation', function* () { + slots.inject('main', function* () { + yield slots.register({ + name: 'main', + key: 'conversation', + children: { 'main.conversation': { kind: 'single', scope: 'session-maybe' } }, + }, ConversationPanel) yield registerConversationRoot() yield registerConversationSession() yield registerConversationHeader() diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index eb38a0f261..cf76e02b1b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -117,6 +117,8 @@ export type UseConversationViews = SnapshotSelectorHook declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Conversation shell beneath its root-scoped main-panel entry. */ + 'main.conversation': { kind: 'single'; scope: 'session-maybe' } /** Strict per-Session Conversation body. */ 'conversation.session': { kind: 'single'; scope: 'session' } /** Strict per-Session title, actions, and View navigation. */ @@ -364,7 +366,7 @@ export interface HeroBrandMarkOwnerProps { /** Full props of the resident optional-Session Conversation shell. */ export type ConversationSlotProps = - PropsRuntime<'conversation'> + PropsRuntime<'main.conversation'> & PropsRenderSlots< | 'conversation.session' | 'conversation.session.header' | 'conversation.composer' | 'conversation.composer.bar' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx new file mode 100644 index 0000000000..8bd11aac33 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx @@ -0,0 +1,12 @@ +/** Root-scoped main occupant; Session binding belongs to its Conversation child. */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '../contract/slots.ts' + +/** + * Render the Conversation with optional current-Session binding. + * @param props - main-slot inputs and the declared Conversation renderer. + * @returns the Conversation subtree. + */ +export function ConversationPanel({ renderSlot }: PropsRuntime<'main'> & PropsRenderSlots<'main.conversation'>) { + return renderSlot('main.conversation', {}) +} 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 2dde9ce7b3..c6d6226bfe 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -49,7 +49,14 @@ async function bench() { } runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const connectWorkspace = vi.fn(async () => ROOT) - runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + const id = await connectWorkspace() + beforeOpen(id) + runtime.sessions.open(id) + }, + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, @@ -60,12 +67,12 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) const feature = await runtime.mount({ inject: [...inject], apply }) runtime.renderRoot() - const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') => + const entryOf = (key: 'main.conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') => runtime.slots.entries(key)[0]! const conversationApi = (id: SessionId) => { const entry = entryOf('conversation.session') @@ -77,7 +84,7 @@ async function bench() { return { instance, injected } } const residentApi = (id: SessionId | undefined) => { - const entry = entryOf('conversation') + const entry = entryOf('main.conversation') return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id) } const headerApi = (id: SessionId) => { 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 eccf4b87a0..3c59deb1c2 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -14,14 +14,20 @@ const SID = 'session-1' as SessionId async function bench(options: { declareConversation?: boolean } = {}) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: unknown, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) if (options.declareConversation !== false) { await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) } @@ -31,7 +37,7 @@ async function bench(options: { declareConversation?: boolean } = {}) { function entry( runtime: SlotTestRuntime, - key: 'conversation' | 'conversation.session' | 'conversation.session.header', + key: 'main.conversation' | 'conversation.session' | 'conversation.session.header', ) { return runtime.slots.entries(key)[0] as { store?: unknown } | undefined } @@ -39,14 +45,18 @@ function entry( describe('target-neutral Conversation apply wiring', () => { it('waits for the layout-owned conversation declaration before registering its subtree', async () => { const b = await bench({ declareConversation: false }) - expect(b.runtime.slots.entries('conversation')).toHaveLength(0) + expect(b.runtime.slots.entries('main')).toHaveLength(0) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0) await b.runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) - expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation']) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1) + expect(b.runtime.slots.spec('main.conversation')) + .toEqual({ kind: 'single', scope: 'session-maybe' }) expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1) expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1) expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1) @@ -65,7 +75,7 @@ describe('target-neutral Conversation apply wiring', () => { const b = await bench() const session = entry(b.runtime, 'conversation.session') const header = entry(b.runtime, 'conversation.session.header') - expect(entry(b.runtime, 'conversation')?.store).toBeUndefined() + expect(entry(b.runtime, 'main.conversation')?.store).toBeUndefined() expect(session?.store).toBeDefined() expect(header?.store).toBe(session?.store) expect(b.runtime.slots.spec('conversation.composer')) @@ -110,7 +120,9 @@ describe('target-neutral Conversation apply wiring', () => { await b.feature.dispose() expect(b.runtime.ctx.get('conversation')).toBeUndefined() expect(b.runtime.ctx.get('uiConversation')).toBeUndefined() - expect(b.runtime.slots.entries('conversation')).toHaveLength(0) + expect(b.runtime.slots.entries('main')).toHaveLength(0) + expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0) + expect(b.runtime.slots.spec('main.conversation')).toBeUndefined() expect(b.runtime.slots.spec('conversation.view')).toBeUndefined() await b.runtime.dispose() }) 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 fd99320d93..573a732780 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -12,6 +12,7 @@ import { import { InputHub } from '../src/client/input/hub.ts' import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). @@ -40,13 +41,13 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) -type AppRootProps = PropsRenderSlots<'conversation'> +type AppRootProps = PropsRenderSlots<'main'> function AppRoot({ renderSlot }: AppRootProps) { - return <>{renderSlot('conversation', {})} + return <>{renderSlot('main', {}, { entryKey: 'conversation' })} } const LAYOUT_CHILDREN = { - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, } as const function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { @@ -60,7 +61,13 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -82,7 +89,13 @@ async function bench(opts?: { blank?: boolean }) { describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -109,7 +122,13 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -174,7 +193,13 @@ describe('resident composer', () => { describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('uiWorkspace', { + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, + } as never) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) 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 0cba7c36b5..4af4d1dbd6 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 @@ -40,6 +40,7 @@ function mount() { const policy = new ComposerSubmissionPolicy() const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) }) const props: EnterBehaviorRowProps = { + usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), useSessionPendingInteraction: noPendingInteraction(), useResource, 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 051aa295e3..75c30b117d 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -164,6 +164,7 @@ function bench(over?: BenchOptions) { return null }) as never const props: InputBarProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), 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 c333d25ada..57319d7087 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -48,6 +48,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled removed: over?.disabled ?? false, }) const props: InputBarProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), 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 9e41f82eb7..88ab3a809f 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -137,6 +137,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const wiring = shell const sessionStore = createSnapshotStore(sessionSnapshot(sessionId)) const barProps: InputBarProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId, SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(sessionStore), 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 446ce0defd..08db337005 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -73,11 +73,13 @@ function liveSession(initial: SessionSnapshot) { const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) function kitFor(snapshot: SessionSnapshot, injected: Partial = {}) { return { sessionId: SID, t, + usePanelInfo, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, useResource, useSessionPendingInteraction: bindSnapshotSelector( diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index f1735ce415..9ffecfdfaa 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -199,6 +199,7 @@ function mount( useChat={useChat} useTrajectory={useTrajectory} useSessions={props.useSessions} + usePanelInfo={props.usePanelInfo} useResource={useResource} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} @@ -225,6 +226,7 @@ function mount( useChat={useChat} useTrajectory={useTrajectory} useSessions={props.useSessions} + usePanelInfo={props.usePanelInfo} useResource={useResource} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} @@ -251,6 +253,7 @@ function mount( useSession={useSession} useConversation={useConversation} useSessions={props.useSessions} + usePanelInfo={props.usePanelInfo} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} @@ -295,6 +298,7 @@ function mount( : (opts?.fallback ?? null) )) as ConversationRootProps['renderSlotChain'] const props: ConversationRootProps = { + usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, SessionProvider: ({ children }) => children, useSession, diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 01417ca259..9cc543ee66 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: bfd4c40a952122db25172bebb077d12800b49308 -README.zh.md: 23c983f938d2d77fa5f33e8dcd2526282268b835 +README.md: ae42abb5a69d29332bd7cfd6d2d2a1191381e79a +README.zh.md: 664dfa3cd2e0d526b43464a80290604a8ecf958c diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index bfd4c40a95..ae42abb5a6 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -27,6 +27,13 @@ This package renders the deliverables row a finished turn ends with — the file Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar as a text-preview tab, with relative paths resolved against the session cwd. The row offers no folder action: the Sidebar has no directory form, so an omitted-file remainder is a label only. + +### Explicit deliveries + +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. + +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. + ### The row The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. @@ -43,7 +50,9 @@ The closing prose carries the same vocabulary: an inline-code token resolves by
Implementation internals — click to expand -The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers `ProducedFiles` into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. +The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. + +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
@@ -72,7 +81,7 @@ One fixed paragraph instructs the model to name primary files from successful cr #### Token effect -One fixed prompt paragraph whenever this package is loaded; no tool schema, tool result, or per-Turn context is added. +One fixed prompt paragraph whenever this package is loaded. The [present tool](../../fs/tool-present/README.md#model-experience) owns the delivery schema and result text. #### KV Cache effect @@ -86,7 +95,8 @@ The section is static at first-party order 9000 for the lifetime of the package These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog. - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. -- **Files created indirectly by terminal commands remain outside the matching vocabulary** — naming such a file in inline code does not make it clickable unless a successful mutation location also records that path. +- **Terminal-created files require explicit delivery** — call `present` to declare them for native opening. +- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403. - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. @@ -99,4 +109,4 @@ None.
-**Runtime invariant:** No companion is published. The prompt section, slot, dictionary, event definition, and optional service registrations are effect-owned with disposal proven by their plugin specs; this package owns no mutable state. +**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 23c983f938..664dfa3cd2 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -27,6 +27,13 @@ kind: "package-reference" 与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略文件的余数只是一个标签才会打开会话工作区。 + +### 显式交付 + +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 + +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 + ### 该行 该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 @@ -43,7 +50,9 @@ kind: "package-reference"
实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把 `ProducedFiles` 注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 +Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 + +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
@@ -72,7 +81,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, #### Token 影响 -加载本包时增加一段固定提示词;不增加工具 schema、工具结果或按轮次变化的上下文。 +加载本包时增加一段固定提示词。[present 工具](../../fs/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。 #### KV Cache 影响 @@ -86,8 +95,9 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 -- **终端命令间接创建的文件仍不在匹配词表内**——除非某个成功修改位置也记录了该路径,否则在行内代码中点名这类文件不会使其可点击。 -- **原生文件夹交接以 Host 桌面为目标**——经非 loopback authority 访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端 Host 看似 loopback 本地,部署必须为 Session Controller 设置 `nativeOpen: false`。 +- **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。 +- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403。 +- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。 ### 开发备注 @@ -99,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
-**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、event definition 与可选 service 注册都归 effect 所有,释放由插件测试证明;本包不持有可变状态。 +**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 187b548e24..67970786a0 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -33,7 +33,8 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-renderer" + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-tool" ], "platform": "web" } @@ -61,7 +62,12 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^" + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", + "@deepseek-ai/dsh-tool-present": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css new file mode 100644 index 0000000000..97befd2946 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -0,0 +1,14 @@ +/** Immutable file deliveries at the end of a turn. */ +.root { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin-top: 12px; } +.label { font-size: 12px; color: var(--dsw-alias-label-secondary); } +.presented { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; } +.file { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 14px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); text-decoration: none; font: inherit; text-align: left; cursor: pointer; } +.file:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l3); } +.file:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; } +.fileIcon { flex: 0 0 auto; width: 24px; height: 24px; } +.details { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; } +.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } +.metadata { color: var(--dsw-alias-label-tertiary); font-size: 11px; } +.description { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; } +.open { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; } +.file:disabled { cursor: wait; opacity: 0.65; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx new file mode 100644 index 0000000000..2faf259ad4 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -0,0 +1,64 @@ +/** Existing changed-file chips and explicitly declared files for a closing turn. */ +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' +import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { PresentedOpenController } from './present-open.ts' +import { ProducedFiles } from './ProducedFiles.tsx' +import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import { presentedFileUrl } from '../presented.ts' +import css from './Deliverables.module.css' + +interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] } + +/** Native-open callbacks and shared gesture status supplied by the plugin. */ +export interface DeliverablesInjected { + hooks: { presentedOpen: ObservableSnapshot> } + openPresented: PresentedOpenController['open'] +} + +/** + * Claim turns containing modified paths or declared files. + * @param owner - closing turn. + * @returns matched files, or null for an empty turn. + */ +export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch | null { + const produced = selectProducedFiles(owner) ?? [] + const presented = presentedForClosing(owner) + return produced.length + presented.length === 0 ? null : { produced, presented } +} + +/** + * Render workspace file actions and default-application buttons for declared files. + * @param props - matched files, workspace opener, and localized copy. + * @returns the closing turn's file rows. + */ +export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen }: Pick & { + matched: DeliverablesMatch +} & PropsLocale & Pick & InjectFace) { + const states = usePresentedOpen(value => value) + return <> + {matched.produced.length > 0 && } + {matched.presented.length > 0 &&
+ {t('presented.label')} +
+ {matched.presented.map((file) => { + const phase = states[presentedFileUrl(sessionId, file.seq, file.index)] + return })} +
+
} + +} diff --git a/packages/client/ui-deliverables/src/client/PresentRow.module.css b/packages/client/ui-deliverables/src/client/PresentRow.module.css new file mode 100644 index 0000000000..b97ff7de02 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/PresentRow.module.css @@ -0,0 +1,6 @@ +/** Present status remains readable beside long workspace paths. */ +.summary { margin-left: 8px; display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; } +.summary > :first-child { flex-shrink: 0; } +.paths { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.output { margin: 8px 0; padding: 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; } +.inspect { align-self: flex-start; border: none; padding: 4px 0; background: transparent; color: var(--dsw-alias-link); font: inherit; font-size: 12px; cursor: pointer; } diff --git a/packages/client/ui-deliverables/src/client/PresentRow.tsx b/packages/client/ui-deliverables/src/client/PresentRow.tsx new file mode 100644 index 0000000000..845d2b4dcc --- /dev/null +++ b/packages/client/ui-deliverables/src/client/PresentRow.tsx @@ -0,0 +1,45 @@ +/** Present call status and expandable durable result text. */ +import { useState } from 'react' +import { DisclosureRow, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { NS } from './locales.ts' +import css from './PresentRow.module.css' + +type PresentRowProps = ToolCallViewProps & PropsLocale + +/** Raw arguments can be partial while a call is streaming. */ +function fileNames(raw: string): string { + let args: unknown + try { args = JSON.parse(raw) } + catch { return raw } // Truncated tool JSON remains visible until the call completes. + if (typeof args !== 'object' || args === null || !('files' in args) || !Array.isArray(args.files)) return raw + return args.files.flatMap((file: unknown) => + typeof file === 'object' && file !== null && 'path' in file && typeof file.path === 'string' + ? [file.path] : [], + ).join(', ') +} + +/** + * Render a present call using its recorded arguments and result. + * @param props - tool call and localized status copy. + * @returns a status row with a result disclosure. + */ +export function PresentRow({ block, inspect, t }: PresentRowProps) { + const settled = 'kind' in block + const state = !settled ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' + const args = (settled ? block.call?.argsRaw : block.argsRaw) ?? '' + const output = settled ? block.content.map(item => item.type === 'text' ? item.text : JSON.stringify(item)).join('\n') : '' + const details = output || (settled && block.error ? `${block.error.name}: ${block.error.code}` : '') + const [expanded, setExpanded] = useState(false) + return
+ } + open={expanded && details !== ''} expandable={details !== ''} expandOnRowClick keepContentWhenOpen + onToggle={() => { setExpanded(value => !value) }} + collapsedContent={{t(`row.${state}`)}{fileNames(args)}}> +
{details}
+ {inspect && } +
+
+} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 01877b7b57..09156448b4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -13,10 +13,12 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import { ProducedFiles } from './ProducedFiles.tsx' +import { PresentedOpenController } from './present-open.ts' +import { PresentRow } from './PresentRow.tsx' +import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' import { - deliverablesDefinition, producedFileMentions, selectProducedFiles, + deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles, } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -37,26 +39,41 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + const opener = new PresentedOpenController() + ctx.effect(() => () => opener.dispose()) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( 'conversation.chat.turnTail', () => ctx.slots.register({ name: 'conversation.chat.turnTail', - select: selectProducedFiles, + select: selectDeliverables, locale: NS, - }, ProducedFiles), + inject: (): DeliverablesInjected => ({ + hooks: { presentedOpen: opener.state }, + openPresented: (sessionId, seq, index) => opener.open(sessionId, seq, index), + }), + }, Deliverables), ) + ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( + { name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow, + )) // The prose side of the same vocabulary: the chat view reaches this face // via ctx.get, so its absence — this plugin composed out — is the off state. const t = ctx.locale.bind(NS) const mentions: ChatFileMentions = { - forClosing(owner) { + forClosing(owner, sessionId) { // Same claim test the turn-tail chain entry runs: no produced files, // no vocabulary — the two surfaces agree by construction. const paths = selectProducedFiles(owner) - if (paths === null) return undefined - return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path })) + const presented = presentedForClosing(owner) + if (paths === null && presented.length === 0) return undefined + const deliveries = new Map(presented.map(file => [file.path, file])) + return producedFileMentions([...new Set([...paths ?? [], ...deliveries.keys()])], (path) => { + const file = deliveries.get(path) + if (file === undefined) owner.openFile(path) + else void opener.open(sessionId, file.seq, file.index) + }, path => t(deliveries.has(path) ? 'presented.open' : 'produced.open', { name: path })) }, } ctx.provide('chatFileMentions', mentions) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 158b659f59..d9b468e15c 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,6 +5,19 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'presented.label': '交付文件', + 'presented.action': '打开', + 'presented.opening': '正在打开…', + 'presented.opened': '已在默认程序中打开', + 'presented.error': '打开失败,点击重试', + 'presented.file': '文件', + 'row.title': '交付文件', + 'row.running': '正在交付', + 'row.ok': '已交付', + 'row.error': '交付失败', + 'row.stopped': '已中断', + 'row.inspect': '查看调用', + 'presented.open': '在默认程序中打开 {name}', 'produced.label': '产物', 'produced.moreOne': '+ 1 个文件', 'produced.more': '+ {count} 个文件', @@ -13,6 +26,19 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { + 'presented.label': 'Deliverables', + 'presented.action': 'Open', + 'presented.opening': 'Opening…', + 'presented.opened': 'Opened in default app', + 'presented.error': 'Could not open. Click to retry.', + 'presented.file': 'File', + 'row.title': 'Present files', + 'row.running': 'Delivering', + 'row.ok': 'Delivered', + 'row.error': 'Delivery failed', + 'row.stopped': 'Interrupted', + 'row.inspect': 'Inspect call', + 'presented.open': 'Open {name} in default app', 'produced.label': 'Produced', 'produced.moreOne': '+ 1 file', 'produced.more': '+ {count} files', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts new file mode 100644 index 0000000000..5b49941a57 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -0,0 +1,54 @@ +/** Shared native-open status for delivery cards and closing-message file mentions. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { presentedFileUrl } from '../presented.ts' + +/** State of the latest explicit open gesture for one saved file. */ +export type PresentedOpenPhase = 'opening' | 'opened' | 'error' + +/** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ +export class PresentedOpenController { + /** File action URLs key the state across Sessions, turns, and both clickable surfaces. */ + readonly state = createSnapshotStore>({}) + private readonly lifetime = new AbortController() + private readonly pending = new Set>() + + /** + * Open a declared workspace file once while a request for the same coordinates is pending. + * Failures remain visible on the card and a later gesture retries them. + * @param sessionId - viewed Session, including a fork's own identity. + * @param seq - durable delivery event sequence. + * @param index - original file index within that event. + * @returns after the Host acknowledges opening or the error state is published. + */ + async open(sessionId: SessionId, seq: number, index: number): Promise { + const url = presentedFileUrl(sessionId, seq, index) + if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return + this.state.update((state) => { state[url] = 'opening' }) + const task = this.request(url) + this.pending.add(task) + try { + await task + } finally { + this.pending.delete(task) + } + } + + /** Cancel outstanding requests and wait until no request can publish state. */ + async dispose(): Promise { + this.lifetime.abort() + await Promise.all(this.pending) + } + + private async request(url: string): Promise { + let phase: PresentedOpenPhase = 'opened' + try { + const response = await fetch(url, { method: 'POST', signal: this.lifetime.signal }) + if (!response.ok) phase = 'error' + } catch { + // Transport failures share the retryable card state with Host open failures. + phase = 'error' + } + if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) + } +} diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 90e3248516..3a8025d68d 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -7,6 +7,14 @@ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types' +import { basename, isPresentedData, isPresentedFile } from '../presented.ts' + +/** A declared file with its authorized open coordinates. */ +export interface PresentedPath extends PresentedFile { + readonly seq: number + readonly index: number +} interface ProducedPath { readonly seq: number @@ -16,6 +24,7 @@ interface ProducedPath { /** Immutable produced-file facts published against one Turn. */ export interface DeliverablesTurnData { readonly produced: readonly ProducedPath[] + readonly presented?: readonly PresentedPath[] } declare module '@deepseek-ai/dsh-client-ui-conversation/client' { @@ -150,6 +159,7 @@ export const deliverablesDefinition: ConversationNodeDefinition { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' } + if (event.type === 'deliverables/presented') return isPresentedData(event.data) ? { id: String(event.data.turn), role: 'update' } : null if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { return { id: String(event.data.turn), role: 'update' } } @@ -160,6 +170,17 @@ export const deliverablesDefinition: ConversationNodeDefinition { + if (match.event.type === 'deliverables/presented') { + const { files } = match.event.data + const seq = match.event.seq + const presented: PresentedPath[] = [] + for (let index = 0; index < files.length; index += 1) { + const file = files[index] + if (isPresentedFile(file)) presented.push({ ...file, seq, index }) + } + if (presented.length === 0) return context.state + return { ...context.state, presented: [...context.state.presented ?? [], ...presented] } + } if (match.event.type === 'tool/call') { const calls = new Map(context.state.calls) calls.set( @@ -182,26 +203,32 @@ export const deliverablesDefinition: ConversationNodeDefinition() + for (const file of owner.turn.data.get('deliverables')?.presented ?? []) { + if (file.seq < owner.seq) files.set(file.path, file) + } + return [...files.values()] } +export { basename } from '../presented.ts' + /** * File-mention vocabulary over one turn's produced paths, for the closing * message's prose: an inline-code token opens the file it names. A token diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 00e1adac9c..4239299e63 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,15 +1,17 @@ /** * Deliverables plugin, node half. Registers the response-format guidance that - * lets the browser half recognize final-response file references. The browser + * lets the browser half recognize final-response file references and serves + * authenticated native opens of workspace files. The browser * half ships via exports["./client"], discovered through the package.json * dsh.client declaration. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-system-prompt' +import { registerPresentOpen } from './present-open.ts' -/** Services required for the model guidance paired with the browser renderer. */ -export const inject = ['systemPrompt'] +/** Services required for file-reference guidance and authenticated native opens of workspace files. */ +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' @@ -20,6 +22,7 @@ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, men * @param ctx - host context carrying the system-prompt registry. */ export function apply(ctx: Context): void { + registerPresentOpen(ctx) ctx.systemPrompt.section({ name: 'ui:deliverable-file-references', order: ctx.systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES'), diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts new file mode 100644 index 0000000000..beac77657a --- /dev/null +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -0,0 +1,71 @@ +/** Open declared source files inside the viewed Session's workspace. */ +import { realpath, stat } from 'node:fs/promises' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-client-connection' +import type {} from '@deepseek-ai/dsh-session-query' +import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH } from './presented.ts' + +/** + * Register native opening inside Connection's authentication fence. + * @param ctx - Session lookup, native opener, and route lifetime. + */ +export function registerPresentOpen(ctx: Context): void { + const lifetime = new AbortController() + const pending = new Set>() + ctx.effect(() => async () => { + lifetime.abort() + await Promise.allSettled(pending) + }) + ctx.connection.fetch.register({ + path: PRESENT_OPEN_PATH, + methods: ['POST'], + requestBody: 'buffered', + fetch: (request) => { + const task = handlePresentOpen(ctx, new Request(request, { + signal: AbortSignal.any([request.signal, lifetime.signal]), + })) + pending.add(task) + void task.then(() => { pending.delete(task) }, () => { pending.delete(task) }) + return task + }, + }) +} + +async function handlePresentOpen(ctx: Context, request: Request): Promise { + const query = new URL(request.url).searchParams + const id = query.get('sessionId') + const seq = query.get('seq') + const index = query.get('index') + if (!id || seq === null || index === null || !/^\d+$/.test(seq) || !/^\d+$/.test(index) + || !Number.isSafeInteger(Number(seq)) || !Number.isSafeInteger(Number(index))) { + return new Response('Invalid Presented file coordinates.', { status: 400 }) + } + try { + request.signal.throwIfAborted() + const { session, target } = await ctx.sessionQuery.readEvent({ + sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0, + }, request.signal) + const file = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[Number(index)] : undefined + if (!isPresentedFile(file)) return new Response('Presented file not found in this Session result.', { status: 404 }) + if (session.cwd === undefined) return new Response('Session workspace unavailable.', { status: 404 }) + const root = await realpath(session.cwd) + const path = await realpath(resolve(root, file.path)) + const within = relative(root, path) + if (isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`)) { + return new Response('Presented file is outside the workspace.', { status: 403 }) + } + if (!(await stat(path)).isFile()) return new Response('Presented path is not a file.', { status: 404 }) + request.signal.throwIfAborted() + await ctx.sessionController.openWorkspacePath({ path }, request.signal) + return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } }) + } catch (error: unknown) { + request.signal.throwIfAborted() + const missing = error instanceof Error && 'code' in error + && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND' + || error.code === 'ENOENT' || error.code === 'ENOTDIR') + return new Response('Presented workspace file unavailable.', { status: missing ? 404 : 500 }) + } +} diff --git a/packages/client/ui-deliverables/src/presented.ts b/packages/client/ui-deliverables/src/presented.ts new file mode 100644 index 0000000000..fd5939671d --- /dev/null +++ b/packages/client/ui-deliverables/src/presented.ts @@ -0,0 +1,52 @@ +/** Validate declared workspace paths and address their native-open actions. */ +import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' + +/** Authenticated POST route for opening a workspace file on the Host desktop. */ +export const PRESENT_OPEN_PATH = '/api/present.open' + +/** + * Validate a file declaration read from a Session log. + * @param value - decoded durable data. + * @returns whether the declaration contains a path and optional description. + */ +export function isPresentedFile(value: unknown): value is PresentedFile { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { path, description } = value as Record + return typeof path === 'string' && path.trim().length > 0 + && (description === undefined || typeof description === 'string') +} + +/** + * Build authenticated coordinates for a declared file. + * @param sessionId - owning Session. + * @param seq - deliverables/presented event sequence. + * @param index - original index in the event's files array. + * @returns same-origin file action URL. + */ +export function presentedFileUrl(sessionId: SessionId, seq: number, index: number): string { + return `${PRESENT_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` +} + +/** + * Validate a delivery event before reading its turn or file declarations. + * @param value - decoded durable event data. + * @returns whether the event identifies a turn, call, and file list. + */ +export function isPresentedData(value: unknown): value is { turn: number; callId: ToolCallId; files: unknown[] } { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { turn, callId, files } = value as Record + return typeof turn === 'number' && Number.isSafeInteger(turn) && turn >= 1 + && typeof callId === 'string' && callId.length > 0 && Array.isArray(files) +} + +/** + * Trailing path segment, the part that identifies the file at a glance. + * @param path - Slash- or backslash-separated path. + * @returns The final segment, or the whole string when separator-free. + */ +export function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts new file mode 100644 index 0000000000..b201895445 --- /dev/null +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -0,0 +1,63 @@ +/** Delivery gestures share pending state, report failures, and cancel with the plugin. */ +import { afterEach, expect, it, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { PresentedOpenController } from '../src/client/present-open.ts' + +afterEach(() => { vi.unstubAllGlobals() }) + +const id = SessionId('fork') +const url = '/api/present.open?sessionId=fork&seq=2&index=1' + +it('coalesces concurrent card and mention gestures, then allows another open', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValue(reply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const first = controller.open(id, 2, 1) + await controller.open(id, 2, 1) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(fetcher).toHaveBeenCalledWith(url, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) + expect(controller.state.getSnapshot()[url]).toBe('opening') + reply.resolve(new Response(null, { status: 204 })) + await first + expect(controller.state.getSnapshot()[url]).toBe('opened') + await controller.open(id, 2, 1) + expect(fetcher).toHaveBeenCalledTimes(2) + await controller.dispose() +}) + +it.each(['http', 'network'])('publishes retryable %s failures', async (failure) => { + const fetcher = vi.fn() + if (failure === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 })) + else fetcher.mockRejectedValueOnce(new Error('offline')) + fetcher.mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('error') + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('opened') + await controller.dispose() +}) + +it('awaits cancellation and prevents late state publication or new requests after disposal', async () => { + const aborted = Promise.withResolvers() + const release = Promise.withResolvers() + const fetcher = vi.fn((_url: string, { signal }: RequestInit) => { + signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + return release.promise + }) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const open = controller.open(id, 2, 1) + const state = controller.state.getSnapshot() + let disposed = false + const disposal = controller.dispose().then(() => { disposed = true }) + await aborted.promise + expect(disposed).toBe(false) + release.resolve(new Response(null, { status: 204 })) + await Promise.all([open, disposal]) + expect(controller.state.getSnapshot()).toBe(state) + await controller.open(id, 2, 1) + expect(fetcher).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts new file mode 100644 index 0000000000..b9319587b6 --- /dev/null +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -0,0 +1,178 @@ +/** Native delivery actions resolve the viewed Session's current workspace files. */ +import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { registerPresentOpen } from '../src/present-open.ts' +import { presentedFileUrl, PRESENT_OPEN_PATH } from '../src/presented.ts' + +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + cleanups.length = 0 + vi.restoreAllMocks() +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-present-open-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const cwd = join(root, 'workspace') + await mkdir(cwd) + const file = { path: '日记模板.docx' } + await writeFile(join(cwd, file.path), Uint8Array.of(80, 75, 0, 255)) + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + const session: { cwd?: string } = { cwd } + const readEvent = vi.fn(async (request: SessionEventReadRequest) => { + if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') + if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') + return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent } + }) + ctx.provide('sessionQuery', { readEvent } as never) + const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const })) + ctx.provide('sessionController', { openWorkspacePath: opener } as never) + const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) + const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen }) + await fiber + const handler = connection.createSharedFetchHandler('/api') + const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( + `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, + )) + return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler } +} + +describe('Presented workspace file native open route', () => { + it('opens the source itself with current bytes and leaves it intact at disposal', async () => { + const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() + const source = await realpath(join(cwd, file.path)) + expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe(`${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`) + expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404) + expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) + for (const contents of ['current source', 'edited source']) { + await writeFile(source, contents) + const response = await open() + expect(response.status).toBe(204) + expect(response.headers.get('content-disposition')).toBeNull() + expect(response.headers.get('cache-control')).toBe('no-store') + expect(opener.mock.lastCall?.[0].path).toBe(source) + expect(await readFile(opener.mock.lastCall![0].path, 'utf8')).toBe(contents) + } + expect(ctx.get('attachments')).toBeUndefined() + await fiber.dispose() + expect(await readFile(source, 'utf8')).toBe('edited source') + expect((await open()).status).toBe(404) + }) + + it('resolves inherited declarations in the viewed fork workspace', async () => { + const { root, file, readEvent, session, open, opener } = await fixture() + const fork = join(root, 'fork') + await mkdir(fork) + await writeFile(join(fork, file.path), 'child source') + readEvent.mockResolvedValueOnce({ session: { ...session, cwd: fork }, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) + expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path))) + }) + + it.each(['', '?seq=7&index=0', '?sessionId=owner&index=0', '?sessionId=owner&seq=7', + '?sessionId=owner&seq=-1&index=0', '?sessionId=owner&seq=7&index=0.1', + '?sessionId=owner&seq=9007199254740992&index=0', '?sessionId=owner&seq=7&index=9007199254740992', + ])('rejects invalid coordinates before reading: %s', async (query) => { + const { open, readEvent } = await fixture() + expect((await open(query)).status).toBe(400) + expect(readEvent).not.toHaveBeenCalled() + }) + + it('refuses unrelated Sessions, absent events, and undeclared file indices', async () => { + const { open, readEvent, session, opener } = await fixture() + expect((await open('?sessionId=other&seq=7&index=0')).status).toBe(404) + expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404) + expect((await open('?sessionId=owner&seq=7&index=1')).status).toBe(404) + readEvent.mockResolvedValueOnce({ session, target: { type: 'turn/start' } as SessionEvent }) + expect((await open()).status).toBe(404) + expect(opener).not.toHaveBeenCalled() + }) + + it.each([null, [], 'invalid', {}, { turn: 1, callId: 'call', files: null }, + { turn: 1, callId: 'call', files: [null] }, { turn: 1, callId: 'call', files: [{ path: '' }] }, + { turn: 1, callId: 'call', files: [{ path: 'a', description: 1 }] }, + ])('refuses malformed recorded delivery data: %j', async (data) => { + const { open, readEvent, session, opener } = await fixture() + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data } as unknown as SessionEvent }) + expect((await open()).status).toBe(404) + expect(opener).not.toHaveBeenCalled() + }) + + it('reports removed files, directories, and absent workspaces without launching', async () => { + const { cwd, file, session, open, opener } = await fixture() + await unlink(join(cwd, file.path)) + expect((await open()).status).toBe(404) + file.path = '.' + expect((await open()).status).toBe(404) + delete session.cwd + expect((await open()).status).toBe(404) + expect(opener).not.toHaveBeenCalled() + }) + + it('refuses traversal and a source replaced by a symlink outside the workspace', async () => { + const { root, cwd, file, open, opener } = await fixture() + const outside = join(root, 'outside.txt') + await writeFile(outside, 'outside') + const source = join(cwd, file.path) + await unlink(source) + await symlink(outside, source) + expect((await open()).status).toBe(403) + file.path = '../outside.txt' + expect((await open()).status).toBe(403) + file.path = outside + expect((await open()).status).toBe(403) + expect(opener).not.toHaveBeenCalled() + }) + + it('reports query and launcher failures without leaking Host paths and allows retry', async () => { + const { open, readEvent, opener } = await fixture() + readEvent.mockRejectedValueOnce(new SessionQueryError('corrupt', 'SESSION_QUERY_CORRUPT_SESSION')) + expect((await open()).status).toBe(500) + opener.mockRejectedValueOnce(new Error('/private/host/path')) + const response = await open() + expect(response.status).toBe(500) + expect(await response.text()).not.toContain('/private/host/path') + expect((await open()).status).toBe(204) + }) + + it('honors cancellation before lookup', async () => { + const { open, readEvent } = await fixture() + const controller = new AbortController() + controller.abort(new Error('cancelled')) + await expect(open(undefined, controller.signal)).rejects.toThrow('cancelled') + expect(readEvent).not.toHaveBeenCalled() + }) + + it('disposal aborts and awaits a pending native launch', async () => { + const entered = Promise.withResolvers() + const aborted = Promise.withResolvers() + const release = Promise.withResolvers() + const { open, fiber, opener } = await fixture() + opener.mockImplementation(async (_request, signal) => { + signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + entered.resolve(undefined) + await release.promise + signal.throwIfAborted() + return { opened: true } + }) + const request = open().catch((error: unknown) => error) + await entered.promise + let disposed = false + const disposal = fiber.dispose().then(() => { disposed = true }) + await aborted.promise + expect(disposed).toBe(false) + release.resolve(undefined) + await Promise.all([request, disposal]) + }) +}) diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx new file mode 100644 index 0000000000..05d6b99540 --- /dev/null +++ b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +/** Present UI derives statuses and details from durable tool records. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { PresentRow } from '../src/client/PresentRow.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) +type Props = Parameters[0] +const running: RunningToolCall = { callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] } +const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'p', call: { name: 'present', argsRaw: running.argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Presented report.txt (4 bytes)' }], isError: false, subCalls: [] } +function props(block: Props['block'], inspect?: () => void): Props { + return { block, callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props +} + +it('discloses the saved result and offers call inspection', () => { + const inspect = vi.fn() + const view = render() + expect(view.getByText('Delivered')).toBeTruthy() + expect(view.getByText('report.txt')).toBeTruthy() + expect(view.queryByText('Presented report.txt (4 bytes)')).toBeNull() + const row = view.getByRole('button') + fireEvent.keyDown(row, { key: 'Enter' }) + expect(view.getByText('Presented report.txt (4 bytes)')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'Inspect call' })) + expect(inspect).toHaveBeenCalledOnce() + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('false') +}) + +it.each([ + [running, 'running', 'Delivering'], + [{ ...settled, isError: true }, 'error', 'Delivery failed'], + [{ ...settled, error: { name: 'Interrupted', code: 'interrupted' } }, 'stopped', 'Interrupted'], +] as const)('renders call lifecycle without claiming failed delivery', (block, state, label) => { + const view = render() + expect(view.container.querySelector('[data-tool="present"]')?.getAttribute('data-state')).toBe(state) + expect(view.getByText(label)).toBeTruthy() + expect(view.queryByText('Delivered')).toBeNull() +}) + +it.each(['', '{', 'null', '[]', '{"files":null}', '{"files":[null,{},1,{"path":false},{"path":"good.txt"}]}'])('tolerates partial arguments %s', (argsRaw) => { + const view = render() + expect(view.getByText('Delivering')).toBeTruthy() + expect(view.queryByRole('button')).toBeNull() +}) + +it('shows orphaned error details and non-text results', () => { + const view = render() + fireEvent.click(view.getByRole('button')) + expect(view.getByText('Missing: missing')).toBeTruthy() + view.rerender() + expect(view.container.textContent).toContain('Recorded detail') + view.rerender() + expect(view.queryByRole('button')).toBeNull() +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9c006c93e7..9b2d38caaa 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -21,15 +21,26 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' +import { PresentedOpenController } from '../src/client/present-open.ts' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' import { - basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, + basename, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, selectProducedFiles, type DeliverablesTurnData, } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { en, zh } from '../src/client/locales.ts' +import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +function openProps(controller = new PresentedOpenController()) { + return { + openPresented: vi.fn((...args: Parameters) => controller.open(...args)), + usePresentedOpen: (select: (state: ReturnType) => T): T => + select(controller.state.getSnapshot()), + } +} + afterEach(() => { cleanup() vi.restoreAllMocks() @@ -368,6 +379,10 @@ describe('produced-file Turn data', () => { )) .toThrow('deliverables start requires turn/start') expect(deliverablesDefinition.update(context, unrelated)).toBe(state) + for (const files of [[], [null, { path: '' }]]) { + const declaration = matched(at(3, 'deliverables/presented', { turn: 1, callId: 'present', files }), 'update') + expect(deliverablesDefinition.update(context, declaration)).toBe(state) + } }) it('replays a tail page once prepend supplies its missing Turn start', () => { @@ -494,7 +509,7 @@ describe('plugin registration', () => { // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', - children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, 'tool.call.toolview': { kind: 'keyed', scope: 'session' } }, } as never, () => null) // ui-theme's Appearance row binds a durable scope through these two. const session = { @@ -513,9 +528,8 @@ describe('plugin registration', () => { await fiber.await() const [entry] = ctx.slots.entries('conversation.chat.turnTail') expect(entry).toBeDefined() - // The row needs no injected Host capability: it hands a path to its owner - // and nothing in it reaches the local machine. - expect(entry?.inject).toBeUndefined() + expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(1) + expect(entry?.inject).toBeDefined() // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. @@ -526,15 +540,106 @@ describe('plugin registration', () => { (path) => { opened.push(path) }, ) const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions') - const mentions = service?.forClosing(owner) + const mentions = service?.forClosing(owner, SessionId('viewed-session')) mentions?.resolve('report.html')?.open() expect(opened).toEqual(['site/report.html']) + const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', seq: 2, index: 0 }] }, 3) + service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() + expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) + const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + await face.openPresented(SessionId('child-session'), 2, 0) + expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. - expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined() + expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() await fiber.dispose() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0) // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) }) + + +describe('presented files', () => { + const file = (path = 'report.docx') => ({ path }) + + it('replays deliveries without mutation calls, preserves indices, and isolates turns', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: [null, { ...file(), description: 'Final report' }] }), + at(3, 'deliverables/presented', { turn: 1, callId: 'again', files: [{ ...file(), description: 'Updated report' }] }), + at(4, 'turn/end', { turn: 1 }), + at(5, 'turn/start', { turn: 2 }), + ]) + const first = presentedForClosing(tailOwner(deliverablesOf(value), 3)) + expect(first).toMatchObject([{ path: 'report.docx', seq: 2, index: 1, description: 'Final report' }]) + expect(presentedForClosing(tailOwner(deliverablesOf(value), 4))) + .toMatchObject([{ path: 'report.docx', seq: 3, description: 'Updated report' }]) + expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull() + }) + + it('uses the viewed fork Session in every open action and retains all delivered files', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: Array.from({ length: 8 }, (_, i) => file(`report-${i}.docx`)) }), + ]) + const owner = tailOwner(deliverablesOf(value), 3) + const matched = selectDeliverables(owner)! + const props = openProps() + props.openPresented.mockResolvedValue(undefined) + const view = render() + expect(view.getAllByRole('button')).toHaveLength(8) + expect(view.queryByRole('link')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0) + expect(view.queryByText('Produced')).toBeNull() + }) +}) + + +it.each([null, [], 'invalid'])('declines non-object delivery data: %j', (data) => { + expect(deliverablesDefinition.match(at(1, 'deliverables/presented', data).event)).toBeNull() +}) + +it.each([{}, { turn: '1', callId: 'bad', files: [] }, + { turn: 1.5, callId: 'bad', files: [] }, { turn: 0, callId: 'bad', files: [] }, + { turn: 1, files: [] }, { turn: 1, callId: '', files: [] }, { turn: 1, callId: 'bad', files: null }, +])('ignores malformed delivery data and keeps the existing produced row: %j', (data) => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(2, 'write-a', 'write', { file_path: 'a.txt', content: 'a' }), + result(3, 'write-a'), + at(4, 'deliverables/presented', data), + ]) + const owner = tailOwner(deliverablesOf(value), 5) + const matched = selectDeliverables(owner)! + const view = render() + expect(view.getByText('Produced')).toBeTruthy() + expect(view.queryByText('Deliverables')).toBeNull() +}) + +it('shows file metadata and descriptions without hiding extensionless deliveries', () => { + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByText('Quarterly summary')).toBeTruthy() + expect(view.getByText('TXT')).toBeTruthy() + expect(view.getByText('File')).toBeTruthy() + expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app') +}) + + +it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => { + const controller = new PresentedOpenController() + controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase }) + const props = openProps(controller) + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) + expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening') +}) diff --git a/packages/client/ui-deliverables/tests/prompt.client.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts similarity index 88% rename from packages/client/ui-deliverables/tests/prompt.client.spec.ts rename to packages/client/ui-deliverables/tests/prompt.host.spec.ts index 9c2ee24471..8f342099fd 100644 --- a/packages/client/ui-deliverables/tests/prompt.client.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -16,6 +16,9 @@ describe('ui-deliverables node plugin', () => { it('registers final-response file-reference guidance only while mounted', async () => { ctx = new Context() await ctx.plugin(SystemPrompt, { personaPrefix: '' }) + ctx.provide('connection', { fetch: { register: () => () => {} } } as never) + ctx.provide('sessionQuery', {} as never) + ctx.provide('sessionController', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json new file mode 100644 index 0000000000..3b8f95c87c --- /dev/null +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -0,0 +1,57 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src/client", + "src/presented.ts", + "src/css-modules.d.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../connection/tsconfig.client.json" + }, + { + "path": "../locale" + }, + { + "path": "../store" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-chat" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../ui-tool" + }, + { + "path": "../../fs/tool-present" + } + ] +} diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json new file mode 100644 index 0000000000..0123290583 --- /dev/null +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/presented.ts", + "src/present-open.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../connection/tsconfig.host.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../session-query/session-query" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../fs/tool-present" + }, + { + "path": "../../api/session-controller/tsconfig.host.json" + } + ] +} diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index a99881d93b..2eca820546 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/packages/client/ui-deliverables/tsconfig.json @@ -1,48 +1,11 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ { - "path": "../../api/remotes/tsconfig.client.json" + "path": "./tsconfig.host.json" }, { - "path": "../../../vendor/cordis" - }, - { - "path": "../connection/tsconfig.client.json" - }, - { - "path": "../locale" - }, - { - "path": "../store" - }, - { - "path": "../ui-conversation" - }, - { - "path": "../ui-chat" - }, - { - "path": "../ui-renderer" - }, - { - "path": "../ui-primitives" - }, - { - "path": "../ui-slots" - }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../core/session" + "path": "./tsconfig.client.json" } ] } diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index aaf735a27f..ec0a9d8ed0 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -59,7 +56,8 @@ "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index bd0993ceba..19c343ed54 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md -README.md: e012295852ad3c957695fcfbd0568a45b3f54856 -README.zh.md: 2423887d60460651a224ffe1cb0fc9b61363c0e2 +README.md: 330bff66577464a26eac50d92ee54cc0cdefc8df +README.zh.md: f5259400055a28ea99f2205f9ba0a28d2cc13bd2 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index e012295852..330bff6657 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -74,6 +74,8 @@ These are not stylistic; each one fixes a defect found in a real browser. ## Build shape +Its static ESM retains third-party imports for the Web shell's Vite build; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). + The package is statically linked: tsdown's `staticLinked` preset emits one browser ESM bundle at `lib/index.js` (every bare specifier stays an import, sourcemaps chain to the sources) and ships the stylesheet under `lib/` at its `src`-relative path, and the Web shell resolves the package name and bundles that artifact itself, so vite stays the only owner of class hashing. One consequence is load-bearing — the kit keeps **one** stylesheet, `dockkit.module.css`, because a consumer de-duplicates injected sheets by file name and a collision would drop one silently. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 2423887d60..f525940005 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -74,6 +74,8 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt ## 构建形态 +静态 ESM 为 Web 壳的 Vite 构建保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 + 本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果是承重的——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。 diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index 8e7a97e4dd..968d1ba505 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -22,18 +22,15 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index a5d7b679d9..65e30abb16 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -61,7 +58,8 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index eb1cbebbee..8d680bc72f 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md -README.md: 0e353e21f39ba8a84e661f1c540db2c2424bcbfa -README.zh.md: 16471fdabb46bbfb35158ef85aa2544db4ceae93 +README.md: 4fe19fb9ec0a549dc76ca311d0564cbe86c9f5f9 +README.zh.md: 7a61e7808ffb3d81214388a2792a901ced8d9564 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 0e353e21f3..4fe19fb9ec 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -25,7 +25,9 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a ## Use this package -The root slot composes the sidebar, conversation, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. +The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. + +Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition. ### Theme presentation @@ -39,7 +41,9 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
Implementation internals — click to expand -One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. +`selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts. + +One registration declares four child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
@@ -51,7 +55,7 @@ One registration declares four child slots and binds `ctx.layout` methods `toggl Read these pages when the layout surface is not enough. They move from the frame to the columns it renders and the theme it presents. - [ui-sidebar](../ui-sidebar/README.md) — occupies the `sidebar` column and its seats. -- [ui-conversation](../ui-conversation/README.md) — occupies the `conversation` column. +- [ui-conversation](../ui-conversation/README.md) — occupies the `main` key `conversation`. - [ui-sidebar-right](../ui-sidebar-right/README.md) — occupies the `rightbar` column with one docking surface per session. - [ui-theme](../ui-theme/README.md) — the theme seam whose resolved snapshots the presenter consumes. - [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 16471fdabb..7a61e7808f 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -25,7 +25,9 @@ kind: "package-reference" ## 使用本包 -本插件在 root slot 组合侧栏、会话与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。 +本插件在 root slot 组合侧栏、中央内容与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。 + +全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。 ### 主题呈现 @@ -39,7 +41,9 @@ kind: "package-reference"
实现细节——点击展开 -一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 +`selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费者在提交导航或搬移草稿前检查 signal。 + +一次注册声明四个子slot并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放列宽。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
@@ -51,7 +55,7 @@ kind: "package-reference" 当布局面不够用时阅读以下页面。它们从框架进入它所渲染的栏与它所呈现的主题。 - [ui-sidebar](../ui-sidebar/README.zh.md)——占据 `sidebar` 栏及其座位。 -- [ui-conversation](../ui-conversation/README.zh.md)——占据 `conversation` 栏。 +- [ui-conversation](../ui-conversation/README.zh.md)——占据 `main` 中的 `conversation` key。 - [ui-sidebar-right](../ui-sidebar-right/README.zh.md)——以每会话一个停靠面占据 `rightbar` 栏。 - [ui-theme](../ui-theme/README.zh.md)——呈现器消费其解析快照的主题 seam。 - [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册槽位。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 1f10a544ad..a2c025f39d 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 905341f503..05bb4b9494 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -3,10 +3,9 @@ * shell renders only 'root'). Owns the grid tracks (sidebar | center | * rightbar), the drag handles (pointer capture + rAF throttle), the column * solve (columns.ts), and the child-slot render decisions: the sidebar slot - * renders HERE with live parameters from that solve, and the session-aware - * occupants render in fixed column positions; the strict right-column entry - * gates itself on current-session availability while the session-maybe - * conversation retains identity. + * receives live parameters from that solve. The root-scoped main slot selects + * the Conversation or a global panel. Each column occupant owns its Session + * binding and reports the geometry it needs. * * The right column is a track, not a box: its occupant draws its panel anchored * to the frame's right edge at the resolved normal width, and the @@ -15,7 +14,7 @@ * track but hides the outer resize handle. Everything arrives through the framework * shares — zero cordis or framework imports, zero self-made hooks. */ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, @@ -28,7 +27,7 @@ import css from './AppFrame.module.css' /** Full composed props: runtime share + child-slot render share + store share. */ export type AppFrameProps = & PropsRuntime<'root'> - & PropsRenderSlots<'sidebar' | 'conversation' | 'rightbar' | 'shell.overlay'> + & PropsRenderSlots<'sidebar' | 'main' | 'rightbar' | 'shell.overlay'> & PropsStore> & PropsLocale<'common'> @@ -37,6 +36,12 @@ function CenterColumn(props: { children?: ReactNode }) { return
{props.children}
} +/** Subscribe to the main key without subscribing the column frame to each panel id. */ +function MainPanel({ usePanelInfo, renderSlot }: Pick, 'usePanelInfo'> & PropsRenderSlots<'main'>) { + const panelId = usePanelInfo(info => info.activePanelId) + return renderSlot('main', {}, { entryKey: panelId ?? 'conversation' }) +} + /** * Right column grid item. Zero-width unless the occupant asked for a track; the * occupant's panel is positioned against the column's right edge, which never @@ -116,18 +121,14 @@ function DragHandle(props: { side: 'sidebar' | 'rightbar'; left: number; onStart export function AppFrame({ useStore, useSessions, + usePanelInfo, actions, renderSlot, - SessionProvider, t, }: AppFrameProps) { - const panels = useStore(s => s) - const documentTitle = useSessions((s) => { - const current = s.current - return current === undefined ? undefined : s.byId[current]?.title - }) + const layoutInfo = useStore(state => state.layoutInfo) const frameRef = useRef(null) - const viewport = panels.viewportWidth + const viewport = layoutInfo.viewportWidth // Track the frame's own box (not the window): rAF-throttled ResizeObserver. useLayoutEffect(() => { @@ -157,15 +158,15 @@ export function AppFrame({ }, [actions]) const narrow = viewport < SIDEBAR_AUTO_COLLAPSE - const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0 + const sidebarCollapsed = narrow ? !layoutInfo.narrowExpanded : layoutInfo.sidebar === 0 const sidebarPreference = sidebarCollapsed ? 0 - : panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar - const rightbarPreference = panels.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO + : layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : layoutInfo.sidebar + const rightbarPreference = layoutInfo.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO // Opening on a narrow frame collapses the left sidebar. Eligibility must // include that space before the occupant's first shown report arrives. - const normal = computeColumns(viewport, !panels.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference) - const cols = computeColumns(viewport, sidebarPreference, panels.rightbarTrack ? rightbarPreference : 0) + const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference) + const cols = computeColumns(viewport, sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0) const colsRef = useRef(cols) colsRef.current = cols const rightbarWidth = useRef(normal.rightbar) @@ -189,6 +190,14 @@ export function AppFrame({ actions.setRightbar(rightbarBase.current - dx) }, [actions]) const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild') + const sidebar = useMemo(() => renderSlot('sidebar', { + collapsed: sidebarCollapsed, + width: cols.sidebar, + }), [renderSlot, sidebarCollapsed, cols.sidebar]) + const main = useMemo(() => ( + + ), [usePanelInfo, renderSlot]) + const overlays = useMemo(() => renderSlot('shell.overlay', {}), [renderSlot]) return (
- {/* Render-site slot call with live concession output: a closed - sidebar keeps the mounted slot at the compact-rail width, and the - component sees its rendered state as owner params decided here - (collapsed follows the resolved rail, so a derived auto-collapse - renders the rail UI too). */} - {renderSlot('sidebar', { - collapsed: sidebarCollapsed, - width: cols.sidebar, - })} + {sidebar}
<> - {/* Both column occupants stay at fixed tree positions from first - paint — no loading gate: a bare status line reads worse than - the shell's own pending rendering. The conversation is - session-maybe; SessionProvider withholds the strict right-column - entry while no session is current. */} - {renderSlot('conversation', {})} + {main} - {/* Strict session entry: with no session there is no surface, and the - column is an empty zero-width track. The occupant receives the - panel width it should draw at; the track is the frame's business. */} - - {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })} - + {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
- {renderSlot('shell.overlay', {})} + {overlays}
{/* The collapsed rail is fixed-width: no resize handle while closed. */} {!sidebarCollapsed && } - {panels.rightbarShown && !panels.rightbarFullscreen && normal.rightbar > 0 && ( + {layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && ( )}
diff --git a/packages/client/ui-layout/src/client/DocumentTitle.tsx b/packages/client/ui-layout/src/client/DocumentTitle.tsx index 4060a3b187..69cd5f4cfe 100644 --- a/packages/client/ui-layout/src/client/DocumentTitle.tsx +++ b/packages/client/ui-layout/src/client/DocumentTitle.tsx @@ -1,9 +1,9 @@ +/** Browser title selection follows the active main panel without subscribing the frame. */ import { useEffect } from 'react' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' /** Props for the browser title projection. */ -export interface DocumentTitleProps { - /** Durable title of the selected session, or undefined for the product title. */ - title?: string +export type DocumentTitleProps = Pick, 'useSessions' | 'usePanelInfo'> & { /** Build-configured or localized product title. */ productTitle: string } @@ -14,7 +14,12 @@ export interface DocumentTitleProps { * @param props - Selected session title projection. * @returns No rendered content. */ -export function DocumentTitle({ title, productTitle }: DocumentTitleProps): null { +export function DocumentTitle({ useSessions, usePanelInfo, productTitle }: DocumentTitleProps): null { + const showSessionTitle = usePanelInfo(info => info.activePanelId === null) + const title = useSessions((state) => { + const current = state.current + return !showSessionTitle || current === undefined ? undefined : state.byId[current]?.title + }) useEffect(() => { document.title = title === undefined ? productTitle : `${title} — ${productTitle}` return () => { document.title = productTitle } diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 649215b96d..ffbbd68bb9 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -3,8 +3,8 @@ * the runtime's built-in 'root' slot and, in the same breath, declares the * four child slots (declaration = exclusive render authority), seats the * layout store (panel geometry), and wires the panel-action service face. - * ctx.layout is the cross-plugin panel-action contract; navigation state lives - * with the runtime sessions service. A second effect seats the theme + * ctx.layout selects the main panel and controls column geometry; Session + * selection belongs to the Session Controller. A second effect seats the theme * presenter, which projects ctx.theme snapshots onto document.body. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' @@ -12,7 +12,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' -import type { PanelActions } from './service.ts' +import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { PanelInfo } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutController } from './service.ts' @@ -24,7 +25,10 @@ import { ThemePresenter } from './theme-presenter.ts' // OwnerShare contracts below are the render-side halves registrants compose // against; the frame components and the store factory are package-internal. export { LayoutController } from './service.ts' -export type { ILayout } from './service.ts' +export type { ILayout, MainPanelId, PanelInfo } from './service.ts' + +/** Selector hook over root-scoped panel selection. */ +export type UsePanelInfo = SnapshotSelectorHook declare module '@deepseek-ai/cordis' { interface Context { @@ -34,6 +38,11 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Subscribe to the selected main panel independently of parent renders. */ + usePanelInfo: UsePanelInfo + } + interface SlotMap { // The 'root' entry itself is the runtime's built-in slot (declared // there); these four are the frame's children, declared by the same @@ -51,18 +60,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps } /** - * The whole center column, across both the no-session hero and a live - * conversation. OCCUPIED by ui-conversation's ConversationRoot, which - * declares the session body, composer, and input seats inside it — - * registering here replaces the entire conversation surface (and removes - * every seat it declares) rather than adding to it. - * - * Current-session-optional: the occupant owns both states without - * changing its React identity, so it keeps its own state across a session - * switch. It receives no owner props; session facts arrive through the - * framework hooks of the `session-maybe` scope. + * Central panel selected by sidebar entry id. The reserved `conversation` + * key hosts the Conversation; other keys receive no Session binding. */ - 'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps } + 'main': { kind: 'keyed'; scope: 'root' } /** * The right column: a track the centre makes room for, or nothing. OCCUPIED * by the right Sidebar, which uses the resolved column width in normal @@ -73,10 +74,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * occupant's own recorded business — it reports the composition of its * expanded and presentation state through `ctx.layout`, and the frame sizes * the track and places the resize handle from that. The expand control is - * not this column's: it is a button in the conversation header. With no - * current session nothing is mounted here. + * not this column's: it is a button in the conversation header. The root + * occupant decides when to render its Session-bound content. */ - 'rightbar': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps } + 'rightbar': { kind: 'single'; scope: 'root'; owner: RightbarOwnerProps } /** * Frame-wide floating layer, above every column and outside their scroll * containers. Deliberately generic and unowned by any feature: a badge, a @@ -105,9 +106,6 @@ export interface SidebarOwnerProps { width: number } -/** Conversation owner share: business state and actions belong to the registrant. */ -export interface ConvOwnerProps {} - /** Right column owner share: resolved normal geometry and opening eligibility. */ export interface RightbarOwnerProps { /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */ @@ -127,34 +125,44 @@ export const inject = ['slots', 'theme', 'locale'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame * into 'root' with the four child-slot declarations, the layout store seat, - * and the inject hook that hands the store's bound actions to the service. + * and the shared root instance supplying commands and the panel-info source. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const layout = new LayoutController() ctx.effect(() => { + const handle = createLayoutStore() + const instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + const layout = new LayoutController(instance.actions, id => + ctx.slots.entries('main').some(entry => entry.options.key === id)) + const retainMainPanels = (): void => { + instance.actions.retainMainPanels(ctx.slots.entries('main').flatMap(entry => + entry.options.key === undefined ? [] : [entry.options.key])) + } + const panelInfo: HostObservable = { + getSnapshot: () => instance.getSnapshot().panelInfo, + subscribe: listener => instance.subscribe(listener), + } + const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo } }) const disposeService = ctx.reflect.provide('layout', layout) const disposeRegistration = ctx.slots.register({ name: 'root', locale: 'common', children: { 'sidebar': { kind: 'single', scope: 'root' }, - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'rightbar': { kind: 'single', scope: 'session' }, + 'main': { kind: 'keyed', scope: 'root' }, + 'rightbar': { kind: 'single', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' }, }, - // Exclusive store: the factory itself — the framework instantiates per - // entry and delivers useStore/actions to AppFrame as standard props. - store: createLayoutStore, - // The hook's only side effect connects the root store to ctx.layout; - // conversation business actions belong to their registrants. - inject: (actions: PanelActions) => { - layout.attachPanels(actions) - return {} - }, + store, }, AppFrame) + const disposePanels = ctx.slots.subscribe('main', retainMainPanels) + retainMainPanels() return () => { + layout.dispose() + disposePanels() disposeRegistration() + disposePanelInfo() // provide()'s disposer settles asynchronously; teardown is synchronous fire-and-forget. void disposeService() } diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 46369f0346..14a3924d63 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -1,26 +1,42 @@ /** * LayoutController: the cross-plugin panel-action face behind ctx.layout. - * Panel geometry itself lives in the root entry's layout store (stores.ts); + * Panel geometry and main-panel selection live in the root layout store; * the current-session selection lives with the runtime sessions service, and * the per-session active view dissolved into ui-conversation's session store * (its only consumer). What remains here is the contract other plugins' - * apply worlds reach for panel transitions (sidebar toggle from ui-sidebar, + * apply worlds reach for panel transitions (main-panel selection and sidebar toggle, * right-panel show/hide from ui-sidebar-right) — writes stay inside the - * store's declared action set, delivered as the registration's bound actions. + * store's declared action set, shared with the root registration. */ import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { createLayoutStore } from './stores.ts' +/** Identity shared by a sidebar panel entry and its main-slot occupant. */ +export type MainPanelId = Branded<'MainPanelId'> + +/** Root-scoped navigation state exposed to panel-aware components. */ +export interface PanelInfo { + /** Selected global panel; null displays the current Conversation. */ + readonly activePanelId: MainPanelId | null +} + /** The layout store's bound action set (framework-baked, draft params peeled). */ export type PanelActions = BoundActions> -/** - * The outward layout face (`ctx.layout`): the panel transitions other - * plugins may trigger — and exactly what a test fake must supply. The - * attachPanels wiring hook stays on the concrete class (root-entry assembly - * only). - */ +/** Panel navigation and geometry actions exposed through ctx.layout. */ export interface ILayout { + /** + * Select a global central panel without changing the current Session. + * @param panelId - registered main key, or null to show the Conversation. + * @throws if the selected main key is not registered; preserves the current selection. + */ + selectPanel(panelId: MainPanelId | null): void + /** + * Start an asynchronous navigation, superseding any earlier pending navigation. + * @returns a signal aborted by the next navigation or layout disposal; check it before committing UI state. + */ + beginNavigation(): AbortSignal /** Toggle the sidebar panel (closed ⟷ contract default width). */ toggleSidebar(): void /** @@ -37,39 +53,50 @@ export interface ILayout { /** Cross-plugin panel-action face (ctx.layout). */ export class LayoutController implements ILayout { - #panels: PanelActions | undefined + private navigation = new AbortController() /** - * Adopt the root entry's bound store actions. Called from the root - * registration's inject hook (a sanctioned assembly side effect), so the - * face is live from the entry's first render; on entry re-register the - * fresh actions overwrite the stale set. - * @param actions - bound actions of the entry's layout store instance. + * @param panels - actions of the instance shared with the root entry. + * @param hasMainPanel - checks the live main-slot registry for a panel id. */ - attachPanels(actions: PanelActions): void { - this.#panels = actions + constructor( + private readonly panels: PanelActions, + private readonly hasMainPanel: (id: MainPanelId) => boolean, + ) {} + + /** Select a global panel or return to the Conversation. */ + selectPanel(panelId: MainPanelId | null): void { + if (panelId !== null && !this.hasMainPanel(panelId)) { + throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`) + } + this.navigation.abort() + this.panels.selectPanel(panelId) + } + + /** @returns the new pending navigation's cancellation signal. */ + beginNavigation(): AbortSignal { + this.navigation.abort() + this.navigation = new AbortController() + return this.navigation.signal + } + + /** Invalidate pending navigations when the layout owner is unloaded. */ + dispose(): void { + this.navigation.abort() } /** Toggle the sidebar panel (closed ⟷ contract default width). */ toggleSidebar(): void { - this.#require().toggleSidebar() + this.panels.toggleSidebar() } /** Report the right panel's track and fullscreen presentation. */ openRightbar(track: boolean, fullscreen: boolean): void { - this.#require().openRightbar(track, fullscreen) + this.panels.openRightbar(track, fullscreen) } /** Report the right panel as hidden: no track, no handle. */ closeRightbar(): void { - this.#require().closeRightbar() - } - - #require(): PanelActions { - // Callers are UI gestures, which cannot fire before the root entry - // rendered (the inject hook runs in its first render) — reaching this - // unwired is a boot-order bug, not a race to tolerate. - if (this.#panels === undefined) throw new Error('layout: panel actions not wired (root entry not mounted)') - return this.#panels + this.panels.closeRightbar() } } diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index 9191745a66..3a53caf4f1 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -3,6 +3,7 @@ * The registration supplies a fresh store and binds its actions to ctx.layout. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { MainPanelId } from './service.ts' import { clampWidth, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, @@ -13,6 +14,14 @@ import { * the right panel's expanded state belongs to its occupant. */ type LayoutState = { + panelInfo: { + /** Null selects the Conversation; global panels keep the current Session intact. */ + activePanelId: MainPanelId | null + } + layoutInfo: LayoutInfo +} + +type LayoutInfo = { sidebar: number /** Last positive frame measurement; window width bootstraps the first render. */ viewportWidth: number @@ -47,6 +56,8 @@ type LayoutState = { * return type); drift fails assignability at the defineStore call. */ type LayoutActions = { + selectPanel: (draft: LayoutState, panelId: MainPanelId | null) => void + retainMainPanels: (draft: LayoutState, panelIds: readonly string[]) => void setSidebar: (draft: LayoutState, px: number) => void toggleSidebar: (draft: LayoutState) => void setViewportWidth: (draft: LayoutState, width: number) => void @@ -67,56 +78,67 @@ type LayoutActions = { export function createLayoutStore(): EngineStoreHandle { const handle = defineStore({ init: (): LayoutState => ({ - sidebar: SIDEBAR_DEFAULT, - viewportWidth: window.innerWidth, - narrowExpanded: false, - rightbar: null, - rightbarShown: false, - rightbarTrack: false, - rightbarFullscreen: false, - rightbarInstant: false, + panelInfo: { activePanelId: null }, + layoutInfo: { + sidebar: SIDEBAR_DEFAULT, + viewportWidth: window.innerWidth, + narrowExpanded: false, + rightbar: null, + rightbarShown: false, + rightbarTrack: false, + rightbarFullscreen: false, + rightbarInstant: false, + }, }), actions: { + selectPanel: (d, panelId: MainPanelId | null) => { + d.panelInfo.activePanelId = panelId + }, + retainMainPanels: (d, panelIds: readonly string[]) => { + if (d.panelInfo.activePanelId !== null && !panelIds.includes(d.panelInfo.activePanelId)) { + d.panelInfo.activePanelId = null + } + }, setSidebar: (d, px: number) => { - d.rightbarInstant = false - d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) + d.layoutInfo.rightbarInstant = false + d.layoutInfo.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) }, // Narrow toggles flip only the override: the width preference survives // untouched, so re-widening restores the pre-squeeze layout. toggleSidebar: (d) => { - d.rightbarInstant = false - if (d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = !d.narrowExpanded - else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 + d.layoutInfo.rightbarInstant = false + if (d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = !d.layoutInfo.narrowExpanded + else d.layoutInfo.sidebar = d.layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : 0 }, // Crossing the breakpoint in either direction drops the override: the // narrow default is auto-collapsed, the wide state is the preference. setViewportWidth: (d, width: number) => { - if (d.viewportWidth === width) return - d.rightbarInstant = false - if ((d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) { - d.narrowExpanded = false + if (d.layoutInfo.viewportWidth === width) return + d.layoutInfo.rightbarInstant = false + if ((d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) { + d.layoutInfo.narrowExpanded = false } - d.viewportWidth = width + d.layoutInfo.viewportWidth = width }, setRightbar: (d, px: number) => { - d.rightbarInstant = false - d.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.viewportWidth * RIGHTBAR_MAX_RATIO)) + d.layoutInfo.rightbarInstant = false + d.layoutInfo.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.layoutInfo.viewportWidth * RIGHTBAR_MAX_RATIO)) }, openRightbar: (d, track: boolean, fullscreen: boolean) => { - if (!d.rightbarShown || d.rightbarTrack !== track || d.rightbarFullscreen !== fullscreen) { - d.rightbarInstant = d.rightbarFullscreen && !fullscreen + if (!d.layoutInfo.rightbarShown || d.layoutInfo.rightbarTrack !== track || d.layoutInfo.rightbarFullscreen !== fullscreen) { + d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen && !fullscreen } - if (!d.rightbarShown && d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = false - d.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.viewportWidth * RIGHTBAR_DEFAULT_RATIO)) - d.rightbarShown = true - d.rightbarTrack = track - d.rightbarFullscreen = fullscreen + if (!d.layoutInfo.rightbarShown && d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = false + d.layoutInfo.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.layoutInfo.viewportWidth * RIGHTBAR_DEFAULT_RATIO)) + d.layoutInfo.rightbarShown = true + d.layoutInfo.rightbarTrack = track + d.layoutInfo.rightbarFullscreen = fullscreen }, closeRightbar: (d) => { - if (d.rightbarShown) d.rightbarInstant = d.rightbarFullscreen - d.rightbarShown = false - d.rightbarTrack = false - d.rightbarFullscreen = false + if (d.layoutInfo.rightbarShown) d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen + d.layoutInfo.rightbarShown = false + d.layoutInfo.rightbarTrack = false + d.layoutInfo.rightbarFullscreen = false }, }, }) diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index 62623b9b81..55e51bdeac 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -1,14 +1,13 @@ // @vitest-environment jsdom /** Frame interactions with a real store and explicitly driven browser measurements. */ -import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' -import { useSyncExternalStore } from 'react' import { AppFrame } from '../src/client/AppFrame.tsx' import type { AppFrameProps } from '../src/client/AppFrame.tsx' -import type { RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts' +import type { MainPanelId, RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts' import { createLayoutStore } from '../src/client/stores.ts' -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -19,8 +18,6 @@ let workspacesReady = true type AttentionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention) -const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) => - selectedSession === undefined ? <>{empty?.() ?? null} : <>{children} let observers: ResizeObserverStub[] class ResizeObserverStub { @@ -63,19 +60,15 @@ function resize(width: number): void { }) } -function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { - return function useSelector(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) } -} - function mountFrame(windowWidth = frameWidth) { vi.stubGlobal('innerWidth', windowWidth) const instance = createLayoutStore().create() - const slotCalls: { key: string; props: object }[] = [] - const renderSlot = ((key: string, owner: object) => { - slotCalls.push({ key, props: owner }) - return
- }) as AppFrameProps['renderSlot'] - const useSessions = ((sel: (s: SessionListState) => unknown) => sel({ + const slotCalls: { key: string; props: object; options: RenderOpts | undefined }[] = [] + const renderSlot: AppFrameProps['renderSlot'] = (key, owner, options) => { + slotCalls.push({ key, props: owner, options }) + return
+ } + const useSessions: AppFrameProps['useSessions'] = sel => sel({ ids: selectedSession === undefined ? [] : [selectedSession], byId: selectedSession === undefined ? {} : { [selectedSession]: { @@ -85,22 +78,29 @@ function mountFrame(windowWidth = frameWidth) { }, current: selectedSession, phase: 'ready', - } as SessionListState)) as AppFrameProps['useSessions'] + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) const workspaceState: WorkspaceSnapshot = { items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, ...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }), } - const useStore = hookOf(instance) + const useStore = bindSnapshotSelector(instance) + const usePanelInfo = bindSnapshotSelector({ + getSnapshot: () => instance.getSnapshot().panelInfo, + subscribe: listener => instance.subscribe(listener), + }) const element = () => ( unknown) => sel(workspaceState)) as AppFrameProps['useWorkspaces']} - SessionProvider={SessionProviderStub} + useWorkspaces={sel => sel(workspaceState)} t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key} /> ) @@ -202,22 +202,22 @@ describe('AppFrame', () => { expect(tracks(frame)).toEqual([280, 0]) expect(sidebarOwner()).toEqual({ collapsed: false, width: 280 }) expect(rightOwner()).toEqual({ width: 864, viewportWidth: 1920, canShow: true }) - expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({}) + expect(slotCalls.find(c => c.key === 'main')).toEqual({ key: 'main', props: {}, options: { entryKey: 'conversation' } }) }) - it('retains conversation and sidebar content without a current Session', () => { + it('renders the main, sidebar, and root-scoped rightbar outlets without a current Session', () => { selectedSession = undefined - const { frame, getByTestId, queryByTestId } = mountFrame() - expect(getByTestId('conversation-content')).toBeTruthy() + const { frame, getByTestId } = mountFrame() + expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation') expect(getByTestId('sidebar-content')).toBeTruthy() - expect(queryByTestId('rightbar-content')).toBeNull() + expect(getByTestId('rightbar-content')).toBeTruthy() expect(tracks(frame)).toEqual([280, 0]) }) it('renders both occupants before workspace baselines settle', () => { workspacesReady = false const { getByTestId } = mountFrame() - expect(getByTestId('conversation-content')).toBeTruthy() + expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation') expect(getByTestId('rightbar-content')).toBeTruthy() }) @@ -229,16 +229,34 @@ describe('AppFrame', () => { expect(getByTestId('sidebar-content')).toBeTruthy() expect(frame.querySelector('[data-side="sidebar"]')).toBeNull() }) + + it('switches only the keyed main outlet when the active panel changes', () => { + selectedSessionTitle = 'Session title' + const { instance, frame, slotCalls, getByTestId } = mountFrame() + const sessionId = selectedSession + const layoutInfo = instance.getSnapshot().layoutInfo + for (const panelId of ['panel-a' as MainPanelId, 'panel-b' as MainPanelId, null]) { + slotCalls.length = 0 + act(() => { instance.actions.selectPanel(panelId) }) + expect(slotCalls).toEqual([{ key: 'main', props: {}, options: { entryKey: panelId ?? 'conversation' } }]) + expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe(panelId ?? 'conversation') + expect(instance.getSnapshot().panelInfo).toEqual({ activePanelId: panelId }) + expect(instance.getSnapshot().layoutInfo).toBe(layoutInfo) + expect(tracks(frame)).toEqual([280, 0]) + expect(selectedSession).toBe(sessionId) + expect(document.title).toBe(panelId === null ? 'Session title — DSH Local Build' : 'DSH Local Build') + } + }) }) describe('AppFrame normal width concessions', () => { it('measures the frame, not the window, before choosing the first-open preference', () => { frameWidth = 1000 const { instance, rightOwner } = mountFrame(1920) - expect(instance.getSnapshot().viewportWidth).toBe(1000) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1000) expect(rightOwner()).toEqual({ width: 450, viewportWidth: 1000, canShow: true }) act(() => { instance.actions.openRightbar(true, false) }) - expect(instance.getSnapshot().rightbar).toBe(450) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(450) resize(1920) expect(rightOwner().width).toBe(450) }) @@ -255,7 +273,7 @@ describe('AppFrame normal width concessions', () => { expect(tracks(frame)).toEqual([420, 0]) expect(rightOwner()).toEqual({ width: 0, viewportWidth: 1119, canShow: false }) expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() - expect(instance.getSnapshot()).toMatchObject({ rightbarShown: true, rightbar: 864 }) + expect(instance.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbar: 864 }) act(() => { instance.actions.closeRightbar() }) resize(455) expect(tracks(frame)).toEqual([56, 0]) @@ -271,7 +289,7 @@ describe('AppFrame normal width concessions', () => { expect(rightOwner()).toEqual({ width: 344, viewportWidth: 800, canShow: true }) act(() => { instance.actions.openRightbar(true, false) }) expect(tracks(frame)).toEqual([56, 344]) - expect(instance.getSnapshot()).toMatchObject({ narrowExpanded: false, rightbar: 360 }) + expect(instance.getSnapshot().layoutInfo).toMatchObject({ narrowExpanded: false, rightbar: 360 }) expect(rightOwner().canShow).toBe(true) }) @@ -312,7 +330,7 @@ describe('AppFrame normal width concessions', () => { resize(980) act(() => { instance.actions.toggleSidebar() }) expect(tracks(frame)[0]).toBe(280) - expect(instance.getSnapshot().sidebar).toBe(0) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) }) }) @@ -404,7 +422,7 @@ describe('AppFrame right panel presentation', () => { act(() => { instance.actions.openRightbar(false, true) }) expect(tracks(frame)).toEqual([56, 0]) expect(rightOwner()).toEqual({ width: 0, viewportWidth: 700, canShow: false }) - expect(instance.getSnapshot().rightbarShown).toBe(true) + expect(instance.getSnapshot().layoutInfo.rightbarShown).toBe(true) expect(frame.querySelector('[data-side="rightbar"]')).toBeNull() }) @@ -415,7 +433,7 @@ describe('AppFrame right panel presentation', () => { expect(tracks(frame)).toEqual([280, 0]) expect(rightOwner().width).toBe(420) drag(handleFor(frame, 'rightbar'), 680, 690) - expect(instance.getSnapshot().rightbar).toBe(410) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410) expect(rightOwner().width).toBe(410) expect(tracks(frame)[1]).toBe(0) }) @@ -436,7 +454,7 @@ describe('AppFrame pointer resizing', () => { act(flushFrames) expect(tracks(frame)[0]).toBe(360) pointer(handle, 'pointerup', 360) - expect(instance.getSnapshot().sidebar).toBe(360) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360) expect(frame.dataset.dragging).toBeUndefined() expect(handle.hasPointerCapture(1)).toBe(false) }) @@ -450,7 +468,7 @@ describe('AppFrame pointer resizing', () => { expect(tracks(frame)[1]).toBe(420) expect(handle.style.left).toBe('680px') drag(handle, 680, 690) - expect(instance.getSnapshot().rightbar).toBe(410) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410) expect(rightOwner().width).toBe(410) expect(tracks(frame)[1]).toBe(410) expect(handle.style.left).toBe('690px') @@ -474,10 +492,10 @@ describe('AppFrame pointer resizing', () => { pointer(handle, 'pointerdown', 280) pointer(handle, 'pointermove', 320) pointer(handle, 'pointerup', 360) - expect(instance.getSnapshot().sidebar).toBe(360) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360) expect(animationFrames.size).toBe(0) act(flushFrames) - expect(instance.getSnapshot().sidebar).toBe(360) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360) }) it('ignores uncaptured motion, secondary buttons, and a second pointer', () => { @@ -493,9 +511,9 @@ describe('AppFrame pointer resizing', () => { pointer(handle, 'pointermove', 500, 9) pointer(handle, 'pointerup', 500, 9) expect(animationFrames.size).toBe(0) - expect(instance.getSnapshot().sidebar).toBe(280) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280) pointer(handle, 'pointerup', 300) - expect(instance.getSnapshot().sidebar).toBe(300) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(300) }) it.each(['pointercancel', 'lostpointercapture'])('ends %s without committing queued motion', (event) => { @@ -506,7 +524,7 @@ describe('AppFrame pointer resizing', () => { if (event === 'lostpointercapture') handle.releasePointerCapture(1) pointer(handle, event, 340) act(flushFrames) - expect(instance.getSnapshot().sidebar).toBe(280) + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280) expect(animationFrames.size).toBe(0) expect(frame.dataset.dragging).toBeUndefined() expect(handle.hasPointerCapture(1)).toBe(false) @@ -526,7 +544,7 @@ describe('AppFrame pointer resizing', () => { const settled = instance.getSnapshot() act(flushFrames) expect(instance.getSnapshot()).toBe(settled) - expect(instance.getSnapshot().rightbar).toBe(864) + expect(instance.getSnapshot().layoutInfo.rightbar).toBe(864) expect(animationFrames.size).toBe(0) expect(handle.hasPointerCapture(1)).toBe(false) if (change !== 'unmount') expect(frame.dataset.dragging).toBeUndefined() @@ -544,17 +562,17 @@ describe('AppFrame frame measurement lifecycle', () => { observer.fire() }) expect(animationFrames.size).toBe(1) - expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920) act(flushFrames) - expect(instance.getSnapshot().viewportWidth).toBe(1200) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1200) expect(rightOwner().viewportWidth).toBe(1200) - expect(instance.getSnapshot().rightbar).toBeNull() + expect(instance.getSnapshot().layoutInfo.rightbar).toBeNull() }) it('retains the last positive measurement while the frame is hidden', () => { const { instance, rightOwner } = mountFrame() resize(0) - expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920) expect(rightOwner().viewportWidth).toBe(1920) }) @@ -568,7 +586,7 @@ describe('AppFrame frame measurement lifecycle', () => { expect(observer.disconnected).toBe(true) expect(animationFrames.size).toBe(0) act(() => { observer.fire(); flushFrames() }) - expect(instance.getSnapshot().viewportWidth).toBe(1920) + expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920) expect(animationFrames.size).toBe(0) }) }) diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 38732eda2c..74c58022c9 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -1,20 +1,58 @@ // @vitest-environment jsdom -import { Context } from '@deepseek-ai/cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' +import type { MainPanelId } from '../src/client/service.ts' +import type { createLayoutStore } from '../src/client/stores.ts' + +const owners = new Set() +let originalRootStyle: string | null +let originalBodyStyle: string | null +let originalDarkTheme: string | null +let originalThemeMetadata: Element[] beforeEach(() => { - document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) + originalRootStyle = document.documentElement.getAttribute('style') + originalBodyStyle = document.body.getAttribute('style') + originalDarkTheme = document.body.getAttribute('data-ds-dark-theme') + originalThemeMetadata = [...document.head.querySelectorAll('meta[name="theme-color"]')] + originalThemeMetadata.forEach((node) => { node.remove() }) + vi.stubGlobal('innerWidth', 1920) }) +afterEach(async () => { + try { + for (const owner of owners) await owner.dispose() + } finally { + owners.clear() + restoreAttribute(document.documentElement, 'style', originalRootStyle) + restoreAttribute(document.body, 'style', originalBodyStyle) + restoreAttribute(document.body, 'data-ds-dark-theme', originalDarkTheme) + document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() }) + document.head.append(...originalThemeMetadata) + vi.unstubAllGlobals() + } +}) + +function restoreAttribute(element: Element, name: string, value: string | null): void { + if (value === null) element.removeAttribute(name) + else element.setAttribute(name, value) +} + async function bench() { - const ctx = new Context() + const root = new Context() + let ctx: Context | undefined + const owner = root.plugin((ownedContext: Context) => { ctx = ownedContext }) + owners.add(owner) + await owner.await() + if (ctx === undefined) throw new Error('the fixture owner did not activate') const slotsFiber = ctx.plugin(SlotRegistry) // Theme registers its Appearance settings row and requires the connection // seam for persistence; model this bench as a remote, memory-only browser. @@ -25,7 +63,15 @@ async function bench() { ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() - return { ctx, slots: ctx.get('slots') as SlotRegistry } + const slots = ctx.get('slots') as SlotRegistry + let host: SlotRendererHost | undefined + slots.install({ renderRoot: (value) => { host = value; return null } }) + const rendererHost = (): SlotRendererHost => { + slots.renderSlot('root', {}) + if (host === undefined) throw new Error('the root renderer did not receive its host') + return host + } + return { ctx, slots, rendererHost } } describe('ui-layout client apply', () => { @@ -33,30 +79,46 @@ describe('ui-layout client apply', () => { expect(inject).toEqual(['slots', 'theme', 'locale']) }) - it('provides ctx.layout and registers AppFrame into root with the three child declarations', async () => { + it('provides ctx.layout and declares the four root-scoped frame slots', async () => { const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() expect(ctx.get('layout')).toBeInstanceOf(LayoutController) - // The one register() call occupied 'root'… expect(slots.entries('root')).toHaveLength(1) - // …and declared the three children in the ledger. expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' }) - expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session-maybe' }) + expect(slots.spec('main')).toEqual({ kind: 'keyed', scope: 'root' }) + expect(slots.spec('rightbar')).toEqual({ kind: 'single', scope: 'root' }) + expect(slots.spec('shell.overlay')).toEqual({ kind: 'list', scope: 'root' }) }) - it('injects no business face and attaches the layout actions', async () => { - const { ctx, slots } = await bench() + it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => { + const { ctx, slots, rendererHost } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - const actions = { - setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(), - } - const injected = (slots.entries('root')[0]!.inject as (actions: never) => object)(actions as never) - expect(injected).toEqual({}) + const entry = slots.entries('root')[0]! + expect(entry.inject).toBeUndefined() + const handle = entry.store as ReturnType + const instance = handle.create() + expect(handle.create()).toBe(instance) const layout = ctx.get('layout') as LayoutController + expect(() => { layout.selectPanel('missing' as MainPanelId) }).toThrow('main panel "missing" is not registered') layout.toggleSidebar() - expect(actions.toggleSidebar).toHaveBeenCalledOnce() + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) + const host = rendererHost() + expect(host.storeOf(entry, undefined)).toBe(instance) + const panelInfo = host.root.getSnapshot().hooks.panelInfo! + expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo) + const panelId = 'panel-a' as MainPanelId + const disposePanel = slots.register({ name: 'main', key: panelId }, () => null) + layout.selectPanel(panelId) + expect(panelInfo.getSnapshot()).toEqual({ activePanelId: panelId }) + disposePanel() + await vi.waitFor(() => { expect(panelInfo.getSnapshot()).toEqual({ activePanelId: null }) }) + expect(() => { layout.selectPanel(panelId) }).toThrow('main panel "panel-a" is not registered') + expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0) + const pending = layout.beginNavigation() + await fiber.dispose() + expect(pending.aborted).toBe(true) }) it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => { @@ -85,13 +147,19 @@ describe('ui-layout client apply', () => { }) it('teardown unwinds the service, the root registration, and the child declarations', async () => { - const { ctx, slots } = await bench() + const { ctx, slots, rendererHost } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() + const host = rendererHost() + expect(host.root.getSnapshot().hooks.panelInfo).toBeDefined() await fiber.dispose() expect(ctx.get('layout')).toBeUndefined() expect(slots.entries('root')).toHaveLength(0) expect(slots.spec('sidebar')).toBeUndefined() + expect(slots.spec('main')).toBeUndefined() + expect(slots.spec('rightbar')).toBeUndefined() + expect(slots.spec('shell.overlay')).toBeUndefined() + expect(host.root.getSnapshot().hooks.panelInfo).toBeUndefined() // The built-in root declaration survives entry teardown (renderer-owned). expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' }) }) diff --git a/packages/client/ui-layout/tests/document-title.client.spec.tsx b/packages/client/ui-layout/tests/document-title.client.spec.tsx index 2cec953cb1..5c3184970f 100644 --- a/packages/client/ui-layout/tests/document-title.client.spec.tsx +++ b/packages/client/ui-layout/tests/document-title.client.spec.tsx @@ -1,36 +1,82 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { DocumentTitle } from '../src/client/DocumentTitle.tsx' +import type { MainPanelId, PanelInfo } from '../src/client/service.ts' +let originalTitle: string +beforeEach(() => { originalTitle = document.title }) afterEach(() => { - cleanup() - document.title = '' - vi.unstubAllEnvs() + try { cleanup() } finally { document.title = originalTitle } }) +function titleSources() { + const sessionId = 'session-title' as SessionId + const sessions = createSnapshotStore({ + ids: [sessionId], + byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } }, + current: sessionId, + phase: 'ready', + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) + const panelInfo = createSnapshotStore({ activePanelId: null }) + return { + sessionId, sessions, panelInfo, + props: { useSessions: bindSnapshotSelector(sessions), usePanelInfo: bindSnapshotSelector(panelInfo) }, + } +} + describe('DocumentTitle', () => { it('projects a durable title and restores the product title', () => { - vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness') + const { sessionId, sessions, props } = titleSources() document.title = 'stale title' - const mounted = render() + const mounted = render() expect(document.title).toBe('DeepSeek Harness') - mounted.rerender() + act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'First title' }) }) expect(document.title).toBe('First title — DeepSeek Harness') - mounted.rerender() + act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Revised title' }) }) expect(document.title).toBe('Revised title — DeepSeek Harness') - mounted.rerender() + act(() => { sessions.update((state) => { state.current = undefined }) }) expect(document.title).toBe('DeepSeek Harness') mounted.unmount() expect(document.title).toBe('DeepSeek Harness') }) - it('uses the generic title when the build provides no title', () => { - vi.stubEnv('DSH_CLIENT_TITLE', '') - delete process.env.DSH_CLIENT_TITLE - const mounted = render() + it('uses the localized product title supplied by the frame', () => { + const { sessionId, sessions, props } = titleSources() + sessions.update((state) => { state.byId[sessionId]!.title = 'First title' }) + const mounted = render() expect(document.title).toBe('First title — DSH Local Build') mounted.unmount() expect(document.title).toBe('DSH Local Build') }) + + it('keeps the product title across global panels and restores the latest Session title on return', () => { + const { sessionId, sessions, panelInfo, props } = titleSources() + sessions.update((state) => { state.byId[sessionId]!.title = 'Session title' }) + render() + expect(document.title).toBe('Session title — Product') + act(() => { panelInfo.set({ activePanelId: 'panel-a' as MainPanelId }) }) + expect(document.title).toBe('Product') + act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Updated title' }) }) + expect(document.title).toBe('Product') + act(() => { panelInfo.set({ activePanelId: 'panel-b' as MainPanelId }) }) + expect(document.title).toBe('Product') + expect(sessions.getSnapshot().current).toBe(sessionId) + act(() => { panelInfo.set({ activePanelId: null }) }) + expect(document.title).toBe('Updated title — Product') + }) + + it('uses the product title when the current Session row is not available', () => { + const { sessions, props } = titleSources() + sessions.update((state) => { state.byId = {}; state.ids = [] }) + render() + expect(document.title).toBe('Product') + }) }) diff --git a/packages/client/ui-layout/tests/layout-store.client.spec.ts b/packages/client/ui-layout/tests/layout-store.client.spec.ts index 60dacf2f4b..edf2a5a3c9 100644 --- a/packages/client/ui-layout/tests/layout-store.client.spec.ts +++ b/packages/client/ui-layout/tests/layout-store.client.spec.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createLayoutStore } from '../src/client/stores.ts' +import type { MainPanelId } from '../src/client/service.ts' beforeEach(() => { vi.stubGlobal('innerWidth', 1920) }) afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) @@ -10,14 +11,17 @@ describe('createLayoutStore', () => { it('starts with the default sidebar and no right panel preference', () => { const { store } = createLayoutStore().create() expect(store.getSnapshot()).toEqual({ - sidebar: 280, - viewportWidth: 1920, - narrowExpanded: false, - rightbar: null, - rightbarShown: false, - rightbarTrack: false, - rightbarFullscreen: false, - rightbarInstant: false, + panelInfo: { activePanelId: null }, + layoutInfo: { + sidebar: 280, + viewportWidth: 1920, + narrowExpanded: false, + rightbar: null, + rightbarShown: false, + rightbarTrack: false, + rightbarFullscreen: false, + rightbarInstant: false, + }, }) }) @@ -27,26 +31,26 @@ describe('createLayoutStore', () => { const b = createLayoutStore().create() a.actions.setSidebar(400) a.actions.openRightbar(true, false) - expect(b.store.getSnapshot().sidebar).toBe(280) - expect(b.store.getSnapshot().rightbar).toBeNull() + expect(b.store.getSnapshot().layoutInfo.sidebar).toBe(280) + expect(b.store.getSnapshot().layoutInfo.rightbar).toBeNull() expect(write).not.toHaveBeenCalled() }) it('clamps the sidebar to 264–420px', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(1) - expect(store.getSnapshot().sidebar).toBe(264) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(264) actions.setSidebar(9999) - expect(store.getSnapshot().sidebar).toBe(420) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(420) }) it('toggles the wide sidebar between closed and default width', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(400) actions.toggleSidebar() - expect(store.getSnapshot().sidebar).toBe(0) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(0) actions.toggleSidebar() - expect(store.getSnapshot().sidebar).toBe(280) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(280) }) it('keeps the sidebar preference while toggling its narrow override', () => { @@ -54,9 +58,9 @@ describe('createLayoutStore', () => { actions.setSidebar(400) actions.setViewportWidth(980) actions.toggleSidebar() - expect(store.getSnapshot()).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true }) actions.toggleSidebar() - expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false }) }) it('clears the manual override only when crossing 1024px', () => { @@ -65,39 +69,101 @@ describe('createLayoutStore', () => { actions.toggleSidebar() actions.setViewportWidth(980) actions.setViewportWidth(1023) - expect(store.getSnapshot().narrowExpanded).toBe(true) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true) actions.setViewportWidth(1024) - expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false) actions.setViewportWidth(980) - expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false) }) }) +describe('main panel selection', () => { + const panelA = 'panel-a' as MainPanelId + const panelB = 'panel-b' as MainPanelId + + it('changes only panelInfo when switching panels and returning to the Conversation', () => { + const { store, actions } = createLayoutStore().create() + actions.setSidebar(400) + actions.openRightbar(true, true) + actions.closeRightbar() + const layoutInfo = store.getSnapshot().layoutInfo + for (const activePanelId of [panelA, panelB, null]) { + const previousPanelInfo = store.getSnapshot().panelInfo + actions.selectPanel(activePanelId) + expect(store.getSnapshot().panelInfo).toEqual({ activePanelId }) + expect(store.getSnapshot().panelInfo).not.toBe(previousPanelInfo) + expect(store.getSnapshot().layoutInfo).toBe(layoutInfo) + } + }) + + it('keeps the complete snapshot when selecting the current panel again', () => { + const { store, actions } = createLayoutStore().create() + actions.selectPanel(panelA) + const selected = store.getSnapshot() + actions.selectPanel(panelA) + expect(store.getSnapshot()).toBe(selected) + expect(store.getSnapshot().panelInfo.activePanelId).toBe(panelA) + }) + + it('returns to the Conversation only when the selected main registration disappears', () => { + const { store, actions } = createLayoutStore().create() + const initial = store.getSnapshot() + actions.retainMainPanels([]) + expect(store.getSnapshot()).toBe(initial) + actions.selectPanel(panelA) + const selected = store.getSnapshot() + actions.retainMainPanels(['conversation', panelA, panelB]) + expect(store.getSnapshot()).toBe(selected) + actions.retainMainPanels(['conversation', panelB]) + expect(store.getSnapshot().panelInfo).toEqual({ activePanelId: null }) + expect(store.getSnapshot().layoutInfo).toBe(selected.layoutInfo) + }) + + it.each(['setSidebar', 'toggleSidebar', 'setViewportWidth', 'setRightbar', 'openRightbar', 'closeRightbar'] as const)( + 'preserves panelInfo identity when %s changes layoutInfo', (action) => { + const { store, actions } = createLayoutStore().create() + actions.selectPanel(panelA) + if (action === 'closeRightbar') actions.openRightbar(true, true) + const previous = store.getSnapshot() + switch (action) { + case 'setSidebar': actions.setSidebar(400); break + case 'toggleSidebar': actions.toggleSidebar(); break + case 'setViewportWidth': actions.setViewportWidth(980); break + case 'setRightbar': actions.setRightbar(500); break + case 'openRightbar': actions.openRightbar(true, true); break + case 'closeRightbar': actions.closeRightbar(); break + } + expect(store.getSnapshot().panelInfo).toBe(previous.panelInfo) + expect(store.getSnapshot().layoutInfo).not.toBe(previous.layoutInfo) + }, + ) +}) + describe('right panel', () => { it('initializes at 45% of the latest frame only on first opening', () => { const { store, actions } = createLayoutStore().create() actions.setViewportWidth(1000) - expect(store.getSnapshot().rightbar).toBeNull() + expect(store.getSnapshot().layoutInfo.rightbar).toBeNull() actions.openRightbar(true, false) - expect(store.getSnapshot().rightbar).toBe(450) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(450) actions.setViewportWidth(2000) actions.openRightbar(true, true) - expect(store.getSnapshot().rightbar).toBe(450) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(450) actions.closeRightbar() actions.openRightbar(true, false) - expect(store.getSnapshot().rightbar).toBe(450) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(450) }) it('keeps track and fullscreen reports independent and clears both on close', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, false) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false }) actions.openRightbar(true, true) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true }) actions.openRightbar(false, true) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true }) actions.closeRightbar() - expect(store.getSnapshot()).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false }) }) it('keeps dragged px preferences across resize, close, and reopen', () => { @@ -105,29 +171,29 @@ describe('right panel', () => { actions.openRightbar(true, false) actions.setRightbar(1100) actions.setViewportWidth(800) - expect(store.getSnapshot().rightbar).toBe(1100) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100) actions.closeRightbar() actions.openRightbar(false, true) - expect(store.getSnapshot().rightbar).toBe(1100) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100) }) it('clamps drag preferences to 300px and 70% of the current frame', () => { const { store, actions } = createLayoutStore().create() actions.setViewportWidth(1600) actions.setRightbar(9999) - expect(store.getSnapshot().rightbar).toBe(1120) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(1120) actions.setViewportWidth(1000) actions.setRightbar(9999) - expect(store.getSnapshot().rightbar).toBe(700) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(700) actions.setRightbar(1) - expect(store.getSnapshot().rightbar).toBe(300) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(300) }) it('retains a minimum normal preference when first opened fullscreen on a phone', () => { const { store, actions } = createLayoutStore().create() actions.setViewportWidth(320) actions.openRightbar(false, true) - expect(store.getSnapshot().rightbar).toBe(300) + expect(store.getSnapshot().layoutInfo.rightbar).toBe(300) }) it('collapses a manually expanded narrow sidebar on opening, not presentation reports', () => { @@ -136,23 +202,23 @@ describe('right panel', () => { actions.setViewportWidth(800) actions.toggleSidebar() actions.openRightbar(true, false) - expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false }) actions.toggleSidebar() actions.openRightbar(true, true) - expect(store.getSnapshot().narrowExpanded).toBe(true) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true) actions.closeRightbar() actions.openRightbar(true, false) - expect(store.getSnapshot().narrowExpanded).toBe(false) + expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false) }) it('keeps the wide sidebar preference and never opens a closed right panel on resize', () => { const { store, actions } = createLayoutStore().create() actions.setSidebar(420) actions.openRightbar(true, false) - expect(store.getSnapshot().sidebar).toBe(420) + expect(store.getSnapshot().layoutInfo.sidebar).toBe(420) actions.closeRightbar() actions.setViewportWidth(3000) - expect(store.getSnapshot()).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false }) }) }) @@ -161,7 +227,7 @@ describe('right panel instant geometry', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(track, true) actions.closeRightbar() - expect(store.getSnapshot()).toMatchObject({ + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false, rightbarInstant: true, }) const closed = store.getSnapshot() @@ -173,38 +239,38 @@ describe('right panel instant geometry', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, true) actions.openRightbar(true, false) - expect(store.getSnapshot()).toMatchObject({ + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false, rightbarInstant: true, }) const restored = store.getSnapshot() actions.openRightbar(true, false) expect(store.getSnapshot()).toBe(restored) actions.openRightbar(false, false) - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) }) it('allows a normal close to animate, including after restoring from fullscreen', () => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, false) actions.closeRightbar() - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) actions.openRightbar(true, true) actions.openRightbar(true, false) - expect(store.getSnapshot().rightbarInstant).toBe(true) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true) actions.closeRightbar() - expect(store.getSnapshot()).toMatchObject({ rightbarTrack: false, rightbarInstant: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarTrack: false, rightbarInstant: false }) }) it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'setViewportWidth'] as const)('clears instant geometry on %s', (action) => { const { store, actions } = createLayoutStore().create() actions.openRightbar(true, true) actions.closeRightbar() - expect(store.getSnapshot().rightbarInstant).toBe(true) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true) if (action === 'toggleSidebar') actions.toggleSidebar() else actions[action](action === 'setViewportWidth' ? 1800 : 350) - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) actions.closeRightbar() - expect(store.getSnapshot().rightbarInstant).toBe(false) + expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false) }) it('does not let an unchanged frame measurement reset the fullscreen-exit marker', () => { @@ -212,7 +278,7 @@ describe('right panel instant geometry', () => { actions.openRightbar(true, true) actions.closeRightbar() const closed = store.getSnapshot() - actions.setViewportWidth(closed.viewportWidth) + actions.setViewportWidth(closed.layoutInfo.viewportWidth) expect(store.getSnapshot()).toBe(closed) }) @@ -221,6 +287,6 @@ describe('right panel instant geometry', () => { actions.openRightbar(true, true) actions.closeRightbar() actions.openRightbar(true, fullscreen) - expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false }) + expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false }) }) }) diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index a95c6a5b44..7ccadb5c3c 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { LayoutController } from '../src/client/service.ts' -import type { PanelActions } from '../src/client/service.ts' +import type { MainPanelId, PanelActions } from '../src/client/service.ts' function fakePanels(): PanelActions { return { + selectPanel: vi.fn(), + retainMainPanels: vi.fn(), setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), @@ -14,10 +16,9 @@ function fakePanels(): PanelActions { } describe('LayoutController', () => { - it('forwards the right column transitions to the attached set', () => { - const service = new LayoutController() + it('forwards right column transitions to the constructor-supplied actions', () => { const panels = fakePanels() - service.attachPanels(panels) + const service = new LayoutController(panels, () => true) service.openRightbar(true, false) service.openRightbar(true, true) @@ -32,10 +33,9 @@ describe('LayoutController', () => { expect(panels.setRightbar).not.toHaveBeenCalled() }) - it('forwards the three panel actions to the attached set', () => { - const service = new LayoutController() + it('can toggle the sidebar immediately after construction', () => { const panels = fakePanels() - service.attachPanels(panels) + const service = new LayoutController(panels, () => true) service.toggleSidebar() @@ -43,21 +43,62 @@ describe('LayoutController', () => { expect(panels.setSidebar).not.toHaveBeenCalled() }) - it('fails loud before the root entry wired its actions', () => { - const service = new LayoutController() - expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/) + it('forwards panel selection and returning to the Conversation without changing geometry', () => { + const panels = fakePanels() + const service = new LayoutController(panels, () => true) + const panelId = 'panel-a' as MainPanelId + service.selectPanel(panelId) + service.selectPanel(panelId) + service.selectPanel(null) + expect(panels.selectPanel).toHaveBeenNthCalledWith(1, panelId) + expect(panels.selectPanel).toHaveBeenNthCalledWith(2, panelId) + expect(panels.selectPanel).toHaveBeenNthCalledWith(3, null) + expect(panels.toggleSidebar).not.toHaveBeenCalled() + expect(panels.openRightbar).not.toHaveBeenCalled() + expect(panels.closeRightbar).not.toHaveBeenCalled() }) - it('re-attach overwrites the stale action set (entry re-register)', () => { - const service = new LayoutController() - const stale = fakePanels() - const fresh = fakePanels() - service.attachPanels(stale) - service.attachPanels(fresh) + it('keeps separately constructed controllers bound to their own instances', () => { + const first = fakePanels() + const second = fakePanels() + const firstService = new LayoutController(first, () => true) + const secondService = new LayoutController(second, () => true) + firstService.toggleSidebar() + expect(first.toggleSidebar).toHaveBeenCalledTimes(1) + expect(second.toggleSidebar).not.toHaveBeenCalled() + secondService.closeRightbar() + expect(first.closeRightbar).not.toHaveBeenCalled() + expect(second.closeRightbar).toHaveBeenCalledTimes(1) + }) - service.toggleSidebar() + it('rejects an absent main entry without changing selection or cancelling pending navigation', () => { + const panels = fakePanels() + const present = new Set(['panel-a']) + const service = new LayoutController(panels, id => present.has(id)) + service.selectPanel('panel-a' as MainPanelId) + const navigation = service.beginNavigation() + present.delete('panel-a') + expect(() => { service.selectPanel('panel-a' as MainPanelId) }).toThrow('main panel "panel-a" is not registered') + expect(panels.selectPanel).toHaveBeenCalledOnce() + expect(navigation.aborted).toBe(false) + service.selectPanel(null) + expect(navigation.aborted).toBe(true) + }) - expect(stale.toggleSidebar).not.toHaveBeenCalled() - expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1) + it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => { + const service = new LayoutController(fakePanels(), () => true) + const first = service.beginNavigation() + const second = service.beginNavigation() + expect(first.aborted).toBe(true) + expect(second.aborted).toBe(false) + service.selectPanel('panel-a' as MainPanelId) + expect(second.aborted).toBe(true) + const repeated = service.beginNavigation() + service.selectPanel('panel-a' as MainPanelId) + expect(repeated.aborted).toBe(true) + const pending = service.beginNavigation() + service.dispose() + expect(pending.aborted).toBe(true) + service.dispose() }) }) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 79a7f1ce54..ed6a3944d2 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 4d87ef6ca0..d3c89480e8 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -63,14 +63,12 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "clsx": "^2.1.1" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ], - "dependencies": { - "clsx": "^2.1.1" - } + ] } diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 5ef380f656..b1af331463 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -13,6 +13,7 @@ import { PermissionPresetSettingsController } from '../src/client/settings-store // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) const schema = new SettingsSchemaService(new Context()) @@ -60,7 +61,7 @@ const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInterac const runtime = { useSessions: (() => { throw new Error('unused') }) as never, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: (() => { throw new Error('unused') }) as never, } diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 4bda8cc5f7..6dd7e78078 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 18b136c78539d8ba6da3b17239112967faf63602 -README.zh.md: f91435e44a5ca79b245fde3dd025a732d35e54e9 +README.md: 3e0922690cc68a477e4b1d5ca1ed142d713369f0 +README.zh.md: de8487499ad31affc95986125c910b368ae39b95 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 18b136c785..3e0922690c 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -25,6 +25,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out ## Use this package +This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)). + Compose feature UI from these atoms whenever the web client needs a standard control or an agent-output renderer. They render through React only and take `--dsw-*` design tokens from the theme, so they fit any plugin without importing the theme or the slot system. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index f91435e44a..de8487499a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -25,6 +25,8 @@ kind: "package-library" ## 使用本包 +本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 + 只要 Web 客户端需要标准控件或 agent 输出渲染器,就用这些原子组件拼装功能 UI。它们只经 React 渲染,并从主题取得 `--dsw-*` 设计 token,因此无需导入主题或 slot 系统即可适配任意插件。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 41fbdb4ebe..2613c3aa98 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -22,32 +22,30 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { - "@shikijs/langs": "^4.3.1", - "@types/mdast": "^4.0.4", - "anser": "^2.3.5", - "clsx": "^2.0.0", - "katex": "^0.16.47", - "mdast-util-from-markdown": "^2.0.3", - "mdast-util-gfm": "^3.1.0", - "mdast-util-math": "^3.0.0", - "micromark-core-commonmark": "^2.0.3", - "micromark-extension-gfm": "^3.0.0", - "micromark-extension-math": "^3.1.0", - "micromark-factory-space": "^2.0.1", - "micromark-util-character": "^2.1.1", - "micromark-util-classify-character": "^2.0.1", - "micromark-util-sanitize-uri": "^2.0.1", - "micromark-util-symbol": "^2.0.1", - "micromark-util-types": "^2.0.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "shiki": "^4.3.1" - }, "devDependencies": { "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0", + "anser": "^2.3.5", + "katex": "^0.16.47", + "micromark-core-commonmark": "^2.0.3", + "micromark-util-character": "^2.1.1", + "micromark-util-classify-character": "^2.0.1", + "micromark-util-symbol": "^2.0.1", + "micromark-util-types": "^2.0.2", + "shiki": "^4.3.1", + "@shikijs/langs": "^4.3.1", + "@types/mdast": "^4.0.4", + "micromark-factory-space": "^2.0.1", + "micromark-extension-math": "^3.1.0", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-gfm": "^3.1.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "micromark-util-sanitize-uri": "^2.0.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 32b616d5a2..2aefdb4b1e 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -40,9 +40,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "use-sync-external-store": "1.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -55,7 +52,8 @@ "@types/react-dom": "~18.3.0", "@types/use-sync-external-store": "^1.5.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "use-sync-external-store": "1.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index a31d2dad8d..2d33e995f4 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -43,8 +43,7 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "clsx": "^2.0.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" @@ -64,7 +63,8 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index c3daeaaeb6..c9cb8eadad 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -13,6 +13,7 @@ import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) /** Store over a real mirror derived from the same scripted context. */ function derivedDocumentStore(remote: object) { @@ -32,7 +33,7 @@ const unusedHook = (() => { throw new Error('unused by settings-general componen type AttentionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention) -const kit = { useSessions: unusedHook, useSessionPendingInteraction, useResource, useWorkspaces: unusedHook } +const kit = { useSessions: unusedHook, useSessionPendingInteraction, usePanelInfo, useResource, useWorkspaces: unusedHook } describe('chrome content', () => { it('TriggerContent renders the icon with the label in the wide column', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index 3ebce030b4..bb936d5659 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -10,6 +10,7 @@ import { en } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(() => { cleanup() @@ -76,7 +77,7 @@ function mount({ const props: SettingsRootComponentProps = { useSessions, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: unusedHook, wide, reconnect, diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index b60f05babf..102eeb21d6 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: 64a295e9ebacdd8cbb2b5a0a10b14c1c1dfb24ee -README.zh.md: a75b6f2300eb59e2961b5ca85799b28b32d0856a +README.md: 8c852023f6c7c90a4aad67beef8c4a3081e5968e +README.zh.md: 6b5fd016dbc17bced816a16192c80640f56b9ab2 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 64a295e9eb..8c852023f6 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) Open the Models page from the Settings navigation to see every configured provider as a row. A whole-section provider whose key is not configured anywhere renders as its open setup card instead, but only in the first-run posture and only until the user closes that card. Each card kind owns its own open state, so closing one never discards a draft in another. +A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic. + ### API keys The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index a75b6f2300..6b5fd016db 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 +存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 + ### API 密钥 编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index b99767a364..54b0610888 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -290,7 +290,8 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS // step: whether the user already has a provider to talk to. const anyUsable = state.rows.some(providerUsable) const configured = state.rows.filter(row => row.configured) - const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '') + const configurable = state.rows.filter(row => state.namespaces.has(row.entry.settingsNs)) + const addable = configurable.filter(row => !row.configured) const addTarget = adding ? editing : undefined const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs) // The draft's directory row, for the card extension seat. A refresh can drop @@ -322,11 +323,15 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS const namespace = state.namespaces.get(target.settingsNs) /* v8 ignore next -- the join marks a row configured only when its namespace resolved */ if (namespace === undefined) return null + const error = row.entry.error === undefined + ? null + :

{row.entry.error}

if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) { // First-run posture: the provider exists but has no key — the // setup card IS its presence on the page, until the user closes it. return (
  • + {error} {renderProviderEditor({ target, namespace, @@ -416,6 +421,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS : null}
  • + {error} {renderSlot( 'settings.models.provider-card', { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) }, @@ -504,37 +510,41 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS // and equal-width so they read as siblings and line up with the // rows above, rather than two pills of different lengths.
    - - + {configurable.length > 0 && ( + + )} + {state.namespaces.has('llm-pi-ai') && ( + + )}
    )}
    diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index fd8eeaf9f9..2af1d54d5c 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -30,6 +30,7 @@ export interface ProviderDirectoryEntry { readonly settingsPath: readonly string[] readonly active: boolean readonly declared?: boolean + readonly error?: string } /** @@ -51,6 +52,7 @@ export function joinProviderDirectory( settingsPath: [...entry.settingsPath], active: active.has(entry.provider), ...entry.declared === undefined ? {} : { declared: entry.declared }, + ...entry.error === undefined ? {} : { error: entry.error }, })) for (const provider of registered) { if (declared.has(provider.id)) continue diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 6d100d800e..0d72c77713 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -307,12 +307,67 @@ async function mountDeepSeekCard(overrides: Parameters[0] = } describe('ModelsSection', () => { + it('hides both add actions when their settings namespaces are absent', async () => { + const scripted = scriptedFace() + scripted.face.settings.describe.mockResolvedValue(remoteOk({ writable: true, hasDocument: false, namespaces: [] })) + await mountFace(scripted) + expect(screen.queryByRole('button', { name: en.add })).toBeNull() + expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull() + }) + + it('offers only providers whose settings namespace can open an editor', async () => { + const scripted = scriptedFace() + scripted.face.settings.describe.mockResolvedValue(remoteOk({ + writable: true, hasDocument: false, + namespaces: wireNamespaces().filter(view => view.ns !== 'llm-pi-ai'), + })) + await mountFace(scripted) + expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.add })) + expect(screen.queryByRole('option', { name: 'anthropic' })).toBeNull() + expect(screen.getByRole('option', { name: 'plain' })).toBeTruthy() + }) + + it('shows a catalog diagnostic while keeping the provider editable', async () => { + const scripted = scriptedFace() + const failure = 'llm-pi-ai: provider "openai" model "111" needs an api' + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], error: failure }, + ])) + await mountFace(scripted) + expect(screen.getByRole('alert').textContent).toBe(failure) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + expect(await screen.findByLabelText(en.keyInput)).toBeTruthy() + expect(screen.getByRole('button', { name: en.add })).toBeTruthy() + expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() + }) + it('renders nothing before the slot injects its dependencies', () => { const uninjected = {} as ModelsSectionProps render() expect(document.body.textContent).toBe('') }) + it('shows a configuration diagnostic inside the first-run setup card', async () => { + const scripted = scriptedFace() + const failure = 'The provider configuration needs repair' + scripted.face.llm.listProviders.mockResolvedValue(remoteOk([ + { id: 'deepseek-official', name: 'DeepSeek' }, + ])) + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], error: failure }, + ])) + scripted.face.credentials.describe.mockResolvedValue(remoteOk({ + DEEPSEEK_API_KEY: { configured: false, writable: true }, + })) + await mountFace(scripted) + + const card = screen.getByRole('listitem') + expect(within(card).getByRole('alert').textContent).toBe(failure) + expect(within(card).getByLabelText(en.keyInput)).toBeTruthy() + expect(within(card).queryByRole('button', { name: deepSeekCopy(en.editProvider) })).toBeNull() + }) + it('dispatches the provider-card seat per rendered row, keyed by the owning namespace', async () => { const { renderSlot } = await mountSection() const cards = cardSeatCalls(renderSlot) diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 9f10184eaa..f4a05c9363 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -17,6 +17,7 @@ import { settingsSchema } from './settings-schema.client.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(() => { cleanup() @@ -147,7 +148,7 @@ function harness(options: { openSection, useSessions: unusedHook, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 7757cf3792..0ce5a995a1 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -4,7 +4,17 @@ import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' -import { ModelsSettingsStore } from '../src/client/store.ts' +import { joinProviderDirectory, ModelsSettingsStore } from '../src/client/store.ts' + +it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => { + expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{ + provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], + error: 'catalog unavailable', + }])).toEqual([{ + provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], + active, error: 'catalog unavailable', + }]) +}) let nextRpc = 0 function ok(value: T): RpcResponse { diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 59b2b7acd8..41abead4e8 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -10,6 +10,7 @@ import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) /** Stateless schema service for scope construction in this jsdom fixture. */ const schemaService = new SettingsSchemaService(new Context()) @@ -93,7 +94,7 @@ function mount( openSection: vi.fn(), useSessions: unusedHook, useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: unusedHook, controller, useWelcome: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 9f82713d90..c4b27fae88 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -54,14 +54,12 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ], - "dependencies": { - "clsx": "^2.0.0" - } + ] } diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index 9e1a7f0663..ce97ee1ffc 100644 --- a/packages/client/ui-sidebar-files/package.json +++ b/packages/client/ui-sidebar-files/package.json @@ -41,11 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -66,7 +61,10 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0" + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 8d11c28b28..eff8b1d7a3 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: feb75c6184098f54961ca161b4dc5ca5d0c7fc78 -README.zh.md: 03c841dfdaac970e938cf934e7f09d151268be8b +README.md: 7520a927f8e77b4fca1bbb7e61fe415dda814b4b +README.zh.md: 648a2c20d8e55a48a684a92a351b7900f3a8d517 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index feb75c6184..7520a927f8 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -54,6 +54,8 @@ While the panel is hidden, one button in the conversation header's corner seat ( The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it. +The `rightbar` entry is a root-scoped controller. It reads `usePanelInfo` and mounts the Session-scoped `rightbar.session` subtree only while the Conversation is selected. Switching to a global panel hides the right Sidebar and releases its frame track without deleting the Session's tab state. + ## State diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 03c841dfda..648a2c20d8 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -54,6 +54,8 @@ kind: "package-reference" 面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。 +`rightbar` 入口是 root 作用域的控制器。它读取 `usePanelInfo`,仅在选中会话界面时挂载 session 作用域的 `rightbar.session` 子树。切换到全局面板会隐藏右侧 Sidebar 并释放框架列宽,但不删除会话的 tab 状态。 + ## 状态 diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index 699bebc01f..c8fad0215c 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -42,12 +42,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "picomatch": "^4.0.4", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -69,7 +63,11 @@ "@testing-library/react": "^16.1.0", "@types/picomatch": "^4.0.2", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0" + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "picomatch": "^4.0.4", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index 49709181c5..f964ee97c6 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -21,6 +21,7 @@ * therefore live with their declarer. */ import type {} from '@deepseek-ai/dsh-client-ui-slots' +import type { RightbarOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client' // The locale plugin's own merge carries the shared `common` vocabulary that the // lookup chain consults after this namespace misses. import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -37,6 +38,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } interface SlotMap { + /** Session content selected by the root-scoped right Sidebar controller. */ + 'rightbar.session': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps } /** * One tab's body, dispatched with the `id` of the type in force for * `tab.kind`. A tab type registers here under its definition's `id` and diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 0971b9d21c..0fc4a4d2e2 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -32,6 +32,7 @@ import type {} from './contract/slots.ts' import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' import { ExpandButton } from './shell/ExpandButton.tsx' import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' +import { RightbarRoot } from './shell/RightbarRoot.tsx' import { createSidebarRightController, type SidebarRightController } from './service.ts' import { SidebarRightTabRegistry } from './tab-registry.ts' import { createSidebarRightStore } from './stores.ts' @@ -143,21 +144,27 @@ export function apply(ctx: ClientContext): void { } const disposeTypes = [tabs.register(guideDefinition(t))] - const disposeSeat = ctx.slots.inject('rightbar', () => ctx.slots.register({ - name: 'rightbar', - locale: NS, - children: { - 'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, - 'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, - 'sidebar.right.tab.menu.item': { kind: 'list', scope: 'session' }, - }, - store, - inject: (sessionId): SidebarRightInjected => ({ - ...injected, - keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, - occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), - }), - }, RightbarSeat)) + const disposeSeat = ctx.slots.inject('rightbar', function* () { + yield ctx.slots.register({ + name: 'rightbar', + children: { 'rightbar.session': { kind: 'single', scope: 'session' } }, + }, RightbarRoot) + yield ctx.slots.register({ + name: 'rightbar.session', + locale: NS, + children: { + 'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, + 'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } }, + 'sidebar.right.tab.menu.item': { kind: 'list', scope: 'session' }, + }, + store, + inject: (sessionId): SidebarRightInjected => ({ + ...injected, + keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, + occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), + }), + }, RightbarSeat) + }) // The expand button shares the panel's store: it only needs to know whether // the panel is expanded, and to ask for it to be. The header's corner seat // is its own place, past the utilities, so showing and hiding it moves diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 25e702b612..ca5ad487a3 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -24,8 +24,8 @@ * hand the store one settled intent and record the navigation in the Tab * domain. Placement is the caller's option, never a type's property. * - * Wiring follows `LayoutController.attachPanels`: the registration hands the - * service its store actions, and the service is the face other plugins hold. + * The registration adopts Session stores and injects the mounted seat binding; + * callers use the service's navigation methods. */ import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' import { activeDockPaneId, canSplit, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' diff --git a/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx b/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx new file mode 100644 index 0000000000..46420e5970 --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx @@ -0,0 +1,20 @@ +/** Root-scoped controller for the right Sidebar's Session content. */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '../contract/slots.ts' + +/** + * Render the Session-bound Sidebar only while the Conversation is selected. + * @param props - frame geometry, panel selection, and the authorized Session renderer. + * @returns the current Session's right Sidebar, or no content for a global panel. + */ +export function RightbarRoot({ + usePanelInfo, SessionProvider, renderSlot, width, viewportWidth, canShow, +}: PropsRuntime<'rightbar'> & PropsRenderSlots<'rightbar.session'>) { + const visible = usePanelInfo(info => info.activePanelId === null) + if (!visible) return null + return ( + + {renderSlot('rightbar.session', { width, viewportWidth, canShow })} + + ) +} diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index ec0c9333e4..008ba4f387 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -33,8 +33,7 @@ import { createPortal } from 'react-dom' import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' -// The frame declares the `rightbar` seat this component fills. -import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '../contract/slots.ts' import type { DockIntents, DockMode, FloatRect, TabId, TabRecord, TabRenderer } from '@deepseek-ai/dsh-client-ui-dockkit' import { canSplit, dockPaneIds, DockSurface, findPaneContentTab, FloatLayer } from '@deepseek-ai/dsh-client-ui-dockkit' import type { HalvesFit, LayoutState, PaneId } from '@deepseek-ai/dsh-client-ui-dockkit' @@ -108,7 +107,7 @@ export interface SidebarRightInjected { /** The column seat's props: session scope, so the session arrives as a standard prop. */ export type RightbarSeatProps = - & PropsRuntime<'rightbar'> + & PropsRuntime<'rightbar.session'> & Children & Store & PropsLocale<'sidebarRight'> diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index c54f98f2af..3f9a30d29b 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -10,6 +10,7 @@ */ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { apply, inject } from '../src/client/index.ts' import type { GuideInjected, SidebarRightInjected } from '../src/client/index.ts' @@ -18,6 +19,7 @@ import { SidebarRightController } from '../src/client/service.ts' import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts' import type { createSidebarRightStore } from '../src/client/stores.ts' import { RightbarSeat } from '../src/client/shell/SidebarRight.tsx' +import { RightbarRoot } from '../src/client/shell/RightbarRoot.tsx' import { ExpandButton } from '../src/client/shell/ExpandButton.tsx' import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx' import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts' @@ -28,7 +30,7 @@ const SESSION = 's-test' as SessionId interface Recorded { name: string key?: string - locale: string + locale?: string store?: unknown children?: unknown inject?: (sessionId: SessionId) => unknown @@ -39,7 +41,7 @@ async function boot() { const ctx = new Context() const registered: Recorded[] = [] const slots = { - inject: vi.fn((_name: string, register: () => () => void) => register()), + inject: vi.fn((_name: string, register: Parameters[1]) => ctx.effect(register)), register: vi.fn((options: Omit, component: unknown) => { const entry: Recorded = { ...options, component } registered.push(entry) @@ -90,26 +92,25 @@ describe('ui-sidebar-right apply', () => { expect(guide?.id).toBe(GUIDE_ID) expect(guide?.priority).toBe('builtin') expect(guide?.title('sidebar://guide')).toBe('tab.guide.title') - // Three registrations: the panel seat, the header's corner seat, and the - // guide body under the guide implementation's id. expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ - ['rightbar', undefined, 'sidebarRight', RightbarSeat], + ['rightbar', undefined, undefined, RightbarRoot], + ['rightbar.session', undefined, 'sidebarRight', RightbarSeat], ['conversation.session.header.corner', undefined, 'sidebarRight', ExpandButton], ['sidebar.right.pane.tab', GUIDE_ID, 'sidebarRight', GuideBody], ]) // The panel declares the extension seats; the guide declares its chain child. - expect(Object.keys(seat('rightbar').children as object)).toEqual([ + expect(Object.keys(seat('rightbar.session').children as object)).toEqual([ 'sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'sidebar.right.tab.menu.item', ]) expect(seat('sidebar.right.pane.tab').children).toMatchObject({ 'sidebar.right.tab.guide': { kind: 'chain', scope: 'session' } }) // Both seats read one store: the button only needs to know whether the panel is expanded. - expect(seat('rightbar').store).toBeDefined() - expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar').store) + expect(seat('rightbar.session').store).toBeDefined() + expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar.session').store) }) it('hands the panel seat the frame report, the service binding, the opens, the observable registry, and the Tab domain', async () => { const { ctx, layout, resources, seat, injectedOf } = await boot() - const injected = injectedOf(seat('rightbar')) as SidebarRightInjected + const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected // The frame learns the composition of expanded and presentation, nothing else. injected.syncPresentation({ shown: true, track: true, fullscreen: false }) expect(layout.openRightbar).toHaveBeenLastCalledWith(true, false) @@ -128,7 +129,7 @@ describe('ui-sidebar-right apply', () => { unsubscribe() // The binding makes the service act on this seat's session; the seat's // store instance is minted here from the handle the registration declared. - const handle = seat('rightbar').store as ReturnType + const handle = seat('rightbar.session').store as ReturnType const instance = handle.create() const release = injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true }) injected.openTab('guide', { revealIfOpened: false }) @@ -145,7 +146,7 @@ describe('ui-sidebar-right apply', () => { it('adopts each session\'s store instance as the runtime mints it, so a tab\'s own actions land with no seat bound', async () => { const { ctx, resources, seat } = await boot() - const handle = seat('rightbar').store as ReturnType + const handle = seat('rightbar.session').store as ReturnType // Both seats declare the same wrapped handle, so either minting adopts. expect(seat('conversation.session.header.corner').store).toBe(handle) const instance = handle.create(SESSION) @@ -178,8 +179,8 @@ describe('ui-sidebar-right apply', () => { it('takes every registration and both faces back when disposed, aborting the open records, so a reload registers again', async () => { const { ctx, registered, dictionaries, fiber, seat, injectedOf } = await boot() - const injected = injectedOf(seat('rightbar')) as SidebarRightInjected - const handle = seat('rightbar').store as ReturnType + const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected + const handle = seat('rightbar.session').store as ReturnType // Minted under the session key, so the instance is adopted and the teardown releases it. const instance = handle.create(SESSION) injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true }) @@ -199,6 +200,6 @@ describe('ui-sidebar-right apply', () => { expect(dictionaries.size).toBe(0) await ctx.plugin({ inject: [...inject], apply }).await() expect(ctx.sidebarRightTabs.get('guide')?.id).toBe(GUIDE_ID) - expect(registered).toHaveLength(3) + expect(registered).toHaveLength(4) }) }) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index adf00ef762..19d28629e8 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -6,6 +6,7 @@ import { useState } from 'react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type { PaneId, SplitId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { dockPaneIds, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -67,7 +68,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true) { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.declare({ - 'rightbar': { kind: 'single', scope: 'session' }, + 'rightbar': { kind: 'single', scope: 'root' }, 'conversation.session.header.corner': { kind: 'single', scope: 'session' }, }) await runtime.sessions.add({ id: SESSION }) @@ -98,7 +99,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true) { runtime.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'test/text' }, Title) }) const view = runtime.renderSlot('rightbar', { width: 420, viewportWidth, canShow }) - const instance = runtime.storeOf('rightbar', SESSION) as ReturnType['create']> + const instance = runtime.storeOf('rightbar.session', SESSION) as ReturnType['create']> const controller = runtime.ctx.sidebarRight const layout = () => instance.getSnapshot().bySession[SESSION]!.layout const open = (name = 'a.txt', options?: Parameters[1]) => { @@ -115,6 +116,19 @@ function element(container: HTMLElement, selector: string): HTMLElement { } describe('RightbarSeat presentation', () => { + it('hides for a global main panel and retains the Session sidebar state', async () => { + const h = await mountSeat() + h.open('retained.txt') + const retained = h.layout() + act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) }) + expect(h.view.container.querySelector('[data-sidebar-right-panel]')).toBeNull() + expect(h.frame.closeRightbar).toHaveBeenCalled() + expect(h.layout()).toBe(retained) + act(() => { h.runtime.panelInfo.set({ activePanelId: null }) }) + expect(h.view.container.querySelector('[data-sidebar-right-panel]')).not.toBeNull() + expect(h.layout()).toBe(retained) + }) + it('keeps the panel mounted while collapsed and releases the frame on unmount', async () => { const h = await mountSeat() const panel = element(h.view.container, '[data-sidebar-right-panel]') diff --git a/packages/client/ui-sidebar-textpreview/package.json b/packages/client/ui-sidebar-textpreview/package.json index 756b6a3dab..7801a14e1d 100644 --- a/packages/client/ui-sidebar-textpreview/package.json +++ b/packages/client/ui-sidebar-textpreview/package.json @@ -41,11 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -67,7 +62,10 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0" + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index d4d0d29e1d..061d06f859 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 87b04e965fbe26591e38d49a5c272113edfd28aa -README.zh.md: 5a7f050d7099437e5c8be965e89be405492d5353 +README.md: 1b520213809ca98a6819483a4ee5ea4f451841d2 +README.zh.md: 8caae9f14eb4b7a70e674b9cef7245aaf077b378 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 87b04e965f..1b52021380 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -31,6 +31,10 @@ The sidebar is the navigation shell: users see the brand, start new sessions, co The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots; the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks a code badge below the label as `version[-commit][-dirty]`, using `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. New Session targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. +### Global panel entries + +Plugins add an icon component to the root-scoped `sidebar.panellist` list with an `id`, optional `order`, and a string or locale-aware `label`. The same id addresses the component registered in the layout's root-scoped `main` keyed slot; selecting a missing main entry throws without changing the current selection. The label supplies plain visible text, the accessible name, and the collapsed tooltip. Each row reads its own selected state through `usePanelInfo`; moving DOM focus to search or a directory picker does not change the displayed panel or its selected row. With no registrations, neither the list nor spacing for it is rendered. The shipped composition registers no example panel. + ### Collapse behavior During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. @@ -47,7 +51,7 @@ Scrollbars in the column are a pointer affordance: the shell rebinds the scrollb
    Implementation internals — click to expand -The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store. +The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. Panel entries and their optional titles use the same composition path. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. ### Slot discipline @@ -100,4 +104,4 @@ None.
    -**Runtime invariant:** No companion is published. A pure-consumer plugin deriving its rows in-component from the standard useSessions delivery — it emits no cordis events and owns no cross-plugin mutable state; derivation and interaction behavior are asserted directly by this package's tree/component specs. +**Runtime invariant:** No companion is published. Panel metadata is a read-only presentation projection of the Slot registry and locale, with no independent write API. The registry owns entry identity and disposal; this package's assembly tests assert the projection after registration and locale notifications settle. The shell owns no separate navigation state to reconcile with those sources. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 5a7f050d70..8caae9f14e 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -31,6 +31,10 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot;收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标使用 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。New Session 优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。 +### 全局面板入口 + +插件在 root 作用域的 `sidebar.panellist` list 中注册图标组件,提供 `id`、可选 `order`,以及字符串或随语言变化的 `label`。同一个 id 寻址布局中 root 作用域 `main` keyed slot 的组件;选择不存在的主面板条目会抛错,并保留当前选中态。标签提供普通可见文字、无障碍名称和折叠提示。每一行通过 `usePanelInfo` 读取自己的选中态;DOM 焦点移到搜索框或目录选择器时,显示的面板及其列表项选中态不变。没有注册项时,列表及其间距均不渲染。默认组合不注册示例面板。 + ### 折叠行为 实时收起时,展开内容在当前宽度淡出,上方控件共用一次淡入并左移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件只共用淡入时序,不发生横向位移。 @@ -47,7 +51,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
    实现细节——点击展开 -外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。 +外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。面板入口及其可选标题使用相同的组合方式。面板元数据由列表注册和语言变化派生;选中态属于布局存储。 ### Slot 纪律 @@ -100,4 +104,4 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
    -**运行时不变式:** 不发布伴生入口。这是从标准 `useSessions` delivery 直接派生行的纯消费插件,不发出 Cordis 事件,也不持有跨插件可变状态。 +**运行时不变式:** 不发布伴生入口。面板元数据是 Slot 注册表与 locale 的只读呈现投影,没有独立写入 API。注册表拥有条目身份和释放;本包的装配测试在注册和语言通知完成后断言投影。外壳没有需要与这些来源协调的独立导航状态。 diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index a6f1b9d4a3..3fb78453b2 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -43,15 +43,13 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -61,7 +59,8 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 4ea10606e1..f899b50c53 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -58,13 +58,14 @@ from { opacity: 0; } } -/* At the 150ms rail settle, the four upper controls enter from the former +/* At the 150ms rail settle, the upper controls enter from the former rail right edge over the remaining 150ms of the AppFrame track transition. The bottom-pinned settings seat shares their opacity timeline but stays horizontally fixed. Only a live collapse gets .railIn; a cold collapsed render stays static. */ .railIn .iconButton, .railIn .newSession, +.railIn .panelList, .railIn .regionArea { animation: rail-in 150ms var(--ds-ease-in-out) backwards; } @@ -296,6 +297,74 @@ max-width: 0; } +.panelList { + flex: none; + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 8px; +} + +.panelRow { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 36px; + padding: 7px 8px; + box-sizing: border-box; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + line-height: 22px; + text-align: left; + cursor: pointer; +} + +.panelRow:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.panelRow.panelActive { + background: var(--dsw-alias-interactive-bg-active); + color: var(--dsw-alias-label-primary); + font-weight: 500; +} + +.panelRow:focus-visible { + outline: 2px solid var(--dsw-alias-label-primary); + outline-offset: -2px; +} + +.panelGlyph { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.panelTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.collapsed .panelList { + gap: 12px; + margin-bottom: 12px; +} + +.collapsed .panelRow { + width: 36px; + height: 36px; + justify-content: center; + padding: 0; + color: var(--dsw-alias-label-primary); +} + /* Region seat: always mounted so the foot never moves. Its trailing margin cancels the wide shell inset so the nested scrollbar can sit at the sidebar edge; the browser restores that inset inside its own rows. */ @@ -351,6 +420,7 @@ .fading > *, .railIn .iconButton, .railIn .newSession, + .railIn .panelList, .railIn .footArea, .railIn .regionArea { transition: none; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 9dde97bf9e..35fdee173b 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -1,12 +1,13 @@ /** - * Sidebar shell: column geometry only. Collapse is a slide plus crossfade: + * Sidebar shell: column geometry and global panel navigation. + * Collapse is a slide plus crossfade: * content freezes at its expanded width (inline style) and fades out in place * while the sliding column (AppFrame grid tracks) clips it — nothing reflows - * mid-slide. At settle the wide-only content unmounts and the four upper + * mid-slide. At settle the wide-only content unmounts and the upper * controls enter the 56px rail from the same horizontal offset (one icon each, * same top-down order) on one fade that ends with the slide. The bottom-pinned * settings control only fades. The workspace/session browsing region between - * the New Session button and the foot is the `sidebar.workspaces` registrant's, + * global panel rows and the foot is the `sidebar.workspaces` registrant's, * and the foot holds `sidebar.settings` plus `sidebar.footer.action`; the shell * hands them the wide flag (plus an expand request callback for the browser). * @@ -20,7 +21,10 @@ import clsx from 'clsx' import { FishLogo, IconNewChatOutline16, IconPanelLeftOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { SidebarRootComponentProps } from './contract/slots.ts' +import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { + SidebarPanelMetadata, SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, +} from './contract/slots.ts' import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ @@ -44,6 +48,38 @@ function localBuildVersion(): string | undefined { + (process.env.DSH_CLIENT_GIT_DIRTY === 'true' ? '-dirty' : '') } +type PanelRowProps = + Pick + & Pick + & Pick, 'usePanelInfo'> + & Pick, 'selectPanel'> + & PropsRenderSlots<'sidebar.panellist'> + +/** Each panel row subscribes only to its own selection state. */ +function PanelRow({ id, label, wide, usePanelInfo, selectPanel, renderSlot }: PanelRowProps) { + const active = usePanelInfo(info => info.activePanelId === id) + return ( + + + + ) +} + /** * Render the sidebar column shell. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). @@ -54,9 +90,13 @@ export function SidebarRoot({ width, startSession, toggleSidebar, + selectPanel, + usePanels, + usePanelInfo, t, renderSlot, }: SidebarRootComponentProps) { + const panels = usePanels(snapshot => snapshot) // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. const [settled, setSettled] = useState(collapsed) @@ -199,6 +239,22 @@ export function SidebarRoot({ + {panels.length > 0 && ( + + )} + {/* The browsing region fills the column between the controls and the foot in both states; its rail icon column rides the same slot. */}
    diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 4b4e2e1538..df2933430e 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -1,17 +1,16 @@ /** * Sidebar slot contract: the registrant-side props composition for the * layout-owned `sidebar` slot, plus the holes this shell declares. The shell - * owns column geometry (fold state machine, brand row, New Session); - * everything between the section header and the list bottom is the + * owns column geometry, the brand row, New Session, and global panel rows; + * everything between the workspace section header and the list bottom is the * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the * `sidebar.settings` registrant's (ui-settings), followed by optional footer * actions in `sidebar.footer.action`. */ -import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' -// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every -// program that sees this contract, so PropsRuntime<'sidebar'> resolves. -import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { @@ -26,6 +25,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * package's `sidebar` entry; the shell supplies a generic text fallback. */ 'sidebar.brand.name': { kind: 'single'; scope: 'root'; owner: SidebarBrandNameOwnerProps } + /** + * Global panel icons. Each list id addresses the matching main panel; + * the sidebar owns the button and resolves its label from list metadata. + */ + 'sidebar.panellist': { kind: 'list'; scope: 'root'; owner: SidebarPanelIconOwnerProps } /** * The workspace/session browsing region: section header, search, the * grouped/flat session list, and every workspace dialog. Declared by this @@ -59,6 +63,24 @@ export interface SidebarBrandNameOwnerProps { children?: never } +/** Icon presentation supplied by the global panel row. */ +export interface SidebarPanelIconOwnerProps { + /** Requested square edge in pixels. */ + size: number + /** Whether this panel is selected in the main column. */ + active: boolean +} + +/** Serializable metadata for one active global panel list registration. */ +export interface SidebarPanelMetadata { + /** List id and matching main panel key. */ + id: MainPanelId + /** Ascending row order; ties retain registration order. */ + order: number + /** Row title and accessible name: resolved label, or the id when omitted. */ + label: string +} + /** * Owner share of the browser hole — the only facts crossing the shell/region * boundary. Business data and actions arrive through the region's own inject. @@ -87,8 +109,7 @@ export interface SidebarFooterActionOwnerProps { /** * Registrant-private injected share (arrives via the register inject - * factory). The shell keeps only its own controls: starting a Session from - * the New Session button and toggling the column. + * factory). The renderer binds the panel metadata source to usePanels. */ export type SidebarRootInjected = { /** @@ -99,20 +120,25 @@ export type SidebarRootInjected = { startSession: (workspaceId?: WorkspaceId) => void /** Toggle the sidebar column through the layout service. */ toggleSidebar: () => void + /** Select the global panel addressed by a sidebar row. */ + selectPanel: (id: MainPanelId) => void + /** Private reactive sources bound to framework selector hooks. */ + hooks: { panels: ObservableSnapshot } } /** * Full component props: layout owner state/actions plus the declared holes' * render shares, this package's injected callbacks, and the standard locale - * seat. No store is registered. + * seat. Panel metadata arrives through an injected observable. */ export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & PropsRenderSlots< | 'sidebar.brand.mark' | 'sidebar.brand.name' + | 'sidebar.panellist' | 'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action' > - & SidebarRootInjected & PropsLocale<'sidebar'> + & InjectFace & PropsLocale<'sidebar'> diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 2103f133e2..38c384f697 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,29 +1,33 @@ -/** Registers the sidebar shell into the layout-owned slot. */ +/** Registers the sidebar shell and global panel navigation. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // Type-only: pulls the Session root standard-props merge. import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import type { SidebarRootInjected } from './contract/slots.ts' +import type { SidebarPanelMetadata, SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' import { en, zh, type SidebarKey } from './locales.ts' export type { SidebarBrandMarkOwnerProps, SidebarBrandNameOwnerProps, SidebarFooterActionOwnerProps, + SidebarPanelIconOwnerProps, SidebarPanelMetadata, SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps, } from './contract/slots.ts' export type { SidebarKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Sidebar shell controls copy. */ + /** Sidebar controls and global panel copy. */ sidebar: SidebarKey } } -/** Dictionary namespace owned by this plugin (shell controls copy). */ +/** Dictionary namespace owned by this plugin. */ const NS = 'sidebar' interface WorkspaceNavigation { @@ -39,29 +43,43 @@ export const inject = ['slots', 'layout', 'uiWorkspace', 'locale'] export function apply(ctx: ClientContext): void { const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries') + const panels = createSnapshotStore([]) + const syncPanels = (): void => { + const next = ctx.slots.entriesOfSlot('sidebar.panellist').map(({ options }) => { + // The list registration requires an id; StoredEntry erases the slot kind. + const id = options.id as MainPanelId + return { id, order: options.order ?? 0, label: resolveSlotLabel(options.label) ?? id } + }).sort((a, b) => a.order - b.order) + const previous = panels.getSnapshot() + if (previous.length === next.length && previous.every((panel, index) => { + const candidate = next[index] as SidebarPanelMetadata + return panel.id === candidate.id && panel.order === candidate.order && panel.label === candidate.label + })) return + panels.set(next) + } + ctx.effect(() => ctx.slots.subscribe('sidebar.panellist', syncPanels), 'ui-sidebar: panel entries') + ctx.effect(() => ctx.locale.subscribe(syncPanels), 'ui-sidebar: panel labels') const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the Workspace UI's shared action // (current Session Workspace, then recent Workspace). startSession: (workspaceId) => { workspaceNavigation.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, + selectPanel: (id) => { ctx.layout.selectPanel(id) }, + hooks: { panels }, }) - ctx.effect( - () => ctx.slots.register({ - name: 'sidebar', - locale: NS, - // The shell owns geometry; ui-workspace registers the whole browsing - // region (header, search, session list, workspace dialogs), ui-settings - // registers the foot trigger + settings panel. - children: { - 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, - 'sidebar.brand.name': { kind: 'single', scope: 'root' }, - 'sidebar.workspaces': { kind: 'single', scope: 'root' }, - 'sidebar.settings': { kind: 'single', scope: 'root' }, - 'sidebar.footer.action': { kind: 'list', scope: 'root' }, - }, - inject: injectProps, - }, SidebarRoot), - 'ui-sidebar: slot registration', - ) + ctx.slots.inject('sidebar', () => ctx.slots.register({ + name: 'sidebar', + locale: NS, + children: { + 'sidebar.brand.mark': { kind: 'single', scope: 'root' }, + 'sidebar.brand.name': { kind: 'single', scope: 'root' }, + 'sidebar.panellist': { kind: 'list', scope: 'root' }, + 'sidebar.workspaces': { kind: 'single', scope: 'root' }, + 'sidebar.settings': { kind: 'single', scope: 'root' }, + 'sidebar.footer.action': { kind: 'list', scope: 'root' }, + }, + inject: injectProps, + }, SidebarRoot)) + syncPanels() } diff --git a/packages/client/ui-sidebar/src/client/locales.ts b/packages/client/ui-sidebar/src/client/locales.ts index 8cf5ac6d7b..bdd1486333 100644 --- a/packages/client/ui-sidebar/src/client/locales.ts +++ b/packages/client/ui-sidebar/src/client/locales.ts @@ -1,4 +1,4 @@ -/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */ +/** `sidebar` namespace dictionaries for shell controls and global panels. */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { @@ -6,6 +6,7 @@ export const zh = { 'session.new.label': '新建会话', 'toggle.open': '打开侧边栏', 'toggle.collapse': '收起侧边栏', + 'panels.label': '全局面板', } satisfies Record /** The sidebar namespace key union. */ @@ -17,4 +18,5 @@ export const en = { 'session.new.label': 'New session', 'toggle.open': 'Open sidebar', 'toggle.collapse': 'Collapse sidebar', + 'panels.label': 'Global panels', } satisfies Record diff --git a/packages/client/ui-sidebar/tests/apply.client.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx index e638e1fb9d..701de45e74 100644 --- a/packages/client/ui-sidebar/tests/apply.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.client.spec.tsx @@ -1,16 +1,36 @@ /** Sidebar shell slot registration and its Session/layout callbacks. */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { Context, type Fiber } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as hostApply } from '../src/index.ts' +const owners = new Set() +afterEach(async () => { + try { + for (const owner of owners) await owner.dispose() + } finally { + owners.clear() + } +}) + +function SidebarFrame({ renderSlot }: PropsRenderSlots<'sidebar'>) { + return renderSlot('sidebar', { collapsed: false, width: 300 }) +} + async function bench(declare = true) { - const ctx = new Context() + const root = new Context() + let ctx: Context | undefined + const owner = root.plugin((owned: Context) => { ctx = owned }) + owners.add(owner) + await owner.await() + if (ctx === undefined) throw new Error('the sidebar fixture owner did not activate') await ctx.plugin(SlotRegistry).await() - const layout = { toggleSidebar: vi.fn() } + const layout = { toggleSidebar: vi.fn(), selectPanel: vi.fn() } const uiWorkspace = { startSession: vi.fn() } ctx.provide('layout', layout) ctx.provide('uiWorkspace', uiWorkspace as never) @@ -18,8 +38,11 @@ async function bench(declare = true) { const slots = ctx.get('slots') as SlotRegistry if (declare) { slots.register( - { name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never, - () => null, + { name: 'root', children: { + 'sidebar': { kind: 'single', scope: 'root' }, + 'main': { kind: 'keyed', scope: 'root' }, + } }, + SidebarFrame, ) } return { ctx, slots, layout, uiWorkspace } @@ -43,10 +66,13 @@ describe('ui-sidebar apply', () => { expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' }) + expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' }) // Copy rides the standard locale seat, not the inject face. expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar') const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() - expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) + expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar', 'selectPanel', 'hooks']) + expect(injected.hooks.panels.getSnapshot()).toEqual([]) + expect(b.slots.entries('main')).toEqual([]) // Both arms delegate to the Workspace UI's shared New Session action. injected.startSession('workspace' as never) expect(b.uiWorkspace.startSession).toHaveBeenCalledWith('workspace') @@ -54,11 +80,55 @@ describe('ui-sidebar apply', () => { expect(b.uiWorkspace.startSession).toHaveBeenLastCalledWith(undefined) injected.toggleSidebar() expect(b.layout.toggleSidebar).toHaveBeenCalledOnce() + const panelId = 'custom-panel' as MainPanelId + injected.selectPanel(panelId) + expect(b.layout.selectPanel).toHaveBeenCalledExactlyOnceWith(panelId) + expectTypeOf[0]>().toEqualTypeOf() }) - it('fails when no live owner declared the sidebar slot', async () => { + it('waits for the sidebar declaration before registering', async () => { const b = await bench(false) - await expect(b.ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/not declared/) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('sidebar')).toHaveLength(0) + const disposeRoot = b.slots.register({ + name: 'root', + children: { sidebar: { kind: 'single', scope: 'root' } }, + }, SidebarFrame) + expect(b.slots.entries('sidebar')).toHaveLength(1) + disposeRoot() + await fiber.dispose() + }) + + it('projects plugin-owned panel entries and removes them with their owner', async () => { + const b = await bench() + const sidebar = b.ctx.plugin({ inject: [...inject], apply }) + await sidebar.await() + const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() + const panel = b.ctx.plugin({ + inject: ['slots'], + apply(ctx: Context) { + ctx.slots.register({ name: 'main', key: 'custom-panel' }, () => 'Custom panel') + ctx.slots.register({ + name: 'sidebar.panellist', id: 'custom-panel', order: 20, label: 'Custom panel', + }, () => null) + }, + }) + try { + await panel.await() + await vi.waitFor(() => { + expect(injected.hooks.panels.getSnapshot()).toEqual([ + { id: 'custom-panel', order: 20, label: 'Custom panel' }, + ]) + }) + expect(b.slots.entries('main').map(entry => entry.options.key)).toEqual(['custom-panel']) + await panel.dispose() + await vi.waitFor(() => { expect(injected.hooks.panels.getSnapshot()).toEqual([]) }) + expect(b.slots.entries('main')).toEqual([]) + } finally { + await panel.dispose() + await sidebar.dispose() + } }) it('removes the entry and child declaration on teardown', async () => { @@ -71,5 +141,7 @@ describe('ui-sidebar apply', () => { expect(b.slots.spec('sidebar.brand.name')).toBeUndefined() expect(b.slots.spec('sidebar.workspaces')).toBeUndefined() expect(b.slots.spec('sidebar.footer.action')).toBeUndefined() + expect(b.slots.spec('sidebar.panellist')).toBeUndefined() + expect(b.slots.entries('main')).toHaveLength(0) }) }) diff --git a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx new file mode 100644 index 0000000000..41a01d53ef --- /dev/null +++ b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +/** Global panel rows and DOM focus through the production slot renderer. */ +import type { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, waitFor, within } from '@testing-library/react' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ILayout, MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' +import type { PropsRenderSlots, PropsRuntime, SlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { apply, inject } from '../src/client/index.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + 'sidebar-panel-test': 'alpha' + } +} + +const ALPHA = 'sidebar-test-alpha' as MainPanelId +const BETA = 'sidebar-test-beta' as MainPanelId +const runtimes = new Set() + +afterEach(async () => { + try { + for (const runtime of runtimes) await runtime.dispose() + } finally { + runtimes.clear() + cleanup() + } +}) + +async function bench(collapsed = false) { + const runtime = await SlotTestRuntime.create() + runtimes.add(runtime) + const locale = new LocaleRuntime(runtime.ctx) + locale.setLocale('en') + const layout = { + beginNavigation: vi.fn(() => new AbortController().signal), + toggleSidebar: vi.fn(), + selectPanel: vi.fn((activePanelId: MainPanelId | null) => { runtime.panelInfo.set({ activePanelId }) }), + openRightbar: vi.fn(), + closeRightbar: vi.fn(), + } satisfies ILayout + await runtime.mount({ + inject: ['slots'], + apply(ctx: Context) { + ctx.provide('layout', layout) + ctx.provide('uiWorkspace', { startSession: vi.fn() } as never) + ctx.provide('locale', locale) + ctx.effect(() => locale.register('common', { zh: commonZh, en: commonEn }), 'panel test: common locale') + ctx.effect(() => locale.register('sidebar-panel-test', { + zh: { alpha: '甲面板' }, en: { alpha: 'Alpha panel' }, + }), 'panel test: panel locale') + ctx.slots.installLocale(locale) + ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: 'conversation' }, () => ( +

    Conversation content

    + ))) + }, + }) + function Frame({ usePanelInfo, renderSlot }: PropsRuntime<'root'> & PropsRenderSlots<'sidebar' | 'main'>) { + const activePanelId = usePanelInfo(info => info.activePanelId) + return ( + <> + +
    {renderSlot('main', {}, { entryKey: activePanelId ?? 'conversation' })}
    + + ) + } + await runtime.root.declare({ + sidebar: { kind: 'single', scope: 'root' }, + main: { kind: 'keyed', scope: 'root' }, + }, Frame) + const sidebar = await runtime.mount({ inject: [...inject], apply }) + const view = runtime.renderRoot() + return { runtime, locale, layout, sidebar, view } +} + +interface TestPanel { + id: MainPanelId + heading: string + order?: number + label?: SlotLabel +} + +async function mountPanel(runtime: SlotTestRuntime, { heading, ...metadata }: TestPanel) { + function Icon({ size, active }: PropsRuntime<'sidebar.panellist'>) { + return ( + + + + ) + } + function Body() { + return ( +
    +

    {heading}

    + + +
    + ) + } + return runtime.mount({ + inject: ['slots'], + apply(ctx: Context) { + ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: metadata.id }, Body)) + ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', ...metadata }, Icon)) + }, + }) +} + +async function mountPanels(runtime: SlotTestRuntime, locale: LocaleRuntime, betaOrder = 10) { + const t = locale.bind('sidebar-panel-test') + const alpha = await mountPanel(runtime, { id: ALPHA, heading: 'Alpha content', label: () => t('alpha'), order: 20 }) + const beta = await mountPanel(runtime, { id: BETA, heading: 'Beta content', label: 'Beta panel', order: betaOrder }) + return { alpha, beta } +} + +describe('sidebar global panels', () => { + it('adds late registrations, removes each plugin contribution, and leaves no empty panel-list DOM', async () => { + const { runtime, locale, view } = await bench() + expect(runtime.slots.entries('sidebar.panellist')).toEqual([]) + expect(view.queryByRole('navigation')).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.panellist"]')).toBeNull() + expect(view.getByText('Conversation content')).toBeTruthy() + + const { alpha, beta } = await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + expect(within(navigation).getAllByRole('button')).toHaveLength(2) + fireEvent.click(within(navigation).getByRole('button', { name: 'Alpha panel' })) + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + + await alpha.dispose() + await waitFor(() => { + expect(view.queryByRole('button', { name: 'Alpha panel' })).toBeNull() + expect(view.queryByRole('heading', { name: 'Alpha content' })).toBeNull() + }) + expect(runtime.slots.entries('main').map(entry => entry.options.key)).toEqual(['conversation', BETA]) + fireEvent.click(within(navigation).getByRole('button', { name: 'Beta panel' })) + expect(view.getByRole('heading', { name: 'Beta content' })).toBeTruthy() + + await beta.dispose() + await waitFor(() => { expect(view.queryByRole('navigation')).toBeNull() }) + expect(view.queryByRole('heading', { name: 'Beta content' })).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.panellist"]')).toBeNull() + expect(runtime.slots.entries('sidebar.panellist')).toEqual([]) + expect(runtime.slots.entries('main').map(entry => entry.options.key)).toEqual(['conversation']) + }) + + it.each([false, true])('renders ordered rows, icon sizes, and tooltip labels with collapsed=%s', async (collapsed) => { + const { runtime, locale, view } = await bench(collapsed) + await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + const rows = within(navigation).getAllByRole('button') + expect(rows.map(row => row.getAttribute('aria-label'))).toEqual(['Beta panel', 'Alpha panel']) + for (const [id, label] of [[BETA, 'Beta panel'], [ALPHA, 'Alpha panel']] as const) { + const row = within(navigation).getByRole('button', { name: label }) + expect(row.getAttribute('aria-current')).toBeNull() + expect(row.textContent).toBe(collapsed ? '' : label) + expect(row.querySelectorAll('svg')).toHaveLength(1) + const icon = within(row).getByTestId(`${id}-icon`) + expect(icon.getAttribute('data-active')).toBe('false') + expect(icon.querySelector('svg')?.getAttribute('width')).toBe(collapsed ? '18' : '16') + expect(icon.querySelector('svg')?.getAttribute('height')).toBe(collapsed ? '18' : '16') + act(() => { row.focus() }) + expect(document.activeElement).toBe(row) + if (collapsed) expect(view.getByRole('tooltip').textContent).toBe(label) + else expect(view.queryByRole('tooltip')).toBeNull() + act(() => { row.blur() }) + expect(document.activeElement).not.toBe(row) + expect(view.queryByRole('tooltip')).toBeNull() + } + }) + + it('keeps registration order when panel rows have equal order', async () => { + const { runtime, locale, view } = await bench() + await mountPanels(runtime, locale, 20) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Alpha panel', 'Beta panel']) + }) + + it('selects registered main content and keeps a repeated selection active', async () => { + const { runtime, locale, layout, view } = await bench() + await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + const alpha = within(navigation).getByRole('button', { name: 'Alpha panel' }) + const beta = within(navigation).getByRole('button', { name: 'Beta panel' }) + fireEvent.click(alpha) + expect(layout.selectPanel).toHaveBeenLastCalledWith(ALPHA) + expect(runtime.panelInfo.getSnapshot()).toEqual({ activePanelId: ALPHA }) + expect(alpha.getAttribute('aria-current')).toBe('page') + expect(beta.getAttribute('aria-current')).toBeNull() + expect(within(alpha).getByTestId(`${ALPHA}-icon`).getAttribute('data-active')).toBe('true') + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + expect(view.queryByRole('heading', { name: 'Beta content' })).toBeNull() + + fireEvent.click(alpha) + expect(layout.selectPanel).toHaveBeenLastCalledWith(ALPHA) + expect(alpha.getAttribute('aria-current')).toBe('page') + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + + fireEvent.click(beta) + expect(layout.selectPanel).toHaveBeenLastCalledWith(BETA) + expect(runtime.panelInfo.getSnapshot()).toEqual({ activePanelId: BETA }) + expect(alpha.getAttribute('aria-current')).toBeNull() + expect(beta.getAttribute('aria-current')).toBe('page') + expect(within(alpha).getByTestId(`${ALPHA}-icon`).getAttribute('data-active')).toBe('false') + expect(within(beta).getByTestId(`${BETA}-icon`).getAttribute('data-active')).toBe('true') + expect(view.queryByRole('heading', { name: 'Alpha content' })).toBeNull() + expect(view.getByRole('heading', { name: 'Beta content' })).toBeTruthy() + }) + + it('refreshes locale labels without re-registering icons or changing plain labels', async () => { + const { runtime, locale, view } = await bench() + await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + const entries = runtime.slots.entries('sidebar.panellist') + act(() => { locale.setLocale('zh') }) + await waitFor(() => { + expect(within(navigation).getByRole('button', { name: '甲面板' }).textContent).toBe('甲面板') + }) + expect(within(navigation).getByRole('button', { name: 'Beta panel' }).textContent).toBe('Beta panel') + expect(runtime.slots.entries('sidebar.panellist')).toBe(entries) + }) + + it('uses an omitted label and order as the panel id and order zero', async () => { + const { runtime, view } = await bench() + await mountPanel(runtime, { id: ALPHA, heading: 'Alpha content', label: 'Alpha panel', order: 20 }) + await mountPanel(runtime, { id: BETA, heading: 'Beta content' }) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual([BETA, 'Alpha panel']) + fireEvent.click(within(navigation).getByRole('button', { name: BETA })) + expect(view.getByRole('heading', { name: 'Beta content' })).toBeTruthy() + }) + + it.each([false, true])('keeps main selection while DOM focus moves between controls with collapsed=%s', async (collapsed) => { + const { runtime, locale, layout, view } = await bench(collapsed) + await mountPanels(runtime, locale) + const navigation = await view.findByRole('navigation', { name: 'Global panels' }) + const alpha = within(navigation).getByRole('button', { name: 'Alpha panel' }) + const beta = within(navigation).getByRole('button', { name: 'Beta panel' }) + act(() => { alpha.focus() }) + expect(document.activeElement).toBe(alpha) + expect(runtime.panelInfo.getSnapshot()).toEqual({ activePanelId: null }) + expect(alpha.getAttribute('aria-current')).toBeNull() + expect(layout.selectPanel).not.toHaveBeenCalled() + fireEvent.click(alpha) + const selected = runtime.panelInfo.getSnapshot() + const textbox = view.getByRole('textbox', { name: 'Alpha content input' }) + const action = view.getByRole('button', { name: 'Alpha content action' }) + for (const target of [textbox, action, beta, alpha]) { + act(() => { target.focus() }) + expect(document.activeElement).toBe(target) + expect(runtime.panelInfo.getSnapshot()).toBe(selected) + expect(alpha.getAttribute('aria-current')).toBe('page') + expect(beta.getAttribute('aria-current')).toBeNull() + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + expect(view.queryByRole('heading', { name: 'Beta content' })).toBeNull() + } + expect(layout.selectPanel).toHaveBeenCalledExactlyOnceWith(ALPHA) + }) + + it('disposes the sidebar while retaining independently owned main panel bodies', async () => { + const { runtime, locale, sidebar, view } = await bench() + await mountPanels(runtime, locale) + fireEvent.click(await view.findByRole('button', { name: 'Alpha panel' })) + await sidebar.dispose() + expect(view.queryByRole('navigation')).toBeNull() + expect(runtime.slots.spec('sidebar.panellist')).toBeUndefined() + expect(runtime.slots.entries('sidebar.panellist')).toEqual([]) + expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() + act(() => { locale.setLocale('zh') }) + expect(view.queryByRole('navigation')).toBeNull() + }) +}) diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index ed5b687e73..e010a3385f 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -14,6 +14,7 @@ import { en } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) /** Pinned column box; the shell compares pointer coordinates against it. */ const COLUMN_WIDTH = 280 @@ -40,6 +41,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 9973a1958b..c7f890303f 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -13,6 +13,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) // English-dictionary translate stub: the shell renders the same copy the // assertions below query by accessible name. @@ -45,6 +46,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={startSession} toggleSidebar={toggleSidebar} t={t} renderSlot={(( @@ -110,6 +112,7 @@ describe('SidebarRoot shell', () => { const { container } = render( {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => @@ -129,6 +132,7 @@ describe('SidebarRoot shell', () => { render( {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => @@ -143,6 +147,7 @@ describe('SidebarRoot shell', () => { render( {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index ed840c93a4..5d20bd9741 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../ui-slots" }, + { + "path": "../store" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index b642fe86a4..5f9dd4940c 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -56,7 +56,8 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", @@ -69,7 +70,6 @@ "watch": "tsdown --watch" }, "dependencies": { - "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index 4e6b61616e..5ad602ff05 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -13,6 +13,7 @@ import type { ThemePreference } from '../src/client/index.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -47,7 +48,7 @@ function mount(preference: ThemePreference = 'system') { const props: AppearanceRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 3c4601e1f8..c917901cab 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -14,6 +14,7 @@ import { createFontSizeRowStore } from '../src/client/settings-store.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -49,7 +50,7 @@ function mount(fontSize = 14) { const props: FontSizeRowComponentProps = { useSessions: emptySessions(), useSessionPendingInteraction, - useResource, + usePanelInfo, useResource, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 453d7867bb..3a3722d64d 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -41,9 +41,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -73,7 +70,8 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^" + "@deepseek-ai/dsh-attachment": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 636124555e..7959b9c60a 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -6,6 +6,7 @@ import { cleanup, fireEvent, waitFor } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { TodoItem } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyChat, inject as injectChat, type ToolResultNode, @@ -59,13 +60,13 @@ const bashResult = (seq: number, callId: string, over?: Partial) }) /** Test-owned AppFrame role: declares and renders the resident conversation area. */ -type AppRootProps = PropsRenderSlots<'conversation'> +type AppRootProps = PropsRenderSlots<'main'> function AppRoot({ renderSlot }: AppRootProps) { - return <>{renderSlot('conversation', {})} + return <>{renderSlot('main', {}, { entryKey: 'conversation' })} } const LAYOUT_CHILDREN = { - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, } as const async function bench(nodes: ToolResultNode[]) { @@ -79,7 +80,11 @@ async function bench(nodes: ToolResultNode[]) { runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index bb471caff9..67417ba4a3 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup } from '@testing-library/react' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { apply as applyChat, inject as injectChat, type ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -42,13 +43,13 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command }) /** Test-owned AppFrame role: declares and renders the resident conversation area. */ -type AppRootProps = PropsRenderSlots<'conversation'> +type AppRootProps = PropsRenderSlots<'main'> function AppRoot({ renderSlot }: AppRootProps) { - return <>{renderSlot('conversation', {})} + return <>{renderSlot('main', {}, { entryKey: 'conversation' })} } const LAYOUT_CHILDREN = { - 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'main': { kind: 'keyed', scope: 'root' }, } as const /** @@ -66,7 +67,11 @@ async function bench(nodes: ToolResultNode[]) { const sidebarRight = { openResource: vi.fn<(address: string) => void>() } runtime.ctx.provide('sidebarRight', sidebarRight as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -212,7 +217,11 @@ describe('registrant declaration injection', () => { runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { - connectWorkspace: vi.fn(async () => SID), + openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { + beforeOpen(SID) + runtime.sessions.open(SID) + }), + openSession: (id: SessionId) => { runtime.sessions.open(id) }, } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 53965b1cb7..7f9752a6a8 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -42,10 +42,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "@tanstack/react-virtual": "^3.14.9", - "diff": "^9.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -70,7 +66,9 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^" + "@deepseek-ai/dsh-attachment": "workspace:^", + "@tanstack/react-virtual": "^3.14.9", + "diff": "^9.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 91ba0468f1..79a0e14b0e 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -55,6 +55,7 @@ import { t as tTrajectory, tZh } from './locale.client.ts' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) function TrajectoryTimeline( props: Omit, 't'>, @@ -224,7 +225,7 @@ function standaloneProps( sessionId: SID, useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)), useSessions: emptySessions(), - useResource, + usePanelInfo, useResource, useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), ), @@ -351,7 +352,7 @@ function mount(fixture: Awaited>) { useConversation, useConversationViews, useSessions, - useResource, + usePanelInfo, useResource, useSessionPendingInteraction, useWorkspaces, useProjection, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 58b6a1c836..b32352c522 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -43,9 +43,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -68,7 +65,8 @@ "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-user-questions/src/index.ts b/packages/client/ui-user-questions/src/index.ts index 4ceb9e0bd1..9357c3bba6 100644 --- a/packages/client/ui-user-questions/src/index.ts +++ b/packages/client/ui-user-questions/src/index.ts @@ -1,13 +1,10 @@ /** * Web question plugin, node half. * - * Deliberately empty. Mounting `ask_user_question` here put it in the tools - * registry's GLOBAL layer, so every agent saw it no matter which preset - * composed it — a two-tool benchmark preset actually presented three, and a - * locally authored `bash-only` preset presented two. Rendering a question is - * a host UI capability; having the tool is an agent capability, and only a - * preset decides that. The `tool-ask-user` row belongs in the presets that - * want it (and in the TUI composition, which has no presets). + * Mounting `ask_user_question` in the tools registry's global layer expands + * every agent's tool list regardless of its preset. Rendering a question is + * a host UI capability; the model-facing tool belongs to the presets that + * include it and to the TUI composition, which has no presets. */ /** Host plugin body — the model-facing tool is composed per preset, not here. */ diff --git a/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts index 8cd942a80a..1ffb0a9caf 100644 --- a/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts @@ -21,11 +21,7 @@ describe('ui-user-questions node plugin', () => { await ctx.plugin({ apply }).await() - // Selecting the Web question FEATURE must not hand every agent the tool. - // `ctx.tools.register` on an unscoped host context files into the global - // layer, which merges into every agent's view regardless of the preset - // that composed it — so a two-tool benchmark preset would really present - // three. The `tool-ask-user` row belongs to the presets that want it. + // Host UI registration must leave each preset's model-facing tool list intact. expect(ctx.tools.get('ask_user_question')).toBeUndefined() }) }) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index f3cc685d4d..238cc195fd 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -14,6 +14,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -115,7 +116,7 @@ const kit: Omit = { pendingInteraction: undefined, useSession: selector => selector(sessionState), useSessions: selector => selector(sessionList), - useResource, + usePanelInfo, useResource, useSessionPendingInteraction: selector => selector(attentionState), useWorkspaces: selector => selector(workspaceState), useConversation: selector => selector(conversationState), diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 80d7d9307c..90848b3e9e 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -13,6 +13,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -114,7 +115,7 @@ const kitBase: Omit = pendingInteraction: undefined, useSession: selector => selector(sessionState), useSessions: selector => selector(sessionList), - useResource, + usePanelInfo, useResource, useSessionPendingInteraction: selector => selector(attentionState), useWorkspaces: selector => selector(workspaceState), useConversation: selector => selector(conversationState), diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index e6b38074a5..4f99fb3b44 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -34,6 +34,7 @@ import type {} from '../src/client/index.ts' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -314,7 +315,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi node: node(data), sessionId: PARENT_ID, useSessions: selector => selector(sessions), - useResource, + usePanelInfo, useResource, useSessionPendingInteraction: selector => selector(panelAttention), useSession: selector => selector(panelSession), useProjection: () => undefined, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 26b70e0843..a79ee51c23 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: a819b07fecb74faeb3bebb6665aaabfdc8ecfba6 -README.zh.md: f9565b12bccd596fa61d1f1436f82b090139f506 +README.md: 857b38c2ca08ca4e9078d8f9dfcfe8462be50722 +README.zh.md: cf6f2d273f00add793cc5ac588db968c22007256 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index a819b07fec..857b38c2ca 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -51,6 +51,8 @@ The value is intentionally best effort for cold Sessions. An identity-matching u ----- +`ctx.uiWorkspace.openSession(id)` selects the Session and returns the main area to the Conversation as one UI navigation action, including when that Session was already current. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. + ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f9565b12bc..cf6f2d273f 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -51,6 +51,8 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** ----- +`ctx.uiWorkspace.openSession(id)` 将选中会话与中央区域返回会话界面作为一次 UI 导航操作,包括目标会话已经是当前会话的情况。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调仅对仍有效的工作区请求执行,因此过期请求不会搬移 composer 草稿。后续导航或所有者释放会阻止晚到的 UI 提交,但不取消底层会话创建。选中失败时保留当前全局面板。会话行读取 `usePanelInfo`,在全局面板活跃时不显示会话选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 + ## 理解实现 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 72876b6d9e..3a35cae4d4 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-sidebar" @@ -46,9 +47,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, @@ -61,6 +59,7 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", @@ -72,7 +71,8 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 7c8e1108dc..9204fe7774 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-api-workspace-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the Session root standard-hook merge. import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' @@ -60,7 +61,7 @@ const NS = 'workspace' * declaration through `slots.inject()` instead of assuming order. */ export const inject = [ - 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', ] /** @@ -95,11 +96,14 @@ export function apply(ctx: Context): void { subscribe: listener => ctx.on('connection/reset', listener), } const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') + const openSession: WorkspaceBrowserInjected['open'] = (sessionId) => { + uiWorkspace.openSession(sessionId) + } const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session inherits // the current Session Workspace before the recent-Workspace fallback. startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) }, - open: (sessionId) => { sessions.open(sessionId) }, + open: openSession, searchSessions, searchResultLimit: sessions.searchResultLimit, renameSession: async (sessionId, title) => { @@ -111,8 +115,7 @@ export function apply(ctx: Context): void { if (!result.ok) throw new Error(result.error.message) }, forkSession: (sessionId) => { - sessions.fork({ sessionId, increaseTitle: true }) - .then((childId) => { sessions.open(childId) }) + uiWorkspace.forkSession(sessionId) .catch(() => { // Fork or child-rename failure keeps the current selection. }) diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index a460afe23a..c968595d8a 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -10,9 +10,28 @@ import type { IWorkspaces, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' /** Workspace archive and directory operations consumed by Client UI domains. */ export interface UiWorkspace { + /** + * Select a Session and show its Conversation as one UI navigation action. + * @param sessionId - listed or retained Session to display. + */ + openSession(sessionId: SessionId): void + /** + * Connect a Workspace and open its Session unless a later navigation supersedes it. + * @param workspaceId - target Workspace. + * @param beforeOpen - optional synchronous preparation for the selected Session, skipped after supersession. + * @returns completion; a superseded request may create a Session but does not open it. + */ + openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise + /** + * Fork a Session and open the child unless a later navigation supersedes it. + * @param sessionId - source Session. + * @returns completion; a superseded request leaves its child available without selecting it. + */ + forkSession(sessionId: SessionId): Promise /** * Resolve the reusable or newly created blank Session for a Workspace. * @param workspaceId - target Workspace. @@ -70,6 +89,7 @@ export class DirectoryBrowseError extends Error { /** Implements Workspace archive and directory UI operations. */ class UiWorkspaceService extends Service implements UiWorkspace { private readonly connecting = new Map>() + private readonly lifetime = new AbortController() /** * @param ctx - Client root Context. @@ -111,6 +131,26 @@ class UiWorkspaceService extends Service implements UiWorkspace { return attempt } + openSession(sessionId: SessionId): void { + this.sessions.open(sessionId) + this.ctx.layout.selectPanel(null) + } + + async openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) + const isCurrent = (): boolean => !navigation.aborted + const sessionId = await this.connectWorkspace(workspaceId) + if (!isCurrent()) return + beforeOpen?.(sessionId) + if (isCurrent()) this.openSession(sessionId) + } + + async forkSession(sessionId: SessionId): Promise { + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) + const childId = await this.sessions.fork({ sessionId, increaseTitle: true }) + if (!navigation.aborted) this.openSession(childId) + } + startSession(workspaceId?: WorkspaceId): void { const workspace = this.workspaces.list.getSnapshot() const sessions = this.sessions.list.getSnapshot() @@ -124,10 +164,10 @@ class UiWorkspaceService extends Service implements UiWorkspace { const target = workspaceId ?? currentWorkspaceId ?? recent if (target === undefined) { this.sessions.clear() + this.ctx.layout.selectPanel(null) return } - void this.connectWorkspace(target).then( - (sessionId) => { this.sessions.open(sessionId) }, + void this.openWorkspace(target).catch( (reason: unknown) => { console.warn('new session failed:', reason) }, ) } @@ -156,9 +196,8 @@ class UiWorkspaceService extends Service implements UiWorkspace { private watchNavigation(): () => void { let initial: 'waiting' | 'connecting' | 'done' = 'waiting' - let disposed = false const reconcile = (): void => { - if (disposed) return + if (this.lifetime.signal.aborted) return if (this.clearArchivedCurrent()) return if (initial !== 'waiting') return const workspace = this.workspaces.list.getSnapshot() @@ -176,14 +215,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { initial = 'connecting' void this.connectWorkspace(target).then( (sessionId) => { - if (disposed) return + if (this.lifetime.signal.aborted) return if (this.sessions.list.getSnapshot().current === undefined) { this.sessions.open(sessionId) } initial = 'done' }, (reason: unknown) => { - if (disposed) return + if (this.lifetime.signal.aborted) return initial = 'waiting' console.warn('initial workspace selection failed:', reason) }, @@ -193,7 +232,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { const disposeSessions = this.sessions.list.subscribe(reconcile) reconcile() return () => { - disposed = true + this.lifetime.abort() disposeSessions() disposeWorkspaces() } diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 0ef21ddb82..585e9c8766 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -235,7 +235,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): type SessionTreeProps = Pick< WorkspaceBrowserProps, 'useSessions' | 'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession' - | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' + | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' | 'usePanelInfo' > & { /** Host account home for POSIX hover-path abbreviation. */ home?: string | undefined @@ -275,16 +275,17 @@ type SessionTreeProps = Pick< /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds, - workspaceReady, + workspaceReady, usePanelInfo, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, revealSessionId, onSessionRevealed, }: SessionTreeProps) { + const panelActive = usePanelInfo(info => info.activePanelId !== null) const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) - const current = list.current + const current = panelActive ? undefined : list.current const revealGroup = revealSessionId === undefined || !workspaceReady ? undefined : owningGroupKey(workspaces, revealSessionId) @@ -619,7 +620,7 @@ function SessionTree({ /** The flat "In one list" body: every session is one draggable top-level row. */ function FlatList({ useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive, - archivedSessionIds, + archivedSessionIds, usePanelInfo, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, revealSessionId, onSessionRevealed, t, }: Pick< @@ -631,6 +632,7 @@ function FlatList({ | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' + | 'usePanelInfo' | 'orderBy' | 'sessionOrderByAccount' | 'sessionUpdatedAtByAccount' @@ -640,6 +642,7 @@ function FlatList({ | 'onSessionRevealed' | 't' >) { + const panelActive = usePanelInfo(info => info.activePanelId !== null) const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const baseRows = useMemo( @@ -706,7 +709,7 @@ function FlatList({ & { +}: Pick & { workspaces: readonly WorkspaceView[] archivedSessionIds: readonly SessionNode['id'][] query: string remote: RemoteSearchState resultLimit: number }) { + const panelActive = usePanelInfo(info => info.activePanelId !== null) const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const currentRemote = remote.query === query @@ -798,7 +803,7 @@ function SearchResults({ @@ -833,6 +838,7 @@ function SearchResults({ */ export function WorkspaceBrowser({ wide, + usePanelInfo, expandSidebar, useSessions, useSessionPendingInteraction, @@ -1251,6 +1257,7 @@ export function WorkspaceBrowser({ {wide && (normalizedQuery !== '' ? ( ({})) const open = vi.fn() const clear = vi.fn() + const selectPanel = vi.fn() + ctx.provide('layout', { selectPanel, beginNavigation: () => new AbortController().signal }) const search = vi.fn(async () => ({ ok: true as const, value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false }, @@ -71,7 +73,7 @@ async function bench() { ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, - insertSessionBefore, open, clear, search, renameSession, binding, fork, pickDirectory, + insertSessionBefore, open, clear, selectPanel, search, renameSession, binding, fork, pickDirectory, } } @@ -90,7 +92,7 @@ describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', ]) }) diff --git a/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx index 1b3e256c97..0dd5eecea9 100644 --- a/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx +++ b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx @@ -28,6 +28,7 @@ function SidebarFrame({ renderSlot }: FrameProps) { /** The assembled sidebar over one Workspace inside the POSIX home the Host reports. */ async function bench() { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('layout', { selectPanel: vi.fn() }) runtime.releaseWorkspaceSource() const directoryPicker = {} const remote = new TestRemote(runtime.ctx) diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index d57a89ba8b..b814e39803 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -33,6 +33,7 @@ beforeEach(() => { localStorage.clear() }) /** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('layout', { selectPanel: vi.fn() }) runtime.releaseWorkspaceSource() // The rename flow never picks a directory; the namespace only has to be there // for ui-workspace's inject to settle. diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 6958423e5f..b25b32e166 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -9,6 +9,7 @@ import type { } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' @@ -18,6 +19,7 @@ import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) const scrollIntoView = vi.fn() @@ -78,7 +80,7 @@ function mount(overrides: Partial = {}) { expandSidebar: vi.fn(), useSessions: hook(sessionState([])), useSessionPendingInteraction: hook(noPendingInteraction), - useResource, + usePanelInfo, useResource, useWorkspaces: hook(workspaceState([])), useStore: bindSnapshotSelector(store), actions: store.actions, @@ -111,6 +113,30 @@ function rerender(b: ReturnType, overrides: Partial { + it('moves focus into Workspace controls without selecting a Session while a main panel is active', () => { + const panelInfo = { activePanelId: 'panel-a' as MainPanelId } + const b = mount({ + usePanelInfo: hook(panelInfo), + useSessions: hook(sessionState([summary('current', 1)], { current: sid('current') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['current'])])), + }) + fireEvent.click(screen.getByText('alpha')) + const current = screen.getByText('current').closest('[role="treeitem"]') + expect(current?.getAttribute('aria-selected')).toBe('false') + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + const input = screen.getByPlaceholderText('搜索会话…') + expect(document.activeElement).toBe(input) + fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) + const add = screen.getByRole('button', { name: '添加工作区' }) + add.focus() + fireEvent.click(add) + expect(document.activeElement).toBe(add) + expect(screen.getByTestId('directory-flow')).toBeTruthy() + expect(panelInfo.activePanelId).toBe('panel-a') + expect(b.props.open).not.toHaveBeenCalled() + expect(b.props.startSession).not.toHaveBeenCalled() + }) + it('workspace hover card shows a POSIX home descendant as ~', () => { vi.useFakeTimers() try { diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index b2ac4a6d1a..3783a1d16a 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -16,6 +16,7 @@ import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null }) afterEach(cleanup) @@ -98,7 +99,7 @@ function mount( anchorRef={anchorRef} useSessions={hook(sessions)} useSessionPendingInteraction={hook(noPendingInteraction)} - useResource={useResource} + usePanelInfo={usePanelInfo} useResource={useResource} useWorkspaces={hook(workspaceState(nextItems))} onPick={onPick} onClose={onClose} @@ -220,7 +221,7 @@ describe('WorkspacePicker', () => { , @@ -237,7 +238,7 @@ describe('WorkspacePicker', () => { , diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 393619bbb7..52f106f0f0 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -10,6 +10,8 @@ import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remote import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { SessionId } from '@deepseek-ai/dsh-session/types' +import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client' +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' const sid = (id: string): SessionId => SessionId(id) @@ -108,6 +110,7 @@ class FakeSessions { readonly create: ReturnType> readonly open: ReturnType void>> readonly clear: ReturnType void>> + readonly fork = vi.fn(async () => sid('forked')) constructor(initial: SessionListState) { this.list = new MutableSource(initial) @@ -189,6 +192,14 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() + const layout = new LayoutController({ + selectPanel: vi.fn(), retainMainPanels: vi.fn(), + setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(), + setRightbar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn(), + }, () => true) + const selectPanel = vi.spyOn(layout, 'selectPanel') + ctx.provide('layout', layout) + ctx.effect(() => () => { layout.dispose() }) const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending')) @@ -198,7 +209,7 @@ function bench(options: BenchOptions = {}) { workspaces, sessions as unknown as ISessions, ) - return { ctx, directoryPicker, sessions, uiWorkspace, workspaces } + return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel } } async function flush(): Promise { @@ -207,6 +218,126 @@ async function flush(): Promise { } describe('UiWorkspaceService', () => { + it('selects a Session before revealing its Conversation, including the current Session', () => { + const current = sid('current') + const b = bench({ sessions: sessionState([summary('current')], current) }) + b.selectPanel.mockImplementation(() => { + expect(b.sessions.list.getSnapshot().current).toBe(current) + }) + b.uiWorkspace.openSession(current) + expect(b.sessions.open).toHaveBeenCalledWith(current) + expect(b.selectPanel).toHaveBeenCalledWith(null) + expect(b.sessions.open.mock.invocationCallOrder[0]).toBeLessThan(b.selectPanel.mock.invocationCallOrder[0]!) + }) + + it('keeps the current panel when selecting a Session throws', () => { + const b = bench() + b.sessions.open.mockImplementationOnce(() => { throw new Error('selection failed') }) + expect(() => { b.uiWorkspace.openSession(sid('target')) }).toThrow('selection failed') + expect(b.selectPanel).not.toHaveBeenCalled() + }) + + it('leaves a later panel selection in place when New Session finishes', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + const created = Promise.withResolvers() + b.sessions.create.mockReturnValue(created.promise) + const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace') + b.uiWorkspace.startSession(wid('alpha')) + b.layout.selectPanel('panel-a' as MainPanelId) + created.resolve(sid('late')) + await opening.mock.results[0]!.value + expect(b.sessions.open).not.toHaveBeenCalled() + expect(b.selectPanel).toHaveBeenCalledExactlyOnceWith('panel-a') + expect(b.sessions.list.getSnapshot().current).toBe(sid('current')) + }) + + it('opens only the latest Workspace request when creation completes out of order', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha'), workspace('beta')]), + }) + const older = Promise.withResolvers() + const newer = Promise.withResolvers() + b.sessions.create.mockImplementation(options => options?.workspaceId === wid('alpha') ? older.promise : newer.promise) + const oldDraft = vi.fn() + const newDraft = vi.fn() + const first = b.uiWorkspace.openWorkspace(wid('alpha'), oldDraft) + const second = b.uiWorkspace.openWorkspace(wid('beta'), newDraft) + newer.resolve(sid('newer')) + await second + older.resolve(sid('older')) + await first + expect(oldDraft).not.toHaveBeenCalled() + expect(newDraft).toHaveBeenCalledExactlyOnceWith(sid('newer')) + expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('newer')) + }) + + it('does not move drafts or reopen a Workspace after reselecting the current Session', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + const created = Promise.withResolvers() + b.sessions.create.mockReturnValue(created.promise) + const moveDraft = vi.fn() + const pending = b.uiWorkspace.openWorkspace(wid('alpha'), moveDraft) + b.uiWorkspace.openSession(sid('current')) + created.resolve(sid('late')) + await pending + expect(moveDraft).not.toHaveBeenCalled() + expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('current')) + }) + + it('keeps navigation performed by preparation and propagates preparation failures', async () => { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + await b.uiWorkspace.openWorkspace(wid('alpha'), () => { b.layout.selectPanel('panel-a' as MainPanelId) }) + expect(b.sessions.open).not.toHaveBeenCalled() + await expect(b.uiWorkspace.openWorkspace(wid('alpha'), () => { throw new Error('draft failed') })).rejects.toThrow('draft failed') + expect(b.sessions.open).not.toHaveBeenCalled() + }) + + it('opens a fork only while its navigation is current and preserves fork failures', async () => { + const b = bench() + await b.uiWorkspace.forkSession(sid('source')) + expect(b.sessions.fork).toHaveBeenCalledWith({ sessionId: sid('source'), increaseTitle: true }) + expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('forked')) + const forked = Promise.withResolvers() + b.sessions.fork.mockReturnValueOnce(forked.promise) + const pending = b.uiWorkspace.forkSession(sid('source')) + b.layout.selectPanel('panel-a' as MainPanelId) + forked.resolve(sid('late-fork')) + await pending + expect(b.sessions.open).toHaveBeenCalledOnce() + b.sessions.fork.mockRejectedValueOnce(new Error('fork failed')) + await expect(b.uiWorkspace.forkSession(sid('source'))).rejects.toThrow('fork failed') + expect(b.sessions.open).toHaveBeenCalledOnce() + }) + + it('does not open a pending Workspace or fork after the navigation owner is disposed', async () => { + for (const kind of ['workspace', 'fork'] as const) { + const b = bench({ + sessions: sessionState([summary('current')], sid('current')), + workspaces: workspaceState([workspace('alpha')]), + }) + const resolved = Promise.withResolvers() + b.sessions.create.mockReturnValue(resolved.promise) + b.sessions.fork.mockReturnValue(resolved.promise) + const pending = kind === 'workspace' + ? b.uiWorkspace.openWorkspace(wid('alpha')) + : b.uiWorkspace.forkSession(sid('current')) + await b.ctx.fiber.dispose() + resolved.resolve(sid('late')) + await pending + expect(b.sessions.open).not.toHaveBeenCalled() + } + }) + it('reuses only an unarchived member blank and coalesces concurrent creation', async () => { const b = bench() const memberBlank = sid('member-blank') diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 8fd074799a..f9cab4ddc3 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-layout" + }, { "path": "../ui-renderer" }, diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index b21b7ec576..dd6411240b 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -33,6 +33,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'compaction/prune', 'compaction/start', 'compaction/summary', + 'deliverables/presented', 'feedback/message-delete', 'feedback/message-put', 'feedback/record', @@ -54,6 +55,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'session/title-llm-request', 'step/end', 'step/start', + 'subagent/catalog', 'subagent/descriptor', 'subagent/model-selection-policy', 'system/message', diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 0630cfaa8e..c26ef3ff28 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -30,7 +30,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { 'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', - 'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'pwsh', 'pwsh', 'ralph', + 'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'present', 'pwsh', 'pwsh', 'ralph', 'read', 'read_image', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate', diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 47bf4af64a..dfc66c56bf 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -83,9 +83,21 @@ export interface TypeApiEntry { export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'layout', - summary: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply.', - description: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply. The attachPanels wiring hook stays on the concrete class (root-entry assembly only).', + summary: 'Panel navigation and geometry actions exposed through ctx.layout.', + description: 'Panel navigation and geometry actions exposed through ctx.layout.', methods: [ + { + signature: 'selectPanel(panelId: MainPanelId | null): void', + description: 'Select a global central panel without changing the current Session.', + parameters: [{ name: 'panelId', description: 'registered main key, or null to show the Conversation.' }], + throws: ['if the selected main key is not registered; preserves the current selection.'], + }, + { + signature: 'beginNavigation(): AbortSignal', + description: 'Start an asynchronous navigation, superseding any earlier pending navigation.', + parameters: [], + returns: 'a signal aborted by the next navigation or layout disposal; check it before committing UI state.', + }, { signature: 'toggleSidebar(): void', description: 'Toggle the sidebar panel (closed ⟷ contract default width).', @@ -314,6 +326,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Workspace archive and directory operations consumed by Client UI domains.', description: 'Workspace archive and directory operations consumed by Client UI domains.', methods: [ + { + signature: 'openSession(sessionId: SessionId): void', + description: 'Select a Session and show its Conversation as one UI navigation action.', + parameters: [{ name: 'sessionId', description: 'listed or retained Session to display.' }], + }, + { + signature: 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise', + description: 'Connect a Workspace and open its Session unless a later navigation supersedes it.', + parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'beforeOpen', description: 'optional synchronous preparation for the selected Session, skipped after supersession.' }], + returns: 'completion; a superseded request may create a Session but does not open it.', + }, + { + signature: 'forkSession(sessionId: SessionId): Promise', + description: 'Fork a Session and open the child unless a later navigation supersedes it.', + parameters: [{ name: 'sessionId', description: 'source Session.' }], + returns: 'completion; a superseded request leaves its child available without selecting it.', + }, { signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise', description: 'Resolve the reusable or newly created blank Session for a Workspace.', @@ -609,6 +638,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LocaleSnapshot', declaration: 'export interface LocaleSnapshot {\n active: LocaleId;\n locales: readonly LocaleDefinition[];\n revision: number;\n}', }, + { + name: 'MainPanelId', + declaration: 'export type MainPanelId = Branded<\'MainPanelId\'>;', + }, { name: 'MatchedShare', declaration: 'export type MatchedShare = E[\'kind\'] extends \'chain\' ? {\n matched: M;\n} : object;', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e9806f64d4..11bda279be 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -79,41 +79,6 @@ export const CLIENT_NOTES: readonly string[] = [ // verbatim; that sameness IS the contract a registrant reads, so clone // detection is told to skip the data rather than the file. export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ - { - key: 'conversation', - kind: 'single', - scope: 'session-maybe', - summary: 'The whole center column, across both the no-session hero and a live conversation.', - doc: 'The whole center column, across both the no-session hero and a live\nconversation. OCCUPIED by ui-conversation\'s ConversationRoot, which\ndeclares the session body, composer, and input seats inside it —\nregistering here replaces the entire conversation surface (and removes\nevery seat it declares) rather than adding to it.\n\nCurrent-session-optional: the occupant owns both states without\nchanging its React identity, so it keeps its own state across a session\nswitch. It receives no owner props; session facts arrive through the\nframework hooks of the `session-maybe` scope.', - registerOptions: [], - ownerProps: [ - '/** Conversation owner share: business state and actions belong to the registrant. */\nexport interface ConvOwnerProps {}', - ], - ownerPropsReferences: [], - standardProps: [ - 'useResource: UseResource', - 'useWorkspaces: SnapshotSelectorHook', - 'useSessions: UseSessions', - 'useSessionPendingInteraction: UseSessionPendingInteraction', - 'useWorkspaces: SnapshotSelectorHook', - 'useConversation: MaybeSnapshotSelectorHook', - 'useInput: MaybeSnapshotSelectorHook', - 'inputActions: InputActions | undefined', - 'useSession: MaybeSnapshotSelectorHook', - 'sessionId: SessionId | undefined', - 'useProjection: UseProjection', - ], - keyDomain: '', - hookContext: '', - slotInject: '', - declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', - occupants: [ - 'client-ui-conversation ConversationRoot', - ], - replaceRisk: 'shadows-shipped-ui', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation\', () => ctx.slots.register(\n { name: \'conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:65', - }, { key: 'conversation.approval.detail', kind: 'single', @@ -128,6 +93,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -186,6 +152,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -207,7 +174,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:212', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:213', }, { key: 'conversation.chat.commandview', @@ -234,6 +201,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -253,7 +221,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:200', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:201', }, { key: 'conversation.chat.node', @@ -283,6 +251,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -320,7 +289,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:181', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:182', }, { key: 'conversation.chat.turnTail', @@ -345,6 +314,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -362,11 +332,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ - 'client-ui-deliverables ProducedFiles', + 'client-ui-deliverables Deliverables', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:206', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:207', }, { key: 'conversation.composer', @@ -393,6 +363,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -408,7 +379,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-approval ApprovalPanel', 'client-ui-subagent SubagentReadOnlyComposer', @@ -416,7 +387,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', }, { key: 'conversation.composer.bar', @@ -434,6 +405,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -447,13 +419,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation InputBar', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', }, { key: 'conversation.composer.dock', @@ -486,6 +458,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -507,7 +480,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', }, { key: 'conversation.hero.agentPreset', @@ -523,6 +496,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -530,13 +504,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-agent-preset AgentPresetSeat', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', }, { key: 'conversation.hero.brand.mark', @@ -552,6 +526,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -559,11 +534,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', }, { key: 'conversation.hero.workspace', @@ -581,6 +556,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -588,13 +564,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-workspace WorkspacePicker', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -610,6 +586,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -644,6 +621,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -663,7 +641,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179', }, { key: 'conversation.input.dock', @@ -701,6 +679,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -716,7 +695,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation QueueDock id \'queue\'', 'client-ui-conversation TodoDock id \'todo\'', @@ -724,7 +703,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', }, { key: 'conversation.input.left', @@ -757,6 +736,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -776,7 +756,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', }, { key: 'conversation.input.model', @@ -792,6 +772,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -813,7 +794,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', }, { key: 'conversation.input.overlay', @@ -846,6 +827,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -868,7 +850,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169', }, { key: 'conversation.input.plan', @@ -884,6 +866,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -905,7 +888,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', }, { key: 'conversation.input.right', @@ -938,6 +921,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -957,7 +941,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', }, { key: 'conversation.message.images', @@ -977,6 +961,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -998,7 +983,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:194', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:195', }, { key: 'conversation.session', @@ -1012,6 +997,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1027,13 +1013,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation ConversationSession', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', }, { key: 'conversation.session.header', @@ -1047,6 +1033,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1062,13 +1049,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation ConversationSessionHeader', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', }, { key: 'conversation.session.header.actions', @@ -1103,6 +1090,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1127,7 +1115,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', }, { key: 'conversation.session.header.corner', @@ -1143,6 +1131,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1164,7 +1153,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151', }, { key: 'conversation.session.header.lineage', @@ -1182,6 +1171,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1203,7 +1193,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', }, { key: 'conversation.session.header.utilities', @@ -1238,6 +1228,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1260,7 +1251,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', }, { key: 'conversation.trajectory.images', @@ -1280,6 +1271,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1338,6 +1330,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1360,14 +1353,83 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', + }, + { + key: 'main', + kind: 'keyed', + scope: 'root', + summary: 'Central panel selected by sidebar entry id.', + doc: 'Central panel selected by sidebar entry id. The reserved `conversation`\nkey hosts the Conversation; other keys receive no Session binding.', + registerOptions: [ + { + name: 'key', + requirement: 'required', + type: 'string', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', + }, + ], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: conversation', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', + occupants: [ + 'client-ui-conversation ConversationPanel key \'conversation\'', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main\', () => ctx.slots.register(\n { name: \'main\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-layout/src/client/index.ts:66', + }, + { + key: 'main.conversation', + kind: 'single', + scope: 'session-maybe', + summary: 'Conversation shell beneath its root-scoped main-panel entry.', + doc: 'Conversation shell beneath its root-scoped main-panel entry.', + registerOptions: [], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useConversation: MaybeSnapshotSelectorHook', + 'useInput: MaybeSnapshotSelectorHook', + 'inputActions: InputActions | undefined', + 'useSession: MaybeSnapshotSelectorHook', + 'sessionId: SessionId | undefined', + 'useProjection: UseProjection', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-conversation), so it exists while that entry is mounted', + occupants: [ + 'client-ui-conversation ConversationRoot', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', }, { key: 'rightbar', kind: 'single', - scope: 'session', + scope: 'root', summary: 'The right column: a track the centre makes room for, or nothing.', - doc: 'The right column: a track the centre makes room for, or nothing. OCCUPIED\nby the right Sidebar, which uses the resolved column width in normal\nmode and covers the viewport in fullscreen, retaining the wide-screen\ncolumn reservation underneath.\n\nWhether the panel is shown, and whether it takes a track, is the\noccupant\'s own recorded business — it reports the composition of its\nexpanded and presentation state through `ctx.layout`, and the frame sizes\nthe track and places the resize handle from that. The expand control is\nnot this column\'s: it is a button in the conversation header. With no\ncurrent session nothing is mounted here.', + doc: 'The right column: a track the centre makes room for, or nothing. OCCUPIED\nby the right Sidebar, which uses the resolved column width in normal\nmode and covers the viewport in fullscreen, retaining the wide-screen\ncolumn reservation underneath.\n\nWhether the panel is shown, and whether it takes a track, is the\noccupant\'s own recorded business — it reports the composition of its\nexpanded and presentation state through `ctx.layout`, and the frame sizes\nthe track and places the resize handle from that. The expand control is\nnot this column\'s: it is a button in the conversation header. The root\noccupant decides when to render its Session-bound content.', registerOptions: [], ownerProps: [ '/** Right column owner share: resolved normal geometry and opening eligibility. */\nexport interface RightbarOwnerProps {\n /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */\n width: number\n /** Current frame width in px. */\n viewportWidth: number\n /**\n * Whether a normal right panel can retain 300px beside a 400px center.\n * Before a narrow opening, includes the space from collapsing the left sidebar.\n */\n canShow: boolean\n}', @@ -1376,6 +1438,37 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', + occupants: [ + 'client-ui-sidebar-right RightbarRoot', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar\', () => ctx.slots.register(\n { name: \'rightbar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-layout/src/client/index.ts:80', + }, + { + key: 'rightbar.session', + kind: 'single', + scope: 'session', + summary: 'Session content selected by the root-scoped right Sidebar controller.', + doc: 'Session content selected by the root-scoped right Sidebar controller.', + registerOptions: [], + ownerProps: [ + '/** Right column owner share: resolved normal geometry and opening eligibility. */\nexport interface RightbarOwnerProps {\n /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */\n width: number\n /** Current frame width in px. */\n viewportWidth: number\n /**\n * Whether a normal right panel can retain 300px beside a 400px center.\n * Before a narrow opening, includes the space from collapsing the left sidebar.\n */\n canShow: boolean\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1391,13 +1484,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', + declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [ 'client-ui-sidebar-right RightbarSeat', ], replaceRisk: 'shadows-shipped-ui', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar\', () => ctx.slots.register(\n { name: \'rightbar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:79', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'rightbar.session\', () => ctx.slots.register(\n { name: \'rightbar.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:42', }, { key: 'root', @@ -1413,6 +1506,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1461,6 +1555,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1490,6 +1585,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1538,6 +1634,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1572,6 +1669,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1620,6 +1718,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1656,6 +1755,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1702,6 +1802,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1739,6 +1840,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1790,6 +1892,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1839,6 +1942,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1871,6 +1975,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1917,6 +2022,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1928,7 +2034,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:90', + source: 'packages/client/ui-layout/src/client/index.ts:91', }, { key: 'sidebar', @@ -1944,6 +2050,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1957,7 +2064,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar\', () => ctx.slots.register(\n { name: \'sidebar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:52', + source: 'packages/client/ui-layout/src/client/index.ts:61', }, { key: 'sidebar.brand.mark', @@ -1973,6 +2080,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -1986,7 +2094,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.mark\', () => ctx.slots.register(\n { name: \'sidebar.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:23', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:22', }, { key: 'sidebar.brand.name', @@ -2002,6 +2110,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2015,7 +2124,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n { name: \'sidebar.brand.name\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:28', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:27', }, { key: 'sidebar.footer.action', @@ -2050,6 +2159,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2063,7 +2173,54 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.footer.action\', () => ctx.slots.register(\n { name: \'sidebar.footer.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:46', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:50', + }, + { + key: 'sidebar.panellist', + kind: 'list', + scope: 'root', + summary: 'Global panel icons.', + doc: 'Global panel icons. Each list id addresses the matching main panel;\nthe sidebar owns the button and resolves its label from list metadata.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** Icon presentation supplied by the global panel row. */\nexport interface SidebarPanelIconOwnerProps {\n /** Requested square edge in pixels. */\n size: number\n /** Whether this panel is selected in the main column. */\n active: boolean\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.panellist\', () => ctx.slots.register(\n { name: \'sidebar.panellist\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:32', }, { key: 'sidebar.right.pane.tab', @@ -2084,6 +2241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2099,7 +2257,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: 'TabHookContext', slotInject: 'SidebarRightTabInjected', - declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted', + declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [ 'client-ui-sidebar-files FilesBody', 'client-ui-sidebar-right GuideBody', @@ -2107,7 +2265,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:47', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:50', }, { key: 'sidebar.right.pane.tab.title', @@ -2128,6 +2286,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2143,11 +2302,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: 'TabHookContext', slotInject: 'SidebarRightTabInjected', - declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted', + declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab.title\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:61', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:64', }, { key: 'sidebar.right.tab.guide', @@ -2168,6 +2327,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2187,7 +2347,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.guide\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.guide\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:72', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:75', }, { key: 'sidebar.right.tab.menu.item', @@ -2224,6 +2384,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2239,11 +2400,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'rightbar\' (client-ui-sidebar-right), so it exists while that entry is mounted', + declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.menu.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:83', + source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:86', }, { key: 'sidebar.settings', @@ -2259,6 +2420,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2272,7 +2434,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.settings\', () => ctx.slots.register(\n { name: \'sidebar.settings\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:41', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:45', }, { key: 'sidebar.workspaces', @@ -2288,6 +2450,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2301,7 +2464,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces\', () => ctx.slots.register(\n { name: \'sidebar.workspaces\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:35', + source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:39', }, { key: 'sidebar.workspaces.directoryFlow', @@ -2317,6 +2480,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2350,6 +2514,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2398,6 +2563,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', @@ -2410,11 +2576,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useProjection: UseProjection', 'useTrajectory: UseTrajectory', ], - keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, read, read_image, skill, todo_write, web_fetch, web_search, write', + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, present, read, read_image, skill, todo_write, web_fetch, web_search, write', hookContext: '', slotInject: '', declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-tool), so it exists while that entry is mounted', occupants: [ + 'client-ui-deliverables PresentRow key \'present\'', 'client-ui-skill SkillRow key \'skill\'', 'client-ui-tool AskQuestionRow key \'ask_user_question\'', 'client-ui-tool BashRow key \'bash\'', @@ -2461,6 +2628,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ standardProps: [ 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionPendingInteraction: UseSessionPendingInteraction', 'useWorkspaces: SnapshotSelectorHook', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index 9e2e106b9e..e291d7c82e 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -3,6 +3,14 @@ import { EVENT_API, queryServiceApi, SERVICE_API } from '../src/client/api-catal describe('Client Cordis inspect catalog', () => { it('publishes the split Workspace Controller and UI navigation services', () => { + expect(SERVICE_API.find(service => service.key === 'layout')?.methods.map(method => method.signature)) + .toEqual([ + 'selectPanel(panelId: MainPanelId | null): void', + 'beginNavigation(): AbortSignal', + 'toggleSidebar(): void', + 'openRightbar(track: boolean, fullscreen: boolean): void', + 'closeRightbar(): void', + ]) expect(SERVICE_API.find(service => service.key === 'workspaces')?.methods.map(method => method.signature)) .toEqual([ 'create(input: { path: string }): Promise', @@ -13,6 +21,9 @@ describe('Client Cordis inspect catalog', () => { ]) expect(SERVICE_API.find(service => service.key === 'uiWorkspace')?.methods.map(method => method.signature)) .toEqual([ + 'openSession(sessionId: SessionId): void', + 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise', + 'forkSession(sessionId: SessionId): Promise', 'connectWorkspace(workspaceId: WorkspaceId): Promise', 'startSession(workspaceId?: WorkspaceId): void', 'archiveSession(sessionId: SessionId): Promise', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index cd97f0f0f1..634ec2de95 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2460,7 +2460,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async start(name: string, request: SubagentStartRequest): Promise', - description: 'Establish a published child on the named provider. Capability and semantic checks run before delegation. Provider ownership lasts until its promise fulfills; a rejection therefore has no run for the caller to dispose and emits no run lifecycle events. Post-publication turn and infrastructure failures settle through the returned run.', + description: 'Establish a published child on the named provider. Capability and semantic checks run before delegation. Provider ownership lasts until its promise fulfills; a rejection therefore has no run for the caller to dispose and emits no run lifecycle events. Post-publication turn and infrastructure failures settle through the returned run. A catalog append failure disposes the run and handles its result rejection; the caller receives the catalog error even if disposal also fails.', parameters: [{ name: 'name', description: 'the provider to use.' }, { name: 'request', description: 'child label, prompt, parent, signal, and optional capabilities.' }], returns: 'the published holder-owned run.', }, @@ -4614,7 +4614,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmConfigurableProvider', - declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n declared?: boolean;\n}', + declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n declared?: boolean;\n error?: string;\n}', }, { name: 'LlmDiscoveredModel', diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml index 5311da4c5b..6f879b8f5f 100644 --- a/packages/fs/README.i18n.yaml +++ b/packages/fs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/README.md -README.md: c8cfc5f8d5dd5d225149abbf192d23df1b51cf71 -README.zh.md: 9596f72e76241de43072ee590c141b043af6a861 +README.md: dd3e6f1fab3a8439dd8e2ff15f854e2872549eef +README.zh.md: 6e0b34b2b9ca7a8076f47c4c3302c67b91e200da diff --git a/packages/fs/README.md b/packages/fs/README.md index c8cfc5f8d5..dd3e6f1fab 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -22,7 +22,7 @@ The `fs/` group gives agents durable, policy-governed access to files: the `ctx. ## Packages -Seven packages plus the remote sibling `fs-e2b` play the filesystem roles; the subsystem reference owns the exhaustive contracts and the error taxonomy. +Eight packages plus the remote sibling `fs-e2b` play the filesystem roles; the subsystem reference owns the exhaustive contracts and the error taxonomy. | Package | Role | ctx key | |---|---|---| @@ -34,6 +34,7 @@ Seven packages plus the remote sibling `fs-e2b` play the filesystem roles; the s | [`tool-fs/`](tool-fs/README.md) | Model-facing `read`, `read_image`, `write`, and `edit` tools plus their executor | registers on `ctx.tools` | | [`tool-fs-search/`](tool-fs-search/README.md) | Model-facing `glob` and `grep` discovery tools backed by the packaged ripgrep binary | registers on `ctx.tools` | | [`tool-str-replace-editor/`](tool-str-replace-editor/README.md) | Standalone `str_replace_editor` tool: `view`, `create`, `str_replace`, and `insert` over `ctx.fs` | registers on `ctx.tools` | +| [`tool-present/`](tool-present/README.md) | Explicit immutable snapshots of delivered files | registers on `ctx.tools` | The policy is a plugin, not a service the tools inject: removing it leaves the bare provider's unconditional mutation behavior instead of breaking the tools. The mode fence in `fs-sandbox` and the read-before-edit gate compose. `tool-fs-search` deliberately does not extend the provider contract — search is a process-backed ripgrep workflow, so filesystem backends stay free of a universal search API. diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md index 9596f72e76..6e0b34b2b9 100644 --- a/packages/fs/README.zh.md +++ b/packages/fs/README.zh.md @@ -22,7 +22,7 @@ kind: "package-group" ## 包 -七个包加上远程同级 `fs-e2b` 承担文件系统角色;子系统参考文档拥有穷尽式约定与错误分类体系。 +八个包加上远程同级 `fs-e2b` 承担文件系统角色;子系统参考文档拥有穷尽式约定与错误分类体系。 | 包 | 职责 | ctx 键 | |---|---|---| @@ -34,6 +34,7 @@ kind: "package-group" | [`tool-fs/`](tool-fs/README.zh.md) | 面向模型的 `read`、`read_image`、`write` 与 `edit` 工具及其执行器 | 注册到 `ctx.tools` | | [`tool-fs-search/`](tool-fs-search/README.zh.md) | 由打包 ripgrep 二进制支持的面向模型 `glob` 与 `grep` 发现工具 | 注册到 `ctx.tools` | | [`tool-str-replace-editor/`](tool-str-replace-editor/README.zh.md) | 独立的 `str_replace_editor` 工具:基于 `ctx.fs` 的 `view`、`create`、`str_replace` 与 `insert` | 注册到 `ctx.tools` | +| [`tool-present/`](tool-present/README.zh.md) | 显式保存交付文件的不可变快照 | 注册到 `ctx.tools` | 策略是插件,不是工具注入的服务:移除它只会让工具回到裸提供方的无条件变更行为,而不会破坏工具。`fs-sandbox` 的模式围栏与编辑前读取门禁可以组合。`tool-fs-search` 有意不扩展提供方约定——搜索是由进程支持的 ripgrep 工作流,因此文件系统后端无需承担通用搜索 API。 diff --git a/packages/fs/tool-present/README.i18n.yaml b/packages/fs/tool-present/README.i18n.yaml new file mode 100644 index 0000000000..8797a9d092 --- /dev/null +++ b/packages/fs/tool-present/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/fs/tool-present/README.md +README.md: 6767bb3b8d8839ae776fdf441ef853192c5117a2 +README.zh.md: 8255e7be42de273d58d01ca9b4e108d4c65a585d diff --git a/packages/fs/tool-present/README.md b/packages/fs/tool-present/README.md new file mode 100644 index 0000000000..6767bb3b8d --- /dev/null +++ b/packages/fs/tool-present/README.md @@ -0,0 +1,102 @@ +--- +description: "Declare workspace files as deliverables with present; configuration, Session ownership, and source-file opening." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-present + +English | [中文](README.zh.md) + +## Summary + +Use `present` to declare final workspace files, including files created through shell commands. Users open the current source files in their default application. The tool records paths and optional descriptions without copying file contents. + +## 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 `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must exist inside the Session workspace and be regular files. Missing files, directories, and paths outside the workspace fail the call. + +Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available: + +```yaml +- name: '@deepseek-ai/dsh-tool-present' + config: + maxFiles: 8 +``` + +| Field | Default | Meaning | +|---|---|---| +| `maxFiles` | `8` | Positive maximum file count per call | + +The file-count limit is validated at mount. The tool requires an agent Session with a workspace and an open turn. Delivery belongs to the calling Session; a parent must call `present` itself to declare files created by a subagent. + +----- + + +## Understand the implementation + +
    +Implementation internals — click to expand + +The tool resolves paths through the configured filesystem provider and checks workspace containment and regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance. + +The pure `./types` entry declares `PresentedFile` and the Session event without importing Host runtime code. The Web consumer validates persisted declarations before displaying or opening them. The event stores no Session ID, so forked history resolves relative paths against the viewed Session's workspace. + +**Runtime invariant:** No companion is published. Tool and event registrations are effect-owned, and the Session log owns file declarations; the plugin maintains no independent file-content store. + +
    + +----- + + +## Further Exploration + +- [Filesystem subsystem](../../../docs/subsystems/filesystem.md) — provider paths and errors. +- [Web deliverables](../../client/ui-deliverables/README.md) — source-file opening and cards. +- [Delivery decision](../../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md) — Session ownership and required-on-read events. + + +## Model Experience + +### present + +#### What the model sees + +The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.” Results report `Presented ` for each file; the program result and durable event contain paths and optional descriptions. + +#### Token effect + +One tool schema per mounted agent and one result line per delivered file. File bytes do not enter model messages. + +#### KV Cache effect + +The tool schema is static for the mount lifetime. Delivery result text extends the conversation without rewriting its prompt prefix. + +## Known Limitations and Deferred Work + + + +- Path containment checks are best effort; they cannot atomically defend against a concurrent symlink replacement before the desktop application opens the file. +- Edits change what opens. Deleted or moved source files cannot be opened from their declarations. +- Session ZIP exports contain declarations, not file contents. Persistent delivery versions and copy-on-write storage are deferred. + + +### Dev Note + +
    +Working context for maintainers — click to expand + +None. + +
    diff --git a/packages/fs/tool-present/README.zh.md b/packages/fs/tool-present/README.zh.md new file mode 100644 index 0000000000..8255e7be42 --- /dev/null +++ b/packages/fs/tool-present/README.zh.md @@ -0,0 +1,102 @@ +--- +description: "通过 present 声明交付工作区文件;配置、Session 归属与源文件打开。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-present + +[English](README.md) | 中文 + +## 概述 + +使用 `present` 声明交付最终工作区文件,包括通过 shell 命令创建的文件。用户使用默认应用打开当前源文件。工具记录路径和可选说明,不复制文件内容。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须存在于 Session 工作区内,且为普通文件。文件缺失、为目录或路径位于工作区外时,调用失败。 + +在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影: + +```yaml +- name: '@deepseek-ai/dsh-tool-present' + config: + maxFiles: 8 +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `maxFiles` | `8` | 每次调用的最大文件数,为正整数 | + +挂载时校验文件数量上限。工具要求 Agent Session 具有工作区和已开始的 turn。交付归调用方 Session 所有;父 Session 如需声明交付子 Agent 创建的文件,必须自行调用 `present`。 + +----- + + +## 理解实现 + +
    +实现细节——点击展开 + +工具通过配置的文件系统提供方解析路径,检查工作区包含关系和普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。 + +纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或打开文件前校验持久声明。事件不保存 Session ID,因此 fork 历史中的相对路径按当前查看的 Session 工作区解析。 + +**运行时不变式:** 不发布伴生入口。工具与事件注册归 effect 所有,Session 日志拥有文件声明;插件不维护独立的文件内容存储。 + +
    + +----- + + +## 进一步探索 + +- [文件系统子系统](../../../docs/subsystems/filesystem.zh.md)——提供方路径与错误。 +- [Web 交付](../../client/ui-deliverables/README.zh.md)——源文件打开与卡片。 +- [交付决策](../../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md)——Session 归属与读取端必须识别的事件。 + + +## 模型体验 + +### present + +#### 模型看到的内容 + +[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.” 每个文件的结果为 `Presented `;程序结果和持久事件包含路径及可选说明。 + +#### Token 影响 + +每个挂载的 Agent 增加一个工具 schema,每个交付文件增加一行结果。文件字节不进入模型消息。 + +#### KV Cache 影响 + +工具 schema 在挂载期间保持静态。交付结果文本扩展对话,不重写提示词前缀。 + +## 已知限制与延期工作 + + + +- 路径包含关系检查是尽力而为的;无法原子防御桌面应用打开文件前发生的并发符号链接替换。 +- 编辑会改变打开的内容。源文件删除或移动后,无法通过原声明打开。 +- Session ZIP 导出包含声明,不包含文件内容。交付版本持久化和写时复制存储延期实现。 + + +### 开发备注 + +
    +维护者的工作上下文——点击展开 + +无。 + +
    diff --git a/packages/fs/tool-present/package.json b/packages/fs/tool-present/package.json new file mode 100644 index 0000000000..d1847ded9d --- /dev/null +++ b/packages/fs/tool-present/package.json @@ -0,0 +1,60 @@ +{ + "name": "@deepseek-ai/dsh-tool-present", + "description": "Explicit workspace file delivery declarations for the DeepSeek Harness", + "version": "0.1.5-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/fs/tool-present" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "MIT", + "files": [ + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^" + } +} diff --git a/packages/fs/tool-present/src/index.ts b/packages/fs/tool-present/src/index.ts new file mode 100644 index 0000000000..a68fab6a0b --- /dev/null +++ b/packages/fs/tool-present/src/index.ts @@ -0,0 +1,105 @@ +/** Scoped tool that declares workspace file deliveries in their owning Session. */ +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { FsError } from '@deepseek-ai/dsh-fs' +import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-projection' +import type { Session } from '@deepseek-ai/dsh-session' +import type { PresentedFile } from './types.ts' + +/** Stable Loader identity. */ +export const name = 'tool-present' + +/** Per-call delivery limit. */ +export interface Config { + /** Maximum number of files in one call. */ + maxFiles: number +} + +/** Validated delivery limit. */ +export const Config: z = z.object({ + maxFiles: z.number().default(8), +}) + +/** Services used by the scoped delivery tool. */ +export const inject = ['tools', 'fs', 'sessionProjections'] + +/** + * Register present with durable file references in its tool result. + * @param ctx - agent-scoped services. + * @param config - maximum files per call. + */ +export function apply(ctx: Context, config: Config): void { + if (!Number.isSafeInteger(config.maxFiles) || config.maxFiles < 1) { + throw new Error('present requires a positive integer maxFiles') + } + const pending = new WeakMap() + ctx.tools.register(defineTool({ + name: 'present', + description: 'Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.', + parameters: { + files: { + type: 'array', required: true, + items: { + type: 'object', additionalProperties: false, + properties: { + path: { type: 'string', required: true, description: 'Path of an existing file inside the workspace.' }, + description: { type: 'string', description: 'Brief description for the user.' }, + }, + }, + }, + }, + output: { + schema: { + type: 'object', additionalProperties: false, + properties: { + turn: { type: 'integer', required: true }, + files: { + type: 'array', required: true, + items: { + type: 'object', additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + description: { type: 'string' }, + }, + }, + }, + }, + }, + render: (_args, value) => [{ type: 'text', text: value.files.map(file => `Presented ${file.path}`).join('\n') }], + }, + async execute(args, exec) { + if (exec.agent === undefined) throw new Error('present requires an agent Session') + const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary') + if (boundary === undefined || boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') + if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`) + const cwd = exec.agent.session.header.cwd + if (cwd === undefined) throw new Error('present requires a workspace') + const options = { cwd, signal: exec.signal } + const root = await ctx.fs.resolve('.', options) + const files: PresentedFile[] = [] + for (const file of args.files) { + if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path') + const target = await ctx.fs.resolve(file.path, options) + if (!ctx.fs.contains(root, target)) throw new Error(`Cannot present ${file.path}: outside the workspace`) + const info = await ctx.fs.stat(target, exec.signal) + if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND') + if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) + files.push({ ...file }) + } + exec.signal.throwIfAborted() + pending.set(exec, { session: exec.agent.session, turn: boundary.lastTurn, files }) + return { turn: boundary.lastTurn, files } + }, + })) + ctx.on('tools/result', (exec, result) => { + const delivery = pending.get(exec) + pending.delete(exec) + if (delivery === undefined || result.isError) return + const { session, turn, files } = delivery + session.append('deliverables/presented', { + turn, callId: exec.callId, files, + }) + }) +} diff --git a/packages/fs/tool-present/src/types.ts b/packages/fs/tool-present/src/types.ts new file mode 100644 index 0000000000..796f0104ab --- /dev/null +++ b/packages/fs/tool-present/src/types.ts @@ -0,0 +1,17 @@ +/** Durable file deliveries produced by the present tool. */ +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' + +/** A declared workspace file whose current contents remain at its source path. */ +export interface PresentedFile { + /** Original workspace path. */ + path: string + /** Optional description supplied by the model. */ + description?: string +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** Declared workspace files from a successful final present result, including nested calls. */ + 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } + } +} diff --git a/packages/fs/tool-present/tests/built-errors.e2e.ts b/packages/fs/tool-present/tests/built-errors.e2e.ts new file mode 100644 index 0000000000..466145038e --- /dev/null +++ b/packages/fs/tool-present/tests/built-errors.e2e.ts @@ -0,0 +1,58 @@ +/** Built tool and runtime bundles must preserve their shared structured error classes. */ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const bundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) +const execFileAsync = promisify(execFile) +const probe = String.raw` +import assert from 'node:assert/strict' +import { Context } from './vendor/cordis/lib/index.js' +import AgentRegistry from './packages/core/agent/lib/index.js' +import LocalFileSystem from './packages/fs/fs-local/lib/index.js' +import SystemPrompt from './packages/core/system-prompt/lib/index.js' +import ToolRuntime from './packages/core/tools/lib/index.js' +import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js' +import * as Present from './packages/fs/tool-present/lib/index.js' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +const root = await mkdtemp(join(tmpdir(), 'present-built-')) +const ctx = new Context() +try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: root }) + ctx.provide('sessionProjections', { stateOf() { return { openTurnStartSeq: 1, lastTurn: 1 } } }) + await ctx.plugin(Present, { maxFiles: 2 }) + const scope = ctx.plugin(() => {}) + const session = Session.create('built-present', [], { version: SESSION_FORMAT_VERSION, id: 'built-present', createdAt: 0, cwd: root, isSeeded: false }) + const owner = { id: 'built-present', session, ctx: scope.ctx, options: {}, status: 'idle' } + ctx.agents.register(owner) + const events = [] + ctx.on('tools/result', (_exec, result) => events.push(result)) + let n = 0 + for (const [files, code] of [[[{ path: 'missing' }], 'FS_NOT_FOUND'], [[{ path: 42 }], 'INVALID_ARGS']]) { + const result = await ctx.tools.execute({ signal: new AbortController().signal, name: 'present', callId: 'call-' + (++n), arguments: { files }, agent: owner }) + assert.equal(result.isError, true) + assert.equal(result.error.info.code, code) + assert.equal(events.at(-1).error.info.code, code) + console.log('built ToolRuntime result preserved ' + code) + } +} finally { + try { await ctx.fiber.dispose() } + finally { await rm(root, { recursive: true, force: true }) } +} +` + +it.skipIf(!existsSync(bundle))('preserves present error codes through built ToolRuntime results', { retry: 0 }, async ({ signal }) => { + const { stdout } = await execFileAsync(process.execPath, ['--input-type=module', '-e', probe], { cwd: repoRoot, signal }) + expect(stdout.trim().split('\n')).toEqual([ + 'built ToolRuntime result preserved FS_NOT_FOUND', + 'built ToolRuntime result preserved INVALID_ARGS', + ]) +}) diff --git a/packages/fs/tool-present/tests/present.spec.ts b/packages/fs/tool-present/tests/present.spec.ts new file mode 100644 index 0000000000..95606e09dc --- /dev/null +++ b/packages/fs/tool-present/tests/present.spec.ts @@ -0,0 +1,167 @@ +/** Explicit deliveries commit only after a successful final tool result. */ +import { mkdtemp, rm, writeFile, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime, { defineTool } from '@deepseek-ai/dsh-tools' +import type { PresentedFile } from '../src/types.ts' +import * as Present from '../src/index.ts' + +const cleanups: Array<() => Promise> = [] +let callNumber = 0 +afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + cleanups.length = 0 + vi.restoreAllMocks() +}) +async function agent(ctx: Context, cwd: string | undefined): Promise { + const id = SessionId(`present-owner-${++callNumber}`) + let scope: Scope + const session = Session.create(id, [], { + version: SESSION_FORMAT_VERSION, id, createdAt: 0, ...cwd === undefined ? {} : { cwd }, isSeeded: false, + }) + const value: Agent = { + id, + options: {}, + session, + inbox: unsupportedInbox(), + status: 'idle', + get ctx() { return scope.ctx }, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, value) }, { inject: ['tools'] })) + ctx.agents.register(value) + return value +} + + +async function setup() { + const root = await mkdtemp(join(tmpdir(), 'dsh-present-minimal-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: root }) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + const fiber = ctx.plugin(Present, { maxFiles: 2 }) + await fiber + const owner = await agent(ctx, root) + owner.session.append('turn/start', { turn: 1 }) + const execute = (files: unknown) => ctx.tools.execute({ + signal: new AbortController().signal, callId: ToolCallId(`call-${++callNumber}`), + name: 'present', arguments: { files }, agent: owner, + }) + return { ctx, owner, root, fiber, execute } +} + +describe('present', () => { + it('declares binary files without reading or copying contents, and records one delivery', async () => { + const { ctx, owner, root, execute, fiber } = await setup() + const data = Uint8Array.of(80, 75, 0, 255) + await writeFile(join(root, '报告.docx'), data) + const read = vi.spyOn(ctx.fs, 'readBytes') + const result = await execute([{ path: '报告.docx', description: 'Report' }]) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('present failed') + const files = (result.value as unknown as { files: PresentedFile[] }).files + expect(files).toHaveLength(1) + expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files) + expect(files).toEqual([{ path: '报告.docx', description: 'Report' }]) + expect(read).not.toHaveBeenCalled() + expect(ctx.get('attachments')).toBeUndefined() + await fiber.dispose() + expect(ctx.tools.get('present', owner)).toBeUndefined() + }) + + it('ignores a different present definition in the calling agent scope', async () => { + const { owner, execute } = await setup() + owner.ctx.tools.register(defineTool({ + name: 'present', description: 'Scoped replacement.', parameters: {}, + output: { + schema: { + type: 'object', additionalProperties: false, + properties: { + turn: { type: 'integer', required: true }, + files: { type: 'array', required: true, items: { type: 'string' } }, + }, + }, + render: () => [], + }, + execute: async () => ({ turn: 1, files: [] }), + })) + expect((await execute([])).isError).toBe(false) + expect(owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')).toEqual([]) + }) + + it('records once when ancestor and agent scopes both mount present', async () => { + const { owner, root, execute } = await setup() + await owner.ctx.plugin(Present, { maxFiles: 2 }) + await writeFile(join(root, 'a'), 'a') + expect((await execute([{ path: 'a' }])).isError).toBe(false) + const deliveries = owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented') + expect(deliveries).toHaveLength(1) + expect(deliveries[0]?.data.files[0]?.path).toBe('a') + }) + + it('does not publish deliveries after post-execute blocks a successful declaration', async () => { + const { ctx, root, owner, execute } = await setup() + await writeFile(join(root, 'a'), 'a') + ctx.on('tools/post-execute', async (_exec, _result, next) => { + await next() + return { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] } + }) + expect((await execute([{ path: 'a' }])).isError).toBe(true) + expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) + }) + + it('rejects missing, non-file, outside-workspace, empty, and excessive inputs', async () => { + const { root, owner, execute } = await setup() + await writeFile(join(root, 'large'), 'four') + await symlink(tmpdir(), join(root, 'outside')) + for (const files of [[], [{ path: '' }], [{ path: 'missing' }], [{ path: '.' }], [{ path: 'outside' }], [{ path: 'large' }, { path: 'large' }, { path: 'large' }]]) { + const result = await execute(files) + expect(result.isError, JSON.stringify(files)).toBe(true) + } + expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) + }) + + +}) + + +it('validates deployment limits before registering the tool', () => { + for (const config of [{ maxFiles: 0 }, { maxFiles: 1.5 }, { maxFiles: Number.POSITIVE_INFINITY }]) { + expect(() => { Present.apply(new Context(), config) }).toThrow('positive integer maxFiles') + } +}) + +it('requires an agent, an open turn, and a workspace', async () => { + const { ctx, owner, execute } = await setup() + const detached = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('detached'), name: 'present', arguments: { files: [{ path: 'a' }] } }) + expect(detached.isError).toBe(true) + owner.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect((await execute([{ path: 'a' }])).isError).toBe(true) + const noWorkspace = await agent(ctx, undefined) + noWorkspace.session.append('turn/start', { turn: 1 }) + const absent = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace }) + expect(absent.isError).toBe(true) +}) diff --git a/packages/fs/tool-present/tsconfig.json b/packages/fs/tool-present/tsconfig.json new file mode 100644 index 0000000000..aae51cd968 --- /dev/null +++ b/packages/fs/tool-present/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../fs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../core/tools" + } + ] +} diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 56757a1750..12266ad5e0 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: f92d377cd7f94c6a75b7b1263e0b02f7526a9741 -README.zh.md: 58bf08adc9a71bbe22f534db370ae3b237ffaf32 +README.md: 7f52d3f04634c805fc69b4c00365b9843d308252 +README.zh.md: 7916d2e6e03c163f9f8031344ff8aa36cfff379e diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index f92d377cd7..7f52d3f046 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -114,6 +114,10 @@ The plugin answers "which models can this provider serve?" for a route a configu A route pi-ai does not ship needs `api`, `baseURL`, and a non-empty `models` list; an unserviceable profile is refused where it is written, naming the route and model. Failures carry stable codes: a credential that cannot be used fails with `INVALID_CREDENTIAL` naming the route and reference, a route whose `apiKeyEnv` reference resolves to nothing fails with `MISSING_CREDENTIAL`, an unconfigured model fails with `UNKNOWN_MODEL`, and terminal provider failures distinguish `QUOTA` from transient `RATE_LIMIT`. `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming UI cannot guarantee it across providers. +Settings writes strictly validate each new or changed provider after merging its composition and user layers. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first available model diagnostic or route failure in `LlmConfigurableProvider.error`; unchanged failed providers do not block edits elsewhere. Serviceable models remain selectable, while unresolved models remain in the editable configuration and fail with `INVALID_CONFIG` before network I/O if requested directly. Repairing or deleting the offending configuration clears its diagnostic. Schema and self-contained profile errors still reject loading. Later external edits validate changed providers and retain the last accepted section on failure. + +Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently. + ----- diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 58bf08adc9..7916d2e6e0 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -114,6 +114,10 @@ profile 通过可选 settings seam 每次操作重新读取:base 与用户的 pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无法服务的 profile 会在写入处被拒绝,并点名路由与模型。失败携带稳定 code:无法使用的凭据以 `INVALID_CREDENTIAL` 失败并点名路由与引用,`apiKeyEnv` 引用解析为空的路由以 `MISSING_CREDENTIAL` 失败,未配置模型以 `UNKNOWN_MODEL` 失败,终止性提供方失败则区分 `QUOTA` 与暂时性 `RATE_LIMIT`。`GenerateOptions.stop` 以 `UNSUPPORTED_OPTION` 被拒绝,因为 pi-ai 的通用流式 UI 无法跨提供方保证它。 +Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmConfigurableProvider.error` 优先返回首个模型诊断,无模型诊断时返回路由错误;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。 + +只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。 + ----- diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 41a2171752..8dba978c31 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -233,7 +233,9 @@ export class PiAiAdapter extends LlmAdapter { const profiles = this.config.profiles() if (this.snapshot?.profiles === profiles) return this.snapshot const models: MutableModels = createModels(this.config.auth) - for (const profile of profiles.values()) models.setProvider(profile.piProvider) + for (const profile of profiles.values()) { + if (profile.piProvider !== undefined) models.setProvider(profile.piProvider) + } this.snapshot = { profiles, models } return this.snapshot } @@ -249,7 +251,10 @@ export class PiAiAdapter extends LlmAdapter { /** The configured descriptor for one exact route/model pair within one snapshot. */ private modelOf(snapshot: PiAiSnapshot, provider: string, model: string): Model { - this.profileOf(snapshot, provider) + const profile = this.profileOf(snapshot, provider) + const failure = profile.modelErrors.get(model) + ?? (profile.piProvider === undefined ? profile.catalogError : undefined) + if (failure !== undefined) throw new LlmError(failure, 'INVALID_CONFIG') const resolved = snapshot.models.getModel(provider, model) if (resolved === undefined) { throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, 'UNKNOWN_MODEL') diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index af52b8a49c..d626c1ac71 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -5,9 +5,9 @@ * stays configuration-free while a route pi-ai has never heard of is fully * describable from `settings.yaml`. * - * Every pi-ai `Model` field the harness cannot default is required here rather - * than at request time: an unserviceable route fails while its configuration is - * being resolved, which is the earliest point that can name the offending key. + * Strict resolution rejects unserviceable models before settings writes. + * Deferred resolution retains their diagnostics so stored catalog drift does + * not prevent inspection, repair, or requests to independently valid models. * * @module dsh-llm-pi-ai/catalog */ @@ -640,9 +640,12 @@ export interface RouteCatalogRequest { defaultInput: Model['input'] } +/** An expected configuration failure that stored-catalog reads may retain for repair. */ +export class PiAiCatalogError extends Error {} + /** Report a route the deployment cannot serve, naming the settings key at fault. */ function invalid(provider: string, detail: string): never { - throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`) + throw new PiAiCatalogError(`llm-pi-ai: provider "${provider}" ${detail}`) } /** @@ -798,6 +801,8 @@ function resolveModelCompat( export interface RouteCatalog { /** The materialized models in configuration order. */ models: readonly Model[] + /** Models that cannot be resolved, retained as diagnostics during stored-config reads. */ + modelErrors: ReadonlyMap /** * Per-request output caps this profile explicitly configured, by model id. * @@ -817,9 +822,13 @@ export interface RouteCatalog { * installed catalog unchanged, which is what keeps an existing * `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched. * @param request - the route-level catalog facts. + * @param validation - strict writes reject every error; deferred reads retain model diagnostics. * @returns the materialized models and the explicitly configured request caps. */ -export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { +export function resolveRouteModels( + request: RouteCatalogRequest, + validation: 'strict' | 'deferred' = 'strict', +): RouteCatalog { const { provider } = request const defaults = catalogModels(provider) const providerBaseUrl = catalogProvider(provider)?.baseUrl @@ -828,8 +837,9 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { // serve no request anyway, so both mean "serve the installed catalog". const configured = request.models ?? [] const overrides = request.modelOverrides ?? {} - // Every miss is refused, never skipped: an override that lands nowhere is a - // typo someone would otherwise hunt for in a silently unchanged model. + const modelErrors = new Map() + // Writes reject missing referents. Stored overrides retain a diagnostic + // after catalog removal rather than silently disappearing. for (const [id, override] of Object.entries(overrides)) { if (id.length === 0) invalid(provider, 'has a modelOverrides entry with an empty model id') if (defaults.size === 0) { @@ -841,7 +851,9 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { + ' catalog, so declare the fields on its entries') } if (!defaults.has(id)) { - invalid(provider, `modelOverrides names "${id}", which the installed catalog does not describe`) + const message = `modelOverrides names "${id}", which the installed catalog does not describe` + if (validation === 'strict') invalid(provider, message) + modelErrors.set(id, `llm-pi-ai: provider "${provider}" ${message}`) } // The id lives in the dict key; a value carrying its own would quietly // rename the model it meant to customize. The static shape already omits @@ -865,12 +877,10 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { // wherever it is written, so it cannot look applied on a route whose models // never reach the protocol that would have taken it. assertOfferedCompatFields(provider, 'route', request.compat) - for (const entry of entries) { - assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat) - } const seen = new Set() const configuredMaxTokens = new Map() - const models = entries.map((entry) => { + const resolveEntry = (entry: PiAiModelProfile): Model => { + assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat) if (entry.id.length === 0) invalid(provider, 'has a model with an empty id') if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`) seen.add(entry.id) @@ -918,16 +928,30 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { ...resolveModelReasoning(provider, entry, base), ...resolveModelCompat(provider, entry, request.compat, base, api), } - }) + } + const models: Model[] = [] + for (const entry of entries) { + let model: Model + try { + model = resolveEntry(entry) + } catch (error) { + if (validation === 'strict' || !(error instanceof PiAiCatalogError)) throw error + modelErrors.set(entry.id, error.message) + continue + } + models.push(model) + } + // A later duplicate invalidates the id, including an earlier resolved entry. + const serviceableModels = models.filter(model => !modelErrors.has(model.id)) // Per field, not per block: a route may default a switch its completions // models take beside one only its anthropic models do, and neither should // fail for the other's sake. What is refused is a route default no model on // the route could ever read, which is a route that will not behave as written. for (const [field] of configuredCompatEntries(request.compat)) { const takers = compatProtocols(field) - if (models.some(model => takers.includes(model.api))) continue + if (serviceableModels.some(model => takers.includes(model.api))) continue invalid(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it;` + ` it exists on ${takers.join(', ')}`) } - return { models, configuredMaxTokens } + return { models: serviceableModels, configuredMaxTokens, modelErrors } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index b50c448af5..2f9e563008 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -6,10 +6,9 @@ * A route key is not required to name an installed pi-ai provider. When it does, * that provider's endpoint, protocol, display name, and model catalog are the * profile's defaults and the profile overrides them field by field; when it does - * not, the profile is the whole provider declaration. Resolution therefore ends - * in a built pi-ai `Provider` per route: everything a request needs is decided - * once, while the configuration key that made a route unserviceable can still be - * named in the failure. + * not, the profile is the whole provider declaration. Stored reads retain + * catalog diagnostics beside serviceable models; writes validate every changed + * provider before persistence. Self-contained profile constraints apply to both. * * @module dsh-llm-pi-ai/config */ @@ -21,11 +20,13 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import { deepEqualJson } from '@deepseek-ai/dsh-util-values' import { CACHE_CONTROL_FORMATS, CHAT_TEMPLATE_VARS, MAX_TOKENS_FIELDS, MODALITIES, + PiAiCatalogError, resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS, @@ -37,6 +38,7 @@ import type { PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts, + RouteCatalog, } from './catalog.ts' import { buildProvider, supportedProtocols } from './provider.ts' @@ -199,12 +201,14 @@ export interface ResolvedPiAiProviderProfile /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy /** - * The pi-ai provider this route registers, built from the resolved models. - * Construction happens here so an unserviceable protocol or an underspecified - * model fails with the rest of resolution, leaving the last good route set - * serving requests. + * The pi-ai provider containing this route's serviceable models. Absent when + * a stored route cannot be constructed; its configuration remains editable. */ - piProvider: Provider + piProvider?: Provider + /** First model diagnostic, or the route failure when no model diagnostic is available. */ + catalogError?: string + /** Per-model failures reported before attempting a request. */ + modelErrors: ReadonlyMap /** * Per-request output caps this profile explicitly configured, by model id. * The seam materializes one only into a request that names no cap of its @@ -346,19 +350,17 @@ export const Config: z = z.object({ }) /** - * Reject a section this adapter could not serve. Registered as the settings - * namespace's validator, so an unserviceable profile is refused where it is - * *written* — `settings.mutate` answers `settings-rejected` with the offending - * route and model named — instead of being stored and then quietly disabling - * every route in the namespace. It stays a validator rather than a schema - * transform because the schema is also the shape a configuration surface - * renders and the value an absent section resolves to; wrapping it would break - * both. + * Reject new or changed provider profiles that cannot be served. Unchanged + * stored profiles may need repair after a catalog upgrade and do not block + * edits to another provider. Removed profiles require no catalog validation. * @param config - the resolved section to check. + * @param previous - current resolved section; omission checks every provider. * @throws Error naming the route and configuration entry that cannot be served. */ -export function assertServiceable(config: Config): void { - resolveProfiles(config.providers) +export function assertServiceable(config: Config, previous?: Config): void { + const changed = Object.fromEntries(Object.entries(config.providers ?? {}).filter(([provider, profile]) => + !deepEqualJson(profile, previous?.providers?.[provider]))) + resolveProfiles(changed) } /** Reject removed pre-release profile fields and name their replacements. */ @@ -394,15 +396,16 @@ function assertValidHeaders(provider: string, headers: Readonly