Compare commits

...
5 Commits
Author SHA1 Message Date
mesalogo 81ae9e0324 chore: prepare Desktop 0.13.2 and Agent 0.11.25
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
GoodBuddy Agent Release / Build darwin arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Prepare bilingual notes and aligned feature lists for the schema-35 history repair, retained tool summaries, Windows DeepSeek workspace paths, and compact project activity navigation.

Agent 0.11.25 is a maintenance release with unchanged protocol and runtime pins. Its compound packages require Desktop 0.13.2. Desktop targets the standard six platforms without a LoongArch preview.

Make activity interaction tests wait for the loaded conversation and enabled send button, reset response mocks between cases, and wait for completed answer rendering. Accept bilingual maintenance-only Agent notes without inventing features or fixes.

Validation: 4,155 tests passed, 66 skipped; typecheck, lint, and release notes verification passed. The affected 20 tests passed three consecutive runs; all 227 App and Agent release-note tests passed. No local production build or packaging was run.
2026-09-14 01:18:42 +08:00
mesalogo f49d6678a3 fix: refine project activity spacing and conversation rows
Add 8px below the activity summary to separate it from new conversation. Remove redundant conversation-row arrows while keeping project submenu arrows, and update the UI documentation.

Validation: 11 ProjectActivity tests and typecheck passed. Lint was interrupted before completion.

Release note: 调整项目活动摘要与新建会话的间距,移除会话列表末尾的多余箭头,保留项目子菜单指示。
2026-09-14 00:40:10 +08:00
mesalogo 0d7ba55a50 fix: preserve tool details and prevent repeated history growth
Show bounded operation summaries for Continue, DeepSeek Harness and direct-model tools, retaining input details across incremental updates. Use one canonical workspace path for DeepSeek Host startup and ACP sessions so equivalent Windows paths reuse a working Runtime.

Compare persisted subagent tool fields by value, release terminal task caches, and avoid rebuilding caches for duplicate remote events. Upgrade to schema 35 to recompact repeated schema-34 tool blocks while preserving events and provenance. Include regression coverage, validation records and temporary-resource test exclusions.

Validation: 4155 tests passed, 66 skipped; typecheck and lint passed. Full desktop real-model validation covered all three tool-summary paths and both Windows path spellings. Storage validation evidence includes local and remote tool workloads and real database upgrade checks.

Release note: Tool records now show paths, commands and search details across runtimes. Fix DeepSeek Windows workspace failures and repeated subagent history growth; schema 35 reclaims redundant history data while preserving execution records, and older clients cannot reopen the upgraded database.
2026-09-14 00:34:51 +08:00
mesalogo c71da4b6ae fix: browse project activity through cascading menus
Replace the production activity dialog with anchored project and conversation menus, preserving exact conversation navigation and adding keyboard and narrow-window coverage. Include the interactive demo and design documentation.

The demo hides idle activity and removes conversation arrows; production still retains those two elements. Validation: typecheck, lint and 11 ProjectActivity tests passed. The full suite timed out after 240 seconds with an agent-package offline inventory test failure.
2026-09-13 23:06:20 +08:00
mesalogo ba6f6b8f22 fix: show operation details in OpenCode tool records
Preserve OpenCode operation titles in main-session tool summaries, including permission waits, failures and interrupted calls. Keep title trimming and length limits aligned with subagent records.

Update tool display documentation and include the activity-menu wording in UI-DESIGN.md. Validation: 132 focused tests passed; main-process typecheck and changed-file lint passed. Full-suite checks were affected by concurrent activity-menu changes and a test timeout.

