Merge commit 'ecbf75a5e70f662b6420375140cf12eb6bac7860' into worktree/retarget-pr828-20260729
# Conflicts: # packages/hooks/README.i18n.yaml # packages/hooks/README.zh.md # packages/hooks/hook-protocol/README.i18n.yaml # packages/hooks/hook-protocol/README.zh.md # packages/hooks/hooks-claude/README.i18n.yaml # packages/hooks/hooks-claude/README.zh.md # packages/hooks/hooks-codex/README.i18n.yaml # packages/hooks/hooks-codex/README.zh.md
This commit is contained in:
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/acp/README.md
|
||||
README.md: 326615210e5cfc39004fc5ab7462623089ac4126
|
||||
README.zh.md: 9999ecdd019501c3f501a6c69fab5e0ccfaf555c
|
||||
README.zh.md: 8679f2428a9e82a81de69d7d1413132a946fcafa
|
||||
|
||||
@@ -6,6 +6,6 @@ ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接拥有的清理。 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接负责的清理。 |
|
||||
|
||||
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以驱动同一服务器契约。
|
||||
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。
|
||||
|
||||
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
|
||||
README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
|
||||
README.zh.md: f8abe9e45a5efffa436513f7d4a931c69c624b84
|
||||
README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、通过策略解决一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
|
||||
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
|
||||
|
||||
此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、mode、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与人类问题属于 Web 和 TUI 模块。
|
||||
此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。
|
||||
|
||||
## 插件
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| `provider` | 无 | 每个已创建 agent 的初始提供方路由。 |
|
||||
| `model` | 无 | 每个已创建 agent 的初始模型。 |
|
||||
|
||||
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。
|
||||
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行的 ACP 组合同时要求两者。
|
||||
|
||||
## 协议契约
|
||||
|
||||
@@ -23,19 +23,19 @@
|
||||
|---|---|
|
||||
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 使用绝对主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 连接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并从该请求拥有的持久 `turn/end` 结算。 |
|
||||
| `session/cancel` | 仅取消被定址的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并根据该请求所属的持久 `turn/end` 结算。 |
|
||||
| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id 的桥接层所有批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
|
||||
一个连接可以拥有多个会话。桥接层使用带品牌的 session id 为记录建键,并在路由事件或权限请求前检查精确的 agent 标识。每个会话都有独立的提示词槽位、workspace、取消路径和 disposer。
|
||||
一个连接可以拥有多个会话。桥接层以带品牌的会话 id 作为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器。
|
||||
|
||||
已提交消息输出有意以逐 token 延迟换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
|
||||
已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
|
||||
|
||||
## 生命周期
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行释放所有已拥有的 agent handle,并等待它们的循环/会话清理完成。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
客户端断开连接与 Cordis 的 dispose(资源释放)共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose,并等待它们的循环/会话清理完成。因此,单独重载 ACP 插件不会遗留孤儿 agent。
|
||||
|
||||
## 运行
|
||||
|
||||
@@ -45,13 +45,13 @@
|
||||
|
||||
### 提示词文本
|
||||
|
||||
#### 模型所见内容
|
||||
#### 模型看到的内容
|
||||
|
||||
`session/prompt` 文本块会原样连接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
|
||||
`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
提示词 token 取决于数据,并保留在该会话的历史中直到压缩。并发 ACP 会话保留独立上下文。
|
||||
提示词 token 取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -59,21 +59,21 @@
|
||||
|
||||
### 权限决策
|
||||
|
||||
#### 模型所见内容
|
||||
#### 模型看到的内容
|
||||
|
||||
没有直接内容。拥有该决策的工具通过常规工具结果路径记录允许、拒绝、取消或不可用结果。
|
||||
不会直接看到任何内容。所属工具通过常规工具结果路径记录其结果:允许、拒绝、取消或不可用。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有拥有该决策的工具结果会贡献 token。
|
||||
只有该工具的结果会贡献 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
通过所属工具结果仅追加。
|
||||
随该工具的结果仅追加。
|
||||
|
||||
## 已知限制与延后工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅新会话**:不支持加载、列出、恢复、删除和 fork。
|
||||
- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会被展平为文本引用,而不是已获取内容。
|
||||
- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不上线。
|
||||
- **连接拥有的生命期**:一个连接会释放其所有会话;尚未实现每会话关闭。
|
||||
- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。
|
||||
- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输。
|
||||
- **由连接管理的生命周期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能。
|
||||
|
||||
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/bash/README.md
|
||||
README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b
|
||||
README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db
|
||||
README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品** 包。
|
||||
规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
@@ -11,4 +11,4 @@
|
||||
| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) |
|
||||
| `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) |
|
||||
|
||||
接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器配置项;受限实现还需选择一个 `ctx.sandbox` 提供方配置项(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。
|
||||
接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。
|
||||
|
||||
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
|
||||
README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
|
||||
README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516
|
||||
README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c <command>` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有。
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c <command>` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。
|
||||
|
||||
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
|
||||
设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下:
|
||||
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
|
||||
- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -36,12 +36,12 @@
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;请求前缀变更由具名消费方负责。
|
||||
不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。
|
||||
- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。
|
||||
- **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。
|
||||
- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。
|
||||
- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。
|
||||
- **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。
|
||||
|
||||
|
||||
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md
|
||||
README.md: ca77a9c626784b29145712535d69de4afbd3a697
|
||||
README.zh.md: c1a65ead539ef3930d70d27f2b176a5346daded3
|
||||
README.zh.md: 4ecc8d533f7af373bdacd133d44a8def6d265868
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
消费 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 的沙箱实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);后者拥有默认模式 + 工作区根目录,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。
|
||||
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用(必须快速失败并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默无约束运行),属于提供方职责;本包只拥有 bash 侧。
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
|
||||
|
||||
| 模式 | 文件影响 |
|
||||
|---|---|
|
||||
@@ -17,12 +17,12 @@
|
||||
语义:
|
||||
|
||||
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
|
||||
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,bash 产生方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent 调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。
|
||||
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。
|
||||
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
|
||||
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
|
||||
|
||||
seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它驱动本包遵守的覆盖。
|
||||
该 seam 只报告拒绝:拒绝是一项结果事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它设置本包所遵守的模式覆盖值。
|
||||
|
||||
```yaml
|
||||
- id: sandbox
|
||||
@@ -36,7 +36,7 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
```
|
||||
|
||||
无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,在真实世界验证,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。
|
||||
无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,从外部验证实际文件效果,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -44,11 +44,11 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布一个执行限制的 `sandboxMode`,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。
|
||||
基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布表明启用隔离的 `sandboxMode` 能力,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
在 `bash` 可见的请求上增加少量固定 schema;模式切换不增加上下文 token。
|
||||
在 `bash` 可见的请求上,schema 固定增加少量内容;模式切换不增加上下文 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -62,29 +62,29 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协
|
||||
|
||||
#### Token 影响
|
||||
|
||||
除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到压缩。
|
||||
除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到上下文压缩(context compaction)。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 间接的 Bash 工具错误
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误;它由 `dsh-sandbox` 持有](../../sandbox/sandbox/README.md#confinement-error-indirectly)。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
该次调用可见的是有条件错误文本,并保留在历史记录中直到压缩。
|
||||
该次调用会在相应条件下显示错误文本,该文本会保留在历史记录中直到上下文压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
|
||||
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但匹配的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
|
||||
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
|
||||
- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
|
||||
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。
|
||||
|
||||
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
|
||||
README.md: d7bf746969f52000fe298b65b995b7c631d8001c
|
||||
README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339
|
||||
README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
|
||||
|
||||
本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
@@ -13,19 +13,19 @@
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 |
|
||||
| `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema |
|
||||
|
||||
该拆分与 LLM seam(`LlmService`/`LlmAdapter`)及 agent 工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
|
||||
该拆分与 LLM(大语言模型) seam(`LlmService`/`LlmAdapter`)及 agent(智能体)工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
|
||||
|
||||
## 服务 API(`ctx.bash`)
|
||||
|
||||
| 成员 | 语义 |
|
||||
|---|---|
|
||||
| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止终止都会 resolve 为描述性 `BashRunResult`。 |
|
||||
| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止导致的终止都会 resolve 为描述性 `BashRunResult`。 |
|
||||
| `start(spec)` | 后台执行。立即返回不含任务语义的 `BashProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.tasks`。 |
|
||||
| `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 |
|
||||
| `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 |
|
||||
| `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 |
|
||||
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose 必须终止每个运行中的进程并等待其退出,详见 HMR 安全测试。
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出,详见 HMR(热模块替换)安全测试。
|
||||
|
||||
## 词汇
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -41,9 +41,9 @@
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;请求前缀变更由具名消费方负责。
|
||||
不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有交互式输入词汇**:`stdin` 只会在 spawn 时写入一次并关闭;seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。
|
||||
- **前台超时始终由执行器拥有**:seam 上的调用方拥有 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。
|
||||
- **前台超时始终由执行器负责**:seam 上由调用方负责 deadline 的模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。
|
||||
|
||||
@@ -80,6 +80,62 @@ const MARKDOWN_FIXTURE = [
|
||||
|
||||
const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
|
||||
|
||||
/**
|
||||
* SGR wrapper for the terminal output sample below: authoring the escapes as
|
||||
* `\u001b` keeps literal control bytes out of this source file.
|
||||
* @param code - the SGR parameter (an ANSI color or attribute number).
|
||||
* @param body - the text the attribute applies to.
|
||||
* @returns the body wrapped in the attribute and a reset.
|
||||
*/
|
||||
function sgr(code: number, body: string): string {
|
||||
return `\u001b[${code}m${body}\u001b[0m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal output sample for fixture turn 65, authored to carry every feature
|
||||
* the terminal card draws that turn 60's two prompt rows cannot reach:
|
||||
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
|
||||
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
|
||||
* rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
|
||||
* height cap collapses the middle. The exit status is authored separately in
|
||||
* TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
|
||||
* presenter CONSUMES its `[exit code: N]` marker out of the body, because a
|
||||
* terminal card shows the exit as its own pill and leaving the marker in would
|
||||
* render it twice (packages/bash/tool-bash/src/render.ts).
|
||||
*/
|
||||
const TERMINAL_OUTPUT_FIXTURE = [
|
||||
sgr(1, 'Running 4 checks'),
|
||||
`${sgr(32, '\u2713')} typecheck 1.82s`,
|
||||
`${sgr(32, '\u2713')} lint 0.94s`,
|
||||
`${sgr(32, '\u2713')} duplication 2.10s`,
|
||||
`${sgr(31, '\u2717')} unit 8.41s`,
|
||||
'',
|
||||
sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'),
|
||||
` ${sgr(31, 'FAIL')} caps output at the configured line budget`,
|
||||
' expected 16 lines, received 24',
|
||||
'',
|
||||
'NAME LINES BRANCHES FUNCTIONS UNCOVERED',
|
||||
'TerminalBlock.tsx 100% 100% 100% -',
|
||||
'ansi.ts 100% 100% 100% -',
|
||||
'clipboard.ts 100% 100% 100% -',
|
||||
'CodeBlock.tsx 98.4% 96.2% 100% 41-43',
|
||||
'highlight.ts 100% 100% 100% -',
|
||||
'Pill.tsx 100% 100% 100% -',
|
||||
'StateDot.tsx 100% 100% 100% -',
|
||||
'markdown/Markdown.tsx 100% 100% 100% -',
|
||||
'',
|
||||
sgr(31, '1 of 4 checks failed'),
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Exit status for each terminal sample, keyed by its output text. Authored
|
||||
* alongside the sample rather than parsed back out of its trailing marker,
|
||||
* which is the bash tool's own job and not something to reimplement here.
|
||||
*/
|
||||
const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
|
||||
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
|
||||
}
|
||||
|
||||
const DEEPSEEK_REASONING = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
@@ -170,7 +226,9 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
|
||||
// A two-line command, so the fixture covers the terminal card's one-row-per-
|
||||
// command-line prompt (and that the card still marks the call exactly once).
|
||||
toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
|
||||
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
|
||||
@@ -224,8 +282,22 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
|
||||
// whose prompt label is its last segment, and a non-zero exit authored beside
|
||||
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
|
||||
// `[exit code: N]` marker, since the real presenter consumes that one out of
|
||||
// the body. Named `bash`, so it also covers
|
||||
// the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
|
||||
// row) — the two chat-row shapes the terminal card renders in.
|
||||
//
|
||||
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
|
||||
// next `turn/start`, so a turn appended after it would leave the dock's plan
|
||||
// strip empty and take the todo surfaces' own coverage with it.
|
||||
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -250,7 +322,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
return undefined
|
||||
}
|
||||
switch (name) {
|
||||
// Both names present the same terminal card: `fx-bash` lands on the
|
||||
// render-site fallback row, `bash` on the keyed BashRow registration.
|
||||
case 'fx-bash':
|
||||
case 'bash':
|
||||
return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
|
||||
case 'fx-write':
|
||||
return {
|
||||
@@ -271,7 +346,10 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR
|
||||
if (call === undefined) return undefined
|
||||
switch (call.card) {
|
||||
case 'terminal':
|
||||
return { card: 'terminal', output: resultText, exitCode: 0 }
|
||||
// The sample's own exit status, authored beside it: re-parsing the
|
||||
// trailing marker here would duplicate the bash tool's `parseExitStatus`,
|
||||
// which this client-side fixture cannot import.
|
||||
return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
|
||||
case 'diff':
|
||||
return { card: 'diff', diffs: call.diffs }
|
||||
case 'generic':
|
||||
@@ -331,6 +409,44 @@ function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: b
|
||||
}
|
||||
|
||||
/** Fixture parallel of the host's projection units: whole current values per key over the full log. */
|
||||
/** Fixture preset table (the host PermissionService defaults). */
|
||||
const PERMISSION_PRESETS: Record<string, { sandbox: string; approval: string; description: string }> = {
|
||||
'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
|
||||
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' },
|
||||
}
|
||||
|
||||
/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */
|
||||
function permissionSelectOf(
|
||||
log: readonly SessionEvent[],
|
||||
): { options: { value: string; name: string; description?: string }[]; currentValue: string } {
|
||||
let preset: string | null = null
|
||||
let sandbox = 'workspace-write'
|
||||
let approval = 'ask'
|
||||
for (const event of log) {
|
||||
const item = event as { type: string; data: Record<string, unknown> }
|
||||
if (item.type === 'permission/preset') preset = item.data['preset'] as string
|
||||
else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string
|
||||
else if (item.type === 'approval/policy') approval = item.data['policy'] as string
|
||||
}
|
||||
const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval
|
||||
let currentValue = 'custom'
|
||||
const folded = preset === null ? undefined : PERMISSION_PRESETS[preset]
|
||||
if (preset !== null && folded !== undefined && matches(folded)) {
|
||||
currentValue = preset
|
||||
} else {
|
||||
for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) {
|
||||
if (matches(spec)) { currentValue = name; break }
|
||||
}
|
||||
}
|
||||
return {
|
||||
options: [
|
||||
...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })),
|
||||
...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [],
|
||||
],
|
||||
currentValue,
|
||||
}
|
||||
}
|
||||
|
||||
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
|
||||
@@ -339,6 +455,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
||||
}
|
||||
// Always present (tool-todo unit composed): null when no plan stands.
|
||||
values['todos'] = backscanTodos(log) ?? null
|
||||
// Always present (permission service composed): the whole select.
|
||||
values['permissions'] = permissionSelectOf(log)
|
||||
// Always present (plan-mode unit composed): the {active, pending} view.
|
||||
values['plan'] = planViewOf(log)
|
||||
// Always present (GoalService unit composed): null before create / after clear.
|
||||
@@ -373,6 +491,16 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
// Knob fold: any of the three whole-value knob events advances the select.
|
||||
if (type === 'permission/preset' || type === 'sandbox/mode' || type === 'approval/policy') {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
key: 'permissions',
|
||||
value: permissionSelectOf(log),
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
// The plan unit advances on its two folded event kinds.
|
||||
if (type === 'plan/mode' || (type === 'command/run'
|
||||
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
|
||||
@@ -607,8 +735,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return crumbs
|
||||
}
|
||||
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
|
||||
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
|
||||
/** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */
|
||||
const pendingApprovalRpcId = mint()
|
||||
const pendingApprovalId = 'fx-approval-1' as Extract<MuxFrame, { type: 'approval/requested' }>['approvalId']
|
||||
/** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */
|
||||
let approvalPending = true
|
||||
const pendingQuestionRpcId = mint()
|
||||
let questionPending = true
|
||||
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
|
||||
@@ -1148,6 +1279,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
|
||||
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
|
||||
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
|
||||
],
|
||||
})
|
||||
@@ -1164,6 +1296,26 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
|
||||
const name = match?.[1]
|
||||
const args = match?.[2] ?? ''
|
||||
// /permission mirrors the host handler: switch through the knob
|
||||
// events (each append pushes a permissions projection frame).
|
||||
if (name === 'permission') {
|
||||
const preset = args.trim()
|
||||
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
|
||||
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
|
||||
const spec = PERMISSION_PRESETS[preset]
|
||||
if (preset === '') {
|
||||
const current = permissionSelectOf(logOf(id)).currentValue
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } })
|
||||
} else if (spec === undefined) {
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
|
||||
} else {
|
||||
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
|
||||
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
|
||||
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
|
||||
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } })
|
||||
}
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
}
|
||||
if (name === 'goal') {
|
||||
// Host parallel: /goal with an objective creates (or reports) the
|
||||
// current goal; the command lifecycle pair brackets the mutation.
|
||||
@@ -1298,14 +1450,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } })
|
||||
}
|
||||
}
|
||||
conn.push({
|
||||
rpcId: pendingApprovalRpcId,
|
||||
payload: {
|
||||
type: 'approval/requested', sessionId: sid('fx-alpha'),
|
||||
approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract<MuxFrame, { type: 'approval/requested' }>['approvalId'],
|
||||
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
|
||||
},
|
||||
})
|
||||
if (approvalPending) {
|
||||
conn.push({
|
||||
rpcId: pendingApprovalRpcId,
|
||||
payload: {
|
||||
type: 'approval/requested', sessionId: sid('fx-alpha'),
|
||||
approvalId: pendingApprovalId,
|
||||
toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (questionPending) {
|
||||
conn.push({
|
||||
rpcId: pendingQuestionRpcId,
|
||||
@@ -1343,6 +1497,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
},
|
||||
},
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
// Same routing discipline as the host: rpcId first, then the payload's
|
||||
// audit correlation; a settled or unknown id is not-pending.
|
||||
if (message.rpcId === pendingApprovalRpcId) {
|
||||
if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||
const value = message.result.value as { approvalId?: unknown; outcome?: unknown }
|
||||
if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) {
|
||||
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||
}
|
||||
approvalPending = false
|
||||
emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome })
|
||||
return Promise.resolve({ accepted: true })
|
||||
}
|
||||
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
|
||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => {
|
||||
expect(response.rpcId).toBe(request.rpcId)
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
const commands = response.result.value.commands
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'plan'])
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
|
||||
// input hint rides only the commands declaring it.
|
||||
const echo = commands.find(c => c.name === 'echo')
|
||||
expect(echo?.input?.hint).toBeTruthy()
|
||||
|
||||
@@ -72,8 +72,19 @@ describe('createFixtureApi', () => {
|
||||
// Fixture composes the todos + plan units (host parallel when tool-todo
|
||||
// and plan-mode are mounted): the empty-log values.
|
||||
expect(empty.result.value).toEqual({
|
||||
events: [], hasMore: false,
|
||||
projections: { asOfSeq: -1, values: { goal: null, todos: null, plan: { active: false, pending: false } } },
|
||||
events: [], hasMore: false, projections: { asOfSeq: -1, values: {
|
||||
todos: null,
|
||||
// Permission unit composed: the composition-default select.
|
||||
permissions: {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
},
|
||||
plan: { active: false, pending: false },
|
||||
goal: null,
|
||||
} },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,7 +219,7 @@ describe('createFixtureApi', () => {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 7) abort.abort()
|
||||
if (envelopes.length >= 8) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
@@ -216,15 +227,16 @@ describe('createFixtureApi', () => {
|
||||
const second = await openOnce()
|
||||
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||
// Projection baseline frames follow the subscribed frame (title + todos + plan + goal units).
|
||||
// Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units).
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
|
||||
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[5]?.rpcId).toBe(first[5]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[6]?.rpcId).toBe(first[6]?.rpcId)
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[7]?.rpcId).toBe(first[7]?.rpcId)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
@@ -311,6 +323,44 @@ describe('createFixtureApi', () => {
|
||||
})).toEqual({ accepted: true })
|
||||
})
|
||||
|
||||
it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => {
|
||||
const api = createFixtureApi()
|
||||
// Discover the resident approval's stable rpcId from the mux baseline.
|
||||
const abort = new AbortController()
|
||||
const seen: { rpcId: string; frame: MuxFrame }[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload })
|
||||
})()
|
||||
await vi.waitFor(() => {
|
||||
expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true)
|
||||
})
|
||||
const requested = seen.find(s => s.frame.type === 'approval/requested')
|
||||
if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable')
|
||||
const approvalId = requested.frame.approvalId
|
||||
|
||||
// Routed but malformed answers.
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } }))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } }))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
// The real answer settles the question and broadcasts resolved.
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } }))
|
||||
.toEqual({ accepted: true })
|
||||
await vi.waitFor(() => {
|
||||
expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true)
|
||||
})
|
||||
// Settled: a duplicate answer is late, and a fresh mux open replays nothing.
|
||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } }))
|
||||
.toEqual({ accepted: false, reason: 'not-pending' })
|
||||
abort.abort()
|
||||
await consuming
|
||||
const abort2 = new AbortController()
|
||||
const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2)
|
||||
expect(replayed.some(f => f.type === 'approval/requested')).toBe(false)
|
||||
})
|
||||
|
||||
it('describe answers the fixture identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.host.describe(req({}))
|
||||
|
||||
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
|
||||
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
|
||||
README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de
|
||||
README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。
|
||||
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
|
||||
浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber 之前执行:只释放 fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、以 `fiber.await()` 将启动失败高声重新抛出。依赖方由 cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR,无需 builder→host 通道。
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(connection/runtime fiber、Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新会随重新连接握手机制落地。
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新将在重新连接握手机制中实现。
|
||||
|
||||
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
|
||||
README.md: 9015af2b44a33771b06863ace139fe97695df616
|
||||
README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba
|
||||
README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf
|
||||
|
||||
@@ -10,9 +10,9 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
|
||||
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。
|
||||
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的界面区域会保留已渲染文本,直到重新挂载。
|
||||
|
||||
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
|
||||
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
|
||||
README.zh.md: 7d1aa8af08256c47c1ae65343e46c30e910128d0
|
||||
README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表构建。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并把该包的 `ClientModuleLoader` 作为其 `internal` seam 注入;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
|
||||
客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表实现。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并把该包(package)的 `ClientModuleLoader` 作为其 `internal` seam 注入;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
|
||||
|
||||
惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 请求另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法交付部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
|
||||
惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;图行(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达 hook(抓取 + 执行,只注册;并发调用共享一个进行中的 task);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取(HMR hook)。
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表叶;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。
|
||||
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只逐记录清点自身拥有的样式标签 id。
|
||||
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。
|
||||
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
|
||||
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9
|
||||
README.zh.md: 8ac2ea10884aee48775dc5c545fac414edcddefe
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录末尾的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
## Code Mode 子调用索引
|
||||
|
||||
@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## Session 标题投影
|
||||
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。
|
||||
|
||||
## 会话模型选择
|
||||
|
||||
@@ -30,14 +30,14 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为 Session 对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
|
||||
无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
|
||||
更改目标可能改变提供方侧的缓存复用,或使其失效;该包(package)本身不会改变提示词前缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose(资源释放) → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘(postmortem)所记录的问题。
|
||||
|
||||
@@ -46,6 +46,13 @@ export interface ISession {
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle).
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
*/
|
||||
command(line: string): Promise<RpcResult<{ matched: boolean }>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* the concrete class. Widening this interface is the explicit act of
|
||||
* widening what features may do to the workspaces domain.
|
||||
*/
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { WorkspaceListState } from '../workspaces/service.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
@@ -37,6 +37,20 @@ export interface IWorkspaces {
|
||||
* @returns the selected path, or null when the user cancelled.
|
||||
*/
|
||||
pickDirectory(): Promise<string | null>
|
||||
/**
|
||||
* List one directory level through the Host's `browse` capability.
|
||||
* @param path - absolute directory to list; absent lists the Host home directory.
|
||||
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
|
||||
* @returns the level's listing with breadcrumb ancestry.
|
||||
*/
|
||||
listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
|
||||
/**
|
||||
* Create one child directory through the Host's `browse` capability.
|
||||
* @param path - absolute existing parent directory.
|
||||
* @param name - single non-blank path segment.
|
||||
* @returns the created directory's absolute path.
|
||||
*/
|
||||
createDirectory(path: string, name: string): Promise<string>
|
||||
/**
|
||||
* Open a filesystem path with the Host operating system's default application.
|
||||
* @param path - absolute or host-resolvable path.
|
||||
|
||||
@@ -154,6 +154,12 @@ export function apply(ctx: Context): void {
|
||||
workspaces.handleConnected()
|
||||
ctx.emit('connection/reset')
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
// Generation death fires before any next-generation frame can arrive
|
||||
// (reconnect replays flow from stream open, ahead of onConnected):
|
||||
// the only safe moment to drop generation-scoped interaction state.
|
||||
if (state === 'reconnecting') sessions.handleDisconnected()
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary {
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth). */
|
||||
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
|
||||
export interface SessionListEntry {
|
||||
sessionId: SessionId
|
||||
title?: string
|
||||
@@ -19,6 +19,8 @@ export interface SessionListEntry {
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
cwd?: string
|
||||
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
|
||||
waitingApproval: boolean
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
depth: number
|
||||
}
|
||||
@@ -28,9 +30,10 @@ export interface SessionListEntry {
|
||||
* follows the established input order; this projection never re-sorts a
|
||||
* hydrated list from mutable timestamps.
|
||||
* @param summaries - the host's session.list items.
|
||||
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
|
||||
* @returns display rows in render order.
|
||||
*/
|
||||
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
|
||||
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
|
||||
const byId = new Map<SessionId, TitledSessionSummary>()
|
||||
for (const s of summaries) byId.set(s.sessionId, s)
|
||||
|
||||
@@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
|
||||
return
|
||||
}
|
||||
visited.add(s.sessionId)
|
||||
out.push({ ...s, depth })
|
||||
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
|
||||
const kids = children.get(s.sessionId)
|
||||
if (kids === undefined) return
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
|
||||
@@ -57,6 +57,11 @@ export class SessionManager {
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
|
||||
* replays of the same requested frame). Manager-owned rather than read off Session instances
|
||||
* because the sidebar must light up for sessions never instantiated. Cleared per connection
|
||||
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
|
||||
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
|
||||
/** Per-session projection value stores, retained independently of instance arrival (the
|
||||
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
||||
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
||||
@@ -360,6 +365,22 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
|
||||
// every session, instantiated or not; approvalId keys make replays idempotent.
|
||||
if (frame.type === 'approval/requested') {
|
||||
let ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
|
||||
if (!ids.has(frame.approvalId)) {
|
||||
ids.add(frame.approvalId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
} else if (frame.type === 'approval/resolved') {
|
||||
const ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids !== undefined && ids.delete(frame.approvalId)) {
|
||||
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
const session = this.sessions.get(frame.sessionId)
|
||||
if (session === undefined) {
|
||||
// Approval/question/queued frames never hit history: buffer for replay on
|
||||
@@ -404,6 +425,7 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
return
|
||||
}
|
||||
@@ -421,6 +443,30 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment a connection generation dies (before any next-generation frame
|
||||
* can arrive — onConnected waits for the readiness handshake while replayed
|
||||
* frames flow from stream open, so clearing there would race the replay):
|
||||
* drop generation-scoped live state. Approvals resolved while disconnected
|
||||
* send no frame, so the stale bits and the buffered answerable frames must
|
||||
* not survive into the next generation — the mux-open replay re-adds every
|
||||
* still-pending question with its live rpcId.
|
||||
*/
|
||||
handleDisconnected(): void {
|
||||
if (this.waitingApprovals.size > 0) {
|
||||
this.waitingApprovals.clear()
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
|
||||
const kept = buffer.filter(item =>
|
||||
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
|
||||
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
|
||||
if (kept.length === buffer.length) continue
|
||||
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
|
||||
else this.pendingBuffers.set(sessionId, kept)
|
||||
}
|
||||
}
|
||||
|
||||
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
|
||||
handleConnected(): void {
|
||||
void this.refreshList()
|
||||
@@ -436,7 +482,7 @@ export class SessionManager {
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
})
|
||||
const fresh = flattenLineage(merged)
|
||||
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
|
||||
const items = fresh.map((entry) => {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
@@ -444,6 +490,7 @@ export class SessionManager {
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.waitingApproval === entry.waitingApproval
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
return entry
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface SessionSummary {
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
/** An approval question is pending on this session (sidebar amber-dot state). */
|
||||
waitingApproval: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
@@ -292,6 +294,11 @@ export class SessionsService implements ISessions {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/** Drop generation-scoped live interaction state the moment a connection generation dies. */
|
||||
handleDisconnected(): void {
|
||||
this.manager.handleDisconnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host. Resolution guarantee: by the time the
|
||||
* promise resolves, the created session is in the list store and
|
||||
@@ -463,6 +470,7 @@ export class SessionsService implements ISessions {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
waitingApproval: entry.waitingApproval,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
|
||||
@@ -252,6 +252,21 @@ export class Session implements SessionFace {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle;
|
||||
* outcomes render as flow nodes, never as a response echo).
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
*/
|
||||
async command(line: string): Promise<RpcResult<{ matched: boolean }>> {
|
||||
try {
|
||||
return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
|
||||
open(): Promise<void> {
|
||||
if (this.openState === 'open') return Promise.resolve()
|
||||
@@ -812,7 +827,10 @@ export class Session implements SessionFace {
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
composerPhase: derivePhase(
|
||||
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
// Command lifecycle nodes are not conversation: running /permission
|
||||
// or /plan on a fresh session keeps the hero (the client mirror of
|
||||
// the host's no-turn sessionBlank predicate).
|
||||
nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
this.promptAttempted,
|
||||
),
|
||||
removed: this.removed,
|
||||
@@ -833,7 +851,9 @@ export class Session implements SessionFace {
|
||||
* object: `hasContent` only grows within a window and `promptAttempted` is
|
||||
* sticky, so blank → engaging → active never steps back; a failed first
|
||||
* prompt stays engaging (retry semantics — see ComposerPhase).
|
||||
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
|
||||
* @param hasContent - any conversation material exists (non-command nodes,
|
||||
* partial, running turn, pending waits; command lifecycle rows alone keep
|
||||
* the session blank).
|
||||
* @param promptAttempted - a prompt was initiated on this session object.
|
||||
* @returns the derived phase.
|
||||
*/
|
||||
|
||||
@@ -195,10 +195,11 @@ export class WorkspacesService implements IWorkspaces {
|
||||
/**
|
||||
* List one directory level through the Host's `browse` capability.
|
||||
* @param path - absolute directory to list; absent lists the Host home directory.
|
||||
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
|
||||
* @returns the level's listing with breadcrumb ancestry.
|
||||
*/
|
||||
async listDirectory(path?: string): Promise<DirectoryListing> {
|
||||
const response = await this.api.host.listDirectory(path === undefined ? {} : { path })
|
||||
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
|
||||
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
|
||||
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient {
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
|
||||
@@ -376,3 +376,62 @@ describe('connected generation', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
// Mux-open replay of the same question (same approvalId) is idempotent.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
})
|
||||
|
||||
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// Removed sessions drop their bit outright.
|
||||
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
// Generation death clears (resolved-while-disconnected questions send no frame)…
|
||||
manager.handleDisconnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// …and a replayed frame arriving before onConnected (stream open precedes
|
||||
// the readiness handshake) survives the later handleConnected untouched.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleConnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
// Buffered pre-instantiation: an approval pair and a queued row.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
manager.handleDisconnected()
|
||||
// Instantiate after the death sweep: no zombie interaction replays (the
|
||||
// pendingBuffers held only dead-generation rpcIds), so the session mints
|
||||
// no pending waits.
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().pending).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,6 +126,21 @@ describe('live event path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => {
|
||||
// A fresh session whose only window content is a command pair (plus the
|
||||
// knob events a /permission switch appends — not surface-eligible, so
|
||||
// they never become nodes) stays phase 'blank': selecting a preset from
|
||||
// the hero must not enter the conversation view.
|
||||
const { session } = await opened([])
|
||||
expect(session.getSnapshot().composerPhase).toBe('blank')
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
|
||||
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
|
||||
expect(snapshot.composerPhase).toBe('blank')
|
||||
})
|
||||
|
||||
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
|
||||
@@ -92,6 +92,14 @@ export class FixtureSession implements SessionFace {
|
||||
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `command` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
command(): never {
|
||||
throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
@@ -183,6 +191,7 @@ export class TestSessions implements ISessions {
|
||||
id,
|
||||
displayTitle: fixture.id,
|
||||
running: false,
|
||||
waitingApproval: false,
|
||||
blank: false,
|
||||
updatedAt: this.records.size + 1,
|
||||
...fixture.summary,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { workspaceListState } from './fixtures.ts'
|
||||
import type { Stabilizer } from './fixtures.ts'
|
||||
@@ -109,6 +109,48 @@ export class TestWorkspaces implements IWorkspaces {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse listing (recorded). The default serves an empty home level; stub
|
||||
* to shape a tree.
|
||||
* @param path - absolute directory to list; absent lists the home level.
|
||||
* @returns the level's listing.
|
||||
*/
|
||||
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
|
||||
// The signal is recorded and forwarded like the production face passes
|
||||
// it to the wire, so cancellation integration tests can observe or
|
||||
// reject on a superseded scan.
|
||||
this.calls.push({ method: 'listDirectory', args: [path, signal] })
|
||||
const stub = this.stubs.get('listDirectory')
|
||||
if (stub !== undefined) return await (stub(path, signal) as Promise<DirectoryListing>)
|
||||
// The chain runs root-to-target inclusive, per the DirectoryListing
|
||||
// contract — a bare root crumb would mislabel the level in browsers
|
||||
// driven by this double.
|
||||
return {
|
||||
path: '/home/test',
|
||||
home: '/home/test',
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'home', path: '/home', hidden: false },
|
||||
{ name: 'test', path: '/home/test', hidden: false },
|
||||
],
|
||||
entries: [],
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse child creation (recorded). The default joins parent and name.
|
||||
* @param path - absolute existing parent directory.
|
||||
* @param name - single path segment.
|
||||
* @returns the created directory's absolute path.
|
||||
*/
|
||||
async createDirectory(path: string, name: string): Promise<string> {
|
||||
this.calls.push({ method: 'createDirectory', args: [path, name] })
|
||||
const stub = this.stubs.get('createDirectory')
|
||||
if (stub !== undefined) return await (stub(path, name) as Promise<string>)
|
||||
return `${path}/${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace (recorded). The default echoes a minimal view.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -322,6 +322,32 @@ describe('workspaces', () => {
|
||||
expect(stub).toHaveBeenCalledOnce()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => {
|
||||
const runtime = await runtimeWithFrame()
|
||||
// Defaults: an empty home level and parent/name joining.
|
||||
await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] })
|
||||
await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' })
|
||||
await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh')
|
||||
// The recorded signal seat mirrors the production face (undefined here;
|
||||
// cancellation tests pass and observe a real one).
|
||||
expect(runtime.workspaces.calls).toEqual([
|
||||
{ method: 'listDirectory', args: [undefined, undefined] },
|
||||
{ method: 'listDirectory', args: ['/home/test', undefined] },
|
||||
{ method: 'createDirectory', args: ['/home/test', 'fresh'] },
|
||||
])
|
||||
// Stubs replace the defaults like every sibling method.
|
||||
const listing = { path: '/x', home: '/x', crumbs: [], entries: [] }
|
||||
const listStub = vi.fn(() => Promise.resolve(listing as never))
|
||||
runtime.workspaces.stub('listDirectory', listStub)
|
||||
runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never)))
|
||||
const scan = new AbortController()
|
||||
await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing)
|
||||
// The stub receives the signal too, like the production face gives the wire.
|
||||
expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal)
|
||||
await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made')
|
||||
await runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('feature mount and disposal', () => {
|
||||
@@ -442,6 +468,7 @@ describe('fixture session face', () => {
|
||||
const bare = runtime.sessions.behavior('s1')
|
||||
expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
|
||||
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
|
||||
expect(() => bare.command()).toThrow(/command is not stubbed/)
|
||||
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -1,6 +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
|
||||
README.md: 17bc4edd7d002d6bba4470c9418a9179b2cb131b
|
||||
README.zh.md: 1291556409b993aa893e102386f75c45bb195adf
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
|
||||
README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da
|
||||
README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md).
|
||||
|
||||
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
|
||||
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
|
||||
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
|
||||
|
||||
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
|
||||
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
|
||||
|
||||
`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key:每个会话恒为 agent-backed,因此 `command.list({sessionId})` 是唯一的寻址形状,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
|
||||
|
||||
|
||||
@@ -43,6 +43,24 @@ export interface CommandContribution {
|
||||
readonly ui: CommandUiSpec
|
||||
}
|
||||
|
||||
/**
|
||||
* A UI decoration hung on one HOST command: what its BARE invocation does on
|
||||
* this client. Not a second command — the host command keeps its catalog
|
||||
* row, its argument claim (space / argued enter), and its lifecycle logging;
|
||||
* the decoration replaces only the bare menu-pick/enter with a popup whose
|
||||
* onSelect typically submits a completed line back through command.execute.
|
||||
* A decoration never manufactures a row: a name with no host catalog entry
|
||||
* in the session's directory simply never reaches the decoration.
|
||||
*/
|
||||
export interface CommandDecoration {
|
||||
/** The HOST command name this decorates (without the leading slash). */
|
||||
readonly name: string
|
||||
/** Capability filter, called with a fresh projection per bare invocation. */
|
||||
available(session: ClientSessionContext): boolean
|
||||
/** The bare-invocation UI (this phase: popupSelect only). */
|
||||
readonly ui: CommandUiSpec
|
||||
}
|
||||
|
||||
/** The `ctx.command` service face visible to business packages. */
|
||||
export interface CommandServiceContract {
|
||||
/**
|
||||
@@ -50,6 +68,11 @@ export interface CommandServiceContract {
|
||||
* names throw at registration.
|
||||
*/
|
||||
register(contribution: CommandContribution): () => void
|
||||
/**
|
||||
* Hang a bare-invocation decoration on one host command; effect disposer.
|
||||
* Duplicate names throw at registration.
|
||||
*/
|
||||
decorate(decoration: CommandDecoration): () => void
|
||||
/** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
|
||||
popupFor(actx: ClientContext): unknown
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
|
||||
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
|
||||
export type { PopupSelectInjected } from './PopupSelectView.tsx'
|
||||
export type {
|
||||
CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption,
|
||||
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
|
||||
} from './contract.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
SubmitOutcome,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandServiceContract } from './contract.ts'
|
||||
import type { CommandContribution, CommandDecoration, CommandServiceContract } from './contract.ts'
|
||||
import type { CommandDescriptor } from './directory.ts'
|
||||
import { CommandDirectory } from './directory.ts'
|
||||
import { PopupSelectController } from './popup.ts'
|
||||
@@ -23,6 +23,7 @@ import type { TokenSegment } from './popup.ts'
|
||||
/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
|
||||
interface LiveState {
|
||||
readonly contributions: Map<string, CommandContribution>
|
||||
readonly decorations: Map<string, CommandDecoration>
|
||||
readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
static inject = ['slash', 'sessions', 'connection']
|
||||
|
||||
private readonly directory: CommandDirectory
|
||||
private readonly live: LiveState = { contributions: new Map(), popups: new Map() }
|
||||
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (plugin fiber; the service registers
|
||||
@@ -79,6 +80,24 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
return () => { void dispose() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang a bare-invocation decoration on one host command; effect disposer
|
||||
* (rides the caller's fiber). Duplicate names throw.
|
||||
* @param decoration - host command name + availability + popup spec.
|
||||
* @returns the disposer removing the registration.
|
||||
*/
|
||||
decorate(decoration: CommandDecoration): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const { decorations } = this.live
|
||||
if (decorations.has(decoration.name)) {
|
||||
throw new Error(`ui-command: duplicate decoration for /${decoration.name}`)
|
||||
}
|
||||
decorations.set(decoration.name, decoration)
|
||||
return () => { decorations.delete(decoration.name) }
|
||||
}, 'command.decorate()')
|
||||
return () => { void dispose() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-session popup controller (lazy; dies with the session
|
||||
* scope). The controller's consume callback dispatches the scoped
|
||||
@@ -148,16 +167,24 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
.filter(c => req.position === 'leading' || c.hint === undefined)
|
||||
}
|
||||
|
||||
/** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */
|
||||
/** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
|
||||
private dispatch(pick: SlashPick): PickOutcome {
|
||||
const name = pick.candidate.name
|
||||
const contribution = this.live.contributions.get(name)
|
||||
if (contribution !== undefined && contribution.available(pick.session)) {
|
||||
this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span })
|
||||
this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
|
||||
return 'handled'
|
||||
}
|
||||
const desc = this.directory.resolve(pick.session.sessionId, name)
|
||||
if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
|
||||
// A decoration replaces the HOST row's bare invocation with its popup;
|
||||
// it decorates only a resolvable host command (checked above), never
|
||||
// manufactures one, and never touches the argument claim below.
|
||||
const decoration = this.live.decorations.get(name)
|
||||
if (decoration !== undefined && decoration.available(pick.session)) {
|
||||
this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
|
||||
return 'handled'
|
||||
}
|
||||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
|
||||
// Menu-pick execute consumes the trigger span before the detached run
|
||||
// (scoped event; the input owns the CAS guard).
|
||||
@@ -193,12 +220,21 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
const contribution = this.live.contributions.get(name)
|
||||
if (contribution !== undefined && contribution.available(session)) {
|
||||
if (!bare) return undefined
|
||||
this.openPopup(contribution, session, { via: 'enter', token })
|
||||
this.openPopup(name, contribution.ui, session, { via: 'enter', token })
|
||||
return 'handled'
|
||||
}
|
||||
await this.directory.ensureReady(session.sessionId, signal)
|
||||
const desc = this.directory.resolve(session.sessionId, name)
|
||||
if (desc === undefined) return undefined
|
||||
// Bare enter on a decorated host command opens its popup; an argued line
|
||||
// never consults the decoration (the claim/detached paths below own it).
|
||||
if (bare) {
|
||||
const decoration = this.live.decorations.get(name)
|
||||
if (decoration !== undefined && decoration.available(session)) {
|
||||
this.openPopup(name, decoration.ui, session, { via: 'enter', token })
|
||||
return 'handled'
|
||||
}
|
||||
}
|
||||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
|
||||
if (!bare) return undefined
|
||||
this.consumeVia(session.sessionId, { via: 'enter', token })
|
||||
@@ -206,15 +242,16 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
return 'handled'
|
||||
}
|
||||
|
||||
/** Open the session's popup for one contribution (menu pick / bare enter). */
|
||||
/** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
|
||||
private openPopup(
|
||||
contribution: CommandContribution,
|
||||
name: string,
|
||||
ui: CommandContribution['ui'],
|
||||
session: ClientSessionContext,
|
||||
segment: TokenSegment,
|
||||
): void {
|
||||
const actx = this.scopeFor(session.sessionId)
|
||||
if (actx === undefined) return
|
||||
this.popupFor(actx).open(contribution.name, contribution.ui, session, segment)
|
||||
this.popupFor(actx).open(name, ui, session, segment)
|
||||
}
|
||||
|
||||
/** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
|
||||
|
||||
@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
import type { CommandDescriptor } from '../src/client/directory.ts'
|
||||
import { CommandService } from '../src/client/service.ts'
|
||||
|
||||
@@ -197,6 +197,68 @@ describe('candidates', () => {
|
||||
command.register(themeContribution({ name: 'plan' }))
|
||||
await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('decorations (bare-invocation UI on host commands)', () => {
|
||||
const goalDecoration = (over: Partial<CommandDecoration> = {}): CommandDecoration => ({
|
||||
name: 'goal',
|
||||
available: () => true,
|
||||
ui: themeUi(),
|
||||
...over,
|
||||
})
|
||||
|
||||
it('adds no catalog row: the host row stands alone', async () => {
|
||||
const { command, source } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
|
||||
expect(names).toEqual(['plan', 'goal'])
|
||||
})
|
||||
|
||||
it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {
|
||||
const { command, source, mint, warm } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
|
||||
const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal)
|
||||
if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
|
||||
expect(argued.claim.token).toBe('/goal ')
|
||||
})
|
||||
|
||||
it('space never consults the decoration (host claim)', async () => {
|
||||
const { command, source, warm } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
await warm(proj('s1'))
|
||||
const outcome = source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
})
|
||||
|
||||
it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => {
|
||||
const { command, source, mint, warm } = await bench()
|
||||
command.decorate(goalDecoration({ name: 'phantom' }))
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined()
|
||||
expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('an unavailable decoration falls through to the host bare path (detached execute)', async () => {
|
||||
const { command, source, warm, executeCalls } = await bench()
|
||||
command.decorate(goalDecoration({ name: 'plan', available: () => false }))
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled')
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
})
|
||||
|
||||
it('duplicate decoration names fail loud', async () => {
|
||||
const { command } = await bench()
|
||||
command.decorate(goalDecoration())
|
||||
expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatch (menu column)', () => {
|
||||
|
||||
@@ -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: 85cf040a48cf43b6ee6a8978ad7110ecdffb4051
|
||||
README.zh.md: 305258e2861fb17966050e295a5b980067a59a2d
|
||||
README.md: 5a1f9f1ad5cac6601e8686af7206bb436e40e91f
|
||||
README.zh.md: ccbf1918ae3d40fd42ff7454f7f983d6261ba28f
|
||||
|
||||
@@ -8,8 +8,12 @@ The resident conversation shell survives no-session and session transitions. Wit
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
@@ -31,8 +35,8 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
|
||||
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
|
||||
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
@@ -31,8 +35,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
|
||||
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
|
||||
- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/clien
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
@@ -15,6 +16,7 @@ import { InputHub } from './input/hub.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
@@ -34,6 +36,11 @@ function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
return conversation
|
||||
}
|
||||
|
||||
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
|
||||
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
|
||||
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
|
||||
}
|
||||
|
||||
/** Mounts the conversation plugin.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
@@ -146,11 +153,27 @@ export function apply(ctx: Context): void {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
command: async (line) => {
|
||||
const session = sessions.binding(sessionId)?.session
|
||||
if (session === undefined) return false
|
||||
const result = await session.command(line)
|
||||
return result.ok && result.value.matched
|
||||
},
|
||||
hooks: { notices: shell.notices, lexicon: shell.lexicon },
|
||||
}
|
||||
},
|
||||
}, InputBar)
|
||||
|
||||
// The approval takeover: a selector-routed entry of the chain this package
|
||||
// just declared (the ui-question registration pattern; the entry lives here
|
||||
// because approval answering is core conversation UX, not an optional tool).
|
||||
// Zero business face — data and verbs both ride the matched carrier.
|
||||
// priority 1: question takeovers (default 0) win when both kinds are
|
||||
// pending — a question is a conversation the model is waiting on, while an
|
||||
// approval only blocks one tool call; answering the question first cannot
|
||||
// strand the approval (it re-elects the moment the question resolves).
|
||||
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// Declaring the keyed toolview hole here is claiming it: ChatView is the
|
||||
// only component authorized to render per-tool rows. Shares the chat
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
/* Assistant flow body: full-width narration (figma 16/28), block gap 16. */
|
||||
/* Assistant flow body: full-width narration (figma 16/28), block gap 16.
|
||||
IconActions sit below the body with an explicit 16px top margin (figma
|
||||
43:32997) — separate from the body's internal gap so the footer spacing
|
||||
stays fixed when the body is a single block. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
|
||||
.stopped {
|
||||
align-self: flex-start;
|
||||
@@ -19,3 +27,18 @@
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
|
||||
@media (hover: hover) {
|
||||
.root:hover .actions,
|
||||
.root:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial;
|
||||
// the turn-level loading dots live in the chat view's tail, not here.
|
||||
// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
@@ -16,6 +20,8 @@ export interface AssistantMarkdownProps {
|
||||
streaming: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
|
||||
interrupted?: boolean | undefined
|
||||
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
|
||||
time?: number | undefined
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
@@ -23,6 +29,15 @@ function firstLine(text: string): string {
|
||||
return nl === -1 ? text : text.slice(0, nl)
|
||||
}
|
||||
|
||||
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
|
||||
function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.kind === 'text') parts.push(block.text)
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
return (
|
||||
@@ -38,7 +53,9 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time,
|
||||
}: AssistantMarkdownProps) {
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
// a node that is only those heads (or empty) would paint an empty root
|
||||
@@ -47,18 +64,30 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|
||||
|| interrupted === true
|
||||
|| blocks.some(block => block.kind !== 'tool-call')
|
||||
if (!hasVisible) return null
|
||||
// Footer only after the turn settles with a known event time; streaming omits it.
|
||||
const showActions = !streaming && time !== undefined
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
}
|
||||
})}
|
||||
{interrupted && <span className={css.stopped}>已停止</span>}
|
||||
<div className={css.body}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
}
|
||||
})}
|
||||
{interrupted && <span className={css.stopped}>已停止</span>}
|
||||
</div>
|
||||
{showActions && (
|
||||
<MessageIconActions
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
clock="end"
|
||||
className={css.actions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -30,7 +30,6 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import { PendingCard } from './PendingCard.tsx'
|
||||
import { StatsLine } from './StatsLine.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
@@ -229,7 +228,6 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
const running = useSession(s => s.running)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const pending = useSession(s => s.pending)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
@@ -332,7 +330,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
key={item.key}
|
||||
blocks={node.blocks}
|
||||
streaming={false}
|
||||
interrupted={node.interrupted}
|
||||
time={node.time}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.kind === 'command') {
|
||||
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
|
||||
@@ -375,9 +381,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map(item => item.kind === 'approval'
|
||||
? <PendingCard key={item.key} item={item} />
|
||||
: null)}
|
||||
{/* No pending placeholders: questions (ui-question) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnDots />}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
|
||||
@@ -27,6 +28,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
@@ -34,9 +36,12 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
|
||||
toolName={toolName}
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
// A terminal presenter's description is the contract's above-card text, so
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow.
|
||||
summary={terminal?.description ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
terminal={terminal}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Shared message IconActions row (user + assistant). Parent modules own
|
||||
hover-reveal selectors and layout offsets via the composed className. */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */
|
||||
.timeStart {
|
||||
padding-right: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeEnd {
|
||||
padding-left: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */
|
||||
@media (hover: hover) {
|
||||
.actions {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy / branch
|
||||
// live (branch still a stub), date-aware clock, optional edit stub.
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
|
||||
import { useCalendarDay } from './use-calendar-day.ts'
|
||||
import css from './MessageIconActions.module.css'
|
||||
|
||||
export interface MessageIconActionsProps {
|
||||
/** Plain text the copy action writes. */
|
||||
text: string
|
||||
/** Unix epoch ms for the clock label. */
|
||||
time: number
|
||||
/** Clock before icons (user) or after (assistant). */
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Parent layout / hover-reveal class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
||||
* @param props - Copy text, event time, clock side, optional edit, className.
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, edit, className,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const onCopy = useCallback(() => {
|
||||
void writeClipboard(text)
|
||||
}, [text])
|
||||
const clockEl = (
|
||||
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
|
||||
{formatMessageClock(time, day)}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
|
||||
{clock === 'start' ? clockEl : null}
|
||||
<Tooltip label="复制" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{edit === true && (
|
||||
<Tooltip label="编辑" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="编辑">
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{clock === 'end' ? clockEl : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,46 +20,14 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
|
||||
hover:none keeps actions visible (opacity:0 still hit-tests). */
|
||||
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
|
||||
@media (hover: hover) {
|
||||
.actions {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.userRow:hover .actions,
|
||||
.userRow:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
|
||||
// copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// clock + copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// injection and unknown-surface JSON rows. Props are frozen node slices off
|
||||
// the snapshot cache; memo holds across streaming because unchanged nodes
|
||||
// keep their references.
|
||||
|
||||
import { memo, useCallback } from 'react'
|
||||
import { memo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
|
||||
JsonBlock, MessageText, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
@@ -30,42 +28,6 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
||||
return { text: texts.join(''), rest }
|
||||
}
|
||||
|
||||
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {
|
||||
// Denied permissions / iframe policy.
|
||||
}
|
||||
return
|
||||
}
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
exec('copy')
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
el.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
@@ -98,32 +60,6 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
|
||||
function UserActions({ text }: { text: string }) {
|
||||
const onCopy = useCallback(() => {
|
||||
void writeClipboard(text)
|
||||
}, [text])
|
||||
return (
|
||||
<div className={css.actions}>
|
||||
<Tooltip label="复制" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="编辑" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="编辑">
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
||||
switch (node.kind) {
|
||||
case 'user': {
|
||||
@@ -134,7 +70,13 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
</div>
|
||||
<UserActions text={text} />
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
edit
|
||||
className={css.actions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/* Amber pending strip (approval waiting = warn semantic, figma state colors). */
|
||||
|
||||
.card {
|
||||
margin: 6px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.reason {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// PendingCard: display-only approval placeholder. Questions render exclusively
|
||||
// through the composer takeover so the same pending wait is never shown twice.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import css from './PendingCard.module.css'
|
||||
|
||||
export interface PendingCardProps {
|
||||
item: PendingWait<'approval'>
|
||||
}
|
||||
|
||||
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div className={css.title}>等待审批:<span className={css.mono}>{item.payload.toolName}</span></div>
|
||||
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
|
||||
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -175,9 +175,23 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The code variant's expanded body is the run_code program, rendered through
|
||||
the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
|
||||
this row's concern. */
|
||||
.codeBody {
|
||||
/* The two block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
|
||||
command output through TerminalBlock. Both are drawn by the shared
|
||||
primitive, so only the row's indentation is this file's concern — the margin
|
||||
also replaces each primitive's own standalone vertical spacing with the
|
||||
flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
/* Indented to the body's own column so the description reads as the card's
|
||||
heading rather than as another summary row, and sits tight against the card
|
||||
below it. Its own rule: grouping it with a body would put description
|
||||
typography on a `CodeBlock` wrapper and change that body's spacing. */
|
||||
.terminalDescription {
|
||||
margin: 4px 0 0 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
|
||||
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
|
||||
// no inline output (full results live in the details panel). Expand state is
|
||||
// separator dot + FILL-truncated summary. The collapsed row is always one
|
||||
// line; the expanded body is indented gray text, the run_code program through
|
||||
// CodeBlock, or — for a call whose render intent is a terminal card — the
|
||||
// command's own output through TerminalBlock, capped at
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
@@ -20,8 +24,15 @@ export interface ToolRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/** Expanded-body text; null = not expandable (leading slot never toggles). */
|
||||
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
|
||||
body: string | null
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text body when present.
|
||||
* Null or absent leaves the text body, and a row with neither is not
|
||||
* expandable (its leading slot never toggles).
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
@@ -52,17 +63,25 @@ export function ToolRow({
|
||||
title,
|
||||
summary,
|
||||
body,
|
||||
terminal,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const terminalBody = terminal ?? null
|
||||
// A row that names a single file keeps one interaction (open that path);
|
||||
// args expand is off whether or not the open callback is wired yet.
|
||||
// args expand is off whether or not the open callback is wired yet. Terminal
|
||||
// material still expands: only the file variants carry a path, so a terminal
|
||||
// card and a file link never land on the same row.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = body !== null && !singleFile
|
||||
const expandable = (body !== null && !singleFile) || terminalBody !== null
|
||||
// The text arms take the empty string for a null body: a row expandable
|
||||
// only through its terminal material renders the terminal body instead, so
|
||||
// this substitution never shows.
|
||||
const text = body ?? ''
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
@@ -137,9 +156,17 @@ export function ToolRow({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{open && (variant === 'code'
|
||||
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{body}</div>)}
|
||||
{/* The terminal presenter's description belongs ABOVE the card per the
|
||||
render-intent contract, so an expanded terminal row keeps showing it
|
||||
even though the collapsed summary is hidden while open. */}
|
||||
{open && terminalBody?.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
)}
|
||||
{open && (terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
|
||||
// and the compact date+clock label from a session-event epoch.
|
||||
|
||||
/**
|
||||
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
|
||||
* @param text - Plain text to place on the clipboard.
|
||||
*/
|
||||
export async function writeClipboard(text: string): Promise<void> {
|
||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||
// that runtime gap is exactly what this guard detects.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {
|
||||
// Denied permissions / iframe policy.
|
||||
}
|
||||
return
|
||||
}
|
||||
// execCommand('copy') is the only clipboard fallback where the async API
|
||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
exec('copy')
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||
el.remove()
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Local calendar-day epoch (ms at local midnight) for an instant.
|
||||
* @param ms - Unix epoch ms.
|
||||
* @returns Midnight of that local calendar day.
|
||||
*/
|
||||
export function startOfLocalDay(ms: number): number {
|
||||
const d = new Date(ms)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay until the next local midnight after `ms` (at least 1ms).
|
||||
* @param ms - Unix epoch ms.
|
||||
* @returns Milliseconds until the following local midnight.
|
||||
*/
|
||||
export function msUntilNextLocalMidnight(ms: number): number {
|
||||
const next = new Date(ms)
|
||||
next.setHours(24, 0, 0, 0)
|
||||
return Math.max(next.getTime() - ms, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact local timestamp for message IconActions.
|
||||
* Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`;
|
||||
* other years → `YYYY年M月D日 HH:mm`.
|
||||
* @param time - Unix epoch ms from the source session event.
|
||||
* @param now - Reference instant for the day/year cut (defaults to wall clock).
|
||||
* @returns Date-aware clock string (24-hour, zero-padded time).
|
||||
*/
|
||||
export function formatMessageClock(time: number, now: number = Date.now()): string {
|
||||
const d = new Date(time)
|
||||
const n = new Date(now)
|
||||
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
||||
if (
|
||||
d.getFullYear() === n.getFullYear()
|
||||
&& d.getMonth() === n.getMonth()
|
||||
&& d.getDate() === n.getDate()
|
||||
) {
|
||||
return clock
|
||||
}
|
||||
const md = `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}`
|
||||
return `${d.getFullYear()}年${md} ${clock}`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Component-local calendar-day tick: memoized message rows keep stable props
|
||||
// across midnight, so the IconActions clock needs a local day seat that
|
||||
// re-fires at the next local midnight without reaching for framework hooks.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { msUntilNextLocalMidnight, startOfLocalDay } from './message-chrome.ts'
|
||||
|
||||
/**
|
||||
* Local calendar-day epoch that advances at each local midnight.
|
||||
* @returns Midnight ms for the current local day; updates after the boundary.
|
||||
*/
|
||||
export function useCalendarDay(): number {
|
||||
const [day, setDay] = useState(() => startOfLocalDay(Date.now()))
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const arm = (): void => {
|
||||
const now = Date.now()
|
||||
setDay(startOfLocalDay(now))
|
||||
timer = setTimeout(arm, msUntilNextLocalMidnight(now))
|
||||
}
|
||||
timer = setTimeout(arm, msUntilNextLocalMidnight(Date.now()))
|
||||
return () => { clearTimeout(timer) }
|
||||
}, [])
|
||||
return day
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
@@ -251,6 +251,12 @@ export interface ComposerBarInjected {
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/**
|
||||
* Submit one slash-command line against this session's agent (the chrome
|
||||
* controls' write path — the permission chip submits `/permission <preset>`).
|
||||
* Resolves admission: false = rejected/unmatched/transport failure.
|
||||
*/
|
||||
command: (line: string) => Promise<boolean>
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
@@ -307,6 +313,68 @@ export type ConversationSessionSlotProps =
|
||||
& PropsStore<ChatStore>
|
||||
& ConversationSessionInjected
|
||||
|
||||
/** The pending approval carrier the owner dispatches into the composer chain. */
|
||||
export type ApprovalWait = PendingWait<'approval'>
|
||||
|
||||
/**
|
||||
* Approval domain face over the carrier (the ui-question PendingQuestion
|
||||
* pattern): render identity and question material forwarded transparently;
|
||||
* answer owns the wire encoding — the ApprovalResponsePayload value shape
|
||||
* with the audit correlation the host reconciles — and turns a rejected
|
||||
* carrier receipt into a thrown error. Minted per carrier via useMemo.
|
||||
*/
|
||||
export class PendingApproval {
|
||||
/**
|
||||
* @param wait - the runtime carrier for one pending approval question.
|
||||
*/
|
||||
constructor(private readonly wait: ApprovalWait) {}
|
||||
|
||||
/** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */
|
||||
get key(): string {
|
||||
return this.wait.key
|
||||
}
|
||||
|
||||
/** The tool the question is about (headline fallback), forwarded from the carrier payload. */
|
||||
get toolName(): string {
|
||||
return this.wait.payload.toolName
|
||||
}
|
||||
|
||||
/** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */
|
||||
get reason(): string | undefined {
|
||||
return this.wait.payload.reason
|
||||
}
|
||||
|
||||
/** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */
|
||||
get callId(): string | undefined {
|
||||
return this.wait.payload.callId
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the user's decision; a rejected carrier receipt throws. Panel
|
||||
* removal stays frame-driven: the broadcast `approval/resolved` settles the
|
||||
* wait and drops it from the pending list.
|
||||
* @param outcome - the only two client-answerable outcomes.
|
||||
*/
|
||||
async answer(outcome: 'allowed-once' | 'rejected'): Promise<void> {
|
||||
const receipt = await this.wait.respond({
|
||||
ok: true,
|
||||
value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome },
|
||||
})
|
||||
if (!receipt.accepted) {
|
||||
throw new Error(`approval response rejected: ${receipt.reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full approval-composer props: the framework runtime share (chain currency +
|
||||
* session/global standard kit) plus the chain `matched` share — the entry's
|
||||
* selector result, already narrowed to the approval carrier. No injected
|
||||
* share: the carrier plus the domain face above carry the whole behavior
|
||||
* surface; the paired command line derives from useSession in-component.
|
||||
*/
|
||||
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
|
||||
|
||||
/**
|
||||
* Injected share of the chat view entry: the two callbacks whose targets live
|
||||
* outside the view (layout orchestration; the session object layer).
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Pure derivation of the terminal-card props from a frozen call slice: the
|
||||
* `card:'terminal'` render intent the bash tool declares arrives on the
|
||||
* snapshot as `callView`/`resultView`, and this is the one place that turns
|
||||
* that pair into what {@link TerminalBlock} draws. Both conversation render
|
||||
* sites (the chat tool row's expanded body and the details panel's Output
|
||||
* section) call this, so the command, cwd, output and exit status they show
|
||||
* are derived once.
|
||||
* @module
|
||||
*/
|
||||
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Output lines the chat row's expanded terminal body shows before collapsing
|
||||
* the middle — half the primitive's own default, which the details panel
|
||||
* keeps. A chat row is a summary surface inside the message flow: the flow
|
||||
* must stay scannable across many calls, while the details panel is the
|
||||
* single-call reading surface. A design constant of this UI's row geometry,
|
||||
* not a deployment choice, so it is fixed here rather than a plugin Config
|
||||
* field.
|
||||
*/
|
||||
export const CHAT_TERMINAL_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link TerminalBlock} props this derivation owns. Picked off the
|
||||
* primitive's props so the two stay in step; `home` is absent because the web
|
||||
* client has no home path for the session host (a cwd renders as its last
|
||||
* path segment), and `maxLines`/`className` belong to each render site.
|
||||
*/
|
||||
export interface TerminalCardModel {
|
||||
/**
|
||||
* The props {@link TerminalBlock} draws. Held as a nested object so a render
|
||||
* site spreads exactly the primitive's own surface and can never leak a
|
||||
* neighbouring field into it.
|
||||
*/
|
||||
card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
/**
|
||||
* The call view's model-authored description, which the contract defines as
|
||||
* rendering ABOVE the card (the card itself has no description slot). Absent
|
||||
* when the presenter supplied none, or when the window dropped the call side;
|
||||
* a row then keeps its args-derived summary.
|
||||
*/
|
||||
description: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a terminal view's working directory the way the render-intent
|
||||
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
|
||||
* one joins under the session workspace, and an omitted one IS the session
|
||||
* workspace. A pure presenter cannot see the session cwd, which is why this
|
||||
* resolution belongs here rather than in the tool. Without a session cwd there
|
||||
* is nothing to resolve against, so a relative path stays as authored and an
|
||||
* omitted one stays absent (the prompt row then draws a bare `$`).
|
||||
* @param viewCwd - the cwd the terminal call view carries, if any.
|
||||
* @param sessionCwd - the session workspace root, if the caller knows it.
|
||||
* @returns the working directory for the prompt label, or undefined.
|
||||
*/
|
||||
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
|
||||
if (viewCwd === undefined || viewCwd === '') return sessionCwd
|
||||
if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
|
||||
return normalizeSegments(resolveToolPath(sessionCwd, viewCwd))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse `.` and `..` segments so the prompt label names the directory the
|
||||
* command actually ran in. The bash executor resolves the workdir before
|
||||
* running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators
|
||||
* are preserved as authored (a Windows path keeps its backslashes) because this
|
||||
* value is only ever displayed; a `..` that would climb past the root is
|
||||
* dropped, which is what a filesystem does with it. A UNC path's `server` and
|
||||
* `share` are part of its root, not poppable segments: Windows cannot climb
|
||||
* above a share, so `\\\\server\\share` with a `..` stays there.
|
||||
* @param path - a joined or absolute path, possibly carrying `.`/`..` segments.
|
||||
* @returns the same path with those segments resolved.
|
||||
*/
|
||||
function normalizeSegments(path: string): string {
|
||||
if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
|
||||
// A UNC path is `\\\\server\\share\\...`: the server and share form the root,
|
||||
// so they are split off here and neither is a segment `..` may pop. Its
|
||||
// separator is fixed to a backslash, since a joined relative part may have
|
||||
// introduced a forward slash that UNC syntax does not use.
|
||||
const unc = /^[/\\]{2}([^/\\]+)[/\\]+([^/\\]+)/.exec(path)
|
||||
if (unc !== null) {
|
||||
// Both groups are mandatory in the pattern, so destructuring types them as
|
||||
// strings without an assertion.
|
||||
const [matched, server, share] = unc
|
||||
const root = `\\\\${String(server)}\\${String(share)}`
|
||||
// Rooted: what follows the share hangs off it, so a `..` at the top is
|
||||
// dropped rather than kept — Windows cannot climb above a share.
|
||||
const rest = collapse(path.slice(matched.length), true)
|
||||
return rest === '' ? root : `${root}\\${rest}`
|
||||
}
|
||||
const backslashed = path.includes('\\') && !path.includes('/')
|
||||
const separator = backslashed ? '\\' : '/'
|
||||
const rooted = /^[/\\]/.test(path)
|
||||
const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
|
||||
const body = collapse(path.slice(drive.length), rooted || drive !== '', separator)
|
||||
const leading = rooted ? separator : ''
|
||||
return drive === '' ? `${leading}${body}` : `${drive}${rooted ? leading : separator}${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the `.`/`..` segments of a path body against a known root state.
|
||||
* @param body - the path after any drive letter or UNC root.
|
||||
* @param rooted - the body hangs off a root, so a `..` at its top is dropped
|
||||
* the way a filesystem drops one; without a root the `..` is kept, since it
|
||||
* stays meaningful against a cwd this function cannot see.
|
||||
* @param separator - separator to rejoin with (default `/`).
|
||||
* @returns the collapsed body, without leading or trailing separators.
|
||||
*/
|
||||
function collapse(body: string, rooted: boolean, separator = '/'): string {
|
||||
const kept: string[] = []
|
||||
for (const segment of body.split(/[/\\]/)) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') {
|
||||
if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
|
||||
else if (!rooted) kept.push(segment)
|
||||
continue
|
||||
}
|
||||
kept.push(segment)
|
||||
}
|
||||
return kept.join(separator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the terminal-card props for a tool call, or null when this call is
|
||||
* not a terminal card and belongs on the generic path.
|
||||
*
|
||||
* The call side supplies the command and its working directory; the result
|
||||
* side supplies the captured output and exit status. Three cases produce
|
||||
* null, all of them the documented generic-card default:
|
||||
*
|
||||
* - Neither side declares `card:'terminal'` — including a `card` value this
|
||||
* UI version does not know, which arrives over the wire and therefore
|
||||
* cannot be trusted to be one of the compiled variants.
|
||||
* - A settled call whose result view is not a terminal card: the result
|
||||
* presentation decides how the settled call renders, and the bash tool
|
||||
* returns a generic fenced card for an execution error or a background
|
||||
* start, whose text and error styling the generic path preserves.
|
||||
*
|
||||
* Window truncation can drop the call head from a settled result (see
|
||||
* `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal
|
||||
* result with no call side. That still renders: the command falls back to the
|
||||
* result view's replacement title, then to an empty command (the prompt line
|
||||
* draws bare), and the prompt shows no cwd.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param sessionCwd - the session workspace root, which resolves an omitted or
|
||||
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
|
||||
* @returns the terminal-card props, or null for the generic path.
|
||||
*/
|
||||
export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
|
||||
const call = block.callView?.card === 'terminal' ? block.callView : null
|
||||
if (!('kind' in block)) {
|
||||
// Running: the call view exists, the result view does not yet.
|
||||
return call === null ? null : {
|
||||
description: call.description,
|
||||
card: {
|
||||
command: call.title,
|
||||
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: undefined,
|
||||
exitCode: undefined,
|
||||
signal: undefined,
|
||||
running: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
const result = block.resultView?.card === 'terminal' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
return {
|
||||
description: call?.description,
|
||||
card: {
|
||||
// The result's title REPLACES the pending one when the tool supplies it
|
||||
// (the presentation contract's replacement-title rule); the call title is
|
||||
// what a result without one keeps.
|
||||
command: result.title ?? call?.title ?? '',
|
||||
// Only a PRESENT call view can mean "omitted the cwd, so use the
|
||||
// workspace". When the window dropped the call head there is no cwd
|
||||
// anywhere — the result view carries none — and the original call may
|
||||
// well have used an explicit workdir, so the prompt draws a bare `$`
|
||||
// rather than naming a directory this card cannot know.
|
||||
cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
running: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Pure row-model derivation for tool summary rows: variant classification,
|
||||
* one-line summary and expanded-body text from the frozen call slice. No
|
||||
* inline output ever — full results live in the details panel.
|
||||
* one-line summary and expanded-body text from the frozen call slice. This
|
||||
* derivation reads the call ARGUMENTS only; a call whose render intent is a
|
||||
* terminal card gets its expanded body from the views instead, through
|
||||
* `terminalCardModel` in terminal-card-model.ts.
|
||||
*/
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Composer-takeover approval panel (draft approval.png): the same floating
|
||||
capsule footprint as the InputBar card, with an amber header band, the
|
||||
justification headline, a muted command line, and right-aligned actions.
|
||||
Warn semantics ride the alias state tokens; no hardcoded colors. */
|
||||
|
||||
/* Mirrors InputBar .root so the takeover is a content swap, not a layout jump. */
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 32px 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
}
|
||||
|
||||
/* Tinted full-width header band. */
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
|
||||
/* The model's justification is the panel's message, not a footnote. */
|
||||
.headline {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.command {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.allow,
|
||||
.reject {
|
||||
padding: 6px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.allow:disabled,
|
||||
.reject:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped
|
||||
always-allow button). */
|
||||
.allow {
|
||||
border: none;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
/* Secondary: quiet outline. */
|
||||
.reject {
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.reject:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// ApprovalPanel: the composer-takeover approval prompt (designer draft
|
||||
// approval.png), registered as a selector-routed entry of the
|
||||
// conversation-declared composer chain. While an approval question is
|
||||
// pending, this panel occupies the composer slot in place of the InputBar:
|
||||
// an amber "Waiting for approval" strip on the card top, the model's
|
||||
// justification as the headline, the paired command in muted code text, and
|
||||
// a right-aligned refuse/allow action row. One-shot: the buttons disable
|
||||
// after a click and the panel leaves (the InputBar returns) on the broadcast
|
||||
// resolved frame. The draft's "Always allow this type" is deferred with
|
||||
// grant storage.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
|
||||
import css from './ApprovalPanel.module.css'
|
||||
|
||||
/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */
|
||||
export function commandOf(call: RunningToolCall | undefined): string | undefined {
|
||||
if (call === undefined) return undefined
|
||||
try {
|
||||
const args = JSON.parse(call.argsRaw) as Record<string, unknown>
|
||||
return typeof args.command === 'string' ? args.command : undefined
|
||||
} catch {
|
||||
// Unparseable model args: the panel still renders, just without the command line.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer takeover boundary: mints the domain face on the carrier's stable
|
||||
* identity and remounts the flow per request key, so the one-shot answered
|
||||
* latch never leaks to the next pending approval.
|
||||
* @param props - the selector-matched pending approval carrier plus the framework standard kit.
|
||||
* @returns The approval prompt for this request.
|
||||
*/
|
||||
export function ApprovalPanel(props: ApprovalComposerProps) {
|
||||
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
|
||||
const command = props.useSession(s => commandOf(
|
||||
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
|
||||
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
|
||||
}
|
||||
|
||||
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
|
||||
// Local one-shot latch: the panel leaves only when the resolved frame
|
||||
// lands; until then the buttons must not re-fire. An answer failure
|
||||
// (rejected receipt / transport) re-arms them for retry.
|
||||
const [answered, setAnswered] = useState(false)
|
||||
const answer = (outcome: 'allowed-once' | 'rejected'): void => {
|
||||
setAnswered(true)
|
||||
void pending.answer(outcome).catch(() => { setAnswered(false) })
|
||||
}
|
||||
return (
|
||||
<div className={css.root} data-approval-key={pending.key}>
|
||||
<div className={css.card}>
|
||||
<div className={css.strip}><span className={css.dot} />等待审批</div>
|
||||
<div className={css.body}>
|
||||
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
|
||||
{command !== undefined && <div className={css.command}>{command}</div>}
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -92,3 +92,17 @@
|
||||
.code[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Above the card, which is where the render-intent contract puts a terminal
|
||||
call's description; the panel has no summary row to carry it. */
|
||||
.terminalDescription {
|
||||
margin: 0 0 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The terminal card sits directly under its section label, so it drops the
|
||||
primitive's standalone vertical margin; the section owns the spacing. */
|
||||
.terminal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,59 @@
|
||||
// DetailsPanel, P-I minimal form: close button + the selected call's args and
|
||||
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
|
||||
// trajectory are deferred (ledger). Reads the selection from the shared chat
|
||||
// result — args as JSON, the result raw except for a terminal-card call, whose
|
||||
// Output section is the command's terminal card. The three-段 Switch /
|
||||
// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the
|
||||
// selection from the shared chat
|
||||
// store (conversation writes, this panel reads — the cross-registration
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
export type DetailsPanelProps = DetailsSlotProps
|
||||
|
||||
/** Selected call material: resolved result node, or the in-flight running call's args. */
|
||||
/**
|
||||
* Selected call material: the call's display name and args plus the frozen
|
||||
* block slice it came from. `block` is a snapshot-cached reference, so the
|
||||
* wrapper stays shallow-equal across unrelated snapshot frames; the settled /
|
||||
* running split is read off it with the `'kind' in block` discrimination
|
||||
* instead of duplicated as flags.
|
||||
*/
|
||||
interface CallMaterial {
|
||||
name: string
|
||||
argsRaw: string | null
|
||||
result: ToolResultNode | null
|
||||
running: boolean
|
||||
block: ToolCallBlock
|
||||
}
|
||||
|
||||
/** Material of a settled result node (native call or run_code sub-dispatch). */
|
||||
function settledMaterial(node: ToolResultNode, callId: string): CallMaterial {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node }
|
||||
}
|
||||
|
||||
/** Material of an in-flight call (native call or run_code sub-dispatch). */
|
||||
function runningMaterial(call: RunningToolCall): CallMaterial {
|
||||
return { name: call.name, argsRaw: call.argsRaw, block: call }
|
||||
}
|
||||
|
||||
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||
for (const node of s.nodes) {
|
||||
if (node.kind === 'tool-result' && node.callId === callId) {
|
||||
return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, result: node, running: false }
|
||||
}
|
||||
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
|
||||
}
|
||||
const open = s.runningCalls.find(c => c.callId === callId)
|
||||
if (open !== undefined) {
|
||||
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
|
||||
}
|
||||
if (open !== undefined) return runningMaterial(open)
|
||||
// run_code sub-dispatches: the native call-block shapes, so a selected
|
||||
// sub-row resolves through the same material as a native call — the
|
||||
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
||||
for (const subs of s.codeDispatches.values()) {
|
||||
for (const sub of subs) {
|
||||
if (sub.callId !== callId) continue
|
||||
if ('kind' in sub) {
|
||||
return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
|
||||
}
|
||||
return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
|
||||
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -56,8 +68,11 @@ function pretty(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
|
||||
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
|
||||
const selection = useStore(s => s.selection)
|
||||
// Session workspace root: an omitted or relative terminal cwd resolves
|
||||
// against it, which the pure presenter cannot see.
|
||||
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const callId = selection?.callId
|
||||
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
|
||||
// stable members (result node reference rides the snapshot's structural sharing).
|
||||
@@ -95,15 +110,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)}
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionLabel}>Output</div>
|
||||
{/* materialFor invariant: result===null ⇔ running (a settled
|
||||
material always carries its result node). */}
|
||||
{material.result === null
|
||||
? <div className={css.empty}>运行中…</div>
|
||||
: (
|
||||
<pre className={css.code} data-error={material.result.isError || undefined}>
|
||||
{renderResult(material.result)}
|
||||
</pre>
|
||||
)}
|
||||
{/* Keyed by the selected call: the body owns per-call view
|
||||
state (the terminal card's expand and copy), which React
|
||||
would otherwise carry into the next selection because the
|
||||
panel does not unmount between calls. */}
|
||||
<OutputBody key={callId} material={material} cwd={sessionCwd} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -112,6 +123,41 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. Every other call, and
|
||||
* a running call with no terminal card yet, keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @returns the Output section's body element.
|
||||
*/
|
||||
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
|
||||
const terminal = terminalCardModel(material.block, cwd)
|
||||
if (terminal !== null) {
|
||||
// The contract renders the presenter's description above the card, and the
|
||||
// panel has no summary row to carry it, so it is drawn here.
|
||||
return (
|
||||
<>
|
||||
{terminal.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminal.description}</div>
|
||||
)}
|
||||
<TerminalBlock {...terminal.card} className={css.terminal} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
|
||||
const result = material.block
|
||||
return (
|
||||
<pre className={css.code} data-error={result.isError || undefined}>
|
||||
{renderResult(result)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
|
||||
function renderResult(node: ToolResultNode): string {
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* region-slot content) ride the owner props. Session facts
|
||||
* (running/removed/promptError) are self-selected via useSession. */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -15,6 +15,7 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Prompt failure surface (derived from promptError). */
|
||||
@@ -25,13 +26,8 @@ export interface InputBarError {
|
||||
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
|
||||
{ id: 'readonly', label: 'Read-only' },
|
||||
{ id: 'readwrite', label: 'Read-write' },
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, useProjection,
|
||||
useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
@@ -64,9 +60,9 @@ export function InputBar({
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// Placeholder chrome: Access selection stays local until its seam lands
|
||||
// (plan/model are real seats now — the named single slots below).
|
||||
const [readonlyId, setReadonlyId] = useState('readonly')
|
||||
// The Access seat's data: the host-computed permissions projection
|
||||
// (undefined = capability absent → the chip renders nothing).
|
||||
const permissions = useProjection('permissions')
|
||||
|
||||
// Queue cut 1: running input stays free; locked = session disabled only.
|
||||
// The transient machine locks (adjudicating pending / submitting) render
|
||||
@@ -229,19 +225,10 @@ export function InputBar({
|
||||
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
|
||||
}
|
||||
|
||||
// Access placeholder select (the one remaining local-chrome control).
|
||||
// The Access seat: the projection-fed permission chip (renders nothing
|
||||
// while the permissions key is absent — permission-less host or Draft).
|
||||
const accessSelect: ReactNode = (
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label="Access mode"
|
||||
value={readonlyId}
|
||||
disabled={locked}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => { setReadonlyId(e.target.value) }}
|
||||
>
|
||||
{READONLY_OPTIONS.map(opt => (
|
||||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<PermissionSelect value={permissions} locked={locked} command={command} />
|
||||
)
|
||||
|
||||
// Mirror-layer decorations: a visible backdrop with transparent text. The
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a
|
||||
quiet text chip with a chevron; hover paints the standard interactive pill.
|
||||
The native select is stretched invisibly over the chip so the platform
|
||||
dropdown does the menu work — keyboard/AT semantics come free. */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
pointer-events: none; /* the overlaid select owns the interaction */
|
||||
}
|
||||
|
||||
.root:hover .chip {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Invisible native select stretched over the chip: real menu, zero drawing. */
|
||||
.select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.root:has(.select:disabled) .chip {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// PermissionSelect: the composer bottom-row permission chip (draft
|
||||
// start.jpeg's `Read-only ∨` control), the Access seat's wired occupant.
|
||||
// Options and the current value read from the host-computed `permissions`
|
||||
// projection (baseline block + push frames — no fetch, no mount timing);
|
||||
// key absence (a permission-less composition, or a Draft with no host
|
||||
// session yet) renders nothing. The visible chip is presentation only — an
|
||||
// invisible native select stretched over it owns the menu and interaction.
|
||||
// A switch submits the `/permission <preset>` command line (the one write
|
||||
// path); the control shows the picked value optimistically and disables
|
||||
// until the admission result, then re-follows the projection — the pushed
|
||||
// frame confirms the switch, and a failed/unmatched submit falls back to
|
||||
// the still-authoritative projection value (`custom` is shown as the
|
||||
// current value but never offered as a target — the host omits it from
|
||||
// switchable options).
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
|
||||
import css from './PermissionSelect.module.css'
|
||||
|
||||
/**
|
||||
* Display transform: kebab-case machine names render as title-case labels
|
||||
* (`workspace-write` → `Workspace Write`). Presentation-only — the wire
|
||||
* vocabulary and the host's advertised names are untouched; a host-configured
|
||||
* name that is not kebab-case (contains spaces or uppercase) passes through.
|
||||
*/
|
||||
function displayName(name: string): string {
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
|
||||
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
export interface PermissionSelectProps {
|
||||
/** The host-computed select, or undefined while the capability is absent. */
|
||||
value: PermissionSelectValue | undefined
|
||||
/** Session-removed lock (the bar's chrome disable state). */
|
||||
locked: boolean
|
||||
/** Submit one slash-command line; resolves admission (false = rejected/unmatched). */
|
||||
command: (line: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
|
||||
// Optimistic pick, shown while the admission round-trip runs; null follows
|
||||
// the projection (the pushed frame lands the confirmed value there).
|
||||
const [pick, setPick] = useState<string | null>(null)
|
||||
if (value === undefined) return null
|
||||
|
||||
const currentValue = pick ?? value.currentValue
|
||||
const current = value.options.find(option => option.value === currentValue)
|
||||
|
||||
const onChange = (next: string): void => {
|
||||
if (next === value.currentValue) return
|
||||
setPick(next)
|
||||
void command(`/permission ${next}`)
|
||||
.catch(() => false)
|
||||
.then(() => { setPick(null) })
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={css.root} title={current?.description}>
|
||||
<span className={css.chip}>
|
||||
{displayName(current?.name ?? currentValue)}
|
||||
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label="Access mode"
|
||||
value={currentValue}
|
||||
disabled={locked || pick !== null}
|
||||
onChange={(e) => { onChange(e.target.value) }}
|
||||
>
|
||||
{value.options.map(option => (
|
||||
<option key={option.value} value={option.value} disabled={option.value === 'custom'}>
|
||||
{displayName(option.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
|
||||
plus the terminal card the row stacks under its summary line. */
|
||||
|
||||
/* Summary line over the terminal card; the summary row keeps its own 24px
|
||||
height, so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.terminal {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
||||
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
||||
// observable inside the component (no parallel registry).
|
||||
//
|
||||
// A bash call declares the terminal render intent, so this row also renders
|
||||
// the command's own output through TerminalBlock. This row has no expand
|
||||
// control and is not a details-panel target either (tool rows stopped being
|
||||
// one), so its terminal body is resident rather than expand-gated as in
|
||||
// ToolRow, and the card's own copy and expand controls are the row's only
|
||||
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
|
||||
// flow's tighter cap over the block's own default of 16 — and the block's
|
||||
// internal expander keeps a long output from taking over the message flow.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
@@ -29,24 +39,40 @@ function stateStatus(state: ToolRowState): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
|
||||
* command's terminal card resident below it. The summary row is not a
|
||||
* details-panel control (tool rows stopped being one), so the card's copy and
|
||||
* expand controls are the row's only interactions.
|
||||
*/
|
||||
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// Session workspace root: the terminal view's cwd resolves against it (an
|
||||
// omitted workdir IS the workspace), which the pure presenter cannot do.
|
||||
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(model.state)
|
||||
return (
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
<div className={css.card}>
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{/* The terminal presenter's description is the contractual
|
||||
above-card summary; it outranks the args-derived one. */}
|
||||
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
|
||||
</div>
|
||||
{terminal !== null && (
|
||||
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
|
||||
// user IconActions, StatsLine no-cache join,
|
||||
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
|
||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||
// renderSlot.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import {
|
||||
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
|
||||
} from '../src/client/chat/message-chrome.ts'
|
||||
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
|
||||
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('user bubbles expose copy / branch / edit actions; copy writes the text', () => {
|
||||
it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||
} as never}
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('14:24')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
|
||||
@@ -51,9 +55,10 @@ describe('MessageItem arms', () => {
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'fallback body' }] as never,
|
||||
} as never}
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
@@ -73,9 +78,10 @@ describe('MessageItem arms', () => {
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'quiet' }] as never,
|
||||
} as never}
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
@@ -113,14 +119,57 @@ describe('MessageItem arms', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('small branch tails', () => {
|
||||
it('PendingCard approval reason renders when present', () => {
|
||||
const view = render(
|
||||
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
|
||||
)
|
||||
expect(view.getByText('careful')).toBeTruthy()
|
||||
describe('formatMessageClock', () => {
|
||||
const now = new Date(2026, 6, 29, 10, 0).getTime()
|
||||
|
||||
it('keeps HH:mm on the same calendar day', () => {
|
||||
expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), now)).toBe('14:24')
|
||||
})
|
||||
|
||||
it('prefixes month and day across days in the same year', () => {
|
||||
expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), now)).toBe('1月1日 14:24')
|
||||
})
|
||||
|
||||
it('prefixes year, month, and day across years', () => {
|
||||
expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05')
|
||||
})
|
||||
|
||||
it('arms the next local midnight from an in-day instant', () => {
|
||||
const noon = new Date(2026, 6, 29, 12, 0).getTime()
|
||||
expect(startOfLocalDay(noon)).toBe(new Date(2026, 6, 29).getTime())
|
||||
expect(msUntilNextLocalMidnight(noon)).toBe(12 * 3_600_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCalendarDay boundary refresh', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('widens a same-day user clock after local midnight', () => {
|
||||
const dayStart = new Date(2026, 6, 29, 23, 50).getTime()
|
||||
vi.setSystemTime(dayStart)
|
||||
const time = new Date(2026, 6, 29, 14, 24).getTime()
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'night bubble' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('14:24')).toBeTruthy()
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(msUntilNextLocalMidnight(dayStart) + 1)
|
||||
})
|
||||
expect(screen.getByText('7月29日 14:24')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('small branch tails', () => {
|
||||
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
|
||||
@@ -128,6 +177,35 @@ describe('small branch tails', () => {
|
||||
expect(view.getByText('one-liner')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const settled = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
/>,
|
||||
)
|
||||
expect(settled.getByText('14:24')).toBeTruthy()
|
||||
expect(settled.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
fireEvent.click(settled.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('answer body')
|
||||
settled.unmount()
|
||||
|
||||
const streaming = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
)
|
||||
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(streaming.queryByText('14:24')).toBeNull()
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// cacheHitPct is null only when input+cacheRead are both zero (pure
|
||||
// output accounting) — any input makes it a real 0%.
|
||||
|
||||
@@ -78,7 +78,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, waitingApproval: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
@@ -123,8 +123,8 @@ describe('bash sample row', () => {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, blank: false, updatedAt: 0 },
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, blank: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
@@ -158,7 +158,7 @@ describe('bash sample row', () => {
|
||||
const orphan = 'late-child' as SessionId
|
||||
store.update((d) => {
|
||||
d.ids.push(orphan)
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, blank: false, updatedAt: 0 }
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, blank: false, updatedAt: 0 }
|
||||
})
|
||||
const view = render(<BashRow {...rowProps(orphan, { store })} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
|
||||
@@ -97,6 +97,11 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
})
|
||||
|
||||
it('a code row with an empty program falls back to the args JSON envelope', () => {
|
||||
expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
|
||||
.toBe('{\n "code": ""\n}')
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
@@ -165,6 +170,22 @@ describe('ToolRow', () => {
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
|
||||
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.keyDown(row, { key: 'Tab' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('a non-expandable expandOnRowClick row exposes no row button', () => {
|
||||
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
|
||||
@@ -392,13 +392,18 @@ describe('ChatView', () => {
|
||||
expect(lv.getByText('载入历史…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pending interactions render placeholder cards', () => {
|
||||
it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => {
|
||||
const h = makeHarness({
|
||||
pending: [new PendingWait('approval', RpcId('r1'), SID,
|
||||
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
|
||||
pending: [
|
||||
new PendingWait('approval', RpcId('r1'), SID,
|
||||
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()),
|
||||
new PendingWait('question', RpcId('r2'), SID,
|
||||
{ questions: [{ id: 'q1', question: '选择' }] }, vi.fn()),
|
||||
],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText(/等待审批/)).toBeTruthy()
|
||||
expect(view.queryByText(/等待回答/)).toBeNull()
|
||||
expect(view.queryByText(/等待审批/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||
// PendingCard approval wait, bash sample state dots, the node-half empty
|
||||
// bash sample state dots, the node-half empty
|
||||
// apply, and AssistantMarkdown reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -8,13 +8,10 @@ import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
@@ -33,13 +30,6 @@ describe('tails', () => {
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('PendingCard renders the approval wait with its tool name', () => {
|
||||
const view = render(
|
||||
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())} />,
|
||||
)
|
||||
expect(view.getByText(/等待审批/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||
const view = render(
|
||||
<AssistantMarkdown
|
||||
@@ -94,7 +84,7 @@ describe('tails', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ interface BenchOptions {
|
||||
modelEntry?: React.ReactNode
|
||||
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
|
||||
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
|
||||
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
|
||||
draft?: string
|
||||
running?: boolean
|
||||
disabled?: boolean
|
||||
@@ -90,14 +91,15 @@ function bench(over?: BenchOptions) {
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})),
|
||||
useProjection: ((_key: string, selector?: (v: unknown) => unknown) =>
|
||||
(selector ?? (v => v))(over?.plan)),
|
||||
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
|
||||
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
stop,
|
||||
command: () => Promise.resolve(true),
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
|
||||
@@ -368,16 +370,37 @@ describe('strips and variants', () => {
|
||||
})
|
||||
|
||||
describe('placeholder chrome and control seats', () => {
|
||||
it('renders attach + Access placeholder; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
const { view, slotCalls } = bench()
|
||||
expect(view.getByLabelText('Add attachment')).toBeTruthy()
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
|
||||
// Capability absent (no projection value): the chip renders nothing.
|
||||
expect(view.queryByLabelText('Access mode')).toBeNull()
|
||||
// Both seats dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
|
||||
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
||||
expect(view.queryByLabelText('Model')).toBeNull()
|
||||
})
|
||||
|
||||
it('the Access chip renders the projection value and submits /permission on pick', async () => {
|
||||
const permissions = {
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
const { view } = bench({ permissions })
|
||||
const select = view.getByLabelText('Access mode') as HTMLSelectElement
|
||||
expect(select.value).toBe('workspace-write')
|
||||
// Title-case display is presentation only; the option values stay machine names.
|
||||
expect([...select.options].map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
|
||||
fireEvent.change(select, { target: { value: 'danger-full-access' } })
|
||||
// Optimistic pick + disable until admission resolves (command stub resolves true).
|
||||
expect(select.disabled).toBe(true)
|
||||
await act(async () => {})
|
||||
expect(select.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
||||
const { view, slotCalls } = bench({
|
||||
disabled: true,
|
||||
@@ -393,12 +416,13 @@ describe('placeholder chrome and control seats', () => {
|
||||
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
})
|
||||
|
||||
it('disabled locks the Access placeholder and attach control (running does not)', () => {
|
||||
const { view } = bench({ disabled: true })
|
||||
it('disabled locks the Access chip and attach control (running does not)', () => {
|
||||
const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }
|
||||
const { view } = bench({ disabled: true, permissions })
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true)
|
||||
cleanup()
|
||||
const live = bench({ running: true })
|
||||
const live = bench({ running: true, permissions })
|
||||
expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
variant: 'composer',
|
||||
}
|
||||
return render(<InputBar {...props} />)
|
||||
|
||||
@@ -133,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
command: () => Promise.resolve(true),
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
|
||||
@@ -64,8 +64,8 @@ function mount(
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
byId: {
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 },
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 },
|
||||
},
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
@@ -123,6 +123,7 @@ function mount(
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
|
||||
600
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
600
packages/client/ui-conversation/tests/terminal-card.spec.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
// @vitest-environment jsdom
|
||||
// The terminal render intent on the web side: the pure terminalCardModel
|
||||
// derivation over callView/resultView, and both conversation render sites that
|
||||
// consume it — the chat tool row's expanded body (GenericToolCard / BashRow)
|
||||
// and the details panel's Output section.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/**
|
||||
* Match an output line with its interior whitespace intact: the column
|
||||
* alignment this card exists to preserve is exactly what the default
|
||||
* whitespace-collapsing matcher would hide.
|
||||
*/
|
||||
const RAW = { normalizer: (text: string) => text }
|
||||
|
||||
/** The rendered card's run-state dot state, so a render site cannot silently drop it. */
|
||||
function runStateOf(container: HTMLElement): string | null {
|
||||
return container.querySelector('[data-terminal] [data-state]')?.getAttribute('data-state') ?? null
|
||||
}
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const ARGS = '{"command":"ls -la","description":"List files"}'
|
||||
|
||||
/** The bash tool's own call view for a foreground command. */
|
||||
const callTerminal = (over?: Partial<Extract<ToolCallView, { card: 'terminal' }>>): ToolCallView => ({
|
||||
card: 'terminal', title: 'ls -la', description: 'List files', ...over,
|
||||
})
|
||||
|
||||
/** The bash tool's own result view for a settled foreground command. */
|
||||
const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal' }>>): ToolResultView => ({
|
||||
card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over,
|
||||
})
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: ARGS,
|
||||
turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
|
||||
})
|
||||
|
||||
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: ARGS },
|
||||
callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
|
||||
callView: callTerminal(), resultView: resultTerminal(), ...over,
|
||||
})
|
||||
|
||||
describe('terminalCardModel', () => {
|
||||
it('derives a running card from the call view alone', () => {
|
||||
expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({
|
||||
description: 'List files',
|
||||
card: {
|
||||
command: 'ls -la', cwd: '/projects/app', output: undefined,
|
||||
exitCode: undefined, signal: undefined, running: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a settled card from both sides, carrying the exit status', () => {
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/projects/app' }),
|
||||
resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }),
|
||||
}))).toEqual({
|
||||
description: 'List files',
|
||||
card: {
|
||||
command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
|
||||
exitCode: 2, signal: undefined, running: false,
|
||||
},
|
||||
})
|
||||
expect(terminalCardModel(settled({
|
||||
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
|
||||
}))?.card.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('takes the result view\'s replacement title over the pending one', () => {
|
||||
// The presentation contract defines a result title as REPLACING the pending
|
||||
// title, so a tool that rewrites it at settle time must win here.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ title: 'pnpm run check' }),
|
||||
resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
|
||||
}))?.card.command).toBe('pnpm run check --filter web')
|
||||
// Without one, the call's title is what the card keeps.
|
||||
expect(terminalCardModel(settled())?.card.command).toBe('ls -la')
|
||||
})
|
||||
|
||||
it('resolves the cwd against the session workspace the way the bridge must', () => {
|
||||
// Omitted workdir — the common bash call — IS the session workspace.
|
||||
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
// A relative workdir joins under it.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'packages/ui' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app/packages/ui')
|
||||
// An absolute one is used as-is.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/srv/other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/srv/other')
|
||||
// With no session cwd there is nothing to resolve against: a relative path
|
||||
// stays as authored and an omitted one stays absent (a bare `$` prompt).
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'packages/ui' }),
|
||||
}))?.card.cwd).toBe('packages/ui')
|
||||
expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
|
||||
// The running arm resolves identically.
|
||||
expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
})
|
||||
|
||||
it('normalizes a relative workdir so the label names the directory actually used', () => {
|
||||
// The bash executor resolves the workdir before running, so `..` against
|
||||
// /w/app runs in /w — the card must say `w`, not `..`.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '.' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../sibling' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/sibling')
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: './nested/../other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/w/app/other')
|
||||
// A `..` that would climb past the root is dropped, as a filesystem does.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../../..' }),
|
||||
}), '/w')?.card.cwd).toBe('/')
|
||||
// An absolute path carrying segments normalizes too.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '/srv/./app/../other' }),
|
||||
}), '/w/app')?.card.cwd).toBe('/srv/other')
|
||||
// A Windows path keeps its separators.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }),
|
||||
}), '/w')?.card.cwd).toBe('C:\\ws')
|
||||
// Without a session cwd a relative `..` has nothing to resolve against, so
|
||||
// it survives as authored rather than being silently dropped.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../elsewhere' }),
|
||||
}))?.card.cwd).toBe('../elsewhere')
|
||||
})
|
||||
|
||||
it('keeps a UNC server and share as an unpoppable root', () => {
|
||||
// Windows cannot climb above a share, so `..` from the share root stays put.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
|
||||
// Below the share it pops normally, keeping the UNC separators.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '..' }),
|
||||
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
// Several `..` cannot escape the root either.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: callTerminal({ cwd: '../../..' }),
|
||||
}), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
|
||||
})
|
||||
|
||||
it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
|
||||
// A truncated call carries no cwd anywhere: the result view has none, and
|
||||
// the original call may have used an explicit workdir. Falling back to the
|
||||
// session workspace here would name a directory the card cannot know.
|
||||
expect(terminalCardModel(settled({
|
||||
call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}), '/w/app')?.card.cwd).toBeUndefined()
|
||||
// A present call view that omits its cwd still means the workspace.
|
||||
expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
|
||||
})
|
||||
|
||||
it('carries the call view\'s description, which the contract renders above the card', () => {
|
||||
expect(terminalCardModel(settled())?.description).toBe('List files')
|
||||
expect(terminalCardModel(running())?.description).toBe('List files')
|
||||
// A presenter that supplies none, and a window-truncated call side, both
|
||||
// leave it absent so the row keeps its args-derived summary.
|
||||
expect(terminalCardModel(settled({
|
||||
callView: { card: 'terminal', title: 'ls' },
|
||||
}))?.description).toBeUndefined()
|
||||
expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a window-truncated call side falls back to the result title, then to an empty command', () => {
|
||||
// Truncation drops both the call head and its view (conversation.ts).
|
||||
const truncated = { call: null, callView: null }
|
||||
expect(terminalCardModel(settled({
|
||||
...truncated, resultView: resultTerminal({ title: 'ls -la' }),
|
||||
}))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
|
||||
expect(terminalCardModel(settled(truncated))?.card).toMatchObject({ command: '', cwd: undefined })
|
||||
})
|
||||
|
||||
it('returns null for every non-terminal call: no views, generic views, unknown cards', () => {
|
||||
expect(terminalCardModel(running({ callView: null }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull()
|
||||
expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
|
||||
// A generic result settles a terminal call as a generic card (the bash
|
||||
// tool's own execution-error and background paths).
|
||||
expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
|
||||
// A card tag this UI version does not know arrives over the wire; the
|
||||
// documented generic-card default takes it, not a crash.
|
||||
const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
|
||||
expect(terminalCardModel(running({ callView: future }))).toBeNull()
|
||||
expect(terminalCardModel(settled({
|
||||
callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
|
||||
}))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat row terminal body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
})
|
||||
|
||||
it('the expanded body is the command output, capped tighter than the panel', () => {
|
||||
expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled())} />)
|
||||
// Collapsed: the one-line summary row only, no output.
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The args JSON body the generic path would have shown is gone.
|
||||
expect(view.queryByText(/"command"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('the cap collapses a long output inside the row, expandable in place', () => {
|
||||
const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('… 其余 3 行')).toBeTruthy()
|
||||
expect(view.queryByText('line-5')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
|
||||
expect(view.getByText('line-5')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a multi-line command as one prompt row per line', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ title: 'ls -la\necho done' }),
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
|
||||
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
|
||||
// Still one dot for the call, on the first row.
|
||||
expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('the fallback row shows the presenter description, not the args summary', () => {
|
||||
// Any terminal-declaring tool without its own keyed row lands here, so the
|
||||
// contract's above-card description has to win at this render site as well.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the presenter description visible once the terminal card is expanded', () => {
|
||||
// The contract puts the description ABOVE the card. The collapsed summary is
|
||||
// hidden while a row is open, so an expanded terminal row has to draw it
|
||||
// itself or the description would only ever be visible collapsed.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call expands to the prompt line with no output yet', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(running())} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
// The card states its own run state: a running command reads as running
|
||||
// even though it has no output yet to distinguish it from an empty settle.
|
||||
expect(runStateOf(view.container)).toBe('ongoing')
|
||||
})
|
||||
|
||||
it('a non-terminal call keeps the args-JSON text body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
callView: null, resultView: null,
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a terminal call with no args still expands, through its terminal body alone', () => {
|
||||
// Empty args make the text body null; the terminal material carries the row.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
call: { name: 'bash', argsRaw: '' },
|
||||
}))} />)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
it('renders the command output under the summary row, without an expand gesture', () => {
|
||||
const view = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
// The card's controls are the row's only interactions: a bash row is not a
|
||||
// path link and no longer a details-panel target, so nothing here navigates.
|
||||
expect(view.container.querySelector('[data-clickable]')).toBeNull()
|
||||
expect(view.getByText('复制')).toBeTruthy()
|
||||
})
|
||||
|
||||
// The row's leading StateDot and the card's run-state dot describe the same
|
||||
// command, so a running row whose card claimed 'done' would be a contradiction
|
||||
// the reader sees on one line.
|
||||
it('agrees with the summary row about the run state', () => {
|
||||
const runningView = render(<BashRow {...rowProps(running())} />)
|
||||
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
|
||||
expect(runStateOf(runningView.container)).toBe('ongoing')
|
||||
cleanup()
|
||||
const settledView = render(<BashRow {...rowProps(settled())} />)
|
||||
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
|
||||
expect(runStateOf(settledView.container)).toBe('done')
|
||||
})
|
||||
|
||||
it('shows the terminal presenter\'s description instead of the args summary', () => {
|
||||
// `terminal_send`-style presenters author a description the args do not
|
||||
// repeat; the contract puts it above the card, which is this row's summary.
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: callTerminal({ description: 'Terminal 3' }),
|
||||
}))} />)
|
||||
expect(view.getByText('Terminal 3')).toBeTruthy()
|
||||
expect(view.queryByText('List files')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the args-derived summary when the presenter authored no description', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'terminal', title: 'ls -la' },
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal bash call (background start) renders the summary row alone', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel Output section', () => {
|
||||
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(sessions)}
|
||||
useWorkspaces={bindSnapshotSelector(workspaces)}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null, ...over,
|
||||
}
|
||||
}
|
||||
|
||||
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' }
|
||||
|
||||
// The panel never unmounts between selections, so per-call view state has to
|
||||
// be keyed off the selected call or it leaks into the next one.
|
||||
it('resets the card\'s expand state when the selected call changes', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
}), target)
|
||||
fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
|
||||
expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
|
||||
// A second call, selected without unmounting the panel, starts collapsed.
|
||||
cleanup()
|
||||
const second = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
|
||||
})],
|
||||
}), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
|
||||
expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the presenter description above the card', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })],
|
||||
}), target)
|
||||
const description = view.getByText('Terminal 3')
|
||||
const card = view.container.querySelector('[data-terminal]')
|
||||
expect(card).not.toBeNull()
|
||||
// Above, not below: document order is what places it as the card's heading.
|
||||
expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('resolves the prompt cwd against the session workspace', () => {
|
||||
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
|
||||
// No workdir in the call view: the prompt label is the workspace basename.
|
||||
expect(view.getByText('app')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the terminal card at full height, keeping the JSON Input section', () => {
|
||||
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
|
||||
}), target)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
// The panel takes the primitive's own default cap (16), not the row's.
|
||||
expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
|
||||
expect(view.getByText('row-0')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running()] }), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
expect(view.queryByText('运行中…')).toBeNull()
|
||||
expect(runStateOf(view.container)).toBe('ongoing')
|
||||
})
|
||||
|
||||
it('a running non-terminal call keeps the 运行中… placeholder', () => {
|
||||
const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target)
|
||||
expect(view.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal result keeps the flattened pre with its error styling', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, isError: true,
|
||||
content: [{ type: 'text', text: 'permission denied' }],
|
||||
})],
|
||||
}), target)
|
||||
const pre = view.container.querySelector('pre[data-error]')
|
||||
expect(pre?.textContent).toBe('permission denied')
|
||||
})
|
||||
|
||||
// The panel resolves a sub-dispatch through the same material as a native
|
||||
// call, so a sub-call that DID carry terminal views would render the card.
|
||||
// The shipped wire cannot produce that yet: `session.ts` folds
|
||||
// `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and
|
||||
// the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This
|
||||
// pins the resolution path with views injected directly, and the arm below
|
||||
// pins what the shipped path actually shows today.
|
||||
it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
|
||||
const view = mount(snapshot({
|
||||
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
|
||||
}), target)
|
||||
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
|
||||
const view = mount(snapshot({
|
||||
codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]),
|
||||
}), target)
|
||||
// No terminal card: the generic path renders the result text in the Output
|
||||
// section's <pre> (the Input section has its own, hence the scoping).
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
const output = view.getByText('Output').closest('section')
|
||||
expect(output?.querySelector('pre')?.textContent).toContain('a.ts b.ts')
|
||||
})
|
||||
|
||||
it('a running run_code sub-dispatch resolves through the running material', () => {
|
||||
const view = mount(snapshot({
|
||||
// The leading non-matching sub-call exercises the scan's skip.
|
||||
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
|
||||
}), target)
|
||||
expect(view.getByText('ls -la')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
|
||||
}), target)
|
||||
expect(view.getByText('c1')).toBeTruthy()
|
||||
expect(view.queryByText('Input')).toBeNull()
|
||||
expect(view.getByText('Output')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('scans past other nodes and other calls before reporting the call out of window', () => {
|
||||
const view = mount(snapshot({
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] },
|
||||
settled({ callId: 'elsewhere' }),
|
||||
],
|
||||
runningCalls: [running({ callId: 'also-elsewhere' })],
|
||||
}), target)
|
||||
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('no selection at all renders the guidance line and the default title', () => {
|
||||
const view = mount(snapshot(), null)
|
||||
expect(view.getByText('详情')).toBeTruthy()
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a step selection without a callId renders the guidance line too', () => {
|
||||
const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 })
|
||||
expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the close button reaches closeDetails', () => {
|
||||
localStorage.clear()
|
||||
const chat = createChatStore().create()
|
||||
const closeDetails = vi.fn()
|
||||
const snap = snapshot()
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' }))}
|
||||
useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}))}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} }}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
|
||||
expect(closeDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
|
||||
const nonText = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null,
|
||||
content: [{ type: 'reasoning', text: 'why' }],
|
||||
})],
|
||||
}), target)
|
||||
// Scope to the Output section: the Input section's CodeBlock renders a
|
||||
// <pre> of its own, and it comes first in document order.
|
||||
expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
|
||||
.toBe('{\n "type": "reasoning",\n "text": "why"\n}')
|
||||
cleanup()
|
||||
const empty = mount(snapshot({
|
||||
nodes: [settled({
|
||||
callView: null, resultView: null, content: [], isError: true,
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
})],
|
||||
}), target)
|
||||
expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/permission"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -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: 26e909b96412985792eeae72d51a2ab2a315c943
|
||||
README.zh.md: 2e5799fd32c41328f8ca8b9e1a439fbccb3cdba2
|
||||
README.md: 9354f4b79f7b1af7d8a20a295e77913ff443c2e4
|
||||
README.zh.md: c949236557e7eb3eed0c698566fb5aa9e9cdd18a
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
|
||||
|
||||
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
|
||||
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts both panels at their default widths and never reads or writes `localStorage`. Hero and other unselected states derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session opens at the default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`.
|
||||
|
||||
@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Details open/width state is global** — it does not follow the session (arbitrated for P-I); the per-session keyed upgrade slot is reserved.
|
||||
- **Concession-chain auto-close derives a zero width without touching the persisted open flag** — the panel restores itself when the window widens; consumers must not read `details.open` as the rendered truth.
|
||||
- **Panel geometry is transient** — reload restores both panels to their defaults; switching between distinct Session ids closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry.
|
||||
- **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth.
|
||||
- **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
|
||||
|
||||
AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。
|
||||
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,两个面板均以默认宽度启动,且从不读写 `localStorage`。hero 和其他未选中状态会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话以默认宽度打开;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
|
||||
|
||||
`/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。
|
||||
|
||||
@@ -18,6 +18,6 @@ AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **详情栏打开/宽度状态是全局状态**:它不会随会话变化(P-I 已裁定);为逐会话键控升级预留了 slot。
|
||||
- **让步链自动关闭通过推导零宽度实现,不会改动持久化的打开标志**:窗口变宽时面板会自行恢复;消费方禁止把 `details.open` 当作实际渲染状态。
|
||||
- **面板几何信息是瞬时状态**:重新加载会将两个面板恢复为默认值;在不同会话 id 之间切换会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。
|
||||
- **让步链自动关闭通过推导零宽度实现,不会改动首选宽度**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。
|
||||
- **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* through the three framework shares — zero cordis or framework imports,
|
||||
* zero self-made hooks.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { computeColumns } from './columns.ts'
|
||||
@@ -86,13 +86,27 @@ function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart:
|
||||
/** The three-column frame (see module doc). */
|
||||
export function AppFrame({
|
||||
useStore,
|
||||
useSessions,
|
||||
actions,
|
||||
renderSlot,
|
||||
}: AppFrameProps) {
|
||||
const panels = useStore(s => s)
|
||||
const detailsSession = useSessions((s) => {
|
||||
const current = s.current
|
||||
return current !== undefined && s.byId[current]?.blank === false ? current : undefined
|
||||
})
|
||||
const frameRef = useRef<HTMLDivElement | null>(null)
|
||||
const [viewport, setViewport] = useState(() => window.innerWidth)
|
||||
|
||||
const lastSession = useRef(detailsSession)
|
||||
useLayoutEffect(() => {
|
||||
if (detailsSession === undefined) return
|
||||
if (lastSession.current !== undefined && lastSession.current !== detailsSession) {
|
||||
actions.closeDetails()
|
||||
}
|
||||
lastSession.current = detailsSession
|
||||
}, [actions, detailsSession])
|
||||
|
||||
// Track the frame's own box (not the window): rAF-throttled ResizeObserver.
|
||||
useEffect(() => {
|
||||
const el = frameRef.current
|
||||
@@ -113,12 +127,12 @@ export function AppFrame({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cols = computeColumns(viewport, panels.sidebar, panels.details)
|
||||
const cols = computeColumns(viewport, panels.sidebar, detailsSession === undefined ? 0 : panels.details)
|
||||
const colsRef = useRef(cols)
|
||||
colsRef.current = cols
|
||||
|
||||
// The drag base is the rendered width captured at drag start (grabbing a
|
||||
// concession-clamped panel must not jump back to the persisted preference);
|
||||
// concession-clamped panel must not jump back to the stored preference);
|
||||
// it stays frozen for the whole gesture so dx deltas do not compound.
|
||||
const sidebarBase = useRef(0)
|
||||
const detailsBase = useRef(0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Pure concession-chain column solver for the three-column AppFrame.
|
||||
* Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking
|
||||
* details, then auto-closing it (derived zero width — persisted width
|
||||
* details, then auto-closing it (derived zero width — preferred width
|
||||
* preferences are never rewritten, so widening the window restores them).
|
||||
* The sidebar never concedes: its rendered width is always the drag
|
||||
* preference (or the collapsed rail), and center absorbs any remaining
|
||||
@@ -45,8 +45,8 @@ export function clampWidth(px: number, min: number, max: number): number {
|
||||
/**
|
||||
* Solve the three column widths for one viewport frame. Pure: no hysteresis —
|
||||
* the output is a function of (viewport, preferences) only, so recovery on
|
||||
* re-widening is automatic. Preferences re-clamp here because they cross a
|
||||
* durable boundary (localStorage rehydration may carry stale ranges).
|
||||
* re-widening is automatic. Preferences re-clamp here because they cross the
|
||||
* store boundary and callers may still supply stale ranges.
|
||||
* @param viewport - available frame width in px.
|
||||
* @param sidebar - sidebar width preference in px (0 = closed).
|
||||
* @param details - details width preference in px (0 = closed).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The root entry's layout store: panel geometry as plain widths in px
|
||||
* (0 = closed), persisted across reloads. Module level exports the factory
|
||||
* only — a module-level handle would pin the store's identity in the module
|
||||
* The root entry's transient layout store: panel geometry as plain widths in
|
||||
* px (0 = closed). Module level exports the factory only — a module-level
|
||||
* handle would pin the store's identity in the module
|
||||
* cache (a de-facto singleton surviving plugin reloads). register() receives
|
||||
* the factory (exclusive use: the framework instantiates per entry), AppFrame
|
||||
* derives its PropsStore share from the return type, and the service face
|
||||
@@ -29,17 +29,16 @@ type LayoutActions = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the layout panel store handle. The persisted preference IS the
|
||||
* width, so closing a panel forgets its drag width — reopening restores the
|
||||
* contract default. Actions are the complete write set: drag writes clamp
|
||||
* Create the layout panel store handle. The preference IS the width, so
|
||||
* closing a panel forgets its drag width — reopening restores the contract
|
||||
* default. Actions are the complete write set: drag writes clamp
|
||||
* into the panel's contract range and never cross the open/closed line;
|
||||
* open/close transitions write 0 / the default explicitly.
|
||||
* @returns the store handle (spec + type + identity + factory in one).
|
||||
*/
|
||||
export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> {
|
||||
const handle = defineStore({
|
||||
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
|
||||
persist: 'dsh.layout.panels',
|
||||
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT }),
|
||||
actions: {
|
||||
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
|
||||
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },
|
||||
|
||||
@@ -15,8 +15,8 @@ export const name = 'client-ui-layout-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: shell viewing-state stores (zustand+persist) behind
|
||||
* ctx.layout — it emits no cordis events; clamp/prune/concession-chain
|
||||
* No runtime invariant: the shell viewing-state store behind ctx.layout emits
|
||||
* no cordis events; clamp/prune/concession-chain
|
||||
* sequencing is asserted directly by this package's columns and service specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
@@ -21,8 +21,9 @@ import type {
|
||||
SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
// Session-mode switch for the SessionProvider stub prop.
|
||||
const sessionMode = { current: true }
|
||||
// Session selection controls for the SessionProvider and useSessions stubs.
|
||||
const selectedSession = { current: 's-test' as SessionId | undefined }
|
||||
const selectedSessionBlank = { current: false }
|
||||
const baselinesReady = { current: true }
|
||||
|
||||
// Render-prop contract stub fed through the standard seat prop (the renderer
|
||||
@@ -31,7 +32,7 @@ const baselinesReady = { current: true }
|
||||
// shape. Typed as the seat's own component type so the branded sessionId
|
||||
// parameter stays contract-checked.
|
||||
const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
|
||||
sessionMode.current ? <>{children('s-test' as Parameters<typeof children>[0])}</> : <>{empty?.() ?? null}</>
|
||||
selectedSession.current === undefined ? <>{empty?.() ?? null}</> : <>{children(selectedSession.current)}</>
|
||||
|
||||
|
||||
/** Observer stub: captures the callback so tests can fire resizes manually. */
|
||||
@@ -54,7 +55,6 @@ function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapsho
|
||||
function mountFrame() {
|
||||
window.innerWidth = frameWidth // first-render viewport source before the observer fires
|
||||
const instance = createLayoutStore().create()
|
||||
instance.actions.openDetails() // seed: sidebar at default 280, details open at default 360
|
||||
const slotCalls: { key: string; props: unknown }[] = []
|
||||
const renderSlot = ((key: string, owner: object) => {
|
||||
slotCalls.push({ key, props: owner })
|
||||
@@ -64,32 +64,35 @@ function mountFrame() {
|
||||
if (key === 'conversation.empty') return <div data-testid="empty-content" />
|
||||
return <div data-testid="other-content" />
|
||||
}) as AppFrameProps['renderSlot']
|
||||
const sessionId = 's-test' as SessionId
|
||||
const sessionState = {
|
||||
ids: sessionMode.current ? [sessionId] : [],
|
||||
byId: sessionMode.current
|
||||
? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } }
|
||||
: {},
|
||||
current: sessionMode.current ? sessionId : undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState
|
||||
const useSessions = ((sel: (s: SessionListState) => unknown) => sel(sessionState)) as never
|
||||
const useSessions = ((sel: (s: SessionListState) => unknown) => {
|
||||
const current = selectedSession.current
|
||||
const sessionState = {
|
||||
ids: current === undefined ? [] : [current],
|
||||
byId: current === undefined
|
||||
? {}
|
||||
: { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } },
|
||||
current,
|
||||
phase: 'ready',
|
||||
} as SessionListState
|
||||
return sel(sessionState)
|
||||
}) as never
|
||||
const workspaceState: WorkspaceListState = {
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
|
||||
}
|
||||
const utils = render(
|
||||
const element = () => (
|
||||
<AppFrame
|
||||
useStore={hookOf(instance) as never}
|
||||
useStore={hookOf(instance)}
|
||||
actions={instance.actions}
|
||||
renderSlot={renderSlot}
|
||||
useSessions={useSessions}
|
||||
useWorkspaces={((sel: (s: WorkspaceListState) => unknown) => sel(workspaceState)) as never}
|
||||
SessionProvider={SessionProviderStub}
|
||||
/>,
|
||||
/>
|
||||
)
|
||||
const utils = render(element())
|
||||
const frame = utils.container.firstElementChild as HTMLElement
|
||||
return { instance, frame, slotCalls, ...utils }
|
||||
return { instance, frame, slotCalls, rerenderFrame: () => { utils.rerender(element()) }, ...utils }
|
||||
}
|
||||
|
||||
function tracks(frame: HTMLElement): number[] {
|
||||
@@ -109,9 +112,9 @@ function drag(handle: Element, fromX: number, toX: number): void {
|
||||
|
||||
beforeEach(() => {
|
||||
frameWidth = 1920
|
||||
sessionMode.current = true
|
||||
selectedSession.current = 's-test' as SessionId
|
||||
selectedSessionBlank.current = false
|
||||
baselinesReady.current = true
|
||||
localStorage.clear() // the layout store persists; instances must not bleed across tests
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
|
||||
@@ -154,7 +157,7 @@ describe('AppFrame', () => {
|
||||
it('keeps the conversation slot mounted while no session is current', () => {
|
||||
// No current session: the session-maybe conversation shell owns the New
|
||||
// Session view itself — the center column renders it unconditionally.
|
||||
sessionMode.current = false
|
||||
selectedSession.current = undefined
|
||||
const { slotCalls, getByTestId } = mountFrame()
|
||||
expect(getByTestId('center-content')).toBeTruthy()
|
||||
expect(slotCalls.map(c => c.key)).toContain('conversation')
|
||||
@@ -169,6 +172,45 @@ describe('AppFrame', () => {
|
||||
expect(slotCalls.map(c => c.key)).toContain('details')
|
||||
})
|
||||
|
||||
it('ignores unselected states and closes only when the Session id changes', () => {
|
||||
const { frame, instance, rerenderFrame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
|
||||
selectedSession.current = 's-next' as SessionId
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
|
||||
act(() => { instance.actions.openDetails() })
|
||||
selectedSession.current = 's-blank' as SessionId
|
||||
selectedSessionBlank.current = true
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(instance.getSnapshot().details).toBe(360)
|
||||
|
||||
selectedSession.current = 's-next' as SessionId
|
||||
selectedSessionBlank.current = false
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
|
||||
selectedSession.current = undefined
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
selectedSession.current = 's-test' as SessionId
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
|
||||
it('keeps the default details width when the first Session materializes', () => {
|
||||
selectedSession.current = undefined
|
||||
const { frame, instance, rerenderFrame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(instance.getSnapshot().details).toBe(360)
|
||||
|
||||
selectedSession.current = 's-first' as SessionId
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
})
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
const { slotCalls } = mountFrame()
|
||||
expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* createLayoutStore unit account: init shape, the action write set (clamp
|
||||
* inside actions), and the persist key round-trip over jsdom localStorage.
|
||||
* Uses the test-sanctioned path: factory self-call + .create() gives the
|
||||
* inside actions), and the absence of browser persistence. Uses the
|
||||
* test-sanctioned path: factory self-call + .create() gives the
|
||||
* real engine instance (same create path as production).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels'
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
describe('createLayoutStore', () => {
|
||||
it('initializes with sidebar open at default and details closed', () => {
|
||||
it('initializes both panels at their default widths', () => {
|
||||
const { store } = createLayoutStore().create()
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT })
|
||||
})
|
||||
|
||||
it('each create() is an independent instance (factory is not a singleton)', () => {
|
||||
@@ -52,6 +52,7 @@ describe('createLayoutStore', () => {
|
||||
|
||||
it('openDetails is a no-op when already open; closeDetails zeroes', () => {
|
||||
const { store, actions } = createLayoutStore().create()
|
||||
actions.closeDetails()
|
||||
actions.openDetails()
|
||||
expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
|
||||
actions.setDetails(500)
|
||||
@@ -61,13 +62,16 @@ describe('createLayoutStore', () => {
|
||||
expect(store.getSnapshot().details).toBe(0)
|
||||
})
|
||||
|
||||
it('persists under dsh.layout.panels and rehydrates on the next create', () => {
|
||||
it('does not persist panel geometry', () => {
|
||||
const first = createLayoutStore().create()
|
||||
first.actions.setSidebar(320)
|
||||
first.actions.openDetails()
|
||||
expect(JSON.parse(localStorage.getItem(PERSIST_KEY) ?? '{}')).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
|
||||
first.actions.setSidebar(400)
|
||||
first.actions.closeDetails()
|
||||
expect(localStorage.getItem(PERSIST_KEY)).toBeNull()
|
||||
|
||||
const second = createLayoutStore().create()
|
||||
expect(second.store.getSnapshot()).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
|
||||
expect(second.store.getSnapshot()).toEqual({
|
||||
sidebar: SIDEBAR_DEFAULT,
|
||||
details: DETAILS_DEFAULT,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
|
||||
README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642
|
||||
README.zh.md: 325b1d93d99ed22e0945c26f5a3a9e5b3b209c85
|
||||
README.zh.md: 6d6f433315336812a51b5110ceeac3eecbd9bbd4
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。逐提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。
|
||||
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。
|
||||
|
||||
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。
|
||||
|
||||
## Model Experience
|
||||
## 模型体验
|
||||
|
||||
间接影响,经两个入口共同提交的 `session.selectModel` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
|
||||
间接影响,经两个入口共同提交的 `session.selectModel` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此下一次请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
|
||||
|
||||
#### KV Cache effect
|
||||
#### KV Cache 影响
|
||||
|
||||
切换路由可能降低或作废提供方侧后续请求的缓存复用;提示词前缀本身不受影响。
|
||||
切换路由可能减少提供方侧后续请求的缓存复用,或使其失效;提示词前缀本身不受影响。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无创建期选择**——两个入口都寻址既有会话的 agent;没有 Draft 期模型选择折入会话创建的通道(host `targetFor` 处的种子序注释记录了该层未来的落点)。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
|
||||
- **无创建期选择**——两个入口都面向既有会话的 agent(智能体);没有将草稿阶段的模型选择纳入会话创建的通道(host 的 `targetFor` 中的种子顺序说明了该层未来的落点)。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或具体模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **不能任意输入推理强度**——composer 仅提供具体模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
|
||||
|
||||
@@ -1,6 +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
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
|
||||
README.md: 13f51d5338affd65d0705cec6a3b4ef78a534f0f
|
||||
README.zh.md: 466505beb27c729246afe04e6378235b91d072cf
|
||||
README.zh.md: 90f4eb5959e105178844c7bd5b07596aff81e706
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user