Release note: OpenCode main-session tool records now show file paths, commands or operation descriptions beside tool names, matching subagent records.
2026-09-13 22:55:12 +08:00
55 changed files with 1547 additions and 393 deletions
+26 -13
View File
@@ -21,7 +21,7 @@ otherwise.
packages target compatible x64 and arm64 environments, including UOS, Kylin,
Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification.
LoongArch has a separate experimental loong64 preview outside standard
releases and automatic updates; no preview is planned for 0.13.1. See the
releases and automatic updates; no preview is planned for 0.13.2. See the
[preview boundaries](./docs/development/loongarch-preview-build.md).
- [x] **Configurable global shortcut**: Enable, disable, or record an Electron
accelerator under Platform Features / General. The default remains
@@ -38,8 +38,10 @@ otherwise.
Host heading and only the remote path on each project row.
- [x] **Cross-project conversation activity**: A shared activity entry lists
running conversations and conversations needing attention across projects,
including older conversations with live work. Open the associated
conversation directly; project selection also shows activity counts.
including older conversations with live work. Anchored project-to-conversation
menus provide keyboard navigation and exact conversation opening without a
full activity dialog. The summary remains visible when idle, and project
selection continues to show activity counts.
- [x] **Compact conversation controls**: Composer options share a compact
settings panel while Runtime, mode, sending, and queue controls remain
available. Conversation search has an inline clear action that restores the
@@ -116,7 +118,10 @@ otherwise.
The first upgrade automatically converts existing history and reclaims disk
space, with visible progress and quit/retry support. Chats, execution details,
results, and remote event deduplication are preserved; older clients cannot
reopen the upgraded database. See
reopen the upgraded database. The 0.13.2 candidate's schema-35 repair also handles
repeated tool blocks written after upgrading to 0.13.1, without deleting events.
Terminal tasks release their write caches; duplicate remote replay does not
rebuild a released cache. See
[storage and upgrade behavior](./docs/features/assistant-workbar/execution-history-storage.md).
- [x] **Direct model Runtime**: Supports question answering, knowledge
@@ -223,7 +228,7 @@ otherwise.
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
database. Successful tool START/END events appear exactly once, with no
Prompt, provider, or tool replay observed. The current Agent source lock is
`0.11.24`, while the current Desktop release candidate is `0.13.1`; formal
`0.11.25`, while the current Desktop release candidate is `0.13.2`; formal
publication status follows the separate Agent and Desktop
release channels. Previous macOS validation covered native package installation,
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
@@ -277,7 +282,9 @@ otherwise.
permissions. Image input follows the selected model connection's declared
capability: text models reject images before Host or model invocation, while
image-capable models receive bounded inline JPEG/PNG content through a
temporary Attachment Store.
temporary Attachment Store. On Windows, Host startup and ACP sessions share
one canonical workspace path, so equivalent path spellings reuse the same
working Runtime instead of failing session creation.
- [x] **DSH npm plugin marketplace**: Disabled by default and searches public
npm `dsh-plugin` packages only after explicit user enablement. It uses the
bundled npm to install exact versions with ordinary lifecycle scripts and
@@ -559,6 +566,9 @@ otherwise.
- [x] **Compact conversation tool records**: Expand individual tool records
to read or copy results, errors, and input parameters. Conversation and
child-task progress use concise rows without repeated Runtime summaries.
OpenCode, Continue, DeepSeek Harness, and direct models show available file
paths, commands, or search summaries and retain them through completion and
conversation reloads.
- [x] **Task and custom-task experience**: Each product-level Task belongs to
one Conversation, while one Conversation can contain multiple Tasks. The
left conversation list exposes Task children through a leading expand
@@ -676,15 +686,18 @@ otherwise.
### Open source, builds, and releases
- Current source candidates are Desktop `0.13.1` and Agent `0.11.24`, with
- Current source candidates are Desktop `0.13.2` and Agent `0.11.25`, with
OpenCode pinned to `1.18.29`. Publication status follows the independent
Desktop and Agent release channels.
- Candidate acceptance remains separate from version preparation. The current
remote-question source has passed real Linux x64 SSH and Electron question
tests with a deterministic loopback model, not the complete production
Main/Preload path with a real provider. That real-model path and candidate
native CI/package validation remain outstanding; see the
[remote-question evidence](./docs/features/assistant-workbar/progress.md).
- Agent `0.11.25` is a maintenance release without standalone remote feature
changes, and its packages require Desktop `0.13.2`. The desktop storage repair
also works with Agent `0.11.24`; updating Agent is not a substitute for updating Desktop.
- Candidate acceptance remains separate from version preparation. Current-source
storage validation covers real local and Linux x64 Host tool workloads,
concurrent projects/conversations, cancellation, and lossless database migration.
Windows tool summaries and DeepSeek path handling have real full-App evidence;
the release candidate still requires main CI and native release packaging. See the
[validation evidence](./docs/features/assistant-workbar/progress.md).
- [x] **0BSD open-source license**: Original code can be freely used, copied,
modified, distributed, and commercialized. Third-party components and
resources retain their own licenses.
+8 -7
View File
@@ -14,10 +14,10 @@
### 桌面基础、工作空间与上下文
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.1` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.2` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
- [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话,可直接打开对应对话;项目选择器也显示活动数量。
- [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话。锚定的“项目 → 会话”菜单支持键盘导航和精确打开对话,不再使用整块活动弹窗;空闲时摘要入口仍显示,项目选择器继续显示活动数量。
- [x] **紧凑会话控件**:输入选项集中在紧凑设置面板,Runtime、工作模式、发送和队列操作保持可用;会话搜索提供内嵌清除按钮,清除后恢复当前范围列表并把焦点返回输入框。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异、当前浏览目录和已展开目录,超过 50 项变更可继续加载。
@@ -32,7 +32,7 @@
### Agent Runtime 与模型连接
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。0.13.2 候选的 schema 35 补修还会整理升级到 0.13.1 后重复写入的工具块,不删除事件;终态及时释放写入缓存,远程重复重放不重建已释放的缓存。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。连续请求自动复用最近一次成功生成的图片和文字历史,已保存会话重开后仍可继续修改,本轮显式附图优先;上游明确不支持编辑时继续按文字生成,仅在回复底部提示未使用参考图。编辑使用 multipart 上传图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。详见[图片生成与连续修改](./docs/features/image-generation/README.md)。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
@@ -44,7 +44,7 @@
- [ ] **SSH Host 环境准备真实 Host 验收**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵仍待验收,候选 CI/原生打包与系统休眠唤醒也尚未验证;部分开发证据不代表已经发布。
- [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。
- [x] **远程原生问答与取消修复**:Agent `0.11.24` 修复远程原生问答转交和取消待答后同一会话续发,需要先升级 Desktop 至 `0.13.0`。
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。Windows Host 启动和 ACP 会话共用规范工作区路径,同目录不同写法可复用正常 Runtime,不再因路径不一致而创建会话失败。
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权,包括工作区外路径及原生子代理工作。
- [x] **Runtime 原生交互转交**:OpenCode 与 Continue 的选择、yes/no、自由文本回答和跳过使用现有问答卡片,本机与托管 SSH OpenCode 同时转交属于当前请求的子会话提问。托管 SSH 需要 Desktop `0.13.0`、Agent `0.11.24` 和新启动的托管 Runtime,不扩展到任意 ACP 服务。回答或跳过成功后保留原问题与答案,支持多轮记录和本地会话重载;旧版本已丢弃的答案不能恢复。并行问题按顺序等待回答,重复事件保留草稿,提交失败可重试;取消远程待答后可在同一会话继续发送。Execute 权限确认自动处理,不等待第二次审批;具体支持范围见[交互边界](./docs/features/assistant-workbar/runtime-interactions.md)。
@@ -91,7 +91,7 @@
### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。OpenCode、Continue、DeepSeek Harness 和直连模型可显示已有文件路径、命令或搜索摘要,并在工具完成和会话重载后保留。
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
- [x] **定时消息沿用会话**:定时消息使用会话当前历史、Runtime、工作模式和已保存的知识检索设置;任务详情显示实际运行模式,当前 occurrence 未结束时禁止重复“立即运行”。
@@ -121,8 +121,9 @@
### 开源、构建与发布
- 当前源码候选为 Desktop `0.13.1`、Agent `0.11.24`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
- 候选验收与版本准备分别记录。当前远程问答源码已通过真实 Linux x64 SSH 与 Electron 问答测试,但模型为确定性 loopback 服务,不是完整生产 Main/Preload 配合真实服务商;该真实模型链路和候选原生 CI/打包验证仍待完成,详见[远程问答证据](./docs/features/assistant-workbar/progress.md)。
- 当前源码候选为 Desktop `0.13.2`、Agent `0.11.25`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
- Agent `0.11.25` 为维护版,不新增独立远端功能,复合包要求 Desktop `0.13.2`。桌面存储补修也适用于 Agent `0.11.24`,更新 Agent 不能代替升级 Desktop。
- 候选验收与版本准备分别记录。当前存储源码已通过本地及 Linux x64 Host 真实工具任务、多项目多会话、取消和无损迁移验证;Windows 工具摘要与 DeepSeek 路径处理有完整 App 的真实模型证据。候选仍需主分支 CI 及原生发布打包,详见[验证记录](./docs/features/assistant-workbar/progress.md)。
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。Windows 提供 NSIS 与 portable ZIP,macOS 仅 DMG,Linux 提供 AppImage、DEB 和 RPM,共 12 个安装包、20 个发布资产。
+3 -1
View File
@@ -285,7 +285,7 @@
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
- 项目选择器下方承载跨项目活动汇总,空闲时隐藏;项目计数与活动对话框遵守
- 项目选择器下方常驻跨项目活动汇总,空闲时显示“暂无活动”;项目计数与项目、会话级联菜单遵守
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
活动数量不改变项目菜单的稳定分组和顺序。
- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整,
@@ -629,7 +629,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- OpenCode 原生 Task 调用在本地项目和托管 SSH 项目中统一解析为子 Agent 状态卡,不显示为普通工具卡。状态卡持续显示 Agent 类型、任务说明、运行状态、完成输出或失败原因;身份徽标、Agent 名称和状态位于首层,任务说明作为主信息,Runtime 来源作为次级元数据,输出与错误在同一卡片内按需展开。父 Agent 的推理不冒充子 Agent 过程,必须与状态卡保持明确间距。状态不能只靠颜色表达。推理、子 Agent、工具和正文统一按事件首次出现顺序进入消息块;增量更新只更新原位置,Task 临时工具块重分类后必须原位替换,不能把子 Agent 卡统一提升到消息顶部。同一调用的增量事件按稳定调用 ID 更新,不能因 ACP 首帧尚无参数而永久保留重复工具卡或活动记录。一次请求内已观察到的子 Agent 均保留在消息与活动记录中;父请求取消、失败或意外中断时仍处于活动状态的卡片必须结算为对应终态,父请求成功结束但子 Agent 未报告终态时必须显示为失败而不是推断为完成。
- Agent 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。
- 普通工具在主会话和子 Agent 过程中使用同一轻量折叠记录:不显示外层“工具执行”标题、计数、背景和边框,保留无障碍区域名称。记录纵向左对齐,头部按内容收宽且不超过消息宽度;展开箭头在左,工具名作为主标题,有额外信息的单行摘要弱化显示。右侧以绿色对勾显示成功完成,以图标显示运行和待执行状态,提供悬停文字和无障碍名称;失败和可恢复状态保留文字。仅重复工具名和执行状态的已知 Runtime 模板摘要在头部与详情中均隐藏;刷新重试、审批等额外提示保留。默认折叠,状态和输出更新不改变用户的展开选择;折叠时露出一行错误,展开详情使用消息可用宽度、浅背景和左侧细线,按错误、结果、独立折叠的输入参数阅读。详情保留原始文本,完整 JSON 输入可缩进显示,复制仍使用收到的原文,并通过应用通知反馈。详情工具栏只保留复制按钮,文本默认自动换行,长内容在固定最大高度内滚动,不提供换行切换和展开全部按钮。
- OpenCode 主会话和子 Agent 的工具记录均保留 Runtime 提供的操作标题作为摘要,显示文件路径、命令或操作描述;本地 SDK 标题去除首尾空白后最多保留 240 字符,没有标题时沿用工具名回退规则。主会话工具失败或中断时保留已收到的标题;远端 ACP 继续在记录名称中显示其提供的标题。
- 工具取消后保留“已取消”文字,避免与失败图标混淆。
- Continue、DeepSeek Harness 和直连模型的工具名后显示单行操作摘要,内容提取、长度与完成态保留规则见[工具操作摘要](./docs/features/assistant-workbar/runtime-interactions.md#工具操作摘要)。
- Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。
- 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。
- 队列区域向上增长并设置有界滚动高度,不提供展开、收起或冗余计数标题。宽容器中的操作采用弱化的行内样式,不渲染成强调卡片或胶囊;窄容器中保留来源、摘要和图标操作并隐藏冗长按钮文字。区域、来源和每个操作必须有可读无障碍名称。队列异步错误进入应用通知,不在 Composer 内复制通知样式。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.24",
"agentVersion": "0.11.25",
"protocol": {
"major": 2,
"minor": 0
+8
View File
@@ -37,6 +37,9 @@
覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题
必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。
对象比较必须覆盖真实生产形状和 JSON 往返,尤其是可选字段值为 `undefined` 时与字段缺省
的差异。再次修复已发布迁移产生的数据时,提高目标 schema,并覆盖已升级用户的入口;
不能只处理尚未迁移的旧库。
### 真实旧库
@@ -52,6 +55,9 @@
源 schema 与目标 schema。确认原件和备份未被改写。
6. 运行 `npm test`、`npm run typecheck`、`npm run lint`;生产构建变化按开发验证规则
检查,发布准备则遵循[发布手册](./release-runbook.md),不重复本地打包。
7. 用真实模型模拟正常用户任务,允许主代理与子代理按需调用工具、修改独立测试项目、
运行测试和处理失败;只读纯文字模型调用不能证明工具事件增长正常。记录实际提供商
请求数与统计范围,不把 Runtime 用量上报数当成全部 HTTP 请求数。
## 发布与维护
@@ -65,3 +71,5 @@
表示,并回收空间。具体规则见
[执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md),
数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。
- 当前未发布源码:schema 35 同时整理旧快照和 schema 34 的重复工具块,工具字段按
JSON 可保存的值比较,不因 `undefined` 被省略而误判变化。
@@ -8,7 +8,7 @@
本轮更新实施计划与范围,旧版功能要求、验收和配套设计仍需按新计划同步,此项列为实现前的第一步。
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与弹窗见
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与级联菜单见
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”指当前运行或待处理会话,
与用于审计的“运行记录”不同;已完成未读面板属于后续范围,本次未实现。
@@ -22,6 +22,7 @@
| [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 |
| [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 |
| [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 |
| [实施进度](./progress.md) | 项目活动级联的生产实现与验证记录 |
| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 |
Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加、移除、排序和商店模拟。
@@ -34,6 +35,12 @@ Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加
附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止,
以及上下文详情和模拟压缩。文件只读取名称,语音使用固定示例文本,不录音、不上传、不调用 AI。
这是输入区设计讨论用的原型,尚未改变正式产品或替代现有 UI 规范。
左上角项目选择器下方仅在有运行中或待处理会话时显示活动摘要,全部空闲时隐藏。点击摘要打开项目
列表,悬停或点击项目向右展开会话子菜单,点击会话才切换项目与对话。子菜单与项目列表
相接,项目行保留子菜单箭头,会话行只显示名称与状态。支持上下方向键选择、右键进入、左键或 Esc 返回,顶层 Esc 关闭;窄屏在同一浮层内
进入会话层,并提供返回按钮。页面上方可切换“有待处理、仅运行中、仅当前项目运行、全部
空闲”四种场景。项目与会话均为演示数据;该级联交互已接入正式产品活动入口,范围与
验证记录见[实施进度](./progress.md)。
按最新讨论,Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
工作栏 Tab 中演示,不列入应用中心。上方正式文档仍保留原设计,待方案确定后同步。
@@ -186,3 +186,11 @@ warming/loading/live/partial/error -> inactive
历史排队项、仅待调度的 Task、终态 Task 和未读标记本身不构成活动。活动结束且没有其他
运行或等待条件时移除该行,不能因历史任务或未读状态继续计数。
级联菜单先按项目归属聚合活动行,沿用本地、SSH Host、通道的稳定项目顺序;缺少项目
元数据的归属组及“未归属项目”组放在已知项目之后。无归属组只用于访问这些会话,不增加
项目计数。每个项目内先列待处理会话,再列运行中会话,同类别保留来源顺序。
展开项目只改变菜单层级,不导航。选中项目的活动全部结束后返回项目列表;全局活动结束后
保留已打开的空状态和“暂无活动”入口。后台更新保留仍存在的焦点项,删除焦点项时将焦点
移至当前层可用项或空菜单。关闭菜单后,新的活动不会自动重新打开菜单。
@@ -5,7 +5,43 @@
--accent: #086fc7; --accent-subtle: #e8f4ff; --accent-selected: #a7cfea;
--font-family-ui: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", system-ui, sans-serif;
--radius-control: 10px; --radius-card: 14px;
--warning: #865200;
}
[data-theme="dark"] { --warning: #f2bd66; }
.project-lab { max-width: 1440px; margin: 0 auto 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; color: var(--text-secondary); font-size: 12px; }
.project-lab select { font: inherit; color: var(--text-primary); background: var(--surface-raised); padding: 4px 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }
.project-entry { position: relative; margin-bottom: 12px; }
#project-trigger { width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; border: 1px solid var(--border-default); }
#project-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-waiting { color: var(--warning); font-weight: 600; }
#activity-trigger { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: -8px 0 12px; padding: 4px 8px; color: var(--text-secondary); font-size: 11px; text-align: left; }
#project-activity { position: fixed; inset: auto; margin: 0; padding: 0; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); max-width: calc(100vw - 32px); }
#project-activity:popover-open { display: flex; align-items: stretch; }
#project-activity > section { width: 264px; max-width: calc(100vw - 34px); max-height: min(480px, calc(100dvh - 32px)); overflow: auto; padding: 8px; }
#project-activity h3 { margin: 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); overflow-wrap: anywhere; }
#activity-project-list > p { padding: 8px; }
#activity-project-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; gap: 4px 8px; text-align: left; }
#activity-project-list button > span:first-child { overflow-wrap: anywhere; }
#activity-project-list small { grid-column: 1; font-size: 11px; color: var(--text-secondary); }
#activity-project-list button > span:last-child { grid-column: 2; grid-row: 1 / 3; align-self: center; }
#activity-project-list [aria-expanded="true"] { background: var(--accent-subtle); color: var(--accent); }
#activity-sessions { border-left: 1px solid var(--border-default); }
#activity-back { display: none; font-size: 12px; color: var(--accent); }
#project-activity.activity-left { flex-direction: row-reverse; }
.activity-left #activity-sessions { border-left: 0; border-right: 1px solid var(--border-default); }
.activity-compact.has-submenu #activity-projects { display: none; }
.activity-compact #activity-back { display: block; }
.activity-compact #activity-sessions { border: 0; }
#project-menu { position: fixed; inset: auto; margin: 0; width: min(300px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow: auto; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); }
#project-list h3, #activity-list h3 { margin: 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); }
#project-list button { width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; text-align: left; }
.project-row-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
#project-list [aria-current="true"] { background: var(--accent-subtle); color: var(--accent); }
#project-list small { width: 100%; display: flex; gap: 12px; font-size: 11px; color: var(--text-secondary); }
#activity-list button { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; text-align: left; padding: 12px; }
#activity-list strong { font-weight: 500; overflow-wrap: anywhere; }
#activity-list small { grid-column: 1; color: var(--text-secondary); overflow-wrap: anywhere; }
.history button small { display: block; font-size: 11px; color: var(--text-secondary); }
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --accent-selected: #28668f; }
* { box-sizing: border-box; }
body { margin: 0; padding: 24px 32px; font: 13px/1.6 var(--font-family-ui); background: var(--surface-subtle); color: var(--text-primary); }
@@ -21,9 +21,14 @@
<button id="theme">切换深色</button>
</header>
<div class="design-note"><span id="variant-label">A / 紧凑 Dock</span><p id="variant-description"></p><span>交互原型 · 商店与指标均为演示数据</span></div>
<div class="project-lab"><label>项目活动演示 <select id="project-scenario"><option value="waiting">有待处理</option><option value="running">仅运行中</option><option value="current">仅当前项目运行</option><option value="idle">全部空闲</option></select></label><span>有活动时显示摘要,悬停或点击项目向右展开会话。</span></div>
<main class="app" data-variant="dock">
<aside class="sidebar" aria-label="主侧栏">
<div class="brand"><span class="brand-mark">GB</span><strong>GoodBuddy</strong><span class="muted">个人空间</span></div>
<div class="project-entry"><button id="project-trigger" popovertarget="project-menu" aria-expanded="false" aria-controls="project-menu"><span id="project-name">GoodBuddy</span><span class="muted" aria-hidden="true">⌄</span></button></div>
<button id="activity-trigger" popovertarget="project-activity" aria-expanded="false" aria-controls="project-activity"><span id="activity-summary"></span><span aria-hidden="true">⌄</span></button>
<div id="project-menu" popover aria-label="切换项目"><div id="project-list"></div></div>
<div id="project-activity" popover aria-label="所有项目活动"><section id="activity-projects"><h3>所有项目活动</h3><div id="activity-project-list"></div></section><section id="activity-sessions" hidden><button id="activity-back">‹ 返回项目列表</button><h3 id="activity-project-title"></h3><div id="activity-list"></div></section></div>
<button class="new-chat" data-page="当前对话"><span>+</span> 新对话</button>
<nav class="navigation" aria-label="应用导航">
<button class="selected" data-page="当前对话"><span data-icon="chat"></span>当前对话</button>
@@ -26,6 +26,112 @@ const variants = {
scenes: ['F / 商店发现', '底部入口强调应用发现,展开后查看商店应用与安装位置。', '先模拟安装,再决定是否常驻;我的应用始终可切换访问。']
};
const $ = selector => document.querySelector(selector);
const demoProjects = [
{ id: 'goodbuddy', name: 'GoodBuddy', group: '本地', conversations: ['优化项目导航', '检查构建问题'] },
{ id: 'website', name: '官网改版', group: '本地', conversations: ['发布首页'] },
{ id: 'remote', name: '客户方案与交付文档工作空间', group: 'SSH', conversations: ['整理报价'] },
{ id: 'channel', name: '团队助手', group: '通道', conversations: ['本周工作计划'] }
];
let currentProject = 'goodbuddy';
let projectActivity = [];
let expandedActivityProject = '';
function renderProjects() {
const scenario = $('#project-scenario').value;
const activity = demoProjects.flatMap(project => project.conversations.map((name, index) => ({ project, name, index,
state: scenario === 'idle' || project.id === 'channel' || (scenario === 'current' && project.id !== currentProject) ? '' : scenario === 'waiting' && project.id === 'website' ? '等待审批' : '运行中'
}))).filter(row => row.state);
projectActivity = activity;
const waiting = activity.filter(row => row.state !== '运行中').length;
const running = activity.length - waiting;
const project = demoProjects.find(entry => entry.id === currentProject);
$('#project-name').textContent = project.name;
$('#project-trigger').setAttribute('aria-label', `切换项目,当前项目:${project.name}`);
$('#activity-trigger').hidden = !activity.length;
if (!activity.length && $('#project-activity').matches(':popover-open')) $('#project-activity').hidePopover();
$('#activity-summary').innerHTML = `${running ? `运行中 ${running}` : ''}${waiting && running ? ' · ' : ''}${waiting ? `<span class="project-waiting">待处理 ${waiting}</span>` : ''}`;
$('#activity-trigger').setAttribute('aria-label', `所有项目活动:${$('#activity-summary').textContent}`);
$('#project-list').innerHTML = ['本地', 'SSH', '通道'].map(group => `<section><h3>${group}</h3>${demoProjects.filter(entry => entry.group === group).map(entry => {
const rows = activity.filter(row => row.project.id === entry.id);
const pending = rows.filter(row => row.state !== '运行中').length;
const active = rows.length - pending;
return `<button data-project="${entry.id}" ${entry.id === currentProject ? 'aria-current="true"' : ''}><span class="project-row-name">${entry.name}</span>${entry.id === currentProject ? '<span aria-hidden="true">✓</span>' : ''}${rows.length ? `<small>${pending ? `<span class="project-waiting">待处理 ${pending}</span>` : ''}${active ? `<span>运行中 ${active}</span>` : ''}</small>` : ''}</button>`;
}).join('')}</section>`).join('');
$('#activity-project-list').innerHTML = demoProjects.map(entry => {
const rows = activity.filter(row => row.project.id === entry.id);
if (!rows.length) return '';
const pending = rows.filter(row => row.state !== '运行中').length;
return `<button data-activity-project="${entry.id}" aria-expanded="false" aria-controls="activity-sessions"><span>${entry.name}</span><small>${pending ? `<span class="project-waiting">待处理 ${pending}</span>` : `运行中 ${rows.length}`}</small><span aria-hidden="true">›</span></button>`;
}).join('') || '<p class="muted">暂无运行中或待处理会话。</p>';
expandActivityProject('');
$('.history').innerHTML = `<span class="eyebrow">当前项目对话</span>${project.conversations.map((name, index) => {
const row = activity.find(entry => entry.project.id === currentProject && entry.index === index);
return `<button data-conversation="${currentProject}" data-conversation-index="${index}">${name}${row ? `<small>${row.state}</small>` : ''}</button>`;
}).join('')}`;
}
$('#project-scenario').onchange = renderProjects;
const projectMenu = $('#project-menu');
function positionProjectMenu() {
const rect = $('#project-trigger').getBoundingClientRect();
projectMenu.style.left = `${Math.max(16, Math.min(rect.left, innerWidth - projectMenu.offsetWidth - 16))}px`;
projectMenu.style.top = `${Math.max(16, Math.min(rect.bottom + 8, window.innerHeight - projectMenu.offsetHeight - 16))}px`;
}
projectMenu.addEventListener('toggle', event => {
$('#project-trigger').setAttribute('aria-expanded', String(event.newState === 'open'));
if (event.newState === 'open') positionProjectMenu();
});
window.addEventListener('resize', () => { if (projectMenu.matches(':popover-open')) positionProjectMenu(); });
window.addEventListener('scroll', () => { if (projectMenu.matches(':popover-open')) positionProjectMenu(); }, true);
function positionActivityMenu() {
const panel = $('#project-activity');
const rect = $('#activity-trigger').getBoundingClientRect();
panel.classList.toggle('activity-compact', innerWidth < 620);
panel.classList.toggle('activity-left', innerWidth >= 620 && rect.left + panel.offsetWidth > innerWidth - 16 && rect.right >= panel.offsetWidth + 16);
const left = panel.classList.contains('activity-left') ? rect.right - panel.offsetWidth : rect.left;
panel.style.left = `${Math.max(16, Math.min(left, innerWidth - panel.offsetWidth - 16))}px`;
panel.style.top = `${Math.max(16, Math.min(rect.bottom + 4, window.innerHeight - panel.offsetHeight - 16))}px`;
}
function expandActivityProject(id, focus = false) {
expandedActivityProject = id;
$('#activity-sessions').hidden = !id;
$('#project-activity').classList.toggle('has-submenu', Boolean(id));
document.querySelectorAll('[data-activity-project]').forEach(button => button.setAttribute('aria-expanded', String(button.dataset.activityProject === id)));
if (id) {
$('#activity-project-title').textContent = demoProjects.find(project => project.id === id).name;
$('#activity-list').innerHTML = projectActivity.filter(row => row.project.id === id).sort((a, b) => Number(a.state === '运行中') - Number(b.state === '运行中')).map(row => `<button data-conversation="${id}" data-conversation-index="${row.index}"><strong>${row.name}</strong><small>${row.state}</small></button>`).join('');
}
if ($('#project-activity').matches(':popover-open')) positionActivityMenu();
if (focus) $('#activity-list button')?.focus();
}
$('#project-activity').addEventListener('toggle', event => {
$('#activity-trigger').setAttribute('aria-expanded', String(event.newState === 'open'));
if (event.newState === 'open') { expandActivityProject(''); positionActivityMenu(); }
});
$('#activity-project-list').addEventListener('pointerover', event => {
const button = event.target.closest('[data-activity-project]');
if (event.pointerType === 'mouse' && innerWidth >= 620 && button && button.dataset.activityProject !== expandedActivityProject) expandActivityProject(button.dataset.activityProject);
});
$('#activity-project-list').addEventListener('click', event => {
const button = event.target.closest('[data-activity-project]');
if (button) expandActivityProject(button.dataset.activityProject, true);
});
$('#activity-back').onclick = () => {
const id = expandedActivityProject;
expandActivityProject('');
$(`[data-activity-project="${id}"]`)?.focus();
};
$('#project-activity').addEventListener('keydown', event => {
const button = event.target.closest('[data-activity-project]');
if (event.key === 'ArrowRight' && button) { event.preventDefault(); expandActivityProject(button.dataset.activityProject, true); }
if ((event.key === 'ArrowLeft' || event.key === 'Escape') && expandedActivityProject) { event.preventDefault(); event.stopPropagation(); $('#activity-back').click(); }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const buttons = [...document.querySelectorAll(button ? '[data-activity-project]' : '#activity-list button')];
const index = buttons.indexOf(document.activeElement);
if (index >= 0) { event.preventDefault(); buttons[(index + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length].focus(); }
}
});
window.addEventListener('resize', () => { if ($('#project-activity').matches(':popover-open')) positionActivityMenu(); });
window.addEventListener('scroll', () => { if ($('#project-activity').matches(':popover-open')) positionActivityMenu(); }, true);
renderProjects();
let variant = 'dock';
let catalogMode = 'apps';
let residentApps = ['notes', 'knowledge'];
@@ -125,6 +231,16 @@ function selectTool(id) {
document.addEventListener('click', event => {
const button = event.target.closest('button'); if (!button) return;
const data = button.dataset;
if (data.project || data.conversation) {
currentProject = data.project || data.conversation;
if (projectMenu.matches(':popover-open')) projectMenu.hidePopover();
if ($('#project-activity').matches(':popover-open')) $('#project-activity').hidePopover();
renderProjects();
const project = demoProjects.find(entry => entry.id === currentProject);
openPage(data.conversation ? project.conversations[Number(data.conversationIndex)] : '当前对话');
$('#page-label').textContent = `${project.name} / ${data.conversation ? project.conversations[Number(data.conversationIndex)] : '当前对话'}`;
$('#project-trigger').focus();
}
if (data.variant) selectVariant(data.variant);
if (data.catalog) openCatalog(data.catalog);
if (data.page) openPage(data.page);
@@ -142,7 +142,9 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
### FR-14 项目活动汇总
用户在任一项目中都能查看跨项目的运行中与待处理会话,并从活动列表直接进入所选会话。
没有活动时隐藏汇总入口;项目菜单保留原有分组与顺序,并显示各项目的活动数量。
汇总入口常驻项目选择器下方,没有活动时显示“暂无活动”。点击后先显示有活动会话的项目,
悬停或点击项目展开会话子菜单,点击会话进入该条对话。项目菜单保留原有分组与顺序,并显示
各项目的活动数量;级联菜单内的项目顺序不随待处理或运行中数量变化。
汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
@@ -0,0 +1,44 @@
# 项目活动级联实施进度
## 2026-09-13:生产活动入口
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e项目活动级联)。
本页只记录项目活动入口,不表示应用中心计划已交付。
`App` 已接入 `ProjectActivity` 常驻摘要和锚定级联菜单。会话和项目计数继续使用现有
`deriveConversationActivity` 结果,点击会话继续调用 `openActivityConversation`。未增加
持久化字段、IPC 或 Runtime 状态。项目选择器分组及计数保持原有行为。
组件与 App 定向回归通过 32 项,覆盖空闲入口、稳定项目及 SSH Host 顺序、待处理优先、
跨项目精确跳转、设置离开检查、实时状态清理、键盘返回、外部关闭和窄侧栏焦点。
Windows Electron 视觉 fixture 使用当前 `ProjectActivity`、生产样式和 i18n,活动数据为
受控样例。浅深主题分别验证 `1280×800`、`960×720`、`720×640`、`640×420`、`375×600`;
原生键盘验证 Enter、上下及右方向键、End、两级 Escape、Tab 退出,另测左侧回退和空闲入口。
此项证明组件的实际 Chromium 排版与交互,不代表完整生产 Main/Preload 的端到端验证。
浏览器遮挡复用共享菜单相交机制,组件测试检查相交和非相交结果;本轮未单独驱动原生
`WebContentsView` 可见性。远程 Agent 执行路径未改动,未发出模型或知识库请求。
提交前复核:`npm run typecheck`、`npm run lint` 通过;单独运行
`npx vitest run src/renderer/src/ProjectActivity.test.tsx`,11 项通过。
`npm test` 在 240 秒后超时,超时前 `tests/agent-package.test.ts` 的
`verifies and installs offline dependency inventories larger than one MiB` 用例失败,未取得完整结果。
Demo 已按最新讨论隐藏空闲摘要并移除会话行箭头;生产实现仍保留空闲摘要,尚未与 Demo 对齐。
后续界面修正已移除生产会话行箭头,并为活动摘要与下方新建会话增加 `8px` 间距。
## 2026-09-14:0.13.2 候选验证
正式发布准备时再次复现 App 问答用例的时序失败。活动摘要改为常驻后,等待该入口出现
不再意味着会话初始化和 Runtime 状态读取已经完成;测试有时点击仍被禁用的发送按钮,
留下未消费的一次性问答 mock,继而影响后续用例。
测试现等待已加载的会话和可用发送按钮,并在每个用例前重置问答及审批响应 mock。
答案重载用例等待提交后的界面状态,而不只等待 API 被调用。未修改生产交互、
未增加固定延时,也未跳过失败用例。相关 20 项连续三轮通过,完整 App 与 Agent
发布说明检查共 227 项通过。
最终候选 `npm test` 通过 4,155 项、跳过 66 项、失败 0 项;`npm run typecheck`、
`npm run lint` 和 `npm run release:notes:verify` 均通过。发布准备未执行本地生产构建、
打包或安装包启动探针,原生包验收由主分支与标签 CI 完成。
@@ -392,7 +392,15 @@ resourceMonitor.getLocalSnapshot(input: {
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
`assistantTasks` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
`ProjectActivity` 渲染汇总与弹窗,`ProjectSwitcher` 复用项目计数。
`ProjectActivity` 渲染常驻汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。
- `App` 向 `ProjectActivity` 传入项目元数据、活动行、侧栏可见状态与既有精确跳转回调。
菜单按项目 ID 分组并记忆化派生集合;排序仅影响展示,不改聚合状态或持久化数据。
- 两层菜单在同一 body Portal 内相接。布局在打开、层级变化、实时数据更新、窗口缩放和
滚动时按锚点重算;窄窗口隐藏项目面板并保留返回操作。菜单不调用 Modal 焦点隔离,
捕获阶段消费 Escape,防止触发窄侧栏的文档级监听;Tab 关闭后交还正常焦点顺序。
菜单关闭与焦点恢复在导航回调之前同步完成。
- `role="menu"` 使浮层直接进入共享 `browser-viewport-occlusion.ts` 相交检测。未增加
浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
- 持久化快照刷新时,同一条仍在流式输出的消息保留本地待审批、待回答信息,持久化终态
则清除这些信息。响应成功后对应 Task 立即退出等待状态;响应期间到达的新问题、审批或
终态不能被旧响应覆盖。
@@ -406,3 +414,6 @@ resourceMonitor.getLocalSnapshot(input: {
列表并合并,再查找同一 ID。刷新失败或会话已不存在时显示通知,不回退到项目最新会话。
设置离开检查通过后才提交项目、会话及界面清理状态。
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
验证记录见[项目活动级联实施进度](./progress.md)。本次只改 Renderer 展示与交互,远程
Runtime、Agent 协议和桌面到 Agent 的执行路径未改变。
@@ -19,7 +19,7 @@
```text
品牌
项目选择
项目活动汇总(有活动时)
项目活动汇总(常驻,空闲显示“暂无活动”)
新对话
搜索
@@ -209,15 +209,25 @@ CPU 4% · 内存 620 MB · 桌面进程 7
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
[活动逻辑](./logic-design.md#9-项目活动汇总)。
- 全局汇总按钮位于项目选择器下方,上行标明“全项目活动”,下行显示待处理与运行中数量;
箭头跨两行居中,无活动时整个按钮隐藏。
- 全局汇总按钮常驻项目选择器下方,紧凑显示待处理与运行中数量,空闲显示“暂无活动”,
右侧显示展开箭头,与下方新建会话保留 `8px` 间距。可访问名称明确数量属于“全项目活动”。
- 项目菜单在项目行显示同口径数量,保留“本地项目 / 远程项目 / 远程通道”分组和 SSH Host
层级,不因活动状态重排项目。数量为零时不显示标记,连接状态仍独立显示。
项目名独占一行,状态数量排列在名称下方,避免窄侧栏中数量挤占名称宽度。
- 点击汇总打开活动对话框,先列“待处理”,再列“运行中”,空分组隐藏。每行显示项目名称、
会话标题及明确状态文字,不展开 Task、Job 或 Run 列表,也不自动切换用户正在查看的会话。
- 点击一行先关闭对话框、释放 Modal 焦点隔离,再请求进入该条会话;允许离开当前页面后,
- 点击汇总在按钮下方打开项目菜单,只列有活动会话的项目,每项显示项目名和同口径计数。
桌面鼠标悬停或点击项目展开右侧会话菜单,右侧空间不足且左侧可容纳时向左展开。两个面板
相接,指针离开项目行进入会话层时不收起。展开项目不切换当前会话。
- 会话层顶部显示项目名,每行显示完整可换行的会话标题与明确状态文字,待处理会话排在
运行中会话之前。项目行保留子菜单箭头,会话行不显示箭头。不展开 Task、Job 或 Run 列表。
- 点击会话先关闭菜单并恢复入口焦点,再请求进入该条会话;允许离开当前页面后,
选择所属项目、清除搜索和该会话未读标记、清除任务选择与会话操作菜单,窄窗口同时关闭侧栏。
设置页沿用未保存修改的离开检查,取消离开时保留原页面。
- 对话框沿用全局 Modal 规范,支持 Tab 循环、Escape、关闭按钮、点击遮罩关闭及焦点恢复;
窄窗口限制宽高并在内容区滚动。活动全部结束时关闭对话框并隐藏入口。
- 菜单挂载到 body,使用 `menu`、`menuitem`、`aria-haspopup`、`aria-expanded` 与控制关系,
不隔离背景、不增加遮罩。原生浏览器相交遮挡沿用共享机制。
- 打开后焦点进入首个项目;上下方向键循环选择当前层项目或会话,Home、End 到首尾,
Enter、Space 激活,右方向键进入会话层。左方向键或 Escape 返回项目层并恢复对应项目
焦点;顶层 Escape 关闭并恢复汇总焦点,不继续关闭窄侧栏。Tab 关闭菜单后继续正常焦点
顺序;点击外部、焦点离开、再次点击入口或隐藏侧栏也关闭菜单。
- 单面板宽度为 `264px`,窗口小于 `620px` 时就地进入会话层并显示“返回项目列表”,
触控无需悬停。浮层距窗口边缘至少 `16px`,每层最大高度为 `480px`,短窗口进一步限制
高度并独立滚动。活动全部结束时,已打开菜单显示“暂无运行中或待处理会话。”。
@@ -234,3 +234,32 @@
- 不因宽度不足隐藏文件、终端、浏览器、资源监控或设置
- 触控点击区域符合统一界面规范
- 关闭侧栏抽屉后焦点返回原触发按钮
## 6. Epic E:项目活动级联
### US-E1 从摘要进入确切会话
对应 [FR-14](./prd.md#fr-14-项目活动汇总)。用户在当前项目工作时,可从项目选择器下方
查看跨项目活动,先定位项目,再进入需要处理的会话。
- Given 多个项目存在运行中和待处理会话,当前搜索仅匹配当前项目
- When 打开全项目活动并悬停或点击另一个项目
- Then 项目顺序保持稳定,该项目会话按待处理优先展示,当前页面和会话保持原选择
- When 点击会话
- Then 进入该确切 Conversation ID,清除搜索、任务选择和会话菜单,窄窗口关闭侧栏
- Given 设置页有未保存修改
- Then 会话跳转仍需通过既有离开检查,取消离开保留设置草稿和原项目
### US-E2 键盘、窄窗口与实时结束
- Given 菜单已打开
- Then 上下方向键与 Home、End 选择当前层,右方向键或 Enter 进入会话层,左方向键或
Escape 返回;再次 Escape 关闭活动菜单并恢复摘要焦点,窄侧栏保持打开
- Given 窗口小于 `620px`
- Then 点击项目后仅显示会话层,可通过返回按钮回到项目列表;长列表可滚动到最后一项
- Given 鼠标从项目行移向相接的会话面板
- Then 子菜单保持打开,点击会话正常导航
- Given 当前项目的活动全部结束
- Then 返回项目层并恢复可用焦点;全局无活动时摘要显示“暂无活动”,已打开菜单显示空状态
- When 点击外部、焦点离开、按 Tab 或隐藏侧栏
- Then 菜单关闭,后台新活动不自动重新展开
@@ -19,7 +19,9 @@
- `messages.metadata_json` 继续保存供会话显示的完整子任务过程。
- `task_events` 的 `subagent` 内容由共享 `SubagentProgressStorage` 编码。
`progressUpdates` 只保存文字追加、变更的块,或确有删除/重排时的完整重置。
完全相同的过程使用空更新数组。其他事件内容不改变。
完全相同的过程使用空更新数组。工具字段按值比较,缺省字段与 `undefined` 等价,
不因 JSON 省略空字段而重复写入整个工具结果;空字符串与实际输出/错误变化仍保留。
其他事件内容不改变。
- 每个任务内按事件 ID 顺序、按 `childTaskId` 独立还原过程。事件 ID、顺序、状态、
最终输出、错误、时间及远程来源字段均保留,不按天数裁剪或删除旧事件。
- Runtime、Agent transcript、Preload 和 Renderer 的公共 `SubagentEvent` 仍传完整
@@ -27,6 +29,9 @@
- 远程消息投影与事件插入仍共用 SQLite 事务。重放时还原已保存内容进行等值校验,
相同来源重复事件不重复投影,不同内容继续报冲突。内存过程缓存通过已提交事件 ID
核对;事务回滚或重新打开后从数据库重建,不保存独立快照或回收日志。
- 存储层最多缓存 16 个任务;收到任务完成、错误、取消或中断的终态记录后立即移除该任务
的缓存引用,不影响其他会话。若事务随后回滚或仍收到该任务的后续事件,按已提交记录重建。
释放引用不等于进程 RSS 立即下降,垃圾回收和 Runtime 自身会话缓存另按生命周期回收。
## 旧库升级
@@ -35,19 +40,25 @@
自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史
快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。
转换可以混合读取旧完整记录与已经转换的记录,因此退出、磁盘写入失败后可以重新扫描
并继续,不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
转换可以混合读取旧完整记录与已经转换的记录,也会重新编码 schema 34 中重复的工具块
更新。因此退出、磁盘写入失败后可以重新扫描并继续;已是相同 JSON 的记录不重复更新,
不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。
业务初始化随后将数据库升级为 schema 34,并建立子任务事件的部分索引。
当前源码的业务初始化随后将数据库升级为 schema 35;schema 34 用户也会执行本次整理,
成功后后续启动直接跳过。继续复用 schema 34 的子任务事件部分索引和同一差量表示。
低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用
升级前备份,不要修改 `user_version` 强行绕过版本检查。
## 验证要求
- 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。
- 工具 fixture 必须含生产对象会携带的 `undefined` 可选字段,并经过实际 JSON 写入/重读。
同时验证旧完整事件和 schema 34 差量事件的整理、混合重试及无变化记录不被重写。
- 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。
- 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、
其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。具体证据见
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。真实场景必须让主代理
与子代理按正常任务需要读取、修改文件和执行测试,不能用“子代理不使用工具”的纯文字
场景代替工具存储验收;记录实际请求数、有效变化、重复工具块及还原结果。具体证据见
[实现与验证进度](./progress.md)。
+91 -1
View File
@@ -1,5 +1,94 @@
# 工作栏实现与验证进度
## 2026-09-14 多 Runtime 工具操作摘要
显示与事件合并规则见[工具操作摘要](./runtime-interactions.md#工具操作摘要)。Continue、
DeepSeek Harness 和直连模型已接入共享摘要函数,完成事件缺少输入时保留已有摘要。
Windows 完整 App 使用当前生产 Main、Preload、Renderer 和隔离配置,模型为
`deepseek-v4-flash`。从真实输入区输入并点击发送读取测试文件的请求,校验码只存于
文件中。三条成功会话都返回正确校验码,工具完成后仍显示路径;原生点击展开可查看
输入与输出,离开后重新打开会话、重新加载 Renderer 后再次打开均保留摘要和详情。
六张对话区截图已实际打开审阅。
| Runtime | 成功模式 | 提交数(含失败重跑) | 模型 HTTP dispatch | 工具执行 |
| --- | --- | ---: | ---: | ---: |
| 直连模型 | Ask | 1 | 3 | 3 |
| Continue | Execute | 2 | 4 | 3 |
| DeepSeek Harness | Execute | 3 | 3 | 2 |
| 合计 | | 6 | 10 | 8 |
计数来自 Main 和真实 Utility Host 的 HTTP dispatch,未注入答案或工具事件。Continue
首轮 Ask 仅提供配置工具,未能读取文件;改用 Execute 成功。DS 前两次因 Windows
工作区路径写法与 Host `realpath` 比较不一致而返回 ACP `-32602`,均未调用模型;
同目录改写路径仍复用缓存 Runtime,改用全新规范路径目录后成功。该路径问题随后由
[DS 工作区路径修复](../deepseek-harness/progress.md#2026-09-14-windows-工作区路径修复)
单独处理,新增验证计数见该记录,不计入上表。驱动的包解析、
DOM 定位及卡片比较器失败已记录,修正比较器后的复验没有追加模型请求。
本机临时 `runtime-summary-live/report.json` 保留逐次结果和产物哈希;源码基线为
`c71da4b6ae1d7228ff968d39c63db205caf61339` 加本次工作树改动,Main SHA-256 为
`2153d72a947ab6f5eab22eab28d7a3f2f86a47740d5223a3e1547f199fa7de84`。
测试进程、隔离配置、数据库、工作区和构建均已清理,正式配置字节未改变;报告和截图
保留在本机临时目录,不作为长期可用的仓库资源。请求使用已配置 HTTPS,沿用生产
证书策略,本轮未验证严格证书校验。未测试 Main 重启或远程 Agent。
专项回归 210 项通过、1 项跳过。完整 `npm test` 首轮 4154 项通过、66 项跳过,另有
6 个 `.agent-resources` 内的本地 `node:test` 文件被 Vitest 误收集导致套件失败。
使用 `npm test -- --exclude "**/.agent-resources/**"` 完整重跑后,353 个文件通过、
9 个跳过,4154 项通过、66 项跳过、0 失败,耗时 646.04 秒。`npm run typecheck`、
`npm run lint` 和 `git diff --check` 通过。
## 2026-09-13–14 schema 35 工具重复写入补修(未发布)
- 0.13.1 的历史库回收成功,但其真实模型验收限制子代理不使用工具,遗漏了生产工具对象
的 `undefined` 可选字段。JSON 重读省略这些键,原深比较误判未变化的工具块。正常开发
任务复现同批 928 条事件的 7,787 次重复工具块写入;仅归一化空字段的对照将负载从
10,467,622 降为 452,391 字节,逐条还原一致。该对照不是产品修复验收。
- 当前修复按共享工具 schema 的字段值比较,不序列化整份工具输出进行比较。空字符串、
输出修订、状态和错误变化仍保留。schema 35 使用同一编码器重整旧完整事件及 schema 34
已存差量,跳过 JSON 未变化的行;不会删除事件、会话、工具结果或来源字段。
- 存储缓存仍最多 16 个任务,终态及时移除缓存引用;回滚后按已提交记录重建,其他任务
不受影响。远程先按来源主键判断是否重放,重复事件不重建已经释放的写入缓存;
`INSERT OR IGNORE`、冲突校验及消息投影事务仍保留。
- Windows 当前源码加便携版内置 OpenCode 完成正常 CSV 项目开发,主/子代理实际读取、
改代码、补测试和执行命令;独立 59 项测试及 CLI 通过。698 条子代理事件共
368,575 字节,重复工具块为 0,与归一化对照字节数完全相同,每条事件还原一致。
- 多项目实测使用两个独立 OpenCode 实例、每项目两个会话,两轮共 8 次 Execute。
第一轮四个开发任务全部完成;第二轮复用会话,主动取消一个正在使用工具的子代理任务,
其余三个完成并通过测试。2,841 条子代理事件共 1,305,340 字节,重复工具块为 0,
全部逐条还原一致。每轮结束后缓存任务数、打开的 SSE 响应流均为 0。
隔离 Main 测试进程在显式 GC 后的堆占用为 12.94 → 12.91 MiB;
这证明本轮未持续积累,不是整个 Electron UI/Runtime 的 RSS 上限或无限负载保证。
- 共享 Linux x64 Host 使用当前桌面源码经真实 SSH、Agent、ACP 和模型 gateway 完成
Ask 与正常工具 Execute。912 条子代理事件共 405,360 字节,重复工具块为 0,
逐条还原及重复重放通过,远程 Python 项目测试通过。修复位于共享桌面层,不修改 Agent
或公开事件协议。两次早期测试驱动错误中断操作,修正瞬态事件处理后才完成上述验收;
不能将早期失败写成产品通过。
- 本轮实际提供商请求为 121 次:本地开发 26,多会话 79,远程账本 16(成功验收 14,
早期驱动中断 2 次结果未知、未交付)。本地计数器只记录 HTTP 状态,远程按测试
request/operation 的 Agent ledger 统计,不记录凭据、正文或请求头。另有先前诊断的
24 次请求,不计入这 121 次。已核对本轮本机和远程测试工作区无残留 Runtime 进程。
- 活动 schema 34 库通过 SQLite online backup 取得一致性备份,并校验独立测试副本。
正常启动升级成功,2,898,984,960 → 607,055,872 字节(2.70 GiB → 578.93 MiB,
回收 79.06%)。完整比较 424,148 条原事件的内容、ID、时间及来源一致。
隔离启动未复制模型配置,既有启动修复调整一条项目/会话 Runtime 选择及一个在途任务、
消息和活动状态,另追加一个状态事件;不是迁移删除或改写历史内容。
- 同一真实备份的另一个副本直接运行实际构建的升级 Worker,在处理 64 条后取消,
重启后完成 316,664 条子代理记录;全量事件再次还原一致。全部非事件表逐表内容一致,
包括 150 个会话、1,915 条消息、914 个任务和 136 个成果;`integrity_check=ok`。
原运行库和校验备份没有被测试改写。schema 35 副本再次进入实际 Worker 时直接完成,
进度事件数为 0,不重复整理。
- 最终专项 99 项、typecheck、lint 及生产开发构建通过。首轮全量产品用例 4,154 通过、
66 跳过,但 6 个临时 Node 测试项目被 Vitest 错误发现;配置已排除 `.agent-resources/`,
独立项目仍单独运行自己的 Node 测试。随后显式双 worker 全量为 4,148 通过、66 跳过、
`App.test.tsx` 6 项时序失败;恢复仓库默认单 worker 后该文件 220 项全部通过,
未修改 UI 代码。最终默认配置全量为 4,151 通过、66 跳过、3 个问答 UI 测试失败,
失败项与双 worker 不同;本轮全量回归尚未全绿。随后仅调整测试等待和 mock 隔离的
尝试仍有不稳定失败,已完整撤回这些额外 UI 测试改动,不把单次重跑通过当作稳定修复。
存储专项、真实工具任务、并发内存和真实数据迁移证据均通过;UI 测试问题仍需单独处理,
本次未提交、未发布,也未替换用户正在运行的 0.13.1。
## 2026-09-13 Desktop 0.13.1 执行记录去重与大库升级
- 实现与兼容边界见[执行记录存储与升级回收](./execution-history-storage.md),通用开发
@@ -31,7 +120,8 @@
实包开发验证通过。0.13.1 版本与发布说明变更后的本地最终验证被用户主动取消,按要求
不再追加本地测试,候选的最终全量测试、类型、lint 与生产构建由 main CI 判定。
- 用户批准 0.13.1 中英文说明及仅标准六平台发布;Agent 保持 0.11.24。
截至本记录尚未提交或打标签,准确候选提交、main CI 和原生发布结果待发布流程确认。
已发布提交 `4937720` 和不可变标签 `v0.13.1`,main CI 与六平台发布通过;
后续真实工具场景的遗漏及补修见本页 schema 35 记录。
## 2026-09-13 原生浏览器与应用浮层修复
@@ -57,6 +57,21 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
- 远端最终启动配置由[远程主机技术设计](../remote-host/technical-design.md)定义,
模型桥生成最终配置时同样保留上述快照策略。
## 工具操作摘要
工具记录沿用 [UI 设计系统](../../../UI-DESIGN.md)的折叠布局。Continue、DeepSeek Harness
和直连模型共用 `toolOperationSummary`:优先使用有效原生标题,否则从参数提取命令、
文件路径、搜索条件或 URL,合并空白后最多显示 240 字符。无法识别时保留原有工具名
回退规则;文件正文、整份参数 JSON 不进入标题,参数详情仍可展开查看。
Continue 在宿主截断输入前提取摘要,流式完成与失败事件使用合并后的输入和摘要。
DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省略输入时仍能显示操作内容。
直连模型从原始参数生成一次摘要,在待执行、执行中和完成时复用;审批失败、拒绝和
执行失败、刷新重试仍保留已有提示。
本机 OpenCode 沿用原生操作标题。远端 ACP 独立使用原生标题作为记录名称,不经过上述
本地转换,本次没有修改 Agent、远程桥或协议。
## 消息底部请求状态
- 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示
+1
View File
@@ -8,5 +8,6 @@
| 文档 | 权威职责 |
| --- | --- |
| [技术设计](./technical-design.md) | Runtime 产品与技术方案、协议、插件、打包、测试和验收 |
| [实现进度](./progress.md) | Windows 工作区路径修复及验证记录 |
现有文档同时包含部分 PRD、功能逻辑和进度内容,后续修改该 Runtime 时再按标准职责拆分。
@@ -0,0 +1,45 @@
# DeepSeek Harness 实现进度
## 2026-09-14 Windows 工作区路径修复
本机 Runtime 的 Host 启动与 `newSession.cwd` 已共用初始化时解析的 `realpath`,
规则见[技术设计](./technical-design.md#52-受控组合不启动用户-profile)。修复此前
`C:/...\workspace` 写法触发 ACP `-32602`,以及随后换成同目录规范写法仍复用
错误 Runtime 的问题。Host 严格比较与 execution-space 缓存规则保持原样。
新增 Windows 真目录回归,连接真实 ACP、Host 和控制面,分别用混合分隔符及规范
路径创建会话,断言共用同一 Runtime、只启动一次 Host、两次均完成。该自动化回归
使用内存模型;外部真实模型验证单独由完整 App 完成。
Windows 完整 App 使用当前源码构建的 Main、Preload、Renderer 和新建隔离配置。
正式配置只读,模型凭据由隔离配置中的 safeStorage 解密;真实 composer 输入请求,
由 `deepseek-v4-flash` 在 Ask 模式读取 `summary-probe.txt`,校验码未放入 prompt。
| 目录写法 | Composer 提交 | 真实模型 HTTP dispatch | 文件读取 | 结果 |
| --- | ---: | ---: | ---: | --- |
| Windows 混合分隔符 | 1 | 2 | 1 | 仅返回文件中校验码,任务完成 |
| 同目录规范路径 | 1 | 2 | 1 | 仅返回文件中校验码,任务完成 |
| 合计 | 2 | 4 | 2 | 两条会话均通过 |
两轮 dispatch 来自同一 DS Utility Host 进程,切换目录写法未新增 Host;ACP 会话
ID 不同。两条工具卡片均显示路径摘要,原生点击展开后可查看输入与输出;重载
Renderer 并重新选择项目后,已保存会话的摘要、详情和完成状态仍通过检查。四张
局部截图已逐张打开审阅。驱动曾返回不可克隆的监听器函数,以及在重载后默认项目
等待工具卡片超时;修正定位后通过,这两次驱动错误没有增加模型请求。
本机临时 `ds-windows-path-live/report.json`、`dispatch.jsonl` 与 `source.json`
记录结果、逐请求计数和构建哈希,原 `runtime-summary-live` 报告未覆盖。
本次 App 正常退出,退出前记录的 5 个所属进程均已停止;隔离 profile、数据库、
工作区、临时构建和私有日志已清理。正式模型配置与启动前逐字节一致,报告和截图
保留在本机临时目录,不作为长期可用的仓库资源。
测试沿用生产 HTTPS 与证书处理,未单独验证严格证书校验或 Main 重启。
专项 7 个测试文件通过,91 项通过、3 项跳过。`npm run typecheck` 与
`npm run lint` 通过。最初两个假时钟测试因异步目录解析尚未结束就推进启动时钟而
超时;调整为等待 launcher 启动后,DS Runtime 的 34 项测试全部通过。
最终全量 `vitest run`(`npm test` 的脚本入口)为 353 个文件通过、9 个跳过,
4155 项通过、66 项跳过、0 失败,耗时 602.00 秒。`git diff --check` 通过。
远端核查:Main 的 SSH 分支创建托管远程 Runtime,最终使用 `AcpRemoteRuntime`;
`create-runtime.ts` 的本机 DS 入口拒绝 SSH execution space。本次未改远端代码,
未调用远端模型,也未进行 Linux Host 实测。
@@ -121,6 +121,12 @@ GoodBuddy 使用自己固定的 Harness Host 入口和只读组合模板,不
模型名称、服务地址、工作区、Skills、MCP schema 和已启用插件的规范化入口通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。插件配置只来自 GoodBuddy 受管状态,不合并用户 profile 或全局补丁。
本机 Runtime 初始化时对工作区调用 `realpath`,将结果保存在该 Host 的运行状态中;
Host 启动与 ACP `newSession.cwd` 共用这一路径。Host 仍校验目录并严格比较会话路径。
Windows 混合分隔符与同目录规范写法可继续通过现有 execution-space identity 复用
Runtime,无需更改缓存键或增加重试。远端 SSH 由 `ManagedRemoteAcpRuntime` /
`AcpRemoteRuntime` 单独处理,不经过本机 DS Runtime 的目录解析。
### 5.3 双层内部控制面
Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不能代替进程内的 Session/Tool 适配层:
@@ -307,6 +313,7 @@ Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段:
- `text`:已提交的助手文本分片。
- `reasoning`:可选的有界推理摘要分片。
- `tool`:工具 ID、名称、状态和有界输入/输出摘要。
- 操作标题与增量完成事件的参数保留遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),本机适配器按调用 ID 合并,详情上限保持不变。
- `model-usage`:模型、提供方、输入、输出和缓存 Token。
- `done`:停止原因和 Session ID。
@@ -53,6 +53,8 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由
### 3.1 标题与摘要
正常执行状态的操作摘要遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),从原始调用参数提取并在完成后保留;异常状态继续显示原有提示。
```text
进程执行
PowerShell · packages/app · npm test
@@ -44,7 +44,8 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭
桌面保存托管 SSH 子任务事件时,共用
[执行记录差量存储](../assistant-workbar/execution-history-storage.md)。
Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号
继续保留。该存储修复不要求单独升级 Agent。
继续保留。工具字段比较与 schema 35 对 schema 34 重复块的整理也在该共享桌面层完成;
不把工具过程改为仅存内存,不要求单独升级 Agent。
```text
Renderer
+6 -1
View File
@@ -84,10 +84,15 @@ Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
转换后回收空间,不靠删聊天或单独修本机数据库解决。
**防复发**:使用含大工具输出的多次更新检查总存储量,并验证重启、重复远程事件、
消息事务回滚及旧库中断续跑。真实旧库需验证逐条等价和物理文件缩小。
消息事务回滚及旧库中断续跑。工具对象须包含生产路径中的 `undefined` 可选字段,
经过真实 JSON 往返后仍识别为无变化,不能按对象键存在性比较。真实模型验证须包含
子代理正常使用工具、修改文件及运行测试,不能只测禁用工具的文本流。
真实旧库需验证逐条等价和物理文件缩小,已升级 schema 的重复数据同样要覆盖。
**历史**:2026-09-13 检查到 305,551 条子任务事件占用 90.11 GiB;
本机 `appendTaskEvent` 与远程 `insertRemoteTaskEventOnce` 同时存在重复保存路径。
0.13.1 发布后的真实工具任务再次复现:同批 928 条事件有 7,787 次重复工具块写入;
只统一 JSON 空字段后,事件负载从 10,467,622 降为 452,391 字节,逐条还原一致。
## 生产路径复用“测试”函数
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.13.1",
"version": "0.13.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.13.1",
"version": "0.13.2",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.13.1",
"version": "0.13.2",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+23
View File
@@ -0,0 +1,23 @@
# GoodBuddy Agent 0.11.25 更新内容
## 维护更新
- **配套 Desktop 0.13.2 的维护版本**:本次重新发布受支持平台的 Agent 复合包,不新增独立远端功能,也不改变 Agent 协议。桌面端的工具记录和执行历史存储补修由 Desktop 0.13.2 提供,更新 Agent 不能代替桌面升级。
## 升级说明
- 需要 **Desktop 0.13.2 或更高版本**,请先升级 Desktop,再按需更新 Host 环境。提供 Linux x64、Linux arm64 和 macOS arm64 复合包,Node 保持 `24.19.0`,OpenCode 保持 `1.18.29`。
- Ask 保持只读,Execute 继续使用所选 SSH 账号权限。更新不会热替换运行中的旧进程,请在当前任务结束后更新并使用新 Runtime。
---
# What's New in GoodBuddy Agent 0.11.25
## Maintenance
- **Maintenance release alongside Desktop 0.13.2**: Agent compound packages are republished for the supported platforms without adding standalone remote features or changing the Agent protocol. Desktop 0.13.2 provides the tool-record and execution-history storage repairs; updating Agent does not replace the Desktop upgrade.
## Upgrade Notes
- Requires **Desktop 0.13.2 or later**; upgrade Desktop before updating Host environments as needed. Compound packages target Linux x64, Linux arm64, and macOS arm64, retaining Node `24.19.0` and OpenCode `1.18.29`.
- Ask remains read-only, and Execute retains the selected SSH account's permissions. Updates do not hot-replace running processes; finish active tasks before updating and using a new Runtime.
+36
View File
@@ -1,6 +1,42 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.13.2",
"releasedAt": "2026-09-14",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**修复升级后仍重复增长的执行记录**:修复 0.13.1 中子代理使用工具时,未变化的工具内容仍被重复保存的问题。首次启动会无损整理这些重复内容,保留会话、执行详情、结果和远程来源记录。任务结束后及时释放存储缓存,不清空其他正在运行会话的缓存。",
"**工具正在做什么更清楚**:OpenCode、Continue、DeepSeek Harness 和直连模型的工具记录可显示文件路径、命令或搜索摘要。工具完成后仍保留已有操作说明,展开记录或重新打开会话可继续查看输入、结果和错误。",
"**修复 Windows DeepSeek 工作区路径错误**:同一个目录使用不同分隔符或规范路径写法时,不再因为 Host 与会话路径不一致而导致请求失败。Host 启动和后续会话使用同一规范路径,保持原有工作区与权限边界。",
"**跨项目活动菜单更紧凑**:点击活动摘要后,可按“项目 → 会话”两级菜单直接进入需要处理的对话,不再打开整块活动弹窗。菜单支持键盘导航,并调整了入口间距和会话行指示,保留原有活动计数及跨项目定位。"
],
"notices": [
"本次会再次整理已升级至 0.13.1 的数据库;大库可能需要时间和额外磁盘空间,支持退出后继续。升级后的数据库不能由旧客户端打开,需要回退时请使用升级前备份,不要手工修改数据库版本。",
"配套发布 **Agent 0.11.25** 维护版,需要先升级至 **Desktop 0.13.2**,再按需更新 Host 环境。存储补修由 Desktop 执行,不依赖升级 Agent;远程项目仍为技术预览,Ask/Execute 权限不变。",
"Desktop 提供 Windows、macOS、Linux 的 x64/arm64 标准安装包;本次不构建 LoongArch 实验预览。"
]
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**Fix repeated execution-history growth after upgrading**: Fixed an issue in 0.13.1 where subagents using tools could repeatedly save unchanged tool content. The first launch compacts these duplicates without losing conversations, execution details, results, or remote provenance. Finished tasks release their storage caches without clearing caches for other active conversations.",
"**See what tools are doing**: Tool records from OpenCode, Continue, DeepSeek Harness, and direct models can show file paths, commands, or search summaries. Existing operation descriptions remain after completion, and expanded records or reopened conversations retain inputs, results, and errors.",
"**Fix Windows DeepSeek workspace-path errors**: Equivalent paths to the same directory no longer fail because the Host and session disagree about separators or canonical spelling. Host startup and subsequent sessions use the same canonical path while preserving workspace and permission boundaries.",
"**More compact cross-project activity navigation**: Open the activity summary and use a two-level project-to-conversation menu to reach conversations needing attention instead of opening a large activity dialog. Keyboard navigation, improved spacing, and simpler conversation indicators preserve activity counts and exact cross-project navigation."
],
"notices": [
"This update also compacts databases already upgraded to 0.13.1; large databases may take time and require extra disk space, with quit-and-resume support. Older clients cannot open the upgraded database, so use a pre-upgrade backup for rollback rather than manually changing its version.",
"The accompanying **Agent 0.11.25** maintenance release requires **Desktop 0.13.2** before updating a Host environment. Desktop performs the storage repair without requiring an Agent update; remote projects remain a technical preview and Ask/Execute permissions are unchanged.",
"Desktop includes standard Windows, macOS, and Linux x64/arm64 packages, without a LoongArch experimental preview."
]
}
}
},
{
"version": "0.13.1",
"releasedAt": "2026-09-13",
+11 -7
View File
@@ -1603,7 +1603,7 @@ describe('ContinueHostAdapter', () => {
expect(existsSync(globalDirectory)).toBe(false)
})
it('uses auto mode and returns audit metadata for agent tools', async () => {
it.each(['completed', 'failed'] as const)('uses auto mode and retains streamed audit metadata on %s', async (toolState) => {
const distribution = await createDistribution()
let launchArgs: string[] = []
const permissionBodies: unknown[] = []
@@ -1681,7 +1681,7 @@ describe('ContinueHostAdapter', () => {
toolCall: {
function: { name: 'Bash' }
},
status: 'done'
status: toolState
}
]
}
@@ -1695,7 +1695,7 @@ describe('ContinueHostAdapter', () => {
type: 'tool',
callId: 'call-1',
name: 'Bash',
state: 'completed',
state: toolState,
output:
'Tests passed\nAuthorization: Bearer secret-token'
},
@@ -1737,9 +1737,10 @@ describe('ContinueHostAdapter', () => {
{
callId: 'call-1',
name: 'Bash',
state: 'completed',
state: toolState,
input:
'{"command":"npm test","token":"secret-token"}',
summary: 'npm test',
output:
'Tests passed\nAuthorization: Bearer secret-token'
}
@@ -1754,7 +1755,8 @@ describe('ContinueHostAdapter', () => {
name: 'Bash',
state: 'running',
input:
'{"command":"npm test","token":"secret-token"}'
'{"command":"npm test","token":"secret-token"}',
summary: 'npm test'
}
},
{
@@ -1762,9 +1764,11 @@ describe('ContinueHostAdapter', () => {
tool: {
callId: 'call-1',
name: 'Bash',
state: 'completed',
state: toolState,
output:
'Tests passed\nAuthorization: Bearer secret-token'
'Tests passed\nAuthorization: Bearer secret-token',
input: '{"command":"npm test","token":"secret-token"}',
summary: 'npm test'
}
},
{ type: 'text', delta: 'TOOLS_OK' }
+18 -1
View File
@@ -1,4 +1,5 @@
import spawn from 'cross-spawn'
import { toolOperationSummary } from './tool-operation-summary'
import { createHash, randomBytes } from 'node:crypto'
import {
copyFile,
@@ -112,6 +113,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
name: z.string().min(1).max(200),
state: z.enum(['running', 'completed', 'failed']),
input: z.string().max(4_000).optional(),
summary: z.string().max(240).optional(),
output: z.string().max(16_000).optional(),
error: z.string().max(1_000).optional()
})
@@ -181,6 +183,7 @@ export type ContinueHostTool = {
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
input?: string
summary?: string
output?: string
error?: string
}
@@ -729,6 +732,10 @@ function extractContinueTools(
callId,
name: name.trim().slice(0, 200),
state: normalizedState,
summary: toolOperationSummary(
(toolFunction as Record<string, unknown>).arguments,
state.title
),
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {})
@@ -749,6 +756,7 @@ function mergeContinueTools(
...previous,
...tool,
input: tool.input ?? previous?.input,
summary: tool.summary ?? previous?.summary,
output: tool.output ?? previous?.output,
error: tool.error ?? previous?.error
})
@@ -976,6 +984,11 @@ export class ContinueHostAdapter {
serverStateMarker,
'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B='
)
patched = replaceExactly(
patched,
'state:"running",input:(()=>',
`state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>`
)
patched = replaceExactly(
patched,
serverStateEndpointMarker,
@@ -1682,6 +1695,7 @@ export class ContinueHostAdapter {
callId: event.callId,
name: event.name,
state: event.state,
summary: toolOperationSummary(event.input, event.summary),
...(event.input
? { input: boundedToolDetail(event.input, 4_000) }
: {}),
@@ -1693,7 +1707,10 @@ export class ContinueHostAdapter {
: {})
}
observedTools = mergeContinueTools(observedTools, [tool])
await runOptions.onEvent?.({ type: 'tool', tool })
await runOptions.onEvent?.({
type: 'tool',
tool: observedTools.find((item) => item.callId === tool.callId)!
})
}
const pendingQuestion = state.goodbuddyQuestion
if (
+1 -1
View File
@@ -1005,7 +1005,7 @@ describe('ContinueAgentRuntime', () => {
type: 'tool',
name: 'Bash',
state: 'completed',
summary: 'Continue 工具:Bash',
summary: 'npm test',
input: '{"command":"npm test"}',
output: 'Tests passed'
}),
+2 -1
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto'
import { toolOperationSummary } from './tool-operation-summary'
import type {
AgentQuestionAnswer,
AgentEvent,
@@ -88,7 +89,7 @@ function toContinueToolEvent(
: terminalize && tool.state !== 'completed'
? 'failed'
: tool.state,
summary: `Continue 工具:${tool.name}`,
summary: toolOperationSummary(tool.input, tool.summary) ?? `Continue 工具:${tool.name}`,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {})
@@ -35,6 +35,8 @@ import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol'
import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
import { SelectedRuntimeManager } from './selected-runtime-manager'
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
const SKILL_CALL_ID = 'e2e-skill-call'
@@ -383,6 +385,49 @@ function createInProcessLaunch(
}
describe('DeepSeek Harness real ACP control-plane E2E', () => {
it.skipIf(process.platform !== 'win32')('reuses a working runtime for mixed-separator and canonical Windows paths', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-path-')))
const workspace = join(root, 'workspace')
const dshHome = join(root, 'home')
await Promise.all([mkdir(workspace), mkdir(dshHome)])
const canonical = await realpath(workspace)
const mixed = `${root.replaceAll('\\', '/')}\\workspace`
const resolver = new ExecutionSpaceResolver()
const inProcess = createInProcessLaunch(dshHome, {
stream: () => textResponse('PATH_OK')
})
const launch = vi.fn(inProcess.launch)
const manager = new SelectedRuntimeManager(async (_selection, space) => {
if (space?.kind !== 'local') throw new Error('local workspace required')
return new DeepSeekHarnessRuntime({
defaultWorkspace: space.rootPath, baseUrl: 'https://api.deepseek.com',
model: 'path-test', launch,
credentialRefs: { [CREDENTIAL_REF]: 'unused-in-memory-model-credential' },
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000
})
})
try {
expect(mixed).not.toBe(canonical)
expect(await realpath(mixed)).toBe(canonical)
const first = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(mixed))
for (const [index, spelling] of [mixed, canonical].entries()) {
const runtime = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(spelling))
expect(runtime).toBe(first)
const events = await collect(runtime.run({
requestId: `path-${index}`, conversationId: `path-${index}`,
workMode: 'ask', prompt: 'Return PATH_OK.'
}, AbortSignal.timeout(20_000)))
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(events.filter(event => event.type === 'text').map(event => event.delta).join('')).toBe('PATH_OK')
}
expect(launch).toHaveBeenCalledOnce()
expect(launch.mock.calls[0]?.[0].cwd).toBe(canonical)
} finally {
await manager.dispose()
await rm(root, { recursive: true, force: true })
}
})
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
const workspace = join(root, 'workspace')
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { resolve } from 'node:path'
import { realpath } from 'node:fs/promises'
import type { RuntimeEvent } from './runtime'
import {
ModelToolProvider,
@@ -243,7 +244,7 @@ function setup(
})
)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: 'C:\\workspace',
defaultWorkspace: process.cwd(),
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
supportsImageInput: options.supportsImageInput,
@@ -474,6 +475,7 @@ describe('DeepSeekHarnessRuntime', () => {
})
const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(10_001)
await expect(status).resolves.toMatchObject({
@@ -503,6 +505,7 @@ describe('DeepSeekHarnessRuntime', () => {
})
const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(12_000)
await expect(status).resolves.toMatchObject({
@@ -674,7 +677,7 @@ describe('DeepSeekHarnessRuntime', () => {
harness.child.stdout
)
expect(harness.launch).toHaveBeenCalledWith({
cwd: 'C:\\workspace',
cwd: await realpath(process.cwd()),
signal: expect.any(AbortSignal),
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
@@ -710,7 +713,12 @@ describe('DeepSeekHarnessRuntime', () => {
await harness.runtime.dispose()
})
it('keeps the original tool name on generic completion updates', async () => {
it.each([
['completed', undefined, 'GoodBuddy'],
['failed', undefined, 'GoodBuddy'],
['completed', 'Search the release notes', 'Search the release notes'],
['failed', 'Search the release notes', 'Search the release notes']
] as const)('keeps tool input and summary on incremental %s updates with title %s', async (status, title, summary) => {
const harness = setup()
const running = collect(
harness.runtime.run(
@@ -725,6 +733,7 @@ describe('DeepSeekHarnessRuntime', () => {
sessionUpdate: 'tool_call',
toolCallId: 'call-web-search',
name: 'web_search',
title,
status: 'pending',
rawInput: { query: 'GoodBuddy' }
})
@@ -732,7 +741,7 @@ describe('DeepSeekHarnessRuntime', () => {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-web-search',
name: 'tool',
status: 'completed',
status,
rawOutput: 'search result'
})
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
@@ -743,13 +752,16 @@ describe('DeepSeekHarnessRuntime', () => {
type: 'tool',
callId: 'call-web-search',
name: 'web_search',
state: 'pending'
state: 'pending',
summary
}),
expect.objectContaining({
type: 'tool',
callId: 'call-web-search',
name: 'web_search',
state: 'completed'
state: status,
summary,
input: expect.stringContaining('GoodBuddy')
})
])
)
+24 -9
View File
@@ -1,3 +1,5 @@
import { toolOperationSummary } from './tool-operation-summary'
import { realpath } from 'node:fs/promises'
import type {
AgentRuntimeStatus,
ModelRequestHeaders,
@@ -210,12 +212,16 @@ type ActiveRun = {
toolController: AbortController
authorize?: RuntimeAuthorizer
updates: AcpSessionNotification['update'][]
toolNames: Map<string, string>
toolNames: Map<
string,
{ name: string; input?: string; summary?: string; title?: string }
>
wake?: () => void
closed: boolean
}
type HarnessState = {
workspace: string
child: DeepSeekHarnessChild
connection: AcpConnection
agent: AcpAgent
@@ -778,9 +784,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
this.launchController = launchController
let child: DeepSeekHarnessChild | undefined
try {
const workspace = await realpath(this.options.defaultWorkspace)
child = await withTimeout(
this.options.launch({
cwd: this.options.defaultWorkspace,
cwd: workspace,
signal: launchController.signal,
baseUrl: this.options.baseUrl,
model: this.options.model,
@@ -977,6 +984,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
throw new Error('DeepSeek Harness ACP 客户端初始化失败')
}
const stateWithoutCapabilities = {
workspace,
child,
connection,
agent
@@ -1306,7 +1314,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
const creation = state.agent
.newSession({
cwd: this.options.defaultWorkspace,
cwd: state.workspace,
mcpServers: []
})
.then((response) => {
@@ -1336,7 +1344,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
private toRuntimeEvent(
requestId: string,
update: AcpSessionNotification['update'],
toolNames: Map<string, string>
toolNames: ActiveRun['toolNames']
): RuntimeEvent | undefined {
if (update.goodBuddyEvent) {
return this.toUsageEvent(
@@ -1378,16 +1386,23 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
'DeepSeek Harness 工具'
).slice(0, 200)
const callId = update.toolCallId.slice(0, 256)
const previous = toolNames.get(callId)
const name =
reportedName === 'tool'
? toolNames.get(callId) ?? reportedName
reportedName === 'tool' || (!update.name && !update.title)
? previous?.name ?? reportedName
: reportedName
const title = toolOperationSummary(
undefined,
update.title === 'tool' || update.title === update.name || update.title === previous?.name
? undefined : update.title
) ?? previous?.title
const input = safeStringify(update.rawInput) ?? previous?.input
const summary = toolOperationSummary(update.rawInput, title) ?? previous?.summary
if (state === 'pending' || state === 'running') {
toolNames.set(callId, name)
toolNames.set(callId, { name, input, summary, title })
} else {
toolNames.delete(callId)
}
const input = safeStringify(update.rawInput)
const output = safeStringify(update.rawOutput)
return {
requestId,
@@ -1395,7 +1410,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
callId,
name,
state,
summary: `DeepSeek Harness 工具:${name}`,
summary: summary ?? `DeepSeek Harness 工具:${name}`,
...(input ? { input } : {}),
...(output ? { output } : {})
}
+10 -5
View File
@@ -2473,7 +2473,10 @@ describe('ModelAgentRuntime', () => {
).toMatch(/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u)
})
it('runs approved direct-model tools and returns their results to OpenAI', async () => {
it.each([false, true])('runs approved tools with summaries from raw arguments (long input: %s)', async (longInput) => {
const args = longInput
? { content: 'x'.repeat(5_000), path: 'README.md' }
: { path: 'README.md' }
const responses = [
{
model: 'qwen3',
@@ -2488,7 +2491,7 @@ describe('ModelAgentRuntime', () => {
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{"path":"README.md"}'
arguments: JSON.stringify(args)
}
}
]
@@ -2596,7 +2599,7 @@ describe('ModelAgentRuntime', () => {
)
expect(toolProvider.getApproval).toHaveBeenCalledWith(
expect.objectContaining({ name: 'workspace_read_text' }),
{ path: 'README.md' },
args,
expect.any(String),
expect.objectContaining({
conversationId: 'conversation-tools',
@@ -2605,7 +2608,7 @@ describe('ModelAgentRuntime', () => {
)
expect(toolProvider.callTool).toHaveBeenCalledWith(
'workspace_read_text',
{ path: 'README.md' },
args,
expect.any(AbortSignal),
expect.objectContaining({
conversationId: 'conversation-tools',
@@ -2617,11 +2620,13 @@ describe('ModelAgentRuntime', () => {
.filter((event) => event.type === 'tool')
.map((event) => event.state)
).toEqual(['pending', 'running', 'completed'])
expect(events.filter((event) => event.type === 'tool').map((event) => event.summary))
.toEqual(['README.md', 'README.md', 'README.md'])
expect(events).toContainEqual(
expect.objectContaining({
type: 'tool',
state: 'completed',
input: '{\n "path": "README.md"\n}',
input: longInput ? expect.any(String) : '{\n "path": "README.md"\n}',
output:
'tool result\n\n[图片结果 1:image/png]'
})
+5 -3
View File
@@ -1,4 +1,5 @@
import { createHash, randomBytes } from 'node:crypto'
import { toolOperationSummary } from './tool-operation-summary'
import { dirname } from 'node:path'
import type {
ApprovalDecision,
@@ -3752,6 +3753,7 @@ export class ModelAgentRuntime implements AgentRuntime {
tool?.name !== 'subagent_delegate'
const displayName = tool?.displayName ?? call.name.slice(0, 128)
const input = boundedToolDetail(call.arguments, 4_000)
const summary = toolOperationSummary(call.arguments) ?? `直连模型工具:${displayName}`
roundSummary.push(
[
`TOOL_CALL: ${displayName}`,
@@ -3767,7 +3769,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'pending',
summary: `直连模型工具:${displayName}`,
summary,
input
}
}
@@ -3844,7 +3846,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'running',
summary: `正在执行直连模型工具:${displayName}`,
summary,
input
}
}
@@ -3925,7 +3927,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id,
name: displayName,
state: 'completed',
summary: `直连模型工具已完成:${displayName}`,
summary,
input,
output: getToolResultPreview(result.parts)
}
+67 -2
View File
@@ -2517,7 +2517,11 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("keeps non-Task tools with Task-shaped input as generic tools", async () => {
it.each([
[" Custom delegate ", "Custom delegate"],
["", "OpenCode 工具:custom_delegate"],
["x".repeat(300), "x".repeat(240)],
])("keeps generic tool titles bounded: %s", async (title, summary) => {
const setup = runClient([
{
id: "custom-tool-completed",
@@ -2536,7 +2540,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
prompt: "Inspect the complete source tree.",
},
output: "Custom tool result.",
title: "Custom delegate",
title,
metadata: {},
time: { start: 1, end: 2 },
},
@@ -2559,12 +2563,73 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
callId: "call-custom-1",
name: "custom_delegate",
state: "completed",
summary,
}),
);
expect(events.some((event) => event.type === "subagent")).toBe(false);
await runtime.dispose();
});
it.each(["error", "interrupted"])("retains the running tool title after %s", async (ending) => {
const part = {
id: "part-read-1",
callID: "call-read-1",
type: "tool" as const,
tool: "read",
};
const setup = runClient([
{
id: "read-running",
type: "message.part.updated",
properties: {
sessionID: "session-1",
part: {
...part,
state: {
status: "running",
input: { filePath: "README.md" },
title: "README.md",
time: { start: 1 },
},
},
},
},
...(ending === "error" ? [{
id: "read-error",
type: "message.part.updated" as const,
properties: {
sessionID: "session-1",
part: {
...part,
state: {
status: "error" as const,
input: { filePath: "README.md" },
error: "File not found",
time: { start: 1, end: 2 },
},
},
},
}] : []),
]);
const runtime = embeddedRuntime(setup.client);
const events: RuntimeEvent[] = [];
await expect((async () => {
for await (const event of runtime.run({
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
conversationId: "conversation-1",
prompt: "Read README.md",
workMode: "execute",
}, new AbortController().signal)) {
events.push(event);
}
})()).rejects.toThrow();
expect(events.filter((event) => event.type === "tool")).toEqual([
expect.objectContaining({ state: "running", summary: "README.md" }),
expect.objectContaining({ state: "failed", summary: "README.md" }),
]);
await runtime.dispose();
});
it("does not impose a default wall-clock deadline on OpenCode runs", async () => {
vi.useFakeTimers();
try {
+12 -4
View File
@@ -2356,6 +2356,7 @@ export class OpenCodeRuntime implements AgentRuntime {
string,
{
name: string;
summary: string;
state: "pending" | "running" | "completed" | "failed";
input?: string;
output?: string;
@@ -2605,6 +2606,11 @@ export class OpenCodeRuntime implements AgentRuntime {
throw new Error("OpenCode 工具调用 ID 格式无效");
}
const toolName = part.tool.slice(0, 200);
const title = "title" in part.state ? part.state.title : undefined;
const summary =
(typeof title === "string" ? title.trim().slice(0, 240) : "") ||
toolStates.get(callId)?.summary ||
`OpenCode 工具:${toolName}`;
const nativeControlTool = toolName.trim().toLowerCase() === "question";
const state =
part.state.status === "error" ? "failed" : part.state.status;
@@ -2636,6 +2642,7 @@ export class OpenCodeRuntime implements AgentRuntime {
: undefined;
toolStates.set(callId, {
name: toolName,
summary,
state,
...(input ? { input } : {}),
...(output ? { output } : {}),
@@ -2657,7 +2664,7 @@ export class OpenCodeRuntime implements AgentRuntime {
callId,
name: toolName,
state,
summary: `OpenCode 工具:${toolName}`,
summary,
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {}),
@@ -2811,14 +2818,15 @@ export class OpenCodeRuntime implements AgentRuntime {
permissionRequest.tool?.callID ?? permissionRequest.id;
const toolName = permissionRequest.permission.slice(0, 200);
if (permissionSessionId === sessionId) {
toolStates.set(callId, { name: toolName, state: "pending" });
const summary = toolStates.get(callId)?.summary ?? `OpenCode 工具:${toolName}`;
toolStates.set(callId, { name: toolName, summary, state: "pending" });
yield {
requestId: request.requestId,
type: "tool",
callId,
name: toolName,
state: "pending",
summary: `OpenCode 工具:${toolName}`,
summary,
};
}
const allowKnowledge =
@@ -2939,7 +2947,7 @@ export class OpenCodeRuntime implements AgentRuntime {
callId,
name: tool.name,
state: "failed",
summary: `OpenCode 工具:${tool.name}`,
summary: tool.summary,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {}),
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { toolOperationSummary } from './tool-operation-summary'
describe('toolOperationSummary', () => {
it.each(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])(
'extracts %s without displaying file contents', (key) => {
expect(toolOperationSummary({ content: 'x'.repeat(10_000), [key]: 'src/main.ts' }))
.toBe('src/main.ts')
}
)
it.each([
[{ command: 'npm test\r\n-- --run', path: '/repo' }, 'npm test -- --run'],
[{ pattern: 'TODO', path: 'src', filePattern: '*.ts' }, 'TODO | src | *.ts'],
[{ query: 'latest release' }, 'latest release'],
[{ glob: '**/*.ts' }, '**/*.ts'],
[{ url: 'https://example.com' }, 'https://example.com']
])('extracts command, search and URL parameters', (args, expected) => {
expect(toolOperationSummary(JSON.stringify(args))).toBe(expected)
})
it.each([undefined, null, [], 12, 'broken JSON', '{"path":', '"text"', { content: 'body' }, { path: {}, command: 42 }])(
'falls back for invalid or irrelevant input %j', (input) => {
expect(toolOperationSummary(input)).toBeUndefined()
}
)
it('prefers a nonempty native title and bounds a single line to 240 characters', () => {
expect(toolOperationSummary({ path: 'file' }, 'Read\n native\tfile')).toBe('Read native file')
expect(toolOperationSummary({ path: 'file' }, ' \n ')).toBe('file')
expect(toolOperationSummary({ command: 'x'.repeat(500) })).toHaveLength(240)
expect(toolOperationSummary({}, 'x'.repeat(500))).toHaveLength(240)
})
it('can run in the Continue host bundle before input preview truncation', () => {
const summarize = new Function(`return (${toolOperationSummary.toString()})`)() as typeof toolOperationSummary
expect(summarize({ content: 'x'.repeat(10_000), filepath: 'report.md' })).toBe('report.md')
})
})
+34
View File
@@ -0,0 +1,34 @@
// Kept self-contained so the Continue host can use it before truncating input.
export function toolOperationSummary(
input: unknown,
title?: unknown
): string | undefined {
const singleLine = (value: unknown): string =>
typeof value === 'string'
? [...value].map((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127 ? ' ' : character
}).join('').replace(/\s+/gu, ' ').trim()
: ''
const nativeTitle = singleLine(title)
if (nativeTitle) return nativeTitle.slice(0, 240)
if (typeof input === 'string') {
try {
input = JSON.parse(input)
} catch {
return undefined
}
}
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined
const args = input as Record<string, unknown>
const first = (keys: string[]): string =>
keys.map((key) => singleLine(args[key])).find(Boolean) ?? ''
const command = first(['command'])
const path = first(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])
const search = first(['query', 'pattern', 'glob'])
const filter = first(['filePattern', 'include'])
const summary = command || (search
? [search, path, filter].filter(Boolean).join(' | ')
: path || first(['url']))
return summary ? summary.slice(0, 240) : undefined
}
+44 -30
View File
@@ -95,9 +95,12 @@ import {
setMagicNoteChecklistCompletion
} from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
import { SubagentProgressStorage } from './subagent-progress-storage'
import {
SubagentProgressStorage,
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 34
export const ASSISTANT_DATABASE_SCHEMA_VERSION = SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
export type RemoteTaskEventInput = {
taskId: string
@@ -5344,34 +5347,9 @@ export class AssistantDatabase {
database: DatabaseSync,
event: ValidatedRemoteTaskEvent
): boolean {
const payloadJson = this.subagentProgress!.serialize(
event.taskId, event.kind, JSON.parse(event.payloadJson)
)
const result = database
.prepare(
`INSERT OR IGNORE INTO task_events
(task_id, run_id, kind, payload_json, created_at,
remote_binding_id, remote_operation_id,
remote_semantic_sequence, remote_event_index)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
event.taskId,
event.kind,
payloadJson,
new Date().toISOString(),
event.bindingId,
event.operationId,
event.semanticSequence,
event.eventIndex
)
if (result.changes === 1) {
this.subagentProgress!.inserted(
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
)
return true
}
const existing = database
const findExisting = (): {
id: number; task_id: string; kind: string; payload_json: string
} | undefined => database
.prepare(
`SELECT id, task_id, kind, payload_json
FROM task_events
@@ -5393,6 +5371,38 @@ export class AssistantDatabase {
payload_json: string
}
| undefined
// Duplicate replay must not recreate a completed task's released write cache.
let existing = findExisting()
if (!existing) {
const payloadJson = this.subagentProgress!.serialize(
event.taskId, event.kind, JSON.parse(event.payloadJson)
)
const result = database
.prepare(
`INSERT OR IGNORE INTO task_events
(task_id, run_id, kind, payload_json, created_at,
remote_binding_id, remote_operation_id,
remote_semantic_sequence, remote_event_index)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
event.taskId,
event.kind,
payloadJson,
new Date().toISOString(),
event.bindingId,
event.operationId,
event.semanticSequence,
event.eventIndex
)
if (result.changes === 1) {
this.subagentProgress!.inserted(
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
)
return true
}
existing = findExisting()
}
if (
existing?.task_id === event.taskId &&
existing.kind === event.kind &&
@@ -9681,6 +9691,10 @@ export class AssistantDatabase {
COMMIT;
`)
}
if (version.user_version < SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION) {
// Startup's worker has already compacted full and schema-34 delta events.
database.exec(`PRAGMA user_version = ${SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION}`)
}
}
private requireDatabase(): DatabaseSync {
@@ -7,11 +7,12 @@ import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import type { SubagentEvent } from '../../shared/contracts'
import { AssistantDatabase } from './assistant-database'
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
import { upgradeAssistantStorage } from './assistant-storage-upgrade'
import {
compactSubagentPayload,
restoreSubagentPayload
restoreSubagentPayload,
SubagentProgressStorage
} from './subagent-progress-storage'
const directories: string[] = []
@@ -78,7 +79,10 @@ describe('subagent progress storage', () => {
const event = subagent(fixtureData.taskId)
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(200_000) }
tool: {
name: 'read', summary: 'Read', state: 'completed',
input: undefined, output: 'x'.repeat(200_000), error: undefined
}
}
const textId = randomUUID()
try {
@@ -102,23 +106,116 @@ describe('subagent progress storage', () => {
const state = new Map<string, ConversationMessageBlock[]>()
let restored: unknown
for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state)
expect(restored).toEqual(event)
expect(restored).toStrictEqual(JSON.parse(JSON.stringify(event)))
} finally { inspection.close() }
} finally { database.close() }
})
it('ignores omitted optional tool fields but preserves every actual field change', () => {
const event = subagent(randomUUID())
const block: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: {
callId: 'read-1', name: 'read', summary: 'Read', state: 'completed',
input: undefined, output: 'unchanged', error: undefined
}
}
const state = new Map<string, ConversationMessageBlock[]>([
[event.childTaskId, JSON.parse(JSON.stringify([block]))]
])
expect(compactSubagentPayload({ ...event, progress: [block] }, state))
.toMatchObject({ progressUpdates: [] })
for (const change of [
{ callId: 'read-2' }, { name: 'write' }, { summary: 'Updated' },
{ state: 'failed' as const }, { input: '' }, { output: 'changed' },
{ error: 'failed' }, { output: undefined }
]) {
const updated = { ...block, tool: { ...block.tool, ...change } }
const compact = compactSubagentPayload({ ...event, progress: [updated] }, state)
expect(compact).toMatchObject({
progressUpdates: [{ type: 'upsert', block: updated }]
})
}
})
it('releases terminal task caches without clearing peers, and rebuilds after rollback', () => {
const database = new DatabaseSync(':memory:')
database.exec(`CREATE TABLE task_events(
id INTEGER PRIMARY KEY, task_id TEXT, kind TEXT, payload_json TEXT
)`)
const storage = new SubagentProgressStorage(database)
const caches = (storage as unknown as { tasks: Map<string, unknown> }).tasks
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: {
name: 'read', state: 'completed', summary: 'Read',
output: 'x'.repeat(200_000), error: undefined
}
}
let id = 0
const append = (taskId: string, kind: string, event: unknown) => {
const payload = storage.serialize(taskId, kind, event)
database.prepare('INSERT INTO task_events VALUES (?, ?, ?, ?)')
.run(++id, taskId, kind, payload)
storage.inserted(taskId, kind, id, payload)
return JSON.parse(payload) as { progressUpdates: unknown[] }
}
try {
const peer = subagent(randomUUID())
append(peer.requestId, 'subagent', { ...peer, progress: [tool] })
for (const [kind, payload] of [
['done', {}], ['error', {}],
...['completed', 'failed', 'cancelled', 'interrupted']
.map((status) => ['status', { status }])
] as Array<[string, unknown]>) {
const event = { ...subagent(randomUUID()), progress: [tool] }
append(event.requestId, 'subagent', event)
expect(caches.size).toBe(2)
database.exec('BEGIN')
append(event.requestId, kind, payload)
expect(caches.has(event.requestId)).toBe(false)
expect(caches.has(peer.requestId)).toBe(true)
database.exec('ROLLBACK')
expect(append(event.requestId, 'subagent', event).progressUpdates).toEqual([])
append(event.requestId, kind, payload)
expect(caches.size).toBe(1)
}
for (let index = 0; index < 40; index++) {
const event = subagent(randomUUID())
append(event.requestId, 'subagent', { ...event, progress: [tool] })
expect(caches.size).toBeLessThanOrEqual(16)
}
} finally { database.close() }
})
it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => {
const { database, taskId, path } = await fixture()
const event = subagent(taskId)
const textId = randomUUID()
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: {
name: 'read', summary: 'Read', state: 'completed',
input: undefined, output: 'x'.repeat(20_000), error: undefined
}
}
const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
const second = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed tail' }] }
const first = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed' }] }
const second = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed tail' }] }
try {
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true)
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true)
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false)
expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts')
database.appendTaskEvent(taskId, 'done', {})
const caches = (database as unknown as {
subagentProgress: { tasks: Map<string, unknown> }
}).subagentProgress.tasks
expect(caches.size).toBe(0)
expect(database.appendRemoteTaskEventOnce({
...provenance, semanticSequence: '2', payload: second
})).toBe(false)
expect(caches.size).toBe(0)
} finally { database.close() }
const reopened = new AssistantDatabase(path)
reopened.initialize(tmpdir())
@@ -208,7 +305,7 @@ describe('subagent progress storage', () => {
} finally { inspection.close(); database.close() }
})
it('converts legacy rows in batches, resumes after cancellation and actually shrinks the file', async () => {
it.each([33, 34])('converts schema %i rows, resumes and shrinks without losing provenance', async (sourceVersion) => {
const { database, taskId, path } = await fixture()
database.close()
const event = subagent(taskId)
@@ -220,10 +317,12 @@ describe('subagent progress storage', () => {
const legacy = new DatabaseSync(path)
const expected: SubagentEvent[] = []
try {
legacy.exec('PRAGMA user_version = 33; BEGIN')
legacy.exec(`PRAGMA user_version = ${sourceVersion}; BEGIN`)
const insert = legacy.prepare(
`INSERT INTO task_events(task_id, kind, payload_json, created_at)
VALUES (?, 'subagent', ?, ?)`
`INSERT INTO task_events(
task_id, kind, payload_json, created_at, remote_binding_id,
remote_operation_id, remote_semantic_sequence, remote_event_index
) VALUES (?, 'subagent', ?, ?, 'binding', 'operation', ?, 0)`
)
for (let index = 0; index < 150; index++) {
const full = {
@@ -231,9 +330,17 @@ describe('subagent progress storage', () => {
progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }]
}
expected.push(full)
insert.run(taskId, JSON.stringify(full), '2026-09-13T00:00:00Z')
const { progress, ...metadata } = full
const stored = sourceVersion === 34 ? {
...metadata,
progressUpdates: progress.map((block) => ({ type: 'upsert', block }))
} : full
insert.run(taskId, JSON.stringify(stored), '2026-09-13T00:00:00Z', String(index + 1))
}
legacy.exec('COMMIT')
legacy.exec(`COMMIT;
CREATE TRIGGER reject_unchanged_payload BEFORE UPDATE OF payload_json ON task_events
WHEN OLD.payload_json = NEW.payload_json
BEGIN SELECT RAISE(ABORT, 'unchanged payload rewritten'); END;`)
} finally { legacy.close() }
const before = (await stat(path)).size
expect(before).toBeGreaterThan(40_000_000)
@@ -248,13 +355,25 @@ describe('subagent progress storage', () => {
try {
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
const rows = inspected.prepare(
`SELECT payload_json, created_at FROM task_events WHERE kind = 'subagent' ORDER BY id`
).all() as Array<{ payload_json: string; created_at: string }>
`SELECT id, task_id, payload_json, created_at, remote_binding_id,
remote_operation_id, remote_semantic_sequence, remote_event_index
FROM task_events WHERE kind = 'subagent' ORDER BY id`
).all() as Array<{
id: number; task_id: string; payload_json: string; created_at: string
remote_binding_id: string; remote_operation_id: string
remote_semantic_sequence: string; remote_event_index: number
}>
expect(rows).toHaveLength(expected.length)
const blocks = new Map<string, ConversationMessageBlock[]>()
rows.forEach((row, index) => {
expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index])
expect(row.created_at).toBe('2026-09-13T00:00:00Z')
expect(row.task_id).toBe(taskId)
expect(row.remote_binding_id).toBe('binding')
expect(row.remote_operation_id).toBe('operation')
expect(row.remote_semantic_sequence).toBe(String(index + 1))
expect(row.remote_event_index).toBe(0)
if (index > 0) expect(row.id).toBe(rows[index - 1]!.id + 1)
})
} finally { inspected.close() }
const reopened = new AssistantDatabase(path)
@@ -263,5 +382,47 @@ describe('subagent progress storage', () => {
const messages: unknown[] = []
upgradeAssistantStorage(path, (value) => messages.push(value))
expect(messages).toEqual([])
const latest = new DatabaseSync(path)
try {
expect(latest.prepare('PRAGMA user_version').get()).toEqual({
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
})
} finally { latest.close() }
}, 30_000)
it('recompacts mixed full and delta events without changing corrections, resets or child state', () => {
const first = subagent(randomUUID())
const second = { ...subagent(first.requestId), state: 'completed' as const, output: 'Done' }
const textId = randomUUID()
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: { name: 'read', state: 'completed', summary: 'Read', output: 'payload' }
}
const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' }
const stored = [
{ ...first, progress: [tool, text] },
{ ...second, progressUpdates: [{ type: 'upsert', block: text }] },
{ ...first, progress: undefined, progressUpdates: [
{ type: 'upsert', block: tool },
{ type: 'append', id: textId, blockType: 'text', delta: ' tail' }
] },
{ ...first, progress: undefined, progressUpdates: [
{ type: 'upsert', block: { ...text, content: 'corrected' } }
] },
{ ...first, progress: undefined, progressUpdates: [
{ type: 'reset', blocks: [text, tool] }
] },
{ ...second, progressUpdates: [] },
{ ...first, progress: [] },
{ ...first, progress: [text] }
]
const oldState = new Map<string, ConversationMessageBlock[]>()
const newState = new Map<string, ConversationMessageBlock[]>()
for (const input of stored) {
const event = JSON.parse(JSON.stringify(input))
const compact = compactSubagentPayload(event, oldState)
const expected = restoreSubagentPayload(event, oldState)
expect(restoreSubagentPayload(compact, newState)).toStrictEqual(expected)
}
})
})
@@ -4,7 +4,8 @@ import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
import {
compactSubagentPayload,
restoreSubagentPayload
restoreSubagentPayload,
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export function upgradeAssistantStorage(
@@ -21,7 +22,10 @@ export function upgradeAssistantStorage(
const version = database.prepare('PRAGMA user_version').get() as {
user_version: number
}
if (version.user_version < 1 || version.user_version >= 34) return
if (
version.user_version < 1 ||
version.user_version >= SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
) return
checkCancelled()
const progress: AssistantStorageProgress = {
stage: 'scanning', processed: 0, total: 0, bytesBefore
@@ -65,8 +69,9 @@ export function upgradeAssistantStorage(
const compact = compactSubagentPayload(payload, blocks)
// Replay mixed old/new rows on retry. Never delete events or provenance.
restoreSubagentPayload(payload, blocks)
if (compact !== payload) {
update.run(JSON.stringify(compact), row.id)
const compactJson = JSON.stringify(compact)
if (compactJson !== row.payload_json) {
update.run(compactJson, row.id)
}
afterId = row.id
}
@@ -4,9 +4,28 @@ import type { DatabaseSync } from 'node:sqlite'
import {
assistantIdSchema,
conversationMessageBlockSchema,
type ConversationMessageBlock
conversationToolActivitySchema,
type ConversationMessageBlock,
type ConversationToolActivity
} from '../../shared/assistant-contracts'
export const SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION = 35
const toolFields = Object.keys(conversationToolActivitySchema.shape) as
Array<keyof ConversationToolActivity>
function sameProgressBlock(
previous: ConversationMessageBlock,
next: ConversationMessageBlock
): boolean {
if (previous.type === 'tool' && next.type === 'tool') {
// JSON omits undefined optional fields. Compare their values, not key presence.
return previous.id === next.id &&
toolFields.every((field) => previous.tool[field] === next.tool[field])
}
return isDeepStrictEqual(previous, next)
}
export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('append'),
@@ -89,7 +108,7 @@ export function diffSubagentProgress(
type: 'append', id: block.id, blockType: block.type, delta
})
}
} else if (!prior || !isDeepStrictEqual(prior, block)) {
} else if (!prior || !sameProgressBlock(prior, block)) {
updates.push({ type: 'upsert', block })
}
}
@@ -114,12 +133,15 @@ export function compactSubagentPayload(
progress: ReadonlyMap<string, ConversationMessageBlock[]>
): unknown {
const event = asSubagent(payload)
if (!event || !Array.isArray(event.progress)) return payload
if (!event || (
!Array.isArray(event.progress) && !Array.isArray(event.progressUpdates)
)) return payload
const previous = progress.get(event.childTaskId) ?? []
const compact = { ...event }
delete compact.progress
compact.progressUpdates = diffSubagentProgress(
progress.get(event.childTaskId) ?? [],
event.progress
previous,
applySubagentProgress(previous, event) ?? []
)
return compact
}
@@ -158,7 +180,17 @@ export class SubagentProgressStorage {
}
inserted(taskId: string, kind: string, id: number, payloadJson: string): void {
if (kind !== 'subagent') return
if (kind !== 'subagent') {
if (
kind === 'done' || kind === 'error' ||
(kind === 'status' && [
'completed', 'failed', 'cancelled', 'interrupted'
].includes((JSON.parse(payloadJson) as { status?: string }).status ?? ''))
) {
this.tasks.delete(taskId)
}
return
}
const task = this.tasks.get(taskId)
if (!task) return
restoreSubagentPayload(JSON.parse(payloadJson), task.blocks)
+41 -32
View File
@@ -1063,6 +1063,8 @@ describe("App", () => {
delete document.documentElement.dataset.theme;
document.documentElement.style.colorScheme = "";
vi.clearAllMocks();
vi.mocked(api.agent.respondQuestion).mockReset().mockResolvedValue();
vi.mocked(api.agent.respondApproval).mockReset().mockResolvedValue();
magicTodoStatusChangedListener = undefined;
vi.mocked(api.magicNotes.getTodoStatus)
.mockReset()
@@ -1276,16 +1278,16 @@ describe("App", () => {
expect(projectRow).toHaveTextContent("1 个运行中");
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
fireEvent.click(summary);
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
const running = within(dialog).getByRole("region", { name: "运行中" });
expect(within(running).getAllByRole("button")).toHaveLength(1);
expect(within(running).getByRole("button", { name: /Background project Exact background discussion/u })).toBeInTheDocument();
expect(within(dialog).queryByRole("region", { name: "待处理" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
const running = screen.getByRole("menu", { name: "Background project" });
expect(within(running).getAllByRole("menuitem")).toHaveLength(1);
expect(within(running).getByRole("menuitem", { name: /Exact background discussion/u })).toHaveTextContent("运行中");
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: "completed" }]);
act(() => conversationQueueChangeListener?.(task.conversationId!));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
await waitFor(() => expect(summary).toHaveTextContent("暂无活动"));
expect(screen.getByRole("menu", { name: "全项目活动" })).toHaveTextContent("暂无运行中或待处理会话");
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
expect(within(screen.getByRole("menu")).getByRole("menuitemradio", { name: /Background project/u }))
.not.toHaveTextContent("个运行中");
@@ -1311,13 +1313,13 @@ describe("App", () => {
fireEvent.click(sidebar.querySelector<HTMLButtonElement>(".conversation-more")!);
expect(sidebar.querySelector(".conversation-actions")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
.getByRole("button", { name: /Exact background discussion/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
expect(sidebar).toHaveClass("sidebar--closed");
expect(within(screen.getByRole("region", { name: "当前会话的任务" }))
.getByRole("button", { name: /会话任务/u })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
expect(screen.getByLabelText("搜索对话")).toHaveValue("");
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
@@ -1341,15 +1343,15 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
const trigger = screen.getByRole("button", { name: /全项目活动/u });
fireEvent.click(trigger);
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
const close = within(dialog).getByRole("button", { name: "关闭全项目活动" });
expect(close).toHaveFocus();
// Native Tab must remain available between controls, even through a React portal.
expect(fireEvent.keyDown(close, { key: "Tab" })).toBe(true);
fireEvent.keyDown(close, { key: "Tab", shiftKey: true });
expect(within(dialog).getByRole("button", { name: /Exact background discussion/u })).toHaveFocus();
const projectItem = screen.getByRole("menuitem", { name: /Background project/u });
expect(projectItem).toHaveFocus();
fireEvent.keyDown(projectItem, { key: "ArrowRight" });
expect(screen.getByRole("menuitem", { name: /Exact background discussion/u })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
expect(projectItem).toHaveFocus();
expect(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
expect(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
expect(trigger).toHaveFocus();
} finally {
@@ -1364,8 +1366,8 @@ describe("App", () => {
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
.getByRole("button", { name: /Exact background discussion/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改");
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("Background project");
@@ -1373,13 +1375,14 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" }));
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
});
it.each(["question", "approval"] as const)("preserves live %s across snapshot refresh and clears persisted terminal prompts", async (kind) => {
render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
await screen.findByLabelText("更多会话操作 Current discussion");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Refresh pending prompt" } });
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
@@ -1439,8 +1442,9 @@ describe("App", () => {
vi.mocked(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval)
.mockImplementationOnce(() => new Promise<void>((yes, no) => { resolve = yes; reject = no; }));
render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
await screen.findByLabelText("更多会话操作 Current discussion");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Response activity" } });
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
@@ -1476,8 +1480,9 @@ describe("App", () => {
it.each(["question", "approval"] as const)("groups pending %s ahead of running, deduplicates live and task activity, and clears terminal events", async (kind) => {
render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
await screen.findByLabelText("更多会话操作 Current discussion");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Track live activity" } });
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
@@ -1497,12 +1502,14 @@ describe("App", () => {
});
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中"));
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
expect(within(dialog).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(["待处理", "运行中"]);
const pending = within(dialog).getByRole("region", { name: "待处理" });
expect(within(pending).getAllByRole("button")).toHaveLength(1);
const menu = screen.getByRole("menu", { name: "全项目活动" });
expect(within(menu).getAllByRole("menuitem")).toHaveLength(2);
fireEvent.click(within(menu).getByRole("menuitem", { name: /1 个待处理/u }));
const pending = screen.getByRole("menu", { name: project.name });
expect(within(pending).getAllByRole("menuitem")).toHaveLength(1);
expect(pending).toHaveTextContent(kind === "question" ? "等待你的回答" : "等待审批");
expect(within(dialog).getByRole("region", { name: "运行中" })).toHaveTextContent("Exact background discussion");
fireEvent.click(within(menu).getByRole("menuitem", { name: /Background project/u }));
expect(screen.getByRole("menu", { name: "Background project" })).toHaveTextContent("Exact background discussion");
vi.mocked(api.tasks.list).mockResolvedValue([]);
act(() => {
agentListener?.(kind === "question"
@@ -1510,8 +1517,8 @@ describe("App", () => {
: { requestId: request.requestId, type: "error", status: "failed", message: "Activity execution failed" });
conversationQueueChangeListener?.(request.conversationId!);
});
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("暂无活动"));
expect(menu).toHaveTextContent("暂无运行中或待处理会话");
});
});
@@ -10778,7 +10785,9 @@ describe("App", () => {
["先写测试"],
]),
);
expect(screen.queryByText("OpenCode 需要补充信息")).not.toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByText("OpenCode 需要补充信息")).not.toBeInTheDocument(),
);
expect(screen.getByText("请选择实现方式")).toBeInTheDocument();
expect(screen.getByText("先写测试")).toBeInTheDocument();
act(() => {
+2
View File
@@ -8353,6 +8353,8 @@ function App(): React.JSX.Element {
/>
<ProjectActivity
activities={projectActivity.activities}
projects={projects}
visible={sidebarOpen}
onOpenConversation={openActivityConversation}
/>
+164 -82
View File
@@ -1,129 +1,211 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProjectActivity, ProjectActivityCounts } from './ProjectActivity'
import type { ConversationActivity } from './conversation-activity'
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
import { workspace } from './i18n/locales/en-US/workspace'
import type { AssistantProject } from '../../shared/assistant-contracts'
const i18n = createInstance()
void i18n.init({
lng: 'en',
initImmediate: false,
resources: { en: { workspace: { projectActivity: {
title: 'All project activity', close: 'Close activity',
attentionCount: '{{count}} need attention', runningCount: '{{count}} running',
groups: { attention: 'Needs attention', running: 'Running' },
status: { running: 'Running', approval: 'Waiting for approval', question: 'Waiting for your answer', attention: 'Needs attention' }
} } } }
})
void i18n.init({ lng: 'en', initImmediate: false, resources: { en: { workspace } } })
const activities: ConversationActivity[] = [
{ conversationId: 'running', projectId: 'local', projectName: 'Local', title: 'Build', status: 'running' },
{ conversationId: 'remote-run', projectId: 'remote', projectName: 'Remote', title: 'Deploy', status: 'running' },
{ conversationId: 'question', projectId: 'remote', projectName: 'Remote', title: 'Choose target', status: 'question' },
{ conversationId: 'approval', projectId: 'remote', projectName: 'Remote', title: 'Approve command', status: 'approval' },
{ conversationId: 'unloaded', projectName: 'Channel', title: 'Background task', status: 'attention' }
{ conversationId: 'unloaded', projectName: 'No project', title: 'Background task', status: 'attention' }
]
function view(rows = activities, onOpenConversation = vi.fn()): React.JSX.Element {
return (
<I18nextProvider i18n={i18n}>
<div className="app-shell">
<button type="button">Destination</button>
<ProjectActivity activities={rows} onOpenConversation={onOpenConversation} />
</div>
</I18nextProvider>
)
function view(rows = activities, onOpenConversation = vi.fn(), projects?: AssistantProject[], visible = true): React.JSX.Element {
return <I18nextProvider i18n={i18n}>
<div className="app-shell">
<button type="button">Destination</button>
<ProjectActivity activities={rows} onOpenConversation={onOpenConversation} projects={projects} visible={visible} />
</div>
</I18nextProvider>
}
afterEach(cleanup)
function open(): HTMLButtonElement {
const trigger = screen.getByRole<HTMLButtonElement>('button', { name: /All project activity/ })
fireEvent.click(trigger)
return trigger
}
afterEach(() => { cleanup(); vi.restoreAllMocks() })
describe('ProjectActivity', () => {
it('hides idle activity and zero counts', () => {
it('keeps an idle summary and an accessible empty menu while hiding zero counts', () => {
render(view([]))
expect(screen.queryByRole('button', { name: /All project activity/ })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const { container } = render(
<I18nextProvider i18n={i18n}><ProjectActivityCounts attention={0} running={0} /></I18nextProvider>
)
const trigger = open()
expect(trigger).toHaveTextContent('No activity')
expect(screen.getByRole('menu')).toHaveTextContent('No running conversations')
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(trigger).toHaveFocus()
const { container } = render(<I18nextProvider i18n={i18n}><ProjectActivityCounts attention={0} running={0} /></I18nextProvider>)
expect(container).toBeEmptyDOMElement()
})
it('shows global counts and attention-first groups with precise labels', () => {
it('shows authoritative counts, stable projects and attention-first conversations', () => {
render(view())
fireEvent.click(screen.getByRole('button', { name: /All project activity 3 need attention 1 running/ }))
const dialog = screen.getByRole('dialog', { name: 'All project activity' })
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(within(dialog).getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent))
.toEqual(['Needs attention', 'Running'])
expect(within(dialog).getByRole('button', { name: 'Remote Choose target Waiting for your answer' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Remote Approve command Waiting for approval' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Channel Background task Needs attention' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Local Build Running' })).toBeInTheDocument()
})
it('isolates the background, traps Tab both ways, and restores focus on Escape', () => {
const { container } = render(view())
const trigger = screen.getByRole('button', { name: /All project activity/ })
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog')
const close = within(dialog).getByRole('button', { name: 'Close activity' })
const last = within(dialog).getByRole('button', { name: 'Local Build Running' })
expect(close).toHaveFocus()
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(true)
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(close).toHaveFocus()
fireEvent.keyDown(close, { key: 'Escape' })
expect(open()).toHaveAccessibleName('All project activity: 3 need attention, 2 running')
const menu = screen.getByRole('menu', { name: 'All project activity' })
expect(within(menu).getAllByRole('menuitem').map((row) => row.textContent)).toEqual([
'Local 1 running', 'Remote 2 need attention 1 running', 'No project 1 need attention '
])
fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ }))
expect(within(screen.getByRole('menu', { name: 'Remote' })).getAllByRole('menuitem').map((row) => row.textContent))
.toEqual(['Choose target Waiting for your answer', 'Approve command Waiting for approval', 'Deploy Running'])
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).not.toBe(true)
})
it('navigates directly to an unloaded conversation without stealing destination focus', () => {
it('uses project metadata order and keeps SSH hosts grouped regardless of activity priority', () => {
const projects = [
{ id: 'remote', name: 'Remote', kind: 'user', executionSpace: { kind: 'ssh', hostId: 'host-a' } },
{ id: 'channel', name: 'Channel', kind: 'channel', executionSpace: { kind: 'local' } },
{ id: 'local', name: 'Local', kind: 'user', executionSpace: { kind: 'local' } },
{ id: 'other-host', name: 'Other host', kind: 'user', executionSpace: { kind: 'ssh', hostId: 'host-b' } },
{ id: 'same-host', name: 'Same host', kind: 'user', executionSpace: { kind: 'ssh', hostId: 'host-a' } }
] as AssistantProject[]
render(view([...activities, ...projects.slice(1).filter((p) => p.id !== 'local').map((p) => ({
conversationId: p.id, projectId: p.id, projectName: p.name, title: p.id, status: 'running' as const
}))], vi.fn(), projects))
open()
expect(screen.getAllByRole('menuitem').map((row) => row.querySelector('.project-activity__identity > span')?.textContent))
.toEqual(['Local', 'Remote', 'Same host', 'Other host', 'Channel', 'No project'])
})
it('navigates arrows, Home/End, Enter and two-stage Escape before the sidebar listener', () => {
render(view())
const sidebarEscape = vi.fn()
document.addEventListener('keydown', sidebarEscape)
const trigger = open()
const local = screen.getByRole('menuitem', { name: /Local/ })
const remote = screen.getByRole('menuitem', { name: /Remote/ })
expect(local).toHaveFocus()
fireEvent.keyDown(local, { key: 'ArrowDown' })
expect(remote).toHaveFocus()
fireEvent.keyDown(remote, { key: 'ArrowRight' })
const first = screen.getByRole('menuitem', { name: 'Choose target Waiting for your answer' })
expect(first).toHaveFocus()
fireEvent.keyDown(first, { key: 'End' })
expect(screen.getByRole('menuitem', { name: 'Deploy Running' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
expect(screen.getByRole('menuitem', { name: 'Approve command Waiting for approval' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
expect(first).toHaveFocus()
sidebarEscape.mockClear()
fireEvent.keyDown(first, { key: 'Escape' })
expect(remote).toHaveFocus()
expect(screen.queryByRole('menu', { name: 'Remote' })).not.toBeInTheDocument()
fireEvent.keyDown(remote, { key: 'Escape' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(sidebarEscape).not.toHaveBeenCalled()
document.removeEventListener('keydown', sidebarEscape)
})
it('navigates the exact unloaded conversation after closing without stealing destination focus', () => {
const onOpen = vi.fn(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Destination' }).focus()
})
render(view(activities, onOpen))
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
fireEvent.click(screen.getByRole('button', { name: 'Channel Background task Needs attention' }))
open()
fireEvent.click(screen.getByRole('menuitem', { name: /No project/ }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Background task Needs attention' }))
expect(onOpen).toHaveBeenCalledExactlyOnceWith('unloaded')
expect(screen.getByRole('button', { name: 'Destination' })).toHaveFocus()
})
it('updates live rows without refocusing and stays closed after becoming idle', () => {
it('keeps the submenu open across the adjoining pointer boundary', () => {
render(view())
open()
const remote = screen.getByRole('menuitem', { name: /Remote/ })
// jsdom does not provide PointerEvent's pointerType.
const event = new MouseEvent('pointerover', { bubbles: true })
Object.defineProperty(event, 'pointerType', { value: 'mouse' })
fireEvent(remote, event)
const menu = screen.getByRole('menu', { name: 'Remote' })
fireEvent.pointerLeave(remote)
fireEvent.pointerEnter(menu)
expect(menu).toBeInTheDocument()
expect(remote).toHaveAttribute('aria-expanded', 'true')
})
it('dismisses on outside pointer, focus, Tab and toggle with focus restoration', () => {
render(view())
const trigger = open()
fireEvent.pointerDown(document.body)
expect(trigger).toHaveFocus()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
open()
act(() => screen.getByRole('button', { name: 'Destination' }).focus())
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Destination' })).toHaveFocus()
open()
fireEvent.keyDown(document.activeElement!, { key: 'Tab' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
open()
fireEvent.click(trigger)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('preserves live focus and recovers when the selected project drains', () => {
const { rerender } = render(view())
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
const row = screen.getByRole('button', { name: 'Remote Choose target Waiting for your answer' })
row.focus()
open()
fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ }))
const row = screen.getByRole('menuitem', { name: 'Choose target Waiting for your answer' })
rerender(view(activities.filter((activity) => activity.status !== 'running')))
expect(row).toHaveFocus()
expect(screen.queryByRole('region', { name: 'Running' })).not.toBeInTheDocument()
rerender(view(activities.filter((activity) => activity.projectId === 'local')))
expect(screen.getByRole('menuitem', { name: /Local/ })).toHaveFocus()
rerender(view([]))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
expect(screen.getByRole('menu')).toHaveTextContent('No running conversations')
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
rerender(view())
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('restores trigger focus when dismissed with the close button', () => {
it('closes when the sidebar is hidden', () => {
const { rerender } = render(view())
open()
rerender(view(activities, vi.fn(), undefined, false))
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('positions beside the anchor, falls left and drills down in a narrow viewport', () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(900)
render(view())
const trigger = screen.getByRole('button', { name: /All project activity/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Close activity' }))
expect(trigger).toHaveFocus()
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(600, 100, 200, 32))
open()
const menu = screen.getByRole('menu', { name: 'All project activity' })
fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ }))
expect(menu).toHaveAttribute('data-left', 'true')
expect(menu.style.left).toBe('336px')
expect(menu.style.top).toBe('136px')
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(400)
fireEvent(window, new Event('resize'))
expect(menu).toHaveAttribute('data-compact', 'true')
expect(menu.style.width).toBe('264px')
fireEvent.click(screen.getByRole('button', { name: 'Back to projects' }))
expect(screen.getByRole('menuitem', { name: /Remote/ })).toHaveFocus()
expect(menu).toHaveAttribute('data-submenu', 'false')
})
it('still handles Escape when a background update removes the focused row', () => {
const { rerender } = render(view())
const trigger = screen.getByRole('button', { name: /All project activity/ })
fireEvent.click(trigger)
screen.getByRole('button', { name: 'Local Build Running' }).focus()
rerender(view(activities.filter((activity) => activity.status !== 'running')))
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
it('uses shared native-browser occlusion only where menus intersect the viewport', () => {
render(view())
open()
const menu = screen.getByRole('menu')
vi.spyOn(menu, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 10, 264, 200))
const host = screen.getByRole('button', { name: 'Destination' })
expect(isBrowserViewportOccluded(host, new DOMRect(200, 10, 500, 500))).toBe(true)
expect(isBrowserViewportOccluded(host, new DOMRect(400, 10, 500, 500))).toBe(false)
})
})
+186 -95
View File
@@ -1,13 +1,15 @@
import { ChevronRight, CircleAlert, LoaderCircle, X } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useRef, useState } from 'react'
import { ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal, flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type { ConversationActivity } from './conversation-activity'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { AssistantProject } from '../../shared/assistant-contracts'
import './project-activity.css'
export type ProjectActivityProps = {
activities: ConversationActivity[]
projects?: readonly AssistantProject[]
visible?: boolean
onOpenConversation: (id: string) => void
}
@@ -38,99 +40,178 @@ export function ProjectActivityCounts({
)
}
function ActivityDialog({
function ActivityMenu({
activities,
projects,
anchorRef,
id,
onOpenConversation,
onClose
}: ProjectActivityProps & { onClose: () => void }): React.JSX.Element {
}: ProjectActivityProps & { anchorRef: React.RefObject<HTMLButtonElement | null>; id: string; onClose: () => void }): React.JSX.Element {
const { t } = useTranslation('workspace')
const dialogRef = useRef<HTMLElement>(null)
const titleId = useId()
const menuRef = useRef<HTMLDivElement>(null)
const projectRef = useRef<HTMLDivElement>(null)
const sessionsRef = useRef<HTMLDivElement>(null)
const [selected, setSelected] = useState<string | null>(null)
const groups = useMemo(() => {
const rows = new Map<string, { id: string; name: string; rows: ConversationActivity[] }>()
// Keep source project order within local, SSH Host and channel groups.
const ordered = new Map<string, AssistantProject[]>()
for (const project of projects ?? []) {
const key = project.kind === 'channel' ? 'channel' : project.executionSpace.kind === 'ssh'
? `ssh:${project.executionSpace.hostId}` : 'local'
const group = ordered.get(key) ?? []
group.push(project)
ordered.set(key, group)
}
const keys = [...ordered.keys()].sort((a, b) =>
(a === 'local' ? 0 : a === 'channel' ? 2 : 1) - (b === 'local' ? 0 : b === 'channel' ? 2 : 1))
for (const key of keys) for (const project of ordered.get(key)!) {
rows.set(project.id, { id: project.id, name: project.name, rows: [] })
}
for (const activity of activities) {
const key = activity.projectId ?? ''
const group = rows.get(key) ?? { id: key, name: activity.projectName, rows: [] }
group.rows.push(activity)
rows.set(key, group)
}
return [...rows.values()].filter((group) => group.rows.length).map((group) => ({
...group,
rows: group.rows.sort((a, b) => Number(a.status === 'running') - Number(b.status === 'running')),
running: group.rows.filter((row) => row.status === 'running').length
}))
}, [activities, projects])
const active = groups.find((group) => group.id === selected)
if (selected !== null && !active) setSelected(null)
const position = useEffectEvent(() => {
const menu = menuRef.current
if (!menu || !anchorRef.current) return
const rect = anchorRef.current.getBoundingClientRect()
const compact = window.innerWidth < 620
const panelWidth = Math.min(264, window.innerWidth - 32)
const baseLeft = Math.max(16, Math.min(rect.left, window.innerWidth - panelWidth - 16))
const leftward = !compact && baseLeft + panelWidth * 2 > window.innerWidth - 16 && baseLeft >= panelWidth + 16
menu.dataset.compact = String(compact)
menu.dataset.left = String(leftward)
const width = panelWidth * (active && !compact ? 2 : 1)
menu.style.width = `${width}px`
menu.style.left = `${Math.max(16, Math.min(baseLeft - (leftward && active ? panelWidth : 0), window.innerWidth - width - 16))}px`
menu.style.top = `${Math.max(16, Math.min(rect.bottom + 4, window.innerHeight - menu.offsetHeight - 16))}px`
if (compact && active && projectRef.current?.contains(document.activeElement)) {
sessionsRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
}
})
useLayoutEffect(() => { position() })
const back = (): void => {
const index = groups.findIndex((group) => group.id === selected)
flushSync(() => setSelected(null))
projectRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')[Math.max(0, index)]?.focus()
}
const enter = (key: string): void => {
flushSync(() => setSelected(key))
sessionsRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
}
const handleKeyDown = useEffectEvent((event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
onClose()
} else {
trapTabFocus(event, dialogRef.current)
if (active) back()
else onClose()
return
}
if (!menuRef.current?.contains(event.target as Node)) return
if (event.key === 'Tab') { flushSync(onClose); return }
if (event.key === 'ArrowLeft' && active) {
event.preventDefault()
back()
return
}
const projectButtons = projectRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')
const projectIndex = Array.from(projectButtons ?? []).indexOf(document.activeElement as HTMLButtonElement)
if (event.key === 'ArrowRight' && projectIndex >= 0) {
event.preventDefault()
enter(groups[projectIndex]!.id)
return
}
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const buttons = Array.from((projectIndex >= 0 ? projectRef : sessionsRef).current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1
: (index + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length
buttons[next]?.focus()
})
const dismiss = useEffectEvent(onClose)
useEffect(() => {
const releaseFocus = activateModalFocus(
() => dialogRef.current?.querySelector<HTMLButtonElement>('button') ?? null
)
const anchor = anchorRef.current
const menu = menuRef.current
const initialFocus = projectRef.current?.querySelector<HTMLButtonElement>('button') ?? menu
initialFocus?.focus()
const onKeyDown = (event: KeyboardEvent): void => handleKeyDown(event)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
releaseFocus()
const outside = (event: Event): void => {
if (!menuRef.current?.contains(event.target as Node) && !anchor?.contains(event.target as Node)) dismiss()
}
}, [])
const reposition = (): void => position()
document.addEventListener('keydown', onKeyDown, true)
document.addEventListener('pointerdown', outside)
document.addEventListener('focusin', outside)
window.addEventListener('resize', reposition)
window.addEventListener('scroll', reposition, true)
return () => {
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('pointerdown', outside)
document.removeEventListener('focusin', outside)
window.removeEventListener('resize', reposition)
window.removeEventListener('scroll', reposition, true)
if (document.activeElement === document.body || menu?.contains(document.activeElement)) {
anchor?.focus()
}
}
}, [anchorRef])
useLayoutEffect(() => {
if (document.activeElement === document.body) {
const fallback = (active ? sessionsRef : projectRef).current?.querySelector<HTMLButtonElement>('[role="menuitem"]') ?? menuRef.current
fallback?.focus()
}
}, [groups, active])
return createPortal(
<div
className="project-activity__backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}
>
<section
aria-labelledby={titleId}
aria-modal="true"
className="project-activity__dialog"
ref={dialogRef}
role="dialog"
>
<header className="project-activity__header">
<h2 id={titleId}>{t('projectActivity.title')}</h2>
<button
aria-label={t('projectActivity.close')}
className="icon-button"
onClick={onClose}
title={t('projectActivity.close')}
type="button"
>
<X aria-hidden="true" size={16} />
<div className="project-activity__menu" data-submenu={Boolean(active)} ref={menuRef} id={id}
role="menu" aria-label={t('projectActivity.title')} tabIndex={-1}>
<div className="project-activity__projects" ref={projectRef} role="presentation">
<h3>{t('projectActivity.title')}</h3>
{!groups.length && <p>{t('projectActivity.empty')}</p>}
{groups.map((group) => (
<button key={group.id} type="button" role="menuitem" tabIndex={-1}
className="project-activity__row" aria-haspopup="menu" aria-expanded={selected === group.id}
aria-controls={selected === group.id ? `${id}-sessions` : undefined}
onPointerEnter={(event) => {
if (event.pointerType === 'mouse' && menuRef.current?.dataset.compact !== 'true') setSelected(group.id)
}}
onClick={() => enter(group.id)}>
<span className="project-activity__identity"><span>{group.name}</span>{' '}
<ProjectActivityCounts running={group.running} attention={group.rows.length - group.running} />
</span><ChevronRight aria-hidden="true" size={14} />
</button>
</header>
<div className="project-activity__content">
{(['attention', 'running'] as const).map((group) => {
const rows = activities.filter((activity) =>
group === 'running' ? activity.status === 'running' : activity.status !== 'running'
)
if (!rows.length) return null
return (
<section aria-labelledby={`${titleId}-${group}`} key={group}>
<h3 id={`${titleId}-${group}`}>{t(`projectActivity.groups.${group}`)}</h3>
<ul>
{rows.map((activity) => (
<li key={activity.conversationId}>
<button
className="project-activity__row"
onClick={() => {
// Release modal isolation and restore focus before navigation owns it.
flushSync(onClose)
onOpenConversation(activity.conversationId)
}}
type="button"
>
<span className="project-activity__identity">
<small>{activity.projectName}</small>{' '}
<span>{activity.title}</span>
</span>{' '}
<span className={`project-activity__${group}`}>
{t(`projectActivity.status.${activity.status}`)}
</span>
<ChevronRight aria-hidden="true" size={14} />
</button>
</li>
))}
</ul>
</section>
)
})}
</div>
</section>
))}
</div>
{active && <div className="project-activity__sessions" ref={sessionsRef} id={`${id}-sessions`}
role="menu" aria-label={active.name}>
<button className="project-activity__back project-activity__row" type="button" onClick={back}>
<ChevronLeft aria-hidden="true" size={14} />{t('projectActivity.back')}
</button>
<h3>{active.name}</h3>
{active.rows.map((activity) => <button key={activity.conversationId} type="button" role="menuitem" tabIndex={-1}
className="project-activity__row" onClick={() => {
flushSync(onClose)
onOpenConversation(activity.conversationId)
}}>
<span className="project-activity__identity"><span>{activity.title}</span>{' '}
<small className={`project-activity__${activity.status === 'running' ? 'running' : 'attention'}`}>
{t(`projectActivity.status.${activity.status}`)}
</small>
</span>
</button>)}
</div>}
</div>,
document.body
)
@@ -138,36 +219,46 @@ function ActivityDialog({
export function ProjectActivity({
activities,
projects,
visible = true,
onOpenConversation
}: ProjectActivityProps): React.JSX.Element | null {
const { t } = useTranslation('workspace')
const [open, setOpen] = useState(false)
if (!activities.length) {
// Adjusting state during render, guarded so it cannot loop: the dialog must
// not stay queued to reopen once the activity it listed has drained.
if (open) setOpen(false)
return null
}
const running = activities.filter((activity) => activity.status === 'running').length
const triggerRef = useRef<HTMLButtonElement>(null)
const id = useId()
if (!visible && open) setOpen(false)
const running = useMemo(() => activities.filter((activity) => activity.status === 'running').length, [activities])
return (
<>
<button
aria-expanded={open}
aria-haspopup="dialog"
aria-haspopup="menu"
aria-controls={open ? id : undefined}
aria-label={`${t('projectActivity.title')}: ${activities.length ? [t('projectActivity.attentionCount', { count: activities.length - running }), t('projectActivity.runningCount', { count: running })].join(', ') : t('projectActivity.idle')}`}
ref={triggerRef}
className="project-activity__summary"
onClick={(event) => {
event.currentTarget.focus()
setOpen(true)
setOpen(!open)
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
}
}}
type="button"
>
<span>{t('projectActivity.title')}</span>{' '}
<ProjectActivityCounts attention={activities.length - running} running={running} />
<ChevronRight aria-hidden="true" size={14} />
{activities.length ? <ProjectActivityCounts attention={activities.length - running} running={running} /> : <span>{t('projectActivity.idle')}</span>}
<ChevronDown aria-hidden="true" size={14} />
</button>
{open && (
<ActivityDialog
{open && visible && (
<ActivityMenu
activities={activities}
projects={projects}
anchorRef={triggerRef}
id={id}
onClose={() => setOpen(false)}
onOpenConversation={onOpenConversation}
/>
@@ -4,6 +4,9 @@ import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
export const workspace = {
projectActivity: {
title: 'All project activity',
idle: 'No activity',
empty: 'No running conversations or conversations needing attention.',
back: 'Back to projects',
close: 'Close project activity',
unassigned: 'No project',
attentionCount: '{{count}} need attention',
@@ -1,6 +1,9 @@
export const workspace = {
projectActivity: {
title: '全项目活动',
idle: '暂无活动',
empty: '暂无运行中或待处理会话。',
back: '返回项目列表',
close: '关闭全项目活动',
unassigned: '未归属项目',
attentionCount: '{{count}} 个待处理',
+24 -44
View File
@@ -21,13 +21,14 @@
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
flex-shrink: 0;
margin-bottom: var(--space-2);
row-gap: var(--space-1);
font-size: var(--font-caption);
}
.project-activity__summary > span { grid-column: 1; }
.project-activity__summary > span:first-child { color: var(--text-secondary); }
.project-activity__summary > svg { grid-column: 2; grid-row: 1 / 3; }
.project-activity__summary > svg { grid-column: 2; grid-row: 1; }
.project-activity__summary > svg,
.project-activity__row > svg {
@@ -47,7 +48,7 @@
}
.project-activity__summary:focus-visible,
.project-activity__dialog button:focus-visible {
.project-activity__menu button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -82,58 +83,45 @@
animation: none;
}
.project-activity__backdrop {
.project-activity__menu {
position: fixed;
inset: 0;
-webkit-app-region: no-drag;
z-index: var(--z-dialog);
display: grid;
place-items: center;
padding: var(--space-4);
background: var(--overlay-backdrop);
}
.project-activity__dialog {
display: flex;
flex-direction: column;
width: min(100%, 520px);
max-height: 100%;
min-height: 0;
align-items: stretch;
max-width: calc(100vw - 32px);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
box-shadow: var(--shadow-dialog);
font-size: var(--font-body);
}
.project-activity__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
flex: 0 0 auto;
}
.project-activity__header h2 {
margin: 0;
font-size: var(--font-section-title);
}
.project-activity__content {
.project-activity__menu > div {
flex: 1;
min-width: 0;
max-height: min(480px, calc(100dvh - 32px));
overflow-y: auto;
padding: var(--space-3);
padding: var(--space-2);
}
.project-activity__content h3 {
.project-activity__menu h3,
.project-activity__menu p {
margin: var(--space-2);
color: var(--text-secondary);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.project-activity__content section + section { margin-top: var(--space-4); }
.project-activity__content ul { margin: 0; padding: 0; list-style: none; }
.project-activity__sessions { border-left: 1px solid var(--border-default); }
.project-activity__menu[data-left="true"] { flex-direction: row-reverse; }
.project-activity__menu[data-left="true"] .project-activity__sessions { border-left: 0; border-right: 1px solid var(--border-default); }
.project-activity__back { display: none; }
.project-activity__menu[data-compact="true"][data-submenu="true"] .project-activity__projects { display: none; }
.project-activity__menu[data-compact="true"] .project-activity__back { display: flex; }
.project-activity__menu[data-compact="true"] .project-activity__sessions { border: 0; }
.project-activity__row[aria-expanded="true"] { background: var(--accent-subtle); border-color: var(--accent-selected); }
.project-activity__identity {
display: grid;
@@ -144,17 +132,9 @@
}
.project-activity__identity small {
color: var(--text-secondary);
font-size: var(--font-caption);
}
.project-activity__row > .project-activity__attention,
.project-activity__row > .project-activity__running {
max-width: 40%;
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
.project-activity__summary,
.project-activity__row { transition: none; }
-1
View File
@@ -3763,7 +3763,6 @@ textarea:focus-visible {
.knowledge-confirm-dialog-backdrop,
.knowledge-library-dialog-backdrop,
.knowledge-dialog-backdrop,
.project-activity__backdrop,
.external-knowledge-modal
) {
-webkit-app-region: no-drag;
+3 -2
View File
@@ -61,10 +61,11 @@ describe('independent Agent release workflow', () => {
expect(scripts('publish')).toContain('test -s "$notes"')
expect(notes).toContain(`# GoodBuddy Agent ${agentVersion} 更新内容`)
expect(notes).toContain(`# What's New in GoodBuddy Agent ${agentVersion}`)
expect(notes).toMatch(/^## (?:功能更新|问题修复)$/mu)
expect(notes).toMatch(/^## (?:Features|Bug Fixes)$/mu)
expect(notes).toMatch(/^## (?:功能更新|问题修复|维护更新)$/mu)
expect(notes).toMatch(/^## (?:Features|Bug Fixes|Maintenance)$/mu)
expect(notes.includes('## 功能更新')).toBe(notes.includes('## Features'))
expect(notes.includes('## 问题修复')).toBe(notes.includes('## Bug Fixes'))
expect(notes.includes('## 维护更新')).toBe(notes.includes('## Maintenance'))
})
it('uses a separate immutable agent-v tag and never enters desktop packaging', () => {
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./src/renderer/src/test-setup.ts'],
exclude: [
'.agent-resources/**',
'shareserver/**',
'**/node_modules/**',
'**/dist/**',