Compare commits

...
7 Commits
Author SHA1 Message Date
mesalogo cc451f698d release: prepare Desktop 0.15.6
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 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
Include the pending sidebar footer style correction and document frosted appearance, chat rendering, image previews, settings layout, and local MCP recovery. Record schema 52 backup guidance and 886 passing focused tests; Agent remains 0.15.1.

Release note: 新增可选磨砂外观,改善聊天输入、图片预览和本地工具连接恢复,并整理设置与笔记布局。
2026-10-01 00:23:54 +08:00
mesalogo 9cc5dc1244 fix: retire uncertain MCP names so late OpenCode adds cannot replace live connections 2026-10-01 00:08:50 +08:00
mesalogo f7f501a8e3 feat: add frosted glass appearance and improve local MCP recovery
Add an opt-in persistent frosted glass setting for the top bar and runtime checklist, with chat scrolling behind the header while preserving scroll anchors and window controls.

Retry transient local MCP initialization failures once after cleanup, include redacted connection diagnostics in feedback, and index lifecycle events to reduce execution statistics scans.

Release note: Add optional frosted glass appearance with chat visible behind the title bar; improve local tool connection recovery and diagnostic feedback.
2026-09-30 23:37:26 +08:00
mesalogo e4f7b8d993 fix: group settings navigation and align switches to the right 2026-09-30 23:33:25 +08:00
mesalogo b6d24ada87 fix: preview draft and queued image attachments directly
Make draft thumbnails and queued images open the shared image viewer without parsing. Keep queued attachment menus read-only and remove OCR source and settings entries while preserving explicit text extraction in drafts.

Validation: 14 focused tests, typecheck and lint passed. Full suite: 5534 passed, 3 failures outside this change in conversation scrolling and frosted-glass descriptions.

Release note: 草稿和队列附件中的图片现在支持点击放大查看,无需 OCR;队列只读菜单不再显示解析来源和解析设置。
2026-09-30 23:18:45 +08:00
mesalogo c23dca65e8 fix: reduce chat typing and cached conversation switching overhead
Keep unchanged history panes and task strips from rerendering during typing, and stabilize message callbacks across cached conversation switches. Preserve streaming updates, scroll positions, drafts, and asynchronous attachment ownership.

Add App regressions for render isolation, warm switches, retry, and image recovery. Focused tests, typecheck, and lint passed; the full suite encountered unrelated settings failures.

Release note: 减少任务运行时输入和切换已缓存长会话的重复渲染,保留实时输出、滚动位置和会话草稿。
2026-09-30 22:59:25 +08:00
mesalogo bf64181c85 fix: align magic note headers and editor spacing
Align the notes list, detail toolbar and comment direction row, removing stacked top padding. Let text editing controls fill the record card while preserving text and action padding and canvas spacing.

Add Electron geometry regressions and document the spacing rules. Release note: Magic Notes now aligns pane headers and removes excess gaps around text editing toolbars and input areas.
2026-09-30 22:31:30 +08:00
59 changed files with 2605 additions and 481 deletions
+11 -2
View File
@@ -333,7 +333,7 @@ records are listed separately and do not introduce another feature status.
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.15.1`, paired with the current Desktop release candidate `0.15.5`; formal
`0.15.1`, paired with the current Desktop release candidate `0.15.6`; 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
@@ -951,9 +951,18 @@ records are listed separately and do not introduce another feature status.
### Open source, builds, and releases
- The current Desktop candidate is `0.15.5`; published Agent `0.15.1` is unchanged, with
- The current Desktop candidate is `0.15.6`; published Agent `0.15.1` is unchanged, with
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
Desktop and Agent release channels.
- Desktop `0.15.6` adds optional, persisted frosted-glass header/checklist surfaces
(off by default, without OS desktop transparency), reduces repeated chat renders,
and supports direct previews for draft/queued images. Settings are grouped with
right-aligned switches, and note headers/editor spacing are tighter.
Local OpenCode retries explicit transient MCP transport failures once after
cleanup and retires uncertain names; it does not replay prompts or tool calls.
Redacted MCP diagnostics support feedback. Schema 52 adds execution-statistics
indexes; back up the database and notes before upgrading.
See the [preparation record](./docs/development/release-preparation-0.15.6.md).
- Desktop `0.15.5` updates Smart heartbeat and incremental review, preserves
cancelled-turn history, floats checklist details, and exits note editing after
saving. It also adds manual note tags with AND filtering and rename/merge/delete,
+3 -2
View File
@@ -52,7 +52,7 @@
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 限于工作区只读搜索和读取,Execute 按当前账号权限搜索;用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的搜索、命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.1`,配套 Desktop 候选为 `0.15.5`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.1`,配套 Desktop 候选为 `0.15.6`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
- **SSH 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。
@@ -156,7 +156,8 @@
### 开源、构建与发布
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
- 当前 Desktop 候选为 `0.15.5`,已发布 Agent 保持 `0.15.1`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- 当前 Desktop 候选为 `0.15.6`,已发布 Agent 保持 `0.15.1`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- Desktop `0.15.6` 增加默认关闭、可保存的顶栏与清单磨砂外观,不透出系统桌面;减少聊天重复渲染,草稿与队列图片支持直接预览,设置分组及开关右对齐,笔记标题和编辑间距更紧凑。本地 OpenCode 在清理后对明确暂时性 MCP 传输错误最多重试一次,作废结果不确定的名称,不重放 Prompt 或工具调用;反馈包含脱敏连接诊断。schema 52 增加执行统计索引,升级前备份数据库和笔记。详见[发布准备](./docs/development/release-preparation-0.15.6.md)。
- Desktop `0.15.5` 更新智能心跳与增量回顾,保留中断轮次历史,执行清单改为浮层,笔记保存成功后退出编辑;自动评论不重新打开编辑器,失败保留草稿。新增手动笔记标签、AND 筛选及重命名/合并/删除,双栏笔记与全宽概览,固定工作区文件操作栏,缓存 OpenCode 终端依赖并缩短 MCP 名称。schema 51 增加标签存储,同时迁移旧待确认记忆为待处理约定;升级前备份数据库与相邻笔记。证据见[发布准备](./docs/development/release-preparation-0.15.5.md)。
- Desktop `0.15.4` 增加 Runtime/模型分层选择及独立系统任务用量,减少 Runtime 指令和历史重复。schema 49 迁移项目与会话选择,回退须恢复完整升级前数据库和相邻笔记备份。Agent `0.15.1` 要求 Desktop `0.15.4`,修复远程 Continue 历史重复封装,Runtime 锁版本不变。证据见[0.15.4 发布准备](./docs/development/release-preparation-0.15.4.md)。
- Desktop `0.15.1` 已完成六平台原生打包及 GitHub/OSS 发布。Desktop `0.15.2` 新增 Story Graph 只读工具和完整可编辑的监督讨论消息,简化笔记采集并补充退出诊断;远程图谱读取需配套 Agent `0.15.0`。主机与云环境管理应用仍为待实现规划,当前候选证据见[0.15.2 发布准备](./docs/development/release-preparation-0.15.2.md)。
+11 -2
View File
@@ -43,6 +43,10 @@
两种主题必须保持相同的信息层级,但不要求机械地反转明暗。浅色主题以白色主内容画布、冰蓝灰侧栏和轻微着色的顶栏建立空间关系;深色主题使用深海军蓝与蓝灰表面逐层提亮,避免纯黑。蓝色承担主要选择和交互,青绿色主要承担成功与可用状态,二者不得混用语义。
外观设置提供“透明磨砂特效”开关,当前默认关闭,文字在左、共享 Switch 在右。选择保存在应用设置中,保存成功后立即生效,重启后恢复。开启时,右侧内容框架顶栏的背景不透明度在浅色主题为 65%、深色主题为 70%,保留 18px 背景模糊与淡分隔线。聊天滚动视口向上延伸 58px,消息上滚时实际经过顶栏背后;不使用人工渐变充当背景。初始留白放在滚动内容中,包含顶栏高度和当前任务/待办条的实测高度,首条消息仍从这些控件下方开始。任务/待办条固定在顶栏下方并保持实色;展开的 Runtime 待办卡片使用两主题各自的 75% 不透明背景、18px 背景模糊和清晰的文字颜色。
已保存的开关状态同步到文档根节点,卸载时清理,使 body/top layer 中的待办卡片也能应用样式;其他菜单保持原有表面。输入区、右侧工作栏和非聊天页面保持原有位置,聊天加载态及无活动消息面板时不扩展视口。窗口拖拽和控制按钮保持原有行为;该效果不透出操作系统桌面。关闭后恢复顶栏下方的聊天布局,顶栏与待办卡片均恢复实色。`tests/frosted-glass.electron.test.ts` 使用真实 `ChatHistoryPane` 检查消息与顶栏的可见矩形交叠、初始留白、关闭恢复、浅深主题、跳底、历史加载锚点和会话切换。
## 3. 设计令牌
令牌以 CSS 自定义属性实现。`:root` 提供浅色值,`[data-theme="dark"]` 覆盖深色值。组件样式不得新增只服务于单个页面的颜色、阴影、圆角或间距常量。
@@ -488,6 +492,7 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开
- Checkbox 保留原生 Checkbox 语义和方形勾选视觉,不得添加 `role="switch"`。多项分配、列表选择、确认声明和“保存时清除密钥”等一次性选择均属于 Checkbox。
- 不创建页面专属 Switch 样式。需要紧凑布局时仍复用同一轨道、滑块、焦点环、禁用状态和动效,只调整共享组件支持的布局变体。
- Switch 支持 Tab 聚焦和 Space 切换,键盘焦点至少显示 `2px` 高对比焦点环。可见标签应描述被控制的能力,不能只显示“开 / 关”。
- 共享开关行统一为标签在左、Switch 靠右,说明放在标签下方;不因 JSX 中输入控件与文字的先后顺序改变视觉位置。窄窗口允许文字换行,开关保持固定尺寸。Checkbox 和 Radio 保留各自的选择布局。
- 异步切换期间禁用重复操作并保留原状态。失败时恢复或保留最后确认状态,通过应用通知或就地可恢复错误说明原因。
- 涉及联网、上传、电脑控制或其他外部影响的 Switch,附近必须持续说明数据去向、权限范围或风险,不能只靠设置名称表达影响。
- 自动化测试应按 `switch` 角色查询二元开关,按 `checkbox` 角色查询多选或确认项,防止视觉迁移后语义回退。
@@ -797,6 +802,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 待办 AI 评论保留方向选择、流式输出、重新分析和已有结果;来源加载失败通过应用通知提示,并提供就地重试。从待办打开原笔记后,“返回待办”恢复任务选择、搜索、筛选和列表滚动;宽屏焦点返回任务行,窄屏返回待办详情中的返回按钮。
- 笔记详情主区按新到旧连续展示全部记录。打开笔记即在标题下、历史记录上显示空白输入区,保留文字/画布类型选择和保存操作,标题栏与索引均不提供“新增记录”按钮。新增保存成功后重建空白输入区,保留所选类型;自动分析结束也不转入旧记录编辑。旧记录通过显式编辑入口修改,保存使用同一 ID 和返回的修订号。点击索引或当前笔记内的 AI 来源只按 entry ID 滚动定位,不卸载编辑器、不清草稿、不询问离开。空白输入区离开不提示,返回总览、打开其他笔记或改为编辑另一条记录时仍保护未保存修改。
- 打开笔记后页面一级标题仍为“魔法笔记”;笔记标题只在记录流顶部出现一次,使用 `22px` 的可编辑大标题,悬停显示下划线,聚焦显示强调色下划线,保留失焦保存、Enter 与 Escape。标题上方为吸顶的细工具栏,依次放置收起笔记列表、记录索引、条数与更新时间、AI 评论开关,均为带工具提示和可访问名称的图标按钮。记录流左右留白 `24px`(窄于 `600px` 时 `12px`),正文最大宽度 `880px` 并居中;吸顶工具栏不受此限制,始终铺满记录流宽度,AI 开关贴在右边缘,底部分隔线横贯整栏。
- 桌面笔记详情的列表标题行、正文工具栏与 AI 评论方向标签行顶部对齐,首行统一使用 `40px` 控件高度并垂直居中;AI 区域不叠加面板和表单的顶部留白。
- 文字记录的新建与编辑区域铺满卡片内沿,格式工具栏紧接头部,输入区域紧接工具栏;编辑器外层不再增加内边距或圆角。正文保留编辑器内部的文字留白,底部操作栏独立保留内边距与顶部分隔线;画布仍保留 `12px` 内容间距。
- 新建记录输入区空闲时收成一行虚线提示框,隐藏类型切换、格式工具栏和保存栏;聚焦、已有草稿、选择画布或出现校验错误时展开,初始约四行并随内容增高。保存成功或切换笔记后恢复为一行。此规则不作用于旧记录编辑器。
- 已保存记录的 AI 分析、编辑、删除为 `28px` 图标按钮,精细指针设备上悬停或键盘聚焦记录时才显示;删除悬停时使用危险色,确认条保持原样。粗指针设备始终显示。笔记内部列表、记录流、AI 区域和文字编辑区在精细指针设备上使用细原生滚动条,轨道透明;强制颜色与粗指针设备保留系统默认。说明、摘要和输入提示使用 `--font-body` 与 `--text-secondary`,时间和短标签保留 `--font-caption`。
- 左侧记录索引默认收起,只在用户展开后记住展开状态;展开宽度默认 `168px`,可拖动右边界在 `140–320px` 内调整栏宽,缩略图随列宽等比适配。开关位于笔记工具栏,提供 `aria-controls`、展开状态和动作提示;收起后完全移除索引列、分隔条、间隙、边框及轨道,不占宽度,隐藏内容不可聚焦。右侧 AI 评论独立显示、滚动和收起,不与索引共享页签。每条记录一个导航项:画布显示真实首页缩略图及总页数,文字显示摘要,两者均显示类型和时间。两侧分别记住桌面展开状态和宽度,共用原有布局偏好;宽布局分隔条共用样式及 `separator` 语义,方向键每次调整 `16px`,Home、End 跳到当前上下限。两侧宽度按可用空间协调限制,为主区保留至少 `300px`,窗口缩小时不覆盖保存的偏好。内容容器不超过 `800px` 时索引默认隐藏,由同一标题栏按钮开闭标题栏下方固定 `168px` 的抽屉,不显示调宽分隔条;标题栏自身保留控件高度,不保留轨道。选择记录或按 Escape 后关闭,Escape 将焦点返回开关。AI 移至底部,高度不超过可用布局的 40% 且最多 `280px`,不显示分隔条。
@@ -823,10 +830,12 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 设置中心以整窗遮罩上的大 Modal 打开,保留底层工作区、会话草稿和助手工作栏,不切换一级页面。固定标题区和右上角关闭按钮,左侧分类与右侧内容各自滚动。关闭后返回原工作区并恢复入口焦点。
- 新建项目与项目设置弹窗固定标题、关闭按钮和底部操作区,仅表单内容滚动;滚动条不进入标题区,关闭按钮保留至少 40 × 40px 的点击区域。
- 项目弹窗通过 Portal 挂载到 `document.body`,避免侧栏内的弹窗与主窗口标题栏拖动区域重叠时,Electron 截获关闭按钮的鼠标输入。
- Modal 最大尺寸为 `1200px × 900px`,常规外边距为 `24px`;窗口宽度不超过 `1020px` 或高度不超过 `720px` 时接近全屏,保留 `16px` 外边距。左侧分类宽度依次为 `220px`、`180px`(宽度不超过 `1020px`)、`120px`(不超过 `720px`);极窄窗口隐藏分类辅助说明,仍保留左右布局。内容区在可用空间内伸缩,最大宽度为 `960px`。
- Modal 最大尺寸为 `1200px × 900px`,常规外边距为 `24px`;窗口宽度不超过 `1020px` 或高度不超过 `720px` 时接近全屏,保留 `16px` 外边距。左侧分类宽度依次为 `220px`、`180px`(宽度不超过 `1020px`)、`120px`(不超过 `720px`);窄窗口仍保留左右布局,长分类名称可换行。内容区在可用空间内伸缩,最大宽度为 `960px`。
- 设置 Modal 通过 Portal 挂载到 `document.body`,复用共享背景隔离与焦点恢复机制。打开后聚焦关闭按钮,Tab 在弹窗内循环;Escape 先交由嵌套弹窗处理,设置自身的 Escape 与关闭按钮共用未保存确认,确认出现后再按 Escape 返回编辑。点击遮罩不关闭;保存、测试和清理数据期间禁止关闭。每次打开重新挂载设置,模型和消息通道等直达入口使用本次指定分类,普通入口默认打开 Agent Runtime。
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与右侧标题来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
- 左侧按“常规 / 模型与执行 / 功能与扩展 / 系统”分组,顺序与键盘导航一致。分类按钮最小高度 `40px`,只显示名称;分类说明集中在右侧页头,外层标题不再重复功能简介。组名使用辅助字号,普通项使用正文和中等字重,选中项使用强调底色与加粗文字。
- 设置区块使用共享圆角、间距和表面令牌;标题为 `--font-section-title`,持久说明至少为 `--font-caption`。嵌套设置区块以顶部细分隔线和留白组织,不叠加卡片背景与四周边框。“能力与工具”的上级页签使用默认 `PageTabs` 外观,与 MCP 内部的分段页签区分。
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
- 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供
@@ -0,0 +1,33 @@
# Desktop 0.15.6 发布准备
日期:2026-10-01。用户授权提交当前未提交改动并继续双远端推送与发布,
如 Agent 实际受影响可升补丁版本;另明确本轮仅标准六平台,不含 LoongArch。
## 候选范围
基线 `v0.15.5` 为 `3be55348a215f4343326b1950a69529247cbae6a`,
六个新增提交截止 `9cc5dc124464a67ed93014c53cbc93f96764caa2`。
初始未提交改动仅 `application-center.css` 一行选择器:
侧栏底部仅对第二个直接子元素文字 span 禁止伸缩,避免影响其他 span。
已核对并纳入本轮,未包含凭据、日志或临时产物。
新增内容包括磨砂外观、输入/缓存会话切换渲染优化、直接图片预览、设置与笔记布局、
本地 MCP 恢复及不确定名称退役、诊断和执行统计索引。
双语说明和功能矩阵按实现更新,不将已有开发记录中的全量失败记为成功。
Agent 保持 0.15.1:相对其发布标签,daemon 与锁文件无变化。
本地 OpenCode 的 HTTP 注册恢复属于 `OpenCodeRuntime`,远端使用
`RuntimeAcpBackend`/`AgentImageToolMcp`,不经过这条 HTTP 注册路径。
没有据此声称远程恢复得到同样改进,也没有新发布 Agent 包。
## 数据与验证
已读数据库迁移指南。schema 52 在事务内增加任务事件部分索引,不转换消息正文,
不执行 VACUUM;建立索引的耗时受历史规模影响,本轮未测真实大库升级。
升级前关闭应用并备份数据库和相邻 `notes/`,降级需要完整升级前备份。
- 当前源码 17 个定向文件、886 项通过,包含真实 OpenCode + HTTP 网关合成服务的
MCP 故障恢复和晚到初始化回归,数据库/统计、诊断、设置、App、图片附件和真实
Electron 磨砂、笔记布局、工作栏设置布局。
- `npm run typecheck`、`npm run lint` 通过;本轮新增付费模型调用 0 次。
- 完整测试和生产构建等待精确候选 CI,不运行本地生产构建、打包或安装包探针。
@@ -1,5 +1,42 @@
# 工作栏实现与验证进度
## 2026-10-01:本地 MCP 晚到初始化覆盖新连接
根因修复见[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
已核对随包 OpenCode 1.18.29:`MCP.add` 的初始化不随客户端取消而中止,完成后写入同名状态,
并关闭该名称下已有的连接;`disconnect` 只处理当时已登记的客户端。旧实现在 `mcp.add`
超时或取消后仍复用同一名称,前一轮的晚到初始化(授权已撤销,结果为 `failed`)会覆盖下一轮
刚建立的连接。现在响应不确定的名称作废,后续改用唯一替代名称。
验证:新增真实 OpenCode+实际网关用例,通过本地代理挂起第一次 MCP `initialize`,
使第一轮在 3 秒控制超时后结束,第二轮建立连接后再放行挂起的初始化。修复前该用例
复现为第二轮连接状态从 `connected` 变为 `failed`;修复后第二轮连接保持 `connected`
且请求完成。`opencode-runtime`、`opencode-runtime-lifecycle`、`knowledge-mcp-gateway`
三个测试文件 180 项通过;`npm run typecheck` 通过;相关文件 ESLint 通过。模型为本地合成
服务,外部模型调用 0 次。9 月 30 日那次失败的原始时序没有底层日志,未确认就是这一竞态;
本次修复的是已复现的同类失败路径。尚未发布或替换已安装应用。
## 2026-09-30:本地 MCP 连接诊断与恢复
已实现[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
常驻 HTTP 网关和请求级授权保留;内置及自定义 MCP 对明确的暂时性传输故障最多重试一次,
必须先成功清理失败连接。SDK 返回错误和抛出错误均有覆盖,未知失败、HTTP/API 拒绝和
鉴权错误不重试。界面保留脱敏错误详情,两端失败诊断通过令牌摘要关联。
网关初始化异常也会回收未完成会话。
最终定向验证:`npx vitest run` 指定 `opencode-runtime`、`opencode-runtime-lifecycle`、
`opencode-runtime-permissions`、`knowledge-mcp-gateway`、`knowledge-mcp-gateway.obsidian`、
`create-runtime`、`desktop-diagnostics`、`feedback-service` 八个测试文件,222 项全部通过。
`npm run typecheck` 和 `npm run lint` 通过。真实 OpenCode 与实际网关的恢复用例在连接成功后
注入响应丢失,验证断开先于重连、授权不变、请求完成和网关继续监听;该用例每轮产生一次
本地合成模型请求,外部模型调用为 0。另有真实 HTTP 初始化连续失败后的会话回收测试。
全量 `npm test` 在 600 秒后超时,未取得汇总;超时前报告 `SettingsPanel.test.tsx` 中
中英文磨砂玻璃开关两个用例失败。这些文件存在并行修改,未判定失败归因,全量验证不记为通过。
本次未发布或替换已安装应用。远程 Agent 的 ACP 和 Agent MCP 连接路径未修改,
不将本地测试结果作为远程恢复验证。
## 2026-09-30:OpenCode 终端启动准备
原先每次“在终端中打开” OpenCode 都会把随包插件依赖目录(约 3,600 个文件、52 MB)
@@ -177,6 +177,29 @@ SelectedRuntimeManager
健康检查也读取 owner 的总 activeRequests,不能从一个空闲 adapter 判断整个 Server
无任务后重启。异常退出使该 owner 的全部绑定失效,明确结算受影响请求,不重放已执行工具。
### 4.1 本地 MCP 初始化失败恢复
桌面 HTTP 工具网关保持监听,MCP 注册和授权仍按请求创建、清理。本地 OpenCode 的内置
工具和自定义 MCP 共用连接逻辑:仅对明确的连接拒绝、连接重置、断管或传输超时,在
一秒内成功断开失败连接后重试一次。SDK 返回的无 HTTP response 网络错误和抛出的网络
错误采用同一判定;HTTP/API 错误、鉴权或协议错误、未知失败不自动重试。
取消立即终止后续尝试。Prompt 和工具调用不重放。
OpenCode 1.18.x 的 `mcp.add` 在服务端独立执行:客户端超时或取消不会中止初始化,
`disconnect` 也只清理当时已登记的连接。晚到的初始化完成后会按名称写入状态,并关闭同名
的现有连接。因此,`mcp.add` 未得到 HTTP 响应(抛错、超时、取消或无 response 的网络错误),
或断开未确认成功时,该名称即作废;同一槽位之后改用本客户端内唯一的替代名称
(前缀加 8 位 base-36 序号,仍为 12 个字符)。槽位随请求结束释放,不因断开失败而长期占用。
已得到响应的正常失败继续复用原名称。
界面显示有界、脱敏的失败详情。`runtime.mcp.failed` 诊断记录连接或断开阶段、失败类别、
耗时、尝试次数、内置或自定义类型及可用的 HTTP 状态;通过本轮令牌的 SHA-256 摘要与
网关鉴权、初始化和工具发现失败记录关联。日志不保存令牌、地址、请求正文或原始错误。
网关初始化抛错时也清理未完成会话,防止失败会话累积。
此恢复逻辑属于本地 `OpenCodeRuntime`。远程 Agent 使用 `RuntimeAcpBackend` 和
`AgentImageToolMcp`,不经过本地 MCP 注册或网关 HTTP 处理;本节不代表远程恢复能力。
## 5. DeepSeek Harness
### 5.1 共享 Host 的最小改法
@@ -51,6 +51,10 @@
菜单项包含附件名称以区分多个文件。普通图片的“提取图片文字”可作为就近次操作,不能和
菜单形成两套状态或重复执行。菜单复用 `AnchoredMenu` 的定位、键盘和焦点恢复。
草稿与队列中的图片缩略图可点击进入共享图片查看器,优先显示已保存的完整图片,只有
缩略图时使用缩略图预览;查看不触发 OCR。队列附件菜单只提供打开原文件,不读取 OCR
来源或显示解析设置;修改发送方式须先恢复到草稿。解析结果入口仅在已有结果时显示。
草稿、历史消息和队列预览中的“查看解析结果”“更多附件操作”使用共享 `icon-button`
视觉,图标分别为文档查阅和省略号,点击区域为 `32px × 32px`,在独立操作行内排列。
按钮提供含文件名的可访问名称、悬停提示及键盘焦点提示;草稿移除和导入取消使用同尺寸
@@ -129,7 +129,10 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平
> 以及你主动添加的截图。默认不上传桌面诊断;只有勾选后才会把有界诊断摘要追加到描述。
> 不会发送对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。
Checkbox 下方持续说明:诊断摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息。
Checkbox 下方持续说明:诊断摘要含时间、组件、阶段、稳定错误码、错误类型和固定短消息,
以及工具连接失败的类别、耗时、HTTP 状态、尝试次数、工具类型和匿名关联标识。
MCP 附加字段由 `normalizeDesktopDiagnosticRecord` 在写入、读取和反馈导出时按白名单
校验;关联标识为请求令牌的 SHA-256 摘要,不含原始令牌、地址或错误正文。
Renderer 不预览、不读取也不接收诊断内容或路径。勾选后详细描述上限为 3,398 字符,为附加
摘要预留 1,600 字符以及分隔换行;超长草稿保留在表单中并显示字段错误,不静默截断。
+8
View File
@@ -293,6 +293,14 @@ Modal,兼顾 top layer 绘制、辅助技术的模态归属及焦点循环;
**注意**:React 允许在渲染期用守卫调用 `setState` 来响应 props 变化,这是官方推荐做法,不要
把它改写成 `useEffect`。仓库 lint 规则 `react-hooks/set-state-in-effect` 会拒绝后者。
2026-09-30 的 App 回归还确认两类关联开销:内联 JSX、回调和空数组使 memo 失效;
`useRef(hasConversationMigrationStorage())` 在每次输入时执行存储读取。初始化读取应延迟到
首次初始化,缓存面板的 props 应保持稳定。即时用户动作可在调用时读取当前会话;异步
草稿恢复、语音和附件操作须保留发起会话,不能统一改成完成时读取活动会话。
App 集成回归应计数 memo 边界内的真实渲染,覆盖输入、热切换和后续流式更新;仅测独立
消息组件无法发现父组件传入的不稳定 props。测量结果见
[PERF-10](../roadmap/product-performance-experience-improvement-plan.md#perf-10-keep-alive-轻量优化)。
## 解析警告被当作提取正文
**症状**:OCR 没有识别文字,但缺图警告让图片附件切成文字发送,或让仅图片结果被判为
@@ -372,14 +372,24 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
### PERF-10 Keep-alive 轻量优化
- **优先级 / 状态:** P2 / 待开始
- **实施条件:** `PERF-09` 确认当前缓存不达标。
- **优先级 / 状态:** P2 / 已完成输入与热切换渲染隔离,压力测试待完成
- **实施依据:** 2026-09-30 的真实 App 组件回归确认输入和热切换会重复渲染缓存内容;
`PERF-09` 的 Electron 内存与 GC 压力测试仍待执行。
- **范围:**
- 优先 memoize 隐藏 `ChatHistoryPane` 并稳定 props。
- `ChatHistoryPane`、任务条和 Runtime 清单使用 memo,稳定快捷输入、重试、图片恢复、
定时任务回调及任务条 JSX。异步草稿操作仍绑定发起时的会话。
- 仍不达标时才调整缓存数量或一小时有效期。
- 暂不引入完整虚拟化框架。
- **验收:** 内存和 commit 指标改善,同时保留会话切换位置和快速恢复体验。
`App.test.tsx` 修复前回归记录:运行请求期间连续输入三次,历史面板、任务条、清单各
渲染三次,会话迁移存储读取三次;两个已缓存会话往返一次,Markdown 渲染 324 次。
长会话已展开 160 条消息,另一会话含一条消息,计数包含展开的推理正文。修复后上述
计数均为零。最终用例还覆盖可见且展开的运行中任务条、实时文本与清单更新、停止、
会话草稿、图片素材选择期间切换会话,以及滚动位置和推理展开状态保留。
这些是 jsdom 中真实 App 的渲染次数,使用 mock preload;尚未测量 Electron 输入延迟、
绘制耗时或内存。流式事件未延后,缓存容量和有效期未改变。
## 8. 体验与可访问性
### UX-01 统一 Ask/Execute 文案
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.15.5",
"version": "0.15.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.15.5",
"version": "0.15.6",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.15.5",
"version": "0.15.6",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+40
View File
@@ -1,6 +1,46 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.15.6",
"releasedAt": "2026-10-01",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**可选的透明磨砂外观**:在外观设置中开启“透明磨砂特效”,聊天内容可滚动经过顶栏背后,执行清单浮层也使用磨砂背景。默认关闭,选择会保存并在重启后恢复;效果只作用于应用内部,不透出操作系统桌面。"
],
"fixes": [
"**改善本地 OpenCode 工具连接恢复**:明确的暂时性 MCP 传输故障会在清理成功后最多重试一次,超时或取消后结果不确定的连接名称不再复用,避免晚到初始化覆盖新连接。鉴权、协议和未知错误不会自动重试,Prompt 与工具调用不会重放;该修正不代表远程 Agent 新增恢复能力。",
"**减少输入和切换会话时的重复处理**:输入时未变化的消息历史及任务条不再反复渲染,切换已缓存会话时复用稳定的消息回调。实时输出、滚动位置、草稿和异步附件归属保持不变,执行统计索引也减少了轮询时对事件记录的扫描。",
"**草稿和队列图片可直接放大预览**:点击图片缩略图即可打开共享查看器,无需先执行 OCR。队列附件保持只读,不再展示解析来源和设置入口;草稿仍可显式提取文字。",
"**整理设置导航与笔记间距**:设置按分组排列,持久开关统一右对齐,侧栏底部文字布局一并修正。笔记列表、详情和评论方向栏的标题对齐,文字编辑工具栏与输入区域减少多余留白,保留画布间距。",
"**补充本地 MCP 故障诊断**:反馈诊断可包含连接阶段、错误类别及关联摘要,帮助定位初始化和清理问题。诊断不记录令牌、服务地址、请求正文或原始错误内容。"
],
"notices": [
"首次启动升级到 schema 52,为任务事件增加执行统计索引;历史较多时建立索引可能需要更长时间。升级前请关闭应用并备份完整数据库及相邻 `notes/` 目录;旧客户端不能打开升级后的数据库,降级需恢复升级前备份。",
"本轮只更新 Desktop,Agent 保持 **0.15.1**,无需更新 Agent 包。仅提供标准六平台产物,不包含新的 LoongArch 预览。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Optional frosted-glass appearance**: Enable the effect in Appearance to let chat content scroll behind the header and give execution-checklist panels a frosted background. It is off by default and your choice persists across restarts; the effect is internal to the app and does not reveal the operating-system desktop."
],
"fixes": [
"**Improve local OpenCode tool connection recovery**: Explicit transient MCP transport failures receive at most one retry after successful cleanup, and names with uncertain timeout or cancellation outcomes are retired so late initialization cannot replace a live connection. Authentication, protocol, and unknown errors are not retried, and prompts or tool calls are not replayed; this is not a new remote Agent recovery capability.",
"**Reduce repeated work while typing and switching conversations**: Unchanged message history and task strips avoid repeated renders during typing, with stable callbacks reused across cached conversation switches. Streaming, scroll positions, drafts, and asynchronous attachment ownership remain intact, while execution-statistics indexes reduce event scans during polling.",
"**Preview draft and queued images directly**: Click an image thumbnail to open the shared viewer without running OCR first. Queued attachments remain read-only and no longer show parsing-source or settings entries; drafts retain explicit text extraction.",
"**Organize settings and tighten note spacing**: Settings navigation is grouped, persistent switches align to the right, and sidebar footer text layout is corrected. Note-list, detail, and comment-direction headers align, with excess gaps removed around text editing controls while preserving canvas spacing.",
"**Add local MCP failure diagnostics**: Feedback diagnostics can include connection phases, error categories, and correlation digests to help identify initialization and cleanup failures. They do not record tokens, service addresses, request bodies, or raw error content."
],
"notices": [
"First launch upgrades to schema 52 and adds task-event indexes for execution statistics; building indexes may take longer with large histories. Close the app and back up the complete database and adjacent `notes/` directory first; older clients cannot open the upgraded database, so downgrading requires a pre-upgrade backup.",
"This is a Desktop-only update; Agent remains **0.15.1**, with no Agent package update required. Only the six standard targets are released, with no new LoongArch preview."
]
}
}
},
{
"version": "0.15.5",
"releasedAt": "2026-09-30",
+10
View File
@@ -73,6 +73,16 @@ function settings(
}
describe('createAgentRuntime model compatibility', () => {
it('passes the desktop failure observer to local OpenCode', async () => {
const observeFailure = vi.fn()
const runtime = createAgentRuntime(process.cwd(), settings({ provider: 'opencode' }), { observeFailure })
try {
expect(runtime).toMatchObject({ options: { observeFailure } })
} finally {
await runtime.dispose()
}
})
it('does not treat the default model profile as the platform DeepSeek source', () => {
const defaultProfile = {
id: '00000000-0000-4000-8000-000000000001',
+3
View File
@@ -44,6 +44,7 @@ import {
type ExecutionSpaceDescriptor
} from '../execution-space'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
import type { DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
const noModelTaskTools: ModelToolProviderLike = {
listTools: async () => [],
@@ -58,6 +59,7 @@ const noModelTaskTools: ModelToolProviderLike = {
}
export type AgentCapabilityContext = {
observeFailure?: DesktopDiagnosticFailureObserver
localRuntimeRegistry?: LocalRuntimeRegistry
skillInstructions?: string
skillPackages?: RuntimeSkillPackage[]
@@ -306,6 +308,7 @@ export function createAgentRuntime(
)
}
const options: OpenCodeRuntimeOptions = {
observeFailure: capabilities.observeFailure,
baseUrl,
embedded,
binaryPath:
@@ -24,6 +24,8 @@ import {
} from './knowledge-mcp-gateway'
import { browserTabIdSchema } from '../../shared/contracts'
import { BrowserService } from '../browser/browser-service'
import { createHash } from 'node:crypto'
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
@@ -178,6 +180,90 @@ function testTool(name: string): Tool {
const gateways: KnowledgeMcpGateway[] = []
it('records safe HTTP rejection phases and isolates throwing observers', async () => {
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error('observer failed') })
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal)!
const correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
const cases = [
{ headers: {}, body: '{}', status: 401, phase: 'authentication' },
{ headers: { authorization: `Bearer ${token}` }, body: 'private-invalid-json', status: 400, phase: 'request' },
{ headers: { authorization: `Bearer ${token}`, 'mcp-session-id': 'private-session' }, body: '{}', status: 404, phase: 'session' },
{ headers: { authorization: `Bearer ${token}` }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } }), status: 406, phase: 'initialize' }
]
for (const entry of cases) {
const response = await fetch(gateway.getEndpoint()!, { method: 'POST', headers: { 'content-type': 'application/json', ...entry.headers }, body: entry.body })
await response.text()
expect(response.status).toBe(entry.status)
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({
code: 'runtime.mcp.failed', mcp: expect.objectContaining({ phase: entry.phase, category: 'http-rejection', status: entry.status, elapsedMs: expect.any(Number) })
}))
}
expect(observeFailure.mock.calls.at(-1)?.[0]).toMatchObject({ mcp: { correlationId } })
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain(token)
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain('private-')
})
it('cleans up failed HTTP initialization and allows a subsequent connection', async () => {
const observeFailure = vi.fn()
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('request', [firstLibraryId], new AbortController().signal)!
const connect = vi.spyOn(McpProtocolServer.prototype, 'connect').mockRejectedValue(new Error('private-connect-error'))
const close = vi.spyOn(McpProtocolServer.prototype, 'close')
for (let index = 0; index < 9; index += 1) {
const response = await fetch(gateway.getEndpoint()!, {
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: index, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } })
})
await response.text()
expect(response.status).toBe(500)
}
expect(close).toHaveBeenCalledTimes(9)
expect(observeFailure).toHaveBeenCalledTimes(9)
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({ mcp: expect.objectContaining({ phase: 'initialize', category: 'handler-failure', status: 500 }) }))
connect.mockRestore()
const client = new Client({ name: 'test', version: '1' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
await client.listTools()
await client.callTool({ name: 'knowledge_list', arguments: {} })
expect(observeFailure).toHaveBeenCalledTimes(9)
} finally { await client.close() }
})
it('persists asynchronous HTTP tool discovery failures without private error details', async () => {
const directory = await mkdtemp(join(tmpdir(), 'gateway-diagnostics-'))
temporaryDirectories.push(directory)
const diagnostics = new DesktopDiagnostics(directory)
const pending: Promise<void>[] = []
let clock = 100
vi.spyOn(performance, 'now').mockImplementation(() => clock)
const gateway = new KnowledgeMcpGateway(createService().service, {
observeFailure: failure => { pending.push(diagnostics.recordFailure(failure)) },
storyGraphService: { available: async () => { clock = 145; throw new Error('private-url-token-body') }, read: () => ({}) }
})
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal, 'none', undefined, undefined, undefined, undefined, undefined, { runtimeTarget: 'opencode' })!
const client = new Client({ name: 'test', version: '1' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
await expect(client.listTools()).rejects.toThrow()
await Promise.all(pending)
const exported = (await diagnostics.exportRecent()).toString('utf8')
expect(JSON.parse(exported)).toMatchObject({ code: 'runtime.mcp.failed', mcp: { phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 45 } })
expect(exported).not.toContain('private-')
expect(exported).not.toContain(token)
} finally {
await client.close()
await diagnostics.dispose()
}
})
it('honors a requested knowledge result count above eight', async () => {
const { service, retrieveMany } = createService()
const [sample] = await retrieveMany([firstLibraryId])
+126 -71
View File
@@ -1,4 +1,5 @@
import { randomBytes } from 'node:crypto'
import { createHash, randomBytes } from 'node:crypto'
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from '../desktop-diagnostics'
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
import type { RuntimeTarget } from '../../shared/capability-contracts'
@@ -256,6 +257,7 @@ type DownstreamMcpSession = {
}
export type KnowledgeMcpGatewayOptions = {
observeFailure?: DesktopDiagnosticFailureObserver
storyGraphService?: StoryGraphService
maximumBodyBytes?: number
magicNotesDatabase?: MagicNotesDatabase
@@ -401,6 +403,7 @@ export class KnowledgeMcpGateway {
): BrowserTabUsageLease
}
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
private readonly observeFailure?: DesktopDiagnosticFailureObserver
private server?: Server
private endpoint?: string
@@ -416,6 +419,18 @@ export class KnowledgeMcpGateway {
this.obsidianService = options.obsidianService
this.browserService = options.browserService
this.launchEnvironmentProvider = options.launchEnvironmentProvider
this.observeFailure = options.observeFailure
}
private observeMcpFailure(mcp: DesktopMcpDiagnosticMetadata): void {
try {
this.observeFailure?.({
component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed',
error: new Error('Local MCP request failed'), mcp
})
} catch {
// Diagnostics must not change the HTTP response or session lifecycle.
}
}
async start(): Promise<void> {
@@ -423,7 +438,23 @@ export class KnowledgeMcpGateway {
return
}
const server = createServer((request, response) => {
void this.handleRequest(request, response).catch(() => {
const context = { phase: 'request' as DesktopMcpDiagnosticMetadata['phase'], startedAt: performance.now(), correlationId: undefined as string | undefined }
let failed = false
response.once('finish', () => {
if (!failed && response.statusCode >= 400) {
this.observeMcpFailure({
phase: context.phase, category: 'http-rejection', status: response.statusCode,
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
})
}
})
void this.handleRequest(request, response, context).catch(() => {
failed = true
this.observeMcpFailure({
phase: context.phase, category: 'handler-failure',
...(response.headersSent ? {} : { status: 500 }),
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
})
sendJson(response, 500, {
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
@@ -1683,76 +1714,86 @@ export class KnowledgeMcpGateway {
mcp.setRequestHandler(
ListToolsRequestSchema,
async (_request, extra) => {
const customBindings = await this.getCustomMcpBindings(
token,
extra.signal
)
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
const inputSchema = z.toJSONSchema(
definition.inputSchema,
{ target: 'draft-7' }
) as Tool['inputSchema'] & { $schema?: string }
Reflect.deleteProperty(inputSchema, '$schema')
return [
{
name,
title: definition.title,
description: definition.description,
inputSchema,
annotations: {
readOnlyHint: definition.access === 'read',
destructiveHint:
name === 'goodbuddy_config_apply' ||
name === 'note_delete' ||
name === 'note_entry_delete'
}
const startedAt = performance.now()
try {
const customBindings = await this.getCustomMcpBindings(
token,
extra.signal
)
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
const inputSchema = z.toJSONSchema(
definition.inputSchema,
{ target: 'draft-7' }
) as Tool['inputSchema'] & { $schema?: string }
Reflect.deleteProperty(inputSchema, '$schema')
return [
{
name,
title: definition.title,
description: definition.description,
inputSchema,
annotations: {
readOnlyHint: definition.access === 'read',
destructiveHint:
name === 'goodbuddy_config_apply' ||
name === 'note_delete' ||
name === 'note_entry_delete'
}
}
]
}
)
const capability = this.getCapability(token)
const imageTool = await imageToolDefinition(capability.imageToolBinding)
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
]
}
)
const capability = this.getCapability(token)
const imageTool = await imageToolDefinition(capability.imageToolBinding)
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
]
} catch (error) {
this.observeMcpFailure({
phase: 'tool-discovery', category: 'handler-failure',
elapsedMs: Math.round(performance.now() - startedAt),
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
})
throw error
}
}
)
@@ -1921,7 +1962,8 @@ export class KnowledgeMcpGateway {
private async handleRequest(
request: IncomingMessage,
response: ServerResponse
response: ServerResponse,
context: { phase: DesktopMcpDiagnosticMetadata['phase']; startedAt: number; correlationId?: string }
): Promise<void> {
if (request.url !== '/mcp') {
sendJson(response, 404, { error: 'Not found' })
@@ -1940,6 +1982,8 @@ export class KnowledgeMcpGateway {
})
return
}
context.phase = 'authentication'
context.startedAt = performance.now()
const authorization = request.headers.authorization
if (
typeof authorization !== 'string' ||
@@ -1949,6 +1993,7 @@ export class KnowledgeMcpGateway {
return
}
const token = authorization.slice('Bearer '.length)
context.correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
try {
this.getCapability(token)
} catch {
@@ -1956,6 +2001,8 @@ export class KnowledgeMcpGateway {
return
}
context.phase = 'request'
context.startedAt = performance.now()
let body: unknown
if (request.method === 'POST') {
try {
@@ -1971,6 +2018,8 @@ export class KnowledgeMcpGateway {
}
}
context.phase = 'session'
context.startedAt = performance.now()
const sessionId = request.headers['mcp-session-id']
let createdSession = false
let session =
@@ -2020,15 +2069,21 @@ export class KnowledgeMcpGateway {
const availableTools = new Set(
this.getAvailableToolNames(token)
)
context.phase = 'initialize'
context.startedAt = performance.now()
session = this.createDownstreamMcpSession(
token,
availableTools
)
createdSession = true
this.downstreamMcpSessions.set(session.registryKey, session)
await session.mcp.connect(session.transport)
}
try {
if (createdSession) await session.mcp.connect(session.transport)
if (body && typeof body === 'object' && 'method' in body && body.method === 'tools/list') {
context.phase = 'tool-discovery'
context.startedAt = performance.now()
}
await session.transport.handleRequest(request, response, body)
} finally {
if (createdSession && session.id === undefined) {
@@ -1,19 +1,258 @@
// @vitest-environment node
import { existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { createServer, request as httpRequest } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createOpencodeClient } from '@opencode-ai/sdk/v2'
import { expect, it } from 'vitest'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { OpenCodeRuntime } from './opencode-runtime'
import type { DesktopDiagnosticFailure } from '../desktop-diagnostics'
const binaryPath = join(
process.cwd(), '.runtime-resources', process.arch,
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
it.skipIf(!existsSync(binaryPath))(
'recovers a lost MCP add response with the real OpenCode binary and gateway',
async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-mcp-retry-'))
let modelCalls = 0
const model = createServer((_request, response) => {
modelCalls++
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(`data: ${JSON.stringify({
id: 'retry', object: 'chat.completion.chunk', created: 1, model: 'retry',
choices: [{ index: 0, delta: { role: 'assistant', content: 'RETRY_OK' }, finish_reason: 'stop' }]
})}\n\ndata: [DONE]\n\n`)
})
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
const address = model.address()
if (!address || typeof address === 'string') throw new Error('No model port')
const gateway = new KnowledgeMcpGateway({} as never)
await gateway.start()
const controller = new AbortController()
const requestId = crypto.randomUUID()
const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)!
const records: DesktopDiagnosticFailure[] = []
const operations: string[] = []
const registrations: Array<{ name: string; config: { headers: { Authorization: string } } }> = []
const runtime = new OpenCodeRuntime({
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
observeFailure: failure => records.push(failure),
modelProfile: {
id: crypto.randomUUID(), name: 'Retry fixture', modelName: 'retry',
baseUrl: `http://127.0.0.1:${address.port}/v1`,
protocol: 'openai-chat-completions', authentication: 'none'
}
}, {
createClient: options => createOpencodeClient({
...options,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const pathname = new URL(request.url).pathname
if (pathname === '/mcp' && request.method === 'POST') {
const registration = await request.clone().json() as typeof registrations[number]
registrations.push(registration)
const response = await fetch(request, init)
const status = await response.clone().json() as Record<string, { status: string }>
expect(status[registration.name]?.status).toBe('connected')
operations.push('add-connected')
if (registrations.length === 1) {
// The server and gateway are already connected when the response is lost.
await response.body?.cancel()
throw new TypeError('fetch failed', { cause: Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }) })
}
return response
}
const response = await fetch(request, init)
if (pathname.endsWith('/disconnect')) {
expect(await response.clone().json()).toBe(true)
operations.push('disconnect-succeeded')
}
return response
}
})
})
const deadline = setTimeout(() => controller.abort(), 30_000)
try {
let text = ''
for await (const event of runtime.run({
requestId, conversationId: crypto.randomUUID(), workMode: 'ask',
prompt: 'Reply RETRY_OK without tools.', knowledgeCapabilityToken: token
}, controller.signal)) {
if (event.type === 'text') text += event.delta
}
expect(text).toBe('RETRY_OK')
expect(modelCalls).toBe(1)
expect(operations).toEqual(['add-connected', 'disconnect-succeeded', 'add-connected', 'disconnect-succeeded'])
expect(registrations).toHaveLength(2)
expect(registrations[1]?.name).not.toBe(registrations[0]?.name)
expect(registrations[1]?.config).toEqual(registrations[0]?.config)
expect(registrations[0]?.config.headers.Authorization).toBe(`Bearer ${token}`)
expect(records).toHaveLength(1)
expect(records[0]?.mcp).toMatchObject({
phase: 'connect', category: 'transport-failure', attempt: 1, kind: 'builtin',
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
})
expect(JSON.stringify(records)).not.toContain(token)
const unauthorized = await fetch(gateway.getEndpoint()!, { method: 'POST' })
expect(unauthorized.status).toBe(401)
await unauthorized.body?.cancel()
} finally {
clearTimeout(deadline)
await runtime.dispose()
gateway.revoke(token)
await gateway.dispose()
model.closeAllConnections()
await new Promise<void>(resolve => model.close(() => resolve()))
await rm(workspace, { recursive: true, force: true })
}
},
60_000
)
it.skipIf(!existsSync(binaryPath))(
'keeps a later MCP connection when a timed-out OpenCode add settles afterwards',
async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-mcp-late-add-'))
let releaseModel!: () => void
const modelReleased = new Promise<void>(resolve => { releaseModel = resolve })
let modelStarted!: () => void
const modelPending = new Promise<void>(resolve => { modelStarted = resolve })
const model = createServer((_request, response) => {
modelStarted()
void modelReleased.then(() => {
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(`data: ${JSON.stringify({
id: 'late', object: 'chat.completion.chunk', created: 1, model: 'late',
choices: [{ index: 0, delta: { role: 'assistant', content: 'LATE_OK' }, finish_reason: 'stop' }]
})}\n\ndata: [DONE]\n\n`)
})
})
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
const modelAddress = model.address()
if (!modelAddress || typeof modelAddress === 'string') throw new Error('No model port')
const gateway = new KnowledgeMcpGateway({} as never)
await gateway.start()
const upstream = new URL(gateway.getEndpoint()!)
// Holds the first MCP initialize so OpenCode keeps initializing after the client gives up.
let releaseInitialize!: () => void
const initializeReleased = new Promise<void>(resolve => { releaseInitialize = resolve })
let heldInitializeSettled!: () => void
const heldInitializeDone = new Promise<void>(resolve => { heldInitializeSettled = resolve })
let heldInitialize = false
const proxy = createServer((request, response) => {
void (async () => {
const chunks: Buffer[] = []
for await (const chunk of request) chunks.push(chunk as Buffer)
const body = Buffer.concat(chunks)
const hold = !heldInitialize && request.method === 'POST' && body.toString().includes('"initialize"')
if (hold) {
heldInitialize = true
await initializeReleased
}
const forwarded = httpRequest({
host: upstream.hostname, port: upstream.port, path: request.url,
method: request.method, headers: request.headers
}, upstreamResponse => {
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers)
upstreamResponse.pipe(response)
if (hold) upstreamResponse.on('end', heldInitializeSettled)
})
forwarded.on('error', () => response.destroy())
response.on('close', () => forwarded.destroy())
forwarded.end(body)
})().catch(() => response.destroy())
})
await new Promise<void>(resolve => proxy.listen(0, '127.0.0.1', resolve))
const proxyAddress = proxy.address()
if (!proxyAddress || typeof proxyAddress === 'string') throw new Error('No proxy port')
const proxiedGateway = new Proxy(gateway, {
get: (target, key, receiver) => key === 'getEndpoint'
? () => `http://127.0.0.1:${proxyAddress.port}/mcp`
: Reflect.get(target, key, receiver)
})
let client: ReturnType<typeof createOpencodeClient> | undefined
const added: string[] = []
const runtime = new OpenCodeRuntime({
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
configPath: '', defaultWorkspace: workspace, knowledgeGateway: proxiedGateway,
modelProfile: {
id: crypto.randomUUID(), name: 'Late add fixture', modelName: 'late',
baseUrl: `http://127.0.0.1:${modelAddress.port}/v1`,
protocol: 'openai-chat-completions', authentication: 'none'
}
}, {
controlRequestTimeoutMs: 3_000,
createClient: options => {
client = createOpencodeClient({
...options,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (new URL(request.url).pathname === '/mcp' && request.method === 'POST') {
added.push((await request.clone().json() as { name: string }).name)
}
return fetch(request, init)
}
})
return client
}
})
const conversationId = crypto.randomUUID()
const run = async (): Promise<string> => {
const controller = new AbortController()
const token = gateway.grant(crypto.randomUUID(), [crypto.randomUUID()], controller.signal)!
const deadline = setTimeout(() => controller.abort(), 30_000)
let text = ''
try {
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId, workMode: 'ask',
prompt: 'Reply LATE_OK without tools.', knowledgeCapabilityToken: token
}, controller.signal)) {
if (event.type === 'text') text += event.delta
}
return text
} finally {
clearTimeout(deadline)
gateway.revoke(token)
}
}
try {
await expect(run()).rejects.toThrow('超时')
expect(added).toHaveLength(1)
const second = run()
await modelPending
expect(added).toHaveLength(2)
expect((await client!.mcp.status({ directory: workspace })).data?.[added[1]!]?.status).toBe('connected')
// The late add now finishes with a revoked token while the second request is live.
releaseInitialize()
await heldInitializeDone
await new Promise(resolve => setTimeout(resolve, 500))
const status = await client!.mcp.status({ directory: workspace })
expect(status.data?.[added[1]!]?.status).toBe('connected')
expect(added[1]).not.toBe(added[0])
releaseModel()
await expect(second).resolves.toBe('LATE_OK')
} finally {
releaseInitialize()
releaseModel()
await runtime.dispose()
await gateway.dispose()
proxy.closeAllConnections()
model.closeAllConnections()
await new Promise<void>(resolve => proxy.close(() => resolve()))
await new Promise<void>(resolve => model.close(() => resolve()))
await rm(workspace, { recursive: true, force: true })
}
},
90_000
)
it.skipIf(!existsSync(binaryPath))(
'closes real event streams after parallel runs and compaction without cancelling a peer',
async () => {
+474 -12
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import {
mkdir,
@@ -11,11 +12,12 @@ import {
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { PassThrough } from "node:stream";
import type { createOpencodeClient } from "@opencode-ai/sdk/v2";
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
import type spawn from "cross-spawn";
import { describe, expect, it, vi } from "vitest";
import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway";
import type { RuntimeEvent } from "./runtime";
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from "../desktop-diagnostics";
import {
createTemporaryMcpName,
OpenCodeRuntime,
@@ -238,6 +240,7 @@ function runClient(events: Record<string, unknown>[]) {
[input.name]: { status: "connected" },
},
error: undefined,
response: { status: 200 },
})),
disconnect: vi.fn().mockResolvedValue({ data: true, error: undefined }),
},
@@ -3220,6 +3223,388 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it.each(["knowledge", "custom"].flatMap<{
kind: string; scenario: string; detail: string; attempts: number;
succeeds?: boolean; thrown?: boolean; status?: string; apiError?: boolean;
}>((kind) => [
{ kind, scenario: "retry success", detail: "MCP error -32001: Request timed out", attempts: 2, succeeds: true },
{ kind, scenario: "retry exhausted", detail: "connect ECONNREFUSED", attempts: 2 },
{ kind, scenario: "thrown connect error", detail: "connect ECONNRESET", attempts: 2, succeeds: true, thrown: true },
{ kind, scenario: "auth", detail: "HTTP 401 Unauthorized; ECONNRESET", attempts: 1 },
{ kind, scenario: "OAuth", detail: "OAuth error: request timed out", attempts: 1 },
{ kind, scenario: "protocol", detail: "Invalid protocol response; connection closed", attempts: 1 },
{ kind, scenario: "unknown", detail: "Unexpected initialization failure", attempts: 1 },
{ kind, scenario: "plain failed", detail: "failed", attempts: 1 },
{ kind, scenario: "needs auth", detail: "needs_auth", attempts: 1, status: "needs_auth" },
{ kind, scenario: "missing status", detail: "unknown", attempts: 1, status: "missing" },
{ kind, scenario: "API error", detail: "HTTP 403 Forbidden", attempts: 1, apiError: true },
]))("handles $kind MCP initialization: $scenario", async ({ kind, detail, attempts, succeeds, thrown, status, apiError }) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const add = vi.mocked(setup.client.mcp.add);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
const secret = "request-private-capability";
const failure = `${detail}\nAuthorization: Bearer ${secret}\npassword=private-password\nhttps://user:private-password@example.com/ECONNRESET?token=private-query\n${"x".repeat(3_000)}`;
const fail = async (input?: { name?: string }) => {
if (thrown) throw new Error(failure);
if (apiError) return { error: { name: "UnknownError", data: { message: failure } }, response: { status: 403 } };
return { response: { status: 200 }, data: status === "missing" ? {} : {
[input!.name!]: { status: status ?? "failed", error: failure },
} };
};
// The SDK returns a map keyed by the registered name, not a top-level status.
if (succeeds) add.mockImplementationOnce(fail as never);
else add.mockImplementation(fail as never);
const gateway = {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
grantCustomMcp: vi.fn(() => secret),
prepareCustomMcpTools: vi.fn(async () => [{ name: "custom_tool" }]),
revoke: vi.fn(),
};
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: gateway as unknown as KnowledgeMcpGateway,
mcpServers: kind === "custom" ? [{
id: "custom", name: "Custom", description: "", transport: "http",
url: "https://example.com/mcp", enabled: true, allowDynamicTools: false,
assignments: ["opencode"], secretConfigured: false,
}] : [],
});
const collect = async () => {
const events: RuntimeEvent[] = [];
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test",
workMode: kind === "custom" ? "execute" : "ask",
...(kind === "knowledge" ? { knowledgeCapabilityToken: secret } : {}),
}, new AbortController().signal)) events.push(event);
return events;
};
try {
if (succeeds) {
await expect(collect()).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} else {
const error = await collect().catch((error: unknown) => error) as Error;
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(detail);
expect(error.message).not.toMatch(/request-private-capability|private-password|private-query/);
expect(error.message.length).toBeLessThan(2_100);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
}
expect(add).toHaveBeenCalledTimes(attempts);
expect(disconnect).toHaveBeenCalledTimes(attempts);
expect(observeFailure).toHaveBeenCalledTimes(succeeds ? 1 : attempts);
for (const [index, [record]] of observeFailure.mock.calls.entries()) {
expect(record).toMatchObject({
component: "runtime", stage: "connect", code: "runtime.mcp.failed",
mcp: {
phase: "connect", attempt: index + 1, kind: kind === "knowledge" ? "builtin" : "custom",
correlationId: `sha256:${createHash("sha256").update(secret).digest("hex")}`,
elapsedMs: expect.any(Number),
category: apiError ? "http-rejection" : thrown ? "transport-failure" : "initialization-failure",
...(apiError ? { status: 403 } : {}),
},
});
expect(record.mcp!.elapsedMs).toBeGreaterThanOrEqual(0);
expect(JSON.stringify(record)).not.toMatch(/request-private-capability|private-password|private-query|example.com|ECONNRESET/);
expect((record.error as Error).message).toBe("Local MCP operation failed");
}
if (attempts === 2) {
expect(add.mock.calls[1]![0]?.config).toEqual(add.mock.calls[0]![0]?.config);
if (thrown) expect(add.mock.calls[1]![0]?.name).not.toBe(add.mock.calls[0]![0]?.name);
else expect(add.mock.calls[1]![0]?.name).toBe(add.mock.calls[0]![0]?.name);
expect(disconnect.mock.invocationCallOrder[0]).toBeGreaterThan(add.mock.invocationCallOrder[0]!);
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(add.mock.invocationCallOrder[1]!);
}
if (succeeds) {
const name = add.mock.calls.at(-1)![0]!.name;
const toolId = `${name}_${kind === "custom" ? "custom_tool" : "knowledge_search"}`;
expect(setup.session.create).toHaveBeenCalledWith(expect.objectContaining({
permission: expect.arrayContaining([{ permission: toolId, pattern: "*", action: "allow" }]),
}), expect.anything());
expect(setup.session.promptAsync).toHaveBeenCalledWith(expect.objectContaining({
tools: expect.objectContaining({ [toolId]: true }),
}), expect.anything());
}
expect(add.mock.calls[0]![0]?.config).toMatchObject({
headers: { Authorization: `Bearer ${secret}` }, oauth: false,
});
if (kind === "custom") {
expect(gateway.grantCustomMcp).toHaveBeenCalledOnce();
expect(gateway.prepareCustomMcpTools).toHaveBeenCalledOnce();
expect(gateway.revoke).toHaveBeenCalledExactlyOnceWith(secret);
}
} finally {
await runtime.dispose();
}
});
it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])(
"handles installed SDK fetch results: %s", async (scenario) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const token = "private-sdk-capability";
const cause = Object.assign(new Error(`socket reset\nAuthorization: Bearer ${token}\npassword=private-password\nhttps://user:private-password@example.com/private-path`), { code: "ECONNRESET" });
// A circular cause must not cause unbounded traversal or expose a stack.
Object.assign(cause, { cause });
const failure = new TypeError("fetch failed", { cause });
const order: string[] = [];
const signals: AbortSignal[] = [];
let adds = 0;
const sdk = createOpencodeClient({
baseUrl: "http://127.0.0.1:4096", throwOnError: false,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
if (new URL(request.url).pathname.endsWith("/disconnect")) {
order.push("disconnect");
signals.push(request.signal);
if (scenario === "cleanup-transport") throw failure;
if (scenario === "cleanup-timeout") return new Promise<Response>(() => {});
return Response.json(scenario !== "cleanup-false");
}
order.push("add");
adds++;
if (scenario.startsWith("http-")) {
return Response.json({ name: "UnknownError", data: { message: `ECONNRESET password=private-password` } }, { status: Number(scenario.slice(5)) });
}
if (scenario === "unknown") throw new TypeError("fetch failed");
if (adds === 1 || scenario === "exhausted") throw failure;
const { name } = await request.json() as { name: string };
return Response.json({ [name]: { status: "connected" } });
},
});
Object.assign(setup.client, { mcp: sdk.mcp });
const add = vi.spyOn(sdk.mcp, "add");
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const collect = async () => {
for await (const event of runtime.run({
requestId: "sdk-request", conversationId: "sdk-conversation", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: token,
}, new AbortController().signal)) void event;
};
try {
if (scenario === "recovered") {
await collect();
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} else {
const error = await collect().catch((error: unknown) => error) as Error;
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(scenario === "unknown" ? "fetch failed" : "ECONNRESET");
expect(error.message).not.toMatch(/private-|example\.com|socket reset.*at /);
expect(error.message.length).toBeLessThan(2_100);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
}
const first = await add.mock.results[0]!.value;
if (scenario.startsWith("http-")) {
expect(first.response.status).toBe(Number(scenario.slice(5)));
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "http-rejection", status: Number(scenario.slice(5)) });
} else {
expect(first.response).toBeUndefined();
expect(first.error).toBeInstanceOf(TypeError);
if (scenario !== "unknown") expect(first.error).toBe(failure);
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "transport-failure" });
}
expect(add).toHaveBeenCalledTimes(scenario === "recovered" || scenario === "exhausted" ? 2 : 1);
if (add.mock.calls.length === 2) {
expect(add.mock.calls[1]![0]?.name).not.toBe(add.mock.calls[0]![0]?.name);
}
expect(order).toEqual(scenario === "recovered" || scenario === "exhausted"
? ["add", "disconnect", "add", "disconnect"]
: scenario.startsWith("cleanup-") ? ["add", "disconnect", "disconnect"] : ["add", "disconnect"]);
if (scenario === "cleanup-timeout") expect(signals.every(signal => signal.aborted)).toBe(true);
const records = observeFailure.mock.calls.map(([record]) => record);
expect(JSON.stringify(records)).not.toMatch(/private-|ECONNRESET|socket reset|example\.com/);
expect(records.every(record => record.mcp?.correlationId === `sha256:${createHash("sha256").update(token).digest("hex")}`)).toBe(true);
} finally {
await runtime.dispose();
}
},
);
it("persists a recovered MCP attempt even when the observer throws", async () => {
const directory = await mkdtemp(join(tmpdir(), "goodbuddy-mcp-diagnostics-"));
const diagnostics = new DesktopDiagnostics(directory);
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const token = "private-correlation-token";
vi.mocked(setup.client.mcp.add).mockImplementationOnce((async (input: { name: string }) => ({
data: { [input.name]: { status: "failed", error: `connect ECONNRESET Authorization: Bearer ${token}` } },
})) as never);
const writes: Promise<void>[] = [];
const observer = vi.fn<DesktopDiagnosticFailureObserver>((failure) => {
writes.push(diagnostics.recordFailure(failure));
throw new Error("observer failed");
});
const runtime = embeddedRuntime(setup.client, {
observeFailure: observer,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: token,
}, new AbortController().signal)) void event;
expect(setup.client.mcp.add).toHaveBeenCalledTimes(2);
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
expect(observer).toHaveBeenCalledOnce();
await Promise.all(writes);
await diagnostics.dispose();
const restarted = new DesktopDiagnostics(directory);
try {
const records = await restarted.readRecent();
expect(records).toHaveLength(1);
expect(records[0]?.mcp).toMatchObject({
phase: "connect", attempt: 1, kind: "builtin",
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
});
expect((await restarted.exportRecent()).toString()).not.toMatch(/private-|ECONNRESET|Authorization|127\.0\.0\.1/);
} finally { await restarted.dispose(); }
} finally {
await runtime.dispose();
await diagnostics.dispose();
await rm(directory, { recursive: true, force: true });
}
});
it.each(["reject", "error", "false", "timeout"])("does not retry or reuse the name after MCP cleanup %s", async (failure) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementationOnce((async (input: { name: string }) => ({
response: { status: 200 },
data: { [input.name]: { status: "failed", error: "connect ECONNREFUSED" } },
})) as never);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
if (failure === "reject") disconnect.mockRejectedValue(new Error("cleanup failed"));
if (failure === "error") disconnect.mockResolvedValue({ error: { message: "cleanup failed" } } as never);
if (failure === "false") disconnect.mockResolvedValue({ data: false } as never);
if (failure === "timeout") disconnect.mockImplementation((() => new Promise(() => {})) as never);
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error("observer failed"); });
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const collect = async (requestId: string) => {
for await (const event of runtime.run({
requestId, conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: requestId,
}, new AbortController().signal)) void event;
};
try {
await expect(collect("first")).rejects.toThrow("ECONNREFUSED");
expect(add).toHaveBeenCalledOnce();
expect(setup.session.promptAsync).not.toHaveBeenCalled();
expect(observeFailure).toHaveBeenCalledTimes(3);
expect(observeFailure.mock.calls.slice(1).map(([record]) => record.mcp)).toEqual([
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin", category: failure === "false" ? "cleanup-failure" : "transport-failure" }),
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin" }),
]);
expect(new Set(observeFailure.mock.calls.map(([record]) => record.mcp?.correlationId)).size).toBe(1);
disconnect.mockResolvedValue({ data: true } as never);
await collect("second");
expect(add.mock.calls[1]![0]?.name).not.toBe(add.mock.calls[0]![0]?.name);
await collect("third");
expect(add.mock.calls[2]![0]?.name).toBe(add.mock.calls[1]![0]?.name);
} finally {
await runtime.dispose();
}
});
it.each(["add", "disconnect", "retry"])("respects cancellation during MCP %s", async (phase) => {
const setup = runClient([]);
const controller = new AbortController();
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementation((async (input: { name: string }) => {
if (phase === "add" || (phase === "retry" && add.mock.calls.length === 2)) {
controller.abort(new Error("cancel initialization"));
}
return { data: { [input.name]: { status: "failed", error: "connect ECONNRESET" } } };
}) as never);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
disconnect.mockImplementation((async () => {
if (phase === "disconnect") controller.abort(new Error("cancel initialization"));
return { data: true };
}) as never);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
await expect((async () => {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "private-token",
}, controller.signal)) void event;
})()).rejects.toThrow("cancel initialization");
expect(add).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
expect(disconnect).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
} finally {
await runtime.dispose();
}
});
it("does not retry MCP initialization or the prompt after a transient tool execution error", async () => {
const setup = runClient([
{
id: "tool-error", type: "message.part.updated",
properties: {
sessionID: "session-1",
part: {
id: "part-1", callID: "call-1", type: "tool", tool: "knowledge_search",
state: {
status: "error", input: {}, error: "connect ECONNRESET",
time: { start: 1, end: 2 },
},
},
},
},
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
const events: RuntimeEvent[] = [];
await expect((async () => {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "private-token",
}, new AbortController().signal)) events.push(event);
})()).rejects.toThrow("connect ECONNRESET");
expect(events).toContainEqual(expect.objectContaining({ type: "tool", state: "failed" }));
expect(setup.client.mcp.add).toHaveBeenCalledOnce();
expect(setup.client.mcp.disconnect).toHaveBeenCalledOnce();
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} finally {
await runtime.dispose();
}
});
it("keeps temporary MCP names stable within a conversation and short enough for providers", async () => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
@@ -3303,12 +3688,10 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
);
const base = names[0]!;
expect(base).toMatch(/^gbd-[a-f0-9]{6}$/u);
// request-1 failed to disconnect (rejected) -> slot 1 stays leased.
expect(names[1]).toBe(`${base}-2`);
// request-2 disconnect returned an error -> slot 2 stays leased.
expect(names[2]).toBe(`${base}-3`);
// request-3 disconnected cleanly -> slot 3 is reusable.
expect(names[3]).toBe(`${base}-3`);
expect(new Set(names.slice(0, 3)).size).toBe(3);
expect(names.every(name => name.length <= 12)).toBe(true);
// Successful cleanup preserves the replacement name for the same slot.
expect(names[3]).toBe(names[2]);
} finally {
await runtime.dispose();
}
@@ -3335,6 +3718,81 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
expect(runtime.acquireTemporaryMcpSlot(setup.client, "abc123")).toBe(4);
expect(createTemporaryMcpName("gbd-", "abc123", 1)).toBe("gbd-abc123");
expect(createTemporaryMcpName("gbc-", "abc123", 4)).toBe("gbc-abc123-4");
for (const generation of [1, 35, 36, 36 ** 8 - 1]) {
const name = createTemporaryMcpName("gbc-", "abc123", 9, generation);
expect(`${name}_${"x".repeat(51)}`).toHaveLength(64);
expect(name).not.toBe(createTemporaryMcpName("gbc-", "abc123", 9));
}
expect(() => createTemporaryMcpName("gbc-", "abc123", 9, 36 ** 8)).toThrow("generations exhausted");
});
it.each(["throw", "no-response", "timeout", "abort"])("retires an uncertain MCP add after %s despite successful disconnect", async (failure) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const controller = new AbortController();
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementationOnce((async () => {
if (failure === "throw") throw new Error("unknown initialization failure");
if (failure === "no-response") return { error: new Error("unknown initialization failure") };
if (failure === "abort") controller.abort(new Error("cancel initialization"));
return new Promise(() => {});
}) as never);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
}, { controlRequestTimeoutMs: 50 });
const run = async (signal: AbortSignal) => {
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId: "uncertain", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "token",
}, signal)) void event;
};
try {
await expect(run(controller.signal)).rejects.toThrow();
expect(setup.client.mcp.disconnect).toHaveBeenCalledOnce();
await run(new AbortController().signal);
await run(new AbortController().signal);
const names = add.mock.calls.map(([input]) => input!.name);
expect(names[1]).not.toBe(names[0]);
expect(names[2]).toBe(names[1]);
expect(vi.mocked(setup.client.mcp.disconnect).mock.calls.map(([input]) => input!.name)).toEqual(names);
} finally { await runtime.dispose(); }
});
it("releases active slots after more than nine failed cleanups and isolates replacement generations", async () => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
disconnect.mockResolvedValue({ data: false } as never);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const run = async (conversationId: string) => {
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId, prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "token",
}, new AbortController().signal)) void event;
};
try {
for (let i = 0; i < 12; i++) await run("cleanup-failures");
await run("other-conversation");
disconnect.mockResolvedValue({ data: true } as never);
await run("other-conversation");
await run("cleanup-failures");
await run("cleanup-failures");
const names = vi.mocked(setup.client.mcp.add).mock.calls.map(([input]) => input!.name);
expect(new Set(names.slice(0, 15)).size).toBe(15);
expect(names[15]).toBe(names[14]);
expect(names.every(name => name !== undefined && name.length <= 12)).toBe(true);
expect(disconnect.mock.calls.map(([input]) => input!.name)).toEqual(names);
} finally { await runtime.dispose(); }
});
it("enables the deterministic MCP tool name when tool ids omit dynamic tools", async () => {
@@ -3400,7 +3858,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("runs embedded conversations concurrently while serializing MCP registrations", async () => {
it.each([false, true])("runs embedded conversations concurrently while serializing MCP registrations (retry=%s)", async (retry) => {
const setup = runClient([
{
id: "idle",
@@ -3437,7 +3895,9 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await firstAdd;
return {
data: {
[input.name]: { status: "connected" },
[input.name]: retry
? { status: "failed", error: "connect ECONNRESET" }
: { status: "connected" },
},
error: undefined,
};
@@ -3500,7 +3960,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
} finally {
resolveFirstAdd();
}
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(retry ? 3 : 2));
await Promise.all([first, second]);
expect(
mcpAdd.mock.calls.map(
@@ -3511,8 +3971,10 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
}
).config.headers.Authorization,
),
).toEqual(["Bearer first-token", "Bearer second-token"]);
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(2);
).toEqual(retry
? ["Bearer first-token", "Bearer first-token", "Bearer second-token"]
: ["Bearer first-token", "Bearer second-token"]);
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(retry ? 3 : 2);
await runtime.dispose();
});
+145 -115
View File
@@ -56,7 +56,8 @@ import {
runtimePrivacyEnvironment,
} from "./process-environment";
import type { LaunchEnvironmentProvider } from "../local-tool-environment/launch-environment-provider";
import { boundedToolDetail, safeToolErrorDetail } from "./approval-summary";
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from "../desktop-diagnostics";
import { boundedToolDetail, redactSensitiveText, safeToolErrorDetail } from "./approval-summary";
import type {
ResolvedMcpServer,
RuntimeSkillPackage,
@@ -503,6 +504,7 @@ export type OpenCodeRuntimeDependencies = {
};
export type OpenCodeRuntimeOptions = {
observeFailure?: DesktopDiagnosticFailureObserver;
baseUrl?: string;
embedded: boolean;
binaryPath: string;
@@ -551,7 +553,13 @@ export function createTemporaryMcpName(
prefix: (typeof TEMPORARY_MCP_PREFIXES)[number],
conversationKey: string,
slot: number,
generation = 0,
): string {
if (generation > 0) {
// Eight base-36 digits keep the registration within the 12-character budget.
if (generation >= 36 ** 8) throw new Error("OpenCode MCP name generations exhausted");
return `${prefix}${generation.toString(36).padStart(8, "0")}`;
}
return `${prefix}${conversationKey}${slot === 1 ? "" : `-${slot}`}`;
}
@@ -774,14 +782,11 @@ export class OpenCodeRuntime implements AgentRuntime {
>();
private readonly conversationRunTails = new Map<string, Promise<void>>();
private mcpMutationTail: Promise<void> = Promise.resolve();
// Temporary MCP name slots per OpenCode client and conversation. A slot is
// held while a request owns its MCP connections and is only freed after
// every disconnect succeeds, so a request never reuses a name that may still
// be bound to another request's capability token. A new client (restarted
// server) starts with a clean MCP registry and therefore fresh slots.
// Slots bound active requests; generations retire uncertain names without
// retaining a slot forever. Replacement names are unique across this client.
private readonly temporaryMcpSlots = new WeakMap<
OpencodeClient,
Map<string, Set<number>>
{ generation: number; conversations: Map<string, Map<number, { active: boolean; generation: number }>> }
>();
private readonly dependencies: OpenCodeRuntimeDependencies;
@@ -863,19 +868,20 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient,
conversationKey: string,
): number {
let byConversation = this.temporaryMcpSlots.get(client);
if (!byConversation) {
byConversation = new Map();
this.temporaryMcpSlots.set(client, byConversation);
let state = this.temporaryMcpSlots.get(client);
if (!state) {
state = { generation: 0, conversations: new Map() };
this.temporaryMcpSlots.set(client, state);
}
let occupied = byConversation.get(conversationKey);
let occupied = state.conversations.get(conversationKey);
if (!occupied) {
occupied = new Set();
byConversation.set(conversationKey, occupied);
occupied = new Map();
state.conversations.set(conversationKey, occupied);
}
for (let slot = 1; slot <= MAX_TEMPORARY_MCP_SLOTS; slot += 1) {
if (!occupied.has(slot)) {
occupied.add(slot);
const entry = occupied.get(slot);
if (!entry?.active) {
occupied.set(slot, { active: true, generation: entry?.generation ?? 0 });
return slot;
}
}
@@ -889,12 +895,14 @@ export class OpenCodeRuntime implements AgentRuntime {
conversationKey: string,
slot: number,
): void {
const byConversation = this.temporaryMcpSlots.get(client);
const byConversation = this.temporaryMcpSlots.get(client)?.conversations;
const occupied = byConversation?.get(conversationKey);
if (!byConversation || !occupied) {
return;
}
occupied.delete(slot);
const entry = occupied.get(slot);
if (entry?.generation) entry.active = false;
else occupied.delete(slot);
if (occupied.size === 0) {
byConversation.delete(conversationKey);
}
@@ -2239,7 +2247,8 @@ export class OpenCodeRuntime implements AgentRuntime {
let knowledgeToolIds: string[] = [];
let customMcpName: string | undefined;
let customMcpToken: string | undefined;
const attemptedMcpNames: string[] = [];
type McpAttempt = Pick<DesktopMcpDiagnosticMetadata, "correlationId" | "kind" | "attempt">;
const attemptedMcpNames = new Map<string, McpAttempt>();
const mcpConversationKey = temporaryMcpConversationKey(
request.conversationId,
);
@@ -2248,13 +2257,123 @@ export class OpenCodeRuntime implements AgentRuntime {
prefix: (typeof TEMPORARY_MCP_PREFIXES)[number],
): string => {
mcpSlot ??= this.acquireTemporaryMcpSlot(client, mcpConversationKey);
return createTemporaryMcpName(prefix, mcpConversationKey, mcpSlot);
const entry = this.temporaryMcpSlots.get(client)!.conversations.get(mcpConversationKey)!.get(mcpSlot)!;
return createTemporaryMcpName(prefix, mcpConversationKey, mcpSlot, entry.generation);
};
const retireMcpName = (name: string): void => {
const state = this.temporaryMcpSlots.get(client)!;
const entry = state.conversations.get(mcpConversationKey)!.get(mcpSlot!)!;
const prefix = name.startsWith(KNOWLEDGE_MCP_PREFIX) ? KNOWLEDGE_MCP_PREFIX : CUSTOM_MCP_PREFIX;
// A late cleanup of an already retired name must not retire its successor.
if (createTemporaryMcpName(prefix, mcpConversationKey, mcpSlot!, entry.generation) === name) {
entry.generation = ++state.generation;
}
};
const subscriptionController = new AbortController();
const imageCapabilityToken = request.imageToolBinding && request.workMode === 'execute'
? this.options.knowledgeGateway?.bindImageTool(request.imageToolBinding, signal, request.knowledgeCapabilityToken)
: undefined;
const scopedCapabilityToken = imageCapabilityToken ?? request.knowledgeCapabilityToken;
const observeMcpFailure = (mcp: DesktopMcpDiagnosticMetadata): void => {
try {
this.options.observeFailure?.({
component: "runtime", stage: mcp.phase, code: "runtime.mcp.failed",
error: new Error("Local MCP operation failed"), mcp,
});
} catch {
// Diagnostics must not affect retry, cancellation, or cleanup.
}
};
const disconnectMcp = async (name: string, metadata: McpAttempt): Promise<boolean> => {
// Called under the mutation lock; queue wait does not consume this budget.
const cleanupSignal = AbortSignal.timeout(1_000);
const startedAt = performance.now();
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let status: number | undefined;
try {
const result = await awaitWithAbort(
client.mcp.disconnect({ name, directory }, { signal: cleanupSignal }),
cleanupSignal,
);
if (result.data === true && !result.error) return true;
status = result.response?.status;
category = result.error && !result.response ? "transport-failure"
: status && status >= 400 ? "http-rejection" : "cleanup-failure";
} catch {
// The name is retired below; the active request can still release its slot.
}
retireMcpName(name);
observeMcpFailure({ ...metadata, phase: "disconnect", category, status,
elapsedMs: Math.round(performance.now() - startedAt) });
return false;
};
const addMcp = (prefix: (typeof TEMPORARY_MCP_PREFIXES)[number], url: string, token: string, label: string) =>
this.mutateMcp(signal, async () => {
let name = temporaryMcpName(prefix);
for (let attempt = 0; ; attempt++) {
signal.throwIfAborted();
const startedAt = performance.now();
const metadata: McpAttempt = {
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
kind: name.startsWith(KNOWLEDGE_MCP_PREFIX) ? "builtin" : "custom",
attempt: attempt === 0 ? 1 : 2,
};
let failure: unknown;
let retryableStatus = true;
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let httpStatus: number | undefined;
let attempted = false;
let confirmed = false;
try {
const added = await this.controlRequest(label, (controlSignal) => {
attemptedMcpNames.set(name, metadata);
attempted = true;
return client.mcp.add({
directory, name,
config: {
type: "remote", url, enabled: true,
headers: { Authorization: `Bearer ${token}` },
oauth: false,
},
}, { signal: controlSignal });
}, signal);
confirmed = !!added.response;
const status = added.data?.[name];
httpStatus = added.response?.status;
const httpRejected = httpStatus !== undefined && httpStatus >= 400;
if (!httpRejected && !added.error && status?.status === "connected") return name;
// With throwOnError:false the SDK resolves fetch failures without a response.
const transportFailure = !!added.error && !added.response;
category = transportFailure ? "transport-failure"
: httpRejected ? "http-rejection" : "initialization-failure";
retryableStatus = transportFailure || (!httpRejected && !added.error && status?.status === "failed");
failure = added.error ?? (status && "error" in status
? `${status.status}: ${status.error}`
: status?.status ?? "unknown");
} catch (error) {
failure = error;
} finally {
// Aborting the HTTP client does not cancel OpenCode's pending add.
if (attempted && !confirmed) retireMcpName(name);
}
observeMcpFailure({ ...metadata, phase: "connect",
category: signal.aborted ? "cancelled" : category, status: httpStatus,
elapsedMs: Math.round(performance.now() - startedAt) });
signal.throwIfAborted();
const failureDetail = safeToolErrorDetail(failure) ?? "unknown";
const detail = redactSensitiveText(
failureDetail.split(token).join("[REDACTED]"),
).replace(/https?:\/\/[^\s<>"']+/giu, "[URL]");
const error = new Error(`OpenCode ${label}失败(${httpStatus && httpStatus >= 400 ? `HTTP ${httpStatus}: ` : ""}${detail})`);
// Only explicit transport failures during initialization may be retried.
const transient = /\b(?:ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE)\b|\b(?:connection closed|socket hang up|request timed out)\b/iu.test(detail)
&& !/\b(?:auth(?:entication|orization)?\s+(?:failed|required|error|denied)|oauth|credentials|unauthorized|forbidden|401|403|protocol|invalid|parse|json|method)\b/iu.test(failureDetail);
if (attempt > 0 || !retryableStatus || !transient) throw error;
if (!await disconnectMcp(name, metadata)) throw error;
attemptedMcpNames.delete(name);
name = temporaryMcpName(prefix);
}
});
try {
if (
scopedCapabilityToken &&
@@ -2262,43 +2381,7 @@ export class OpenCodeRuntime implements AgentRuntime {
this.options.knowledgeGateway?.getEndpoint()
) {
const knowledgeEndpoint = this.options.knowledgeGateway.getEndpoint()!;
knowledgeMcpName = temporaryMcpName(KNOWLEDGE_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接内置只读工具",
(controlSignal) => {
attemptedMcpNames.push(knowledgeMcpName!);
return client.mcp.add(
{
directory,
name: knowledgeMcpName,
config: {
type: "remote",
url: knowledgeEndpoint,
enabled: true,
headers: {
Authorization: `Bearer ${scopedCapabilityToken}`,
},
oauth: false,
},
},
{ signal: controlSignal },
);
},
signal,
),
);
if (added.error || !added.data) {
throw new Error(
opencodeErrorMessage(added.error, "OpenCode 内置只读工具连接失败"),
);
}
const addedStatus = added.data[knowledgeMcpName];
if (!addedStatus || addedStatus.status !== "connected") {
throw new Error(
`OpenCode 内置只读工具连接失败(${addedStatus?.status ?? "unknown"})`,
);
}
knowledgeMcpName = await addMcp(KNOWLEDGE_MCP_PREFIX, knowledgeEndpoint, scopedCapabilityToken, "连接内置只读工具");
// OpenCode 1.18.x does not include dynamically added MCP tools in
// experimental/tool/ids. Its model tool namespace is deterministic:
// "<MCP server name>_<declared tool name>".
@@ -2324,43 +2407,7 @@ export class OpenCodeRuntime implements AgentRuntime {
customMcpToken,
signal,
);
customMcpName = temporaryMcpName(CUSTOM_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接自定义 MCP 工具",
(controlSignal) => {
attemptedMcpNames.push(customMcpName!);
return client.mcp.add(
{
directory,
name: customMcpName,
config: {
type: "remote",
url: customMcpEndpoint,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`,
},
oauth: false,
},
},
{ signal: controlSignal },
);
},
signal,
),
);
const addedStatus = added.data?.[customMcpName];
if (
added.error ||
!added.data ||
!addedStatus ||
addedStatus.status !== "connected"
) {
throw new Error(
`OpenCode 自定义 MCP 连接失败(${addedStatus?.status ?? "unknown"})`,
);
}
customMcpName = await addMcp(CUSTOM_MCP_PREFIX, customMcpEndpoint, customMcpToken, "连接自定义 MCP 工具");
knowledgeToolIds.push(
...tools.map((tool) => `${customMcpName}_${tool.name}`),
);
@@ -3192,30 +3239,13 @@ export class OpenCodeRuntime implements AgentRuntime {
} finally {
subscriptionController.abort();
if (imageCapabilityToken && imageCapabilityToken !== request.knowledgeCapabilityToken) this.options.knowledgeGateway?.revoke(imageCapabilityToken);
let allMcpDisconnected = true;
for (const name of attemptedMcpNames) {
const disconnected = await this.mutateMcp(
for (const [name, metadata] of attemptedMcpNames) {
await this.mutateMcp(
new AbortController().signal,
() => {
// Queue wait must not consume the disconnect timeout.
const cleanupSignal = AbortSignal.timeout(1_000);
return awaitWithAbort(
client.mcp.disconnect(
{ name, directory },
{ signal: cleanupSignal },
),
cleanupSignal,
);
},
).then(
(result) => !(result as { error?: unknown } | undefined)?.error,
() => false,
() => disconnectMcp(name, metadata),
);
allMcpDisconnected &&= disconnected;
}
// A slot whose connection may still be alive stays occupied so a later
// request cannot reuse a name bound to this request's token.
if (mcpSlot !== undefined && allMcpDisconnected) {
if (mcpSlot !== undefined) {
this.releaseTemporaryMcpSlot(client, mcpConversationKey, mcpSlot);
}
if (customMcpToken) {
@@ -44,6 +44,34 @@ afterEach(async () => {
})
describe('ApplicationSettingsStore', () => {
it.each([11, 12])('defaults missing frosted glass to off in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
delete legacy.transparentFrostedEffectEnabled
await writeFile(filePath, JSON.stringify(legacy))
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, transparentFrostedEffectEnabled: undefined }).transparentFrostedEffectEnabled).toBe(false)
})
it('persists and publishes both frosted glass choices across unrelated updates and reloads', async () => {
const { filePath, store } = await createStore()
expect((await store.get()).transparentFrostedEffectEnabled).toBe(false)
const changed = vi.fn()
store.onChanged(changed)
for (const transparentFrostedEffectEnabled of [true, false]) {
const saved = await store.update({ transparentFrostedEffectEnabled })
expect(saved.transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
expect(changed).toHaveBeenLastCalledWith(saved)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
expect(JSON.parse(await readFile(filePath, 'utf8')).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
}
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
for (const transparentFrostedEffectEnabled of ['false', 0, null]) {
await expect(store.update({ transparentFrostedEffectEnabled })).rejects.toThrow()
}
})
it.each([11, 12])('defaults missing desktop notifications to on in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
+4
View File
@@ -251,6 +251,7 @@ export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentR
export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -291,6 +292,7 @@ export class ApplicationSettingsStore {
StoredApplicationSettings,
| 'conversationHtmlRenderingEnabled'
| 'desktopNotificationsEnabled'
| 'transparentFrostedEffectEnabled'
| 'localToolEnvironment'
| 'version'
| 'applicationNavigation'
@@ -526,6 +528,7 @@ export class ApplicationSettingsStore {
return {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
desktopNotificationsEnabled: stored.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
@@ -572,6 +575,7 @@ export class ApplicationSettingsStore {
const settings: ApplicationSettings = {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
desktopNotificationsEnabled: next.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
@@ -3709,6 +3709,8 @@ describe('AssistantDatabase', () => {
ALTER TABLE tasks DROP COLUMN current_user_message_id;
ALTER TABLE tasks DROP COLUMN remote_recoverable;
DROP INDEX task_events_remote_provenance_unique;
DROP INDEX task_events_remote_task_idx;
DROP INDEX task_events_lifecycle_idx;
ALTER TABLE task_events DROP COLUMN remote_event_index;
ALTER TABLE task_events DROP COLUMN remote_semantic_sequence;
ALTER TABLE task_events DROP COLUMN remote_operation_id;
+15 -1
View File
@@ -133,7 +133,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 51
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 52
export type RemoteTaskEventInput = {
taskId: string
@@ -11895,6 +11895,20 @@ export class AssistantDatabase {
database.exec('PRAGMA user_version = 51; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
if (version.user_version < 52) {
database.exec('BEGIN IMMEDIATE')
try {
// Execution stats poll every few seconds. Filtering lifecycle and remote
// rows through task_events_task_idx reads every large stream payload row.
database.exec(`
CREATE INDEX IF NOT EXISTS task_events_lifecycle_idx
ON task_events(task_id, id) WHERE kind IN ('status', 'done', 'error');
CREATE INDEX IF NOT EXISTS task_events_remote_task_idx
ON task_events(task_id) WHERE remote_operation_id IS NOT NULL;
`)
database.exec('PRAGMA user_version = 52; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
}
supervisionReviewStore(): SupervisionReviewStore {
@@ -427,9 +427,12 @@ describe('execution duration evidence', () => {
const pairMs = (performance.now() - start) / 5
const query = prepare.mock.calls.find(([sql]) => sql.includes('WITH scoped AS MATERIALIZED'))![0]
const plan = raw.prepare(`EXPLAIN QUERY PLAN ${query}`).all(projectId).map(row => row.detail as string)
for (const alias of ['proof', 'remote', 'tail', 'previous', 'e']) {
for (const alias of ['proof', 'tail', 'previous']) {
expect(plan.some(detail => detail.includes(`SEARCH ${alias} USING INDEX task_events_task_idx`))).toBe(true)
}
// Filtering these through task_events_task_idx would read every payload row.
expect(plan.some(detail => detail.includes('SEARCH remote USING INDEX task_events_remote_task_idx'))).toBe(true)
expect(plan.some(detail => detail.includes('SEARCH e USING INDEX task_events_lifecycle_idx'))).toBe(true)
// Malformed bodies make accidental JSON extraction fail deterministically.
raw.prepare("UPDATE task_events SET payload_json = '{unparsed' WHERE task_id = ? AND kind IN ('text', 'tool')").run(id)
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
+46
View File
@@ -34,6 +34,52 @@ afterEach(async () => {
})
describe('DesktopDiagnostics', () => {
it('gates MCP metadata and discards unknown or unsafe fields', () => {
const record = { timestamp: '2026-09-30T00:00:00Z', component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed' }
const mcp = { phase: 'initialize', category: 'http-rejection', elapsedMs: 25, status: 406, correlationId: `sha256:${'a'.repeat(64)}`, token: 'private-token', headers: { authorization: 'private-token' } }
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp })?.mcp).toEqual({ phase: 'initialize', category: 'http-rejection', elapsedMs: 25, status: 406, correlationId: mcp.correlationId })
expect(normalizeDesktopDiagnosticRecord({ ...record, code: 'runtime.run.failed', mcp })).not.toHaveProperty('mcp')
expect(normalizeDesktopDiagnosticRecord({ ...record, component: 'desktop', mcp })).not.toHaveProperty('mcp')
for (const invalid of [{ phase: 'private-url' }, { category: 'private-body' }, { elapsedMs: -1 }, { elapsedMs: Infinity }, { elapsedMs: 1.5 }]) {
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, ...invalid } })).not.toHaveProperty('mcp')
}
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, status: 999, correlationId: 'raw-token' } })?.mcp)
.toEqual({ phase: 'initialize', category: 'http-rejection', elapsedMs: 25 })
})
it('renormalizes stored MCP metadata during export after restart', async () => {
const directory = await temporaryDirectory()
const diagnostics = new DesktopDiagnostics(directory)
const mcp = { phase: 'tool-discovery' as const, category: 'handler-failure' as const, elapsedMs: 42, correlationId: `sha256:${'b'.repeat(64)}` }
await diagnostics.recordFailure({ component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed', error: new Error('private-error'), mcp })
await diagnostics.dispose()
const path = join(directory, 'desktop-diagnostics.ndjson')
const stored = JSON.parse(await readFile(path, 'utf8'))
expect(stored.mcp).toEqual(mcp)
await writeFile(path, JSON.stringify({ ...stored, message: 'private-body', mcp: { ...mcp, token: 'private-token', url: 'private-url', stack: 'private-stack', status: 'private-status' } }) + '\n')
const restarted = new DesktopDiagnostics(directory)
try {
const exported = (await restarted.exportRecent()).toString('utf8')
expect(JSON.parse(exported).mcp).toEqual(mcp)
expect(exported).not.toContain('private-')
} finally { await restarted.dispose() }
})
it.each(['connect', 'disconnect'])('allowlists runtime MCP %s attempts', (phase) => {
const record = { timestamp: '2026-09-30T00:00:00Z', component: 'runtime', stage: phase, code: 'runtime.mcp.failed' }
const mcp = { phase, category: 'transport-failure', elapsedMs: 1000, attempt: 2, kind: 'custom', correlationId: `sha256:${'c'.repeat(64)}` }
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, error: 'private-error', token: 'private-token' } })?.mcp).toEqual(mcp)
for (const category of ['initialization-failure', 'cleanup-failure', 'cancelled']) {
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, category } })?.mcp?.category).toBe(category)
}
for (const attempt of [0, 3, 1.5, '1']) {
const normalized = normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, attempt, kind: 'private-kind' } })?.mcp
expect(normalized).not.toHaveProperty('attempt')
expect(normalized).not.toHaveProperty('kind')
expect(normalized?.correlationId).toBe(mcp.correlationId)
}
})
it('preserves renderer exit details on disk and in exports after restart', async () => {
const directory = await temporaryDirectory()
const diagnostics = new DesktopDiagnostics(directory)
+37 -1
View File
@@ -35,6 +35,7 @@ const allowedCodes = new Set([
'desktop.startup.failed',
'runtime.operation.failed',
'runtime.run.failed',
'runtime.mcp.failed',
'remote.connection.network',
'remote.connection.host-invalidated',
'remote.connection.host-identity',
@@ -68,6 +69,35 @@ export type DesktopDiagnosticComponent =
| 'runtime'
| 'remote-agent'
export type DesktopMcpDiagnosticMetadata = Readonly<{
phase: 'request' | 'authentication' | 'session' | 'initialize' | 'tool-discovery' | 'connect' | 'disconnect'
category: 'http-rejection' | 'handler-failure' | 'transport-failure' | 'initialization-failure' | 'cleanup-failure' | 'cancelled'
elapsedMs: number
status?: number
correlationId?: string
attempt?: 1 | 2
kind?: 'builtin' | 'custom'
}>
function normalizeMcpMetadata(value: unknown): DesktopMcpDiagnosticMetadata | undefined {
if (!value || typeof value !== 'object') return undefined
const candidate = value as DesktopMcpDiagnosticMetadata
if (!['request', 'authentication', 'session', 'initialize', 'tool-discovery', 'connect', 'disconnect'].includes(candidate.phase) ||
!['http-rejection', 'handler-failure', 'transport-failure', 'initialization-failure', 'cleanup-failure', 'cancelled'].includes(candidate.category) ||
!Number.isSafeInteger(candidate.elapsedMs) || candidate.elapsedMs < 0) return undefined
return Object.freeze({
phase: candidate.phase,
category: candidate.category,
elapsedMs: candidate.elapsedMs,
...(candidate.attempt === 1 || candidate.attempt === 2 ? { attempt: candidate.attempt } : {}),
...(candidate.kind === 'builtin' || candidate.kind === 'custom' ? { kind: candidate.kind } : {}),
...(Number.isInteger(candidate.status) && candidate.status! >= 100 && candidate.status! <= 599
? { status: candidate.status } : {}),
...(typeof candidate.correlationId === 'string' && /^sha256:[a-f0-9]{64}$/.test(candidate.correlationId)
? { correlationId: candidate.correlationId } : {})
})
}
export type DesktopDiagnosticFailure = Readonly<{
component: DesktopDiagnosticComponent
stage: string
@@ -75,6 +105,7 @@ export type DesktopDiagnosticFailure = Readonly<{
error: unknown
reason?: string
exitCode?: number
mcp?: DesktopMcpDiagnosticMetadata
}>
export type DesktopDiagnosticFailureObserver = (
@@ -90,6 +121,7 @@ export type DesktopDiagnosticRecord = Readonly<{
message: string
reason?: string
exitCode?: number
mcp?: DesktopMcpDiagnosticMetadata
}>
export type DesktopDiagnosticsOptions = Readonly<{
@@ -178,6 +210,8 @@ export function normalizeDesktopDiagnosticRecord(
const component = safeComponent(candidate.component)
const stage = safeStage(candidate.stage)
const code = safeCode(candidate.code)
const mcp = component === 'runtime' && code === 'runtime.mcp.failed'
? normalizeMcpMetadata(candidate.mcp) : undefined
const rendererGone = component === 'desktop' && stage === 'renderer' &&
code === 'desktop.renderer.gone'
return Object.freeze({
@@ -187,6 +221,7 @@ export function normalizeDesktopDiagnosticRecord(
code,
errorType: safeStoredErrorType(candidate.errorType),
message: fixedMessage(component, stage),
...(mcp ? { mcp } : {}),
...(rendererGone && typeof candidate.reason === 'string' &&
rendererExitReasons.has(candidate.reason) ? { reason: candidate.reason } : {}),
...(rendererGone && Number.isSafeInteger(candidate.exitCode)
@@ -266,7 +301,8 @@ export class DesktopDiagnostics {
code: failure.code,
errorType: safeErrorType(failure.error),
reason: failure.reason,
exitCode: failure.exitCode
exitCode: failure.exitCode,
mcp: failure.mcp
})
if (normalized === undefined) {
return Promise.reject(
@@ -113,6 +113,23 @@ describe('FeedbackService', () => {
code: 'runtime.run.failed',
errorType: 'TimeoutError',
message: 'password=hunter2'
},
{
timestamp: '2026-08-25T01:02:04.000Z',
component: 'runtime' as const,
stage: 'connect',
code: 'runtime.mcp.failed',
errorType: 'Error',
message: 'private-mcp-message',
mcp: {
phase: 'tool-discovery' as const,
category: 'handler-failure' as const,
elapsedMs: 123,
correlationId: `sha256:${'a'.repeat(64)}`,
token: 'private-mcp-token',
headers: { authorization: 'private-mcp-auth' },
url: 'private-mcp-url'
}
}
])
const fixture = service(
@@ -146,6 +163,11 @@ describe('FeedbackService', () => {
'{"timestamp":"2026-08-25T01:02:03.000Z","component":"runtime","stage":"run","code":"runtime.run.failed","errorType":"TimeoutError","message":"Runtime request failed"}'
)
expect(payload.description).not.toContain('hunter2')
expect(payload.description).toContain(JSON.stringify({
phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 123,
correlationId: `sha256:${'a'.repeat(64)}`
}))
expect(payload.description).not.toContain('private-mcp-')
expect(payload.description).toMatch(
/\n\[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_END\]$/u
)
+3
View File
@@ -1030,6 +1030,7 @@ if (hasSingleInstanceLock) {
const startupKnowledgeGateway = new KnowledgeMcpGateway(
startupKnowledgeService,
{
observeFailure: observeDesktopFailure,
storyGraphService: {
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
@@ -1078,6 +1079,7 @@ if (hasSingleInstanceLock) {
: Promise.resolve([])
])
return createAgentRuntime(defaultWorkspace, settings, {
observeFailure: observeDesktopFailure,
localRuntimeRegistry,
skillInstructions: skillContext.instructions,
skillPackages: skillContext.packages,
@@ -1495,6 +1497,7 @@ if (hasSingleInstanceLock) {
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
openExternal: (url) => shell.openExternal(url),
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
observeFailure: observeDesktopFailure,
storyGraphService: {
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
+229 -3
View File
@@ -55,6 +55,34 @@ const speechRecognitionMocks = vi.hoisted(() => ({
const messageRenderProbe = vi.hoisted(() => vi.fn());
const assistantTasksProbe = vi.hoisted(() => vi.fn());
const activityRenderProbe = vi.hoisted(() => vi.fn());
const historyRenderProbes = vi.hoisted(() => ({
pane: vi.fn(), task: vi.fn(), checklist: vi.fn(),
instrument<T>(component: T, probe: () => void): T {
const wrap = (render: (...args: never[]) => unknown) => new Proxy(render, {
apply(target, receiver, args) {
probe();
return Reflect.apply(target, receiver, args);
},
});
// Preserve the production memo boundary, and count calls inside it.
return (typeof component === 'function'
? wrap(component as (...args: never[]) => unknown)
: { ...component, type: wrap((component as { type: (...args: never[]) => unknown }).type) }) as T;
},
}));
vi.mock('./ChatHistoryPane', async (importOriginal) => {
const original = await importOriginal<typeof import('./ChatHistoryPane')>();
return { ...original, ChatHistoryPane: historyRenderProbes.instrument(original.ChatHistoryPane, historyRenderProbes.pane) };
});
vi.mock('./ConversationTaskStrip', async (importOriginal) => {
const original = await importOriginal<typeof import('./ConversationTaskStrip')>();
return { ...original, ConversationTaskStrip: historyRenderProbes.instrument(original.ConversationTaskStrip, historyRenderProbes.task) };
});
vi.mock('./RuntimeChecklistStrip', async (importOriginal) => {
const original = await importOriginal<typeof import('./RuntimeChecklistStrip')>();
return { ...original, RuntimeChecklistStrip: historyRenderProbes.instrument(original.RuntimeChecklistStrip, historyRenderProbes.checklist) };
});
vi.mock("./WorkspacePrimitives", async (importOriginal) => {
const original = await importOriginal<typeof import("./WorkspacePrimitives")>();
@@ -1183,6 +1211,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
let settings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3263,6 +3292,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3367,6 +3397,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3383,6 +3414,7 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3485,6 +3517,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -3501,6 +3534,7 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -4498,7 +4532,88 @@ describe("App", () => {
expect(screen.getByRole("button", { name: "到底部" })).toBeInTheDocument();
});
it("keeps each conversation history window and reader position", async () => {
it('retries into the active draft after warm conversation switches', async () => {
const histories: ConversationSnapshot[] = ['First retry', 'Second retry'].map((title, index) => ({
id: crypto.randomUUID(), projectId, title, updatedAt: 2 - index,
messages: [
{ id: crypto.randomUUID(), role: 'user', content: `${title} prompt`, createdAt: 1, state: 'complete' },
{ id: crypto.randomUUID(), role: 'assistant', content: `${title} failed`, createdAt: 2, state: 'error' },
],
}));
vi.mocked(api.conversations.list).mockResolvedValueOnce(histories);
render(<App />);
await screen.findByText('First retry failed');
fireEvent.click(screen.getByText('Second retry').closest('button')!);
await screen.findByText('Second retry failed');
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
const composer = screen.getByLabelText('向 GoodBuddy 提问');
expect(composer).toHaveValue('Second retry prompt');
expect(composer).toHaveFocus();
fireEvent.click(screen.getByText('First retry').closest('button')!);
expect(composer).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
expect(composer).toHaveValue('First retry prompt');
fireEvent.click(screen.getByText('Second retry').closest('button')!);
expect(composer).toHaveValue('Second retry prompt');
expect(run).not.toHaveBeenCalled();
});
it("isolates history and task strips while typing during a running request", async () => {
const conversationId = crypto.randomUUID();
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
id: conversationId, projectId, title: 'Running render isolation', updatedAt: 2, messages: [],
}]);
vi.mocked(api.tasks.list).mockResolvedValue([{
id: crypto.randomUUID(), conversationId, projectId, title: 'Running scheduled task',
instructions: 'Keep task details live', origin: 'schedule', status: 'running',
createdAt: '2026-09-30T00:00:00.000Z',
}]);
render(<App />);
const taskStrip = await screen.findByRole('region', { name: '当前会话的任务' });
fireEvent.click(within(taskStrip).getByRole('button'));
expect(within(taskStrip).getByText('运行中')).toBeVisible();
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
fireEvent.change(composer, { target: { value: 'Running render isolation' } });
await waitFor(() => expect(screen.getByLabelText('加入待发送队列')).toBeEnabled());
fireEvent.click(screen.getByLabelText('加入待发送队列'));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
act(() => agentListener?.({ requestId, type: 'text', delta: 'Live response' }));
await screen.findByText('Live response');
act(() => agentListener?.({ requestId, type: 'checklist', checklist: {
source: 'opencode', items: [{ content: 'Working while typing', status: 'in_progress' }],
} }));
await screen.findByText('Working while typing');
expect(taskStrip).toBeVisible();
historyRenderProbes.pane.mockClear();
historyRenderProbes.task.mockClear();
historyRenderProbes.checklist.mockClear();
messageRenderProbe.mockClear();
const storageReads = vi.spyOn(Storage.prototype, 'getItem');
for (const value of ['a', 'ab', 'abc']) {
fireEvent.change(composer, { target: { value } });
}
expect.soft(historyRenderProbes.pane).toHaveBeenCalledTimes(0);
expect.soft(historyRenderProbes.task).toHaveBeenCalledTimes(0);
expect.soft(historyRenderProbes.checklist).toHaveBeenCalledTimes(0);
expect.soft(messageRenderProbe).toHaveBeenCalledTimes(0);
expect.soft(storageReads.mock.calls.filter(([key]) => key === 'goodbuddy.conversations.v1')).toHaveLength(0);
storageReads.mockRestore();
act(() => agentListener?.({ requestId, type: 'text', delta: ' continues' }));
await screen.findByText('Live response continues');
expect(historyRenderProbes.pane).toHaveBeenCalled();
expect(historyRenderProbes.checklist).toHaveBeenCalled();
expect(historyRenderProbes.task).toHaveBeenCalledTimes(0);
act(() => agentListener?.({ requestId, type: 'checklist', checklist: {
source: 'opencode', items: [{ content: 'Checklist updated live', status: 'in_progress' }],
} }));
await screen.findByText('Checklist updated live');
expect(composer).toHaveValue('abc');
fireEvent.click(screen.getByLabelText('停止生成'));
await waitFor(() => expect(api.agent.cancel).toHaveBeenCalledWith(requestId));
});
it("keeps each conversation history window and reader position on warm switches without rerendering messages", async () => {
const firstConversationId = "00000000-0000-4000-8000-000000000461";
const secondConversationId = "00000000-0000-4000-8000-000000000462";
const draftAttachment = {
@@ -4602,6 +4717,21 @@ describe("App", () => {
"第一段会话草稿",
);
expect(screen.getByText(draftAttachment.name)).toBeInTheDocument();
messageRenderProbe.mockClear();
historyRenderProbes.task.mockClear();
historyRenderProbes.checklist.mockClear();
fireEvent.click(screen.getByText("第二段会话").closest("button")!);
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("第二段会话草稿"));
fireEvent.click(screen.getByText("第一段长会话").closest("button")!);
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("第一段会话草稿"));
expect(messageRenderProbe).toHaveBeenCalledTimes(0);
expect(historyRenderProbes.task).toHaveBeenCalledTimes(0);
expect(historyRenderProbes.checklist).toHaveBeenCalledTimes(0);
expect(firstChat.scrollTop).toBe(225);
expect(reasoningDetails).toHaveAttribute('open');
historyRenderProbes.pane.mockClear();
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Warm cached draft' } });
expect(historyRenderProbes.pane).toHaveBeenCalledTimes(0);
});
it("requires an accessible confirmation before permanently deleting a conversation", async () => {
@@ -5798,6 +5928,7 @@ describe("App", () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -6829,6 +6960,12 @@ describe("App", () => {
await within(composer).findByText("需求说明.md"),
).toBeInTheDocument();
expect(within(composer).getByText("页面截图.png")).toBeInTheDocument();
const draftImage = within(composer).getByRole("button", { name: "查看图片 页面截图.png" });
fireEvent.click(draftImage);
const draftViewer = await screen.findByRole("dialog", { name: "页面截图.png" });
expect(within(draftViewer).getByRole("img")).toHaveAttribute("src", imageAttachment.contentUrl);
fireEvent.click(within(draftViewer).getByRole("button", { name: "关闭图片查看器" }));
await waitFor(() => expect(draftImage).toHaveFocus());
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "分析这些附件" },
});
@@ -7705,6 +7842,77 @@ describe("App", () => {
expect(localStorage.getItem("goodbuddy.appearance-theme")).toBe("dark");
});
it('applies frosted glass only after saving and removes it when disabled', async () => {
const updates = api.updates!
const { container, unmount } = render(<App />)
const shell = container.querySelector('.app-shell')
expect(shell).not.toHaveAttribute('data-frosted-glass')
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
fireEvent.click(await screen.findByRole('button', { name: '设置' }))
fireEvent.click(await screen.findByRole('tab', { name: '外观' }))
const toggle = screen.getByRole('switch', { name: '透明磨砂特效' })
expect(toggle.closest('.toggle-row')?.firstElementChild).toHaveTextContent('透明磨砂特效')
expect(toggle.closest('.toggle-row')?.lastElementChild).toBe(toggle)
await waitFor(() => expect(toggle).toBeEnabled())
const saved = { ...await updates.getSettings(), transparentFrostedEffectEnabled: true }
let resolveSave!: (settings: ApplicationSettings) => void
vi.mocked(updates.updateSettings).mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
fireEvent.click(toggle)
expect(toggle).toBeDisabled()
expect(toggle).not.toBeChecked()
expect(shell).not.toHaveAttribute('data-frosted-glass')
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
expect(updates.updateSettings).toHaveBeenLastCalledWith({ transparentFrostedEffectEnabled: true })
await act(async () => resolveSave(saved))
expect(toggle).toBeChecked()
expect(toggle).toBeEnabled()
expect(shell).toHaveAttribute('data-frosted-glass', 'true')
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
fireEvent.click(toggle)
await waitFor(() => {
expect(toggle).not.toBeChecked()
expect(toggle).toBeEnabled()
expect(shell).not.toHaveAttribute('data-frosted-glass')
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
})
expect(updates.updateSettings).toHaveBeenLastCalledWith({ transparentFrostedEffectEnabled: false })
fireEvent.click(toggle)
await waitFor(() => expect(shell).toHaveAttribute('data-frosted-glass', 'true'))
unmount()
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
const reloaded = render(<App />)
await waitFor(() => expect(reloaded.container.querySelector('.app-shell')).toHaveAttribute('data-frosted-glass', 'true'))
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
})
it('retains confirmed frosted glass after a failed save and allows rereading locked settings', async () => {
const updates = api.updates!
await updates.updateSettings({ transparentFrostedEffectEnabled: true })
const { container } = render(<App />)
fireEvent.click(await screen.findByRole('button', { name: '设置' }))
fireEvent.click(await screen.findByRole('tab', { name: '外观' }))
const toggle = screen.getByRole('switch', { name: '透明磨砂特效' })
await waitFor(() => {
expect(toggle).toBeEnabled()
expect(toggle).toBeChecked()
})
vi.mocked(updates.updateSettings).mockRejectedValueOnce(new Error('response lost'))
vi.mocked(updates.getSettings).mockRejectedValueOnce(new Error('read unavailable'))
fireEvent.click(toggle)
await screen.findByText('无法确认保存结果,请重新读取后再修改。')
expect(toggle).toBeChecked()
expect(toggle).toBeDisabled()
expect(container.querySelector('.app-shell')).toHaveAttribute('data-frosted-glass', 'true')
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => expect(toggle).toBeEnabled())
fireEvent.click(toggle)
await waitFor(() => {
expect(toggle).not.toBeChecked()
expect(container.querySelector('.app-shell')).not.toHaveAttribute('data-frosted-glass')
})
})
it("loads token usage in activity and refreshes it when a run finishes", async () => {
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
totals: {
@@ -11651,6 +11859,7 @@ describe("App", () => {
it('recovers failed image editing through settings and composer attachments without resubmitting the operation', async () => {
const conversationId = crypto.randomUUID();
const otherId = crypto.randomUUID();
const operation: import('../../shared/image-generation-contracts').ImageOperation = {
id: crypto.randomUUID(), conversationId, messageId: crypto.randomUUID(), requestId: crypto.randomUUID(), callId: 'failed-edit',
modelProfileId: crypto.randomUUID(), modelName: 'original-image-model', modelProfileName: 'Original image connection',
@@ -11659,14 +11868,23 @@ describe("App", () => {
};
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: conversationId, projectId, title: 'Failed image editing', updatedAt: 2, workMode: 'execute', messages: [
{ id: operation.messageId, role: 'assistant', content: '', createdAt: 1, state: 'complete', imageOperations: [operation] },
] }]);
] }, { id: otherId, projectId, title: 'Other image draft', updatedAt: 1, messages: [] }]);
const source = { id: crypto.randomUUID(), name: 'replacement.png', size: 64, preview: 'Replacement image', kind: 'image' as const, contentUrl: 'data:image/png;base64,iVBORw0KGgo=' };
vi.mocked(api.context.selectFiles).mockResolvedValueOnce([source]);
const selection = deferred<ContextAttachment[]>();
vi.mocked(api.context.selectFiles).mockReturnValueOnce(selection.promise);
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: '重新选择素材' }));
const composer = screen.getByLabelText('向 GoodBuddy 提问');
expect(composer).toHaveValue('使用图片模型“Original image connection”:Change the background');
expect(composer).toHaveFocus();
await waitFor(() => expect(api.context.selectFiles).toHaveBeenCalledWith(conversationId));
fireEvent.click(screen.getByText('Other image draft').closest('button')!);
fireEvent.change(composer, { target: { value: 'Keep other image draft' } });
await act(async () => selection.resolve([source]));
expect(composer).toHaveValue('Keep other image draft');
expect(within(composer.closest<HTMLElement>('.composer')!).queryByText('replacement.png')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Failed image editing').closest('button')!);
expect(composer).toHaveValue('使用图片模型“Original image connection”:Change the background');
expect(await within(composer.closest<HTMLElement>('.composer')!).findByText('replacement.png')).toBeVisible();
expect(api.context.selectFiles).toHaveBeenCalledOnce();
expect(run).not.toHaveBeenCalled();
@@ -13708,6 +13926,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -13724,6 +13943,7 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -13777,6 +13997,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -13837,6 +14058,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -13874,6 +14096,7 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -13890,6 +14113,7 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -13952,6 +14176,7 @@ describe("App", () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -14011,6 +14236,7 @@ describe("App", () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
+80 -67
View File
@@ -575,6 +575,7 @@ const emptyTokenUsage: TokenUsageSummary = {
};
const storageKey = "goodbuddy.conversations.v1";
const emptyConversationTasks: AssistantTask[] = [];
const activeProjectStorageKey = "goodbuddy.active-project.v1";
@@ -1657,9 +1658,8 @@ function App(): React.JSX.Element {
tRef.current = t;
}, [t]);
const locale = i18n.resolvedLanguage === "en-US" ? "en-US" : "zh-CN";
const conversationMigrationStoragePresent = useRef(
hasConversationMigrationStorage(),
);
const [initialConversationMigrationStoragePresent] = useState(hasConversationMigrationStorage);
const conversationMigrationStoragePresent = useRef(initialConversationMigrationStoragePresent);
const [conversations, setConversations] = useState(() =>
loadConversations(
t("conversation.greeting"),
@@ -1773,10 +1773,10 @@ function App(): React.JSX.Element {
Record<string, string>
>({});
const input = conversationDrafts[activeId] ?? "";
const setInput = useCallback(
(update: SetStateAction<string>): void => {
const setConversationInput = useCallback(
(conversationId: string, update: SetStateAction<string>): void => {
setConversationDrafts((current) => {
const currentValue = current[activeId] ?? "";
const currentValue = current[conversationId] ?? "";
const nextValue =
typeof update === "function" ? update(currentValue) : update;
if (nextValue === currentValue) {
@@ -1784,13 +1784,18 @@ function App(): React.JSX.Element {
}
if (!nextValue) {
const next = { ...current };
delete next[activeId];
delete next[conversationId];
return next;
}
return { ...current, [activeId]: nextValue };
return { ...current, [conversationId]: nextValue };
});
},
[activeId],
[],
);
// Async composer operations retain the conversation that started them.
const setInput = useCallback(
(update: SetStateAction<string>): void => setConversationInput(activeId, update),
[activeId, setConversationInput],
);
const [voiceListening, setVoiceListening] = useState(false);
const [voiceRecording, setVoiceRecording] = useState(false);
@@ -1991,6 +1996,14 @@ function App(): React.JSX.Element {
const [applicationMenuOpen, setApplicationMenuOpen] = useState(false);
const applicationMenuTriggerRef = useRef<HTMLButtonElement>(null);
const [applicationSettings, setApplicationSettings] = useState<ApplicationSettings>();
useLayoutEffect(() => {
// FloatingPortal surfaces live outside app-shell, including in the top layer.
delete document.documentElement.dataset.frostedGlass;
if (applicationSettings?.transparentFrostedEffectEnabled) {
document.documentElement.dataset.frostedGlass = 'true';
}
return () => { delete document.documentElement.dataset.frostedGlass; };
}, [applicationSettings?.transparentFrostedEffectEnabled]);
const [applicationSettingsPending, setApplicationSettingsPending] = useState(false);
const [applicationSettingsUnconfirmed, setApplicationSettingsUnconfirmed] = useState(false);
const applicationSettingsPendingRef = useRef(0);
@@ -2449,11 +2462,15 @@ function App(): React.JSX.Element {
>({});
const retryMessage = useCallback(
(content: string): void => {
setInput(content);
setConversationInput(activeConversationIdRef.current, content);
inputRef.current?.focus();
},
[setInput],
[setConversationInput],
);
const setQuickActionInput = useCallback((value: string): void => {
setConversationInput(activeConversationIdRef.current, value);
requestAnimationFrame(() => inputRef.current?.focus());
}, [setConversationInput]);
useEffect(
() =>
scheduleIdleRoutePreload(
@@ -7690,14 +7707,14 @@ function App(): React.JSX.Element {
}, [setView]);
const reselectImageSources = useCallback((operation: ImageOperation): void => {
if (operation.conversationId !== activeId) return;
setInput(current => current || t("chat.images.recoveryPrompt", {
if (operation.conversationId !== activeConversationIdRef.current) return;
setConversationInput(operation.conversationId, current => current || t("chat.images.recoveryPrompt", {
model: operation.modelProfileName ?? operation.modelName,
prompt: operation.input.prompt,
}));
inputRef.current?.focus();
attachmentButtonRef.current?.click();
}, [activeId, setInput, t]);
}, [setConversationInput, t]);
const selectContextFiles = async (paths?: string[]): Promise<void> => {
if (selectingContextFilesRef.current) {
@@ -8206,16 +8223,16 @@ function App(): React.JSX.Element {
return schedule;
};
const runAssistantSchedule = async (scheduleId: string): Promise<void> => {
const runAssistantSchedule = useCallback(async (scheduleId: string): Promise<void> => {
await window.goodbuddy.schedules.runNow(scheduleId);
setAssistantTasks(await window.goodbuddy.tasks.list());
notify({
tone: "success",
message: t("notices.scheduleStarted"),
});
};
}, [notify, t]);
const setAssistantScheduleEnabled = async (
const setAssistantScheduleEnabled = useCallback(async (
scheduleId: string,
enabled: boolean,
): Promise<void> => {
@@ -8225,14 +8242,39 @@ function App(): React.JSX.Element {
schedule.id === scheduleId ? { ...schedule, enabled } : schedule,
),
);
};
}, []);
const removeAssistantSchedule = async (scheduleId: string): Promise<void> => {
const removeAssistantSchedule = useCallback(async (scheduleId: string): Promise<void> => {
await window.goodbuddy.schedules.remove(scheduleId);
setAssistantSchedules((current) =>
current.filter((schedule) => schedule.id !== scheduleId),
);
};
}, []);
const conversationTaskStrips = useMemo(() => new Map(cachedConversations.map(conversation => {
const tasks = tasksByConversation.get(conversation.id) ?? emptyConversationTasks;
return [conversation.id, (
<div className="conversation-context-strips">
{!conversation.remote && (
<ConversationTaskStrip
conversationMode={conversation.id === activeId
? effectiveWorkMode
: normalizeInteractiveWorkMode(conversation.workMode ?? projects.find(project => project.id === conversation.projectId)?.defaultWorkMode)}
locale={locale}
onRemoveSchedule={removeAssistantSchedule}
onRunSchedule={runAssistantSchedule}
onSelectTask={setSelectedAssistantTaskId}
onSetScheduleEnabled={setAssistantScheduleEnabled}
schedules={assistantSchedules}
selectedTaskId={tasks.some(task => task.id === selectedAssistantTaskId) ? selectedAssistantTaskId : undefined}
tasks={tasks}
/>
)}
<RuntimeChecklistStrip messages={conversation.messages} activeMessageId={conversation.activeRequest?.messageId} />
</div>
)];
})), [cachedConversations, tasksByConversation, activeId, effectiveWorkMode, projects, locale,
removeAssistantSchedule, runAssistantSchedule, setAssistantScheduleEnabled, assistantSchedules, selectedAssistantTaskId]);
const clearLocalData = async (): Promise<void> => {
conversationPersistencePausedRef.current = true;
@@ -8525,7 +8567,7 @@ function App(): React.JSX.Element {
};
return (
<div className="app-shell">
<div className="app-shell" data-frosted-glass={applicationSettings?.transparentFrostedEffectEnabled ? 'true' : undefined}>
<DocumentConversationContext value={{
activeId: activeConversation?.remote ? undefined : activeId,
create: () => new Promise((resolve, reject) => {
@@ -9504,55 +9546,13 @@ function App(): React.JSX.Element {
onRespondQuestion={respondToQuestion}
onRetry={retryMessage}
onScrollSnapshotChange={handleChatScrollSnapshotChange}
onSetInput={(value) => {
setInput(value);
requestAnimationFrame(() =>
inputRef.current?.focus(),
);
}}
onSetInput={setQuickActionInput}
onVisibleMessageCountChange={
handleVisibleMessageCountChange
}
quickActions={quickActions}
scrollSnapshot={chatScrollSnapshots[conversation.id]}
taskStrip={
<div className="conversation-context-strips">
{!conversation.remote ? (
<ConversationTaskStrip
conversationMode={
conversation.id === activeId
? effectiveWorkMode
: normalizeInteractiveWorkMode(
conversation.workMode ??
projects.find((project) => project.id === conversation.projectId)?.defaultWorkMode
)
}
locale={locale}
onRemoveSchedule={removeAssistantSchedule}
onRunSchedule={runAssistantSchedule}
onSelectTask={setSelectedAssistantTaskId}
onSetScheduleEnabled={setAssistantScheduleEnabled}
schedules={assistantSchedules}
selectedTaskId={
(
tasksByConversation.get(conversation.id) ?? []
).some(
(task) => task.id === selectedAssistantTaskId,
)
? selectedAssistantTaskId
: undefined
}
tasks={
tasksByConversation.get(conversation.id) ?? []
}
/>
) : undefined}
<RuntimeChecklistStrip
messages={conversation.messages}
activeMessageId={conversation.activeRequest?.messageId}
/>
</div>
}
taskStrip={conversationTaskStrips.get(conversation.id)}
visibleMessageCount={
visibleMessageCounts[conversation.id] ??
messageRenderBatchSize
@@ -9664,12 +9664,19 @@ function App(): React.JSX.Element {
title={attachment.preview}
>
{attachment.kind === "image" &&
attachment.thumbnailUrl ? (
(attachment.thumbnailUrl || attachment.contentUrl) ? (
<button
type="button"
className="message-image-button"
aria-label={t('chat.images.viewNamed', { title: attachment.name })}
onClick={(event) => openImageViewer({ src: attachment.contentUrl ?? attachment.thumbnailUrl!, title: attachment.name }, event.currentTarget)}
>
<img
alt=""
className="context-chip__thumbnail"
src={attachment.thumbnailUrl}
src={attachment.thumbnailUrl ?? attachment.contentUrl}
/>
</button>
) : (
<FileText size={14} />
)}
@@ -10972,6 +10979,12 @@ function App(): React.JSX.Element {
<Suspense fallback={null}>
<SettingsPanel
appearanceTheme={appearanceTheme}
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? false}
applicationSettingsPending={applicationSettingsPending}
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
applicationSettingsError={applicationSettingsError}
onTransparentFrostedEffectEnabledChange={(enabled) => updateApplicationSettings({ transparentFrostedEffectEnabled: enabled })}
onRetryApplicationSettings={() => void reloadApplicationSettings()}
brandingFallbackLogo={
resolvedAppearanceTheme === "dark"
? goodbuddyDarkIcon
+4 -3
View File
@@ -60,10 +60,11 @@ it('keeps attachment actions icon-only and named, and restores keyboard focus af
})
it('keeps queued attachments read-only and exposes no action for legacy attachments without assets', async () => {
const { rerender } = render(<AttachmentActions attachment={attachment} readOnly />)
const { rerender } = render(<AttachmentActions attachment={{ ...attachment, sendMode: 'text' }} conversationId="conversation" readOnly />)
fireEvent.click(screen.getByRole('button'))
await screen.findByText('解析来源:HTTP PaddleOCR-VL(远程服务)')
expect(screen.getAllByRole('menuitem')).toHaveLength(2)
expect(screen.queryByText(/解析来源/)).not.toBeInTheDocument()
expect(window.goodbuddy.documentParsing!.getSnapshot).not.toHaveBeenCalled()
expect(screen.getAllByRole('menuitem')).toHaveLength(1)
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
rerender(<AttachmentActions attachment={{ ...attachment, resourceId: undefined }} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
+5 -5
View File
@@ -31,24 +31,24 @@ export function AttachmentActions({ attachment, conversationId, onBusyChange, re
return <>
<button type="button" className="icon-button attachment-action" title="更多附件操作" data-tooltip="更多附件操作" ref={anchor} aria-haspopup="menu" aria-expanded={menu} aria-controls={menu ? id : undefined}
aria-label={`更多附件操作:${attachment.name}`} disabled={Boolean(operation)} onClick={() => {
if (!menu) {
if (!menu && !readOnly) {
setProvider('正在读取已保存的解析来源')
void window.goodbuddy.documentParsing!.getSnapshot().then((snapshot) => setProvider(snapshot.settings.ocrProvider === 'paddleocr-vl' ? '解析来源:HTTP PaddleOCR-VL(远程服务)' : '解析来源:本地 OCR'), () => setProvider('来源读取失败;操作仍使用已保存的解析设置'))
}
setMenu((value) => !value)
}}><Ellipsis size={16} aria-hidden="true" /></button>
{menu && <AnchoredMenu anchorRef={anchor} id={id} label={`附件操作:${attachment.name}`} onClose={() => setMenu(false)}>
<small>{provider}</small>
{!readOnly && <small>{provider}</small>}
<button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.openOriginal(attachment.resourceId!))}>打开原文件:{attachment.originalName ?? attachment.name}</button>
{conversationId && <button type="button" role="menuitem" onClick={() => run((operationId) => window.goodbuddy.context.reparseDraft(conversationId, attachment.id, operationId))}>
{!readOnly && conversationId && <button type="button" role="menuitem" onClick={() => run((operationId) => window.goodbuddy.context.reparseDraft(conversationId, attachment.id, operationId))}>
{attachment.sendMode ? '提取图片文字' : '使用当前设置重新解析'}:{attachment.originalName ?? attachment.name}
</button>}
{conversationId && attachment.sendMode === 'text' && <button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.sendOriginal(conversationId, attachment.id))}>改为发送原图:{attachment.originalName}</button>}
{!readOnly && conversationId && attachment.sendMode === 'text' && <button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.sendOriginal(conversationId, attachment.id))}>改为发送原图:{attachment.originalName}</button>}
{!readOnly && !conversationId && target?.activeId && <button type="button" role="menuitem" onClick={() => run(async (operationId) => {
await window.goodbuddy.context.copyToDraft(target.activeId!, attachment.resourceId!, operationId)
target.notify('已按当前设置解析并添加到目标会话草稿')
})}>{attachment.sendMode ? '添加到当前草稿并提取文字' : '添加到当前草稿重新解析'}:{attachment.originalName ?? attachment.name}</button>}
<button type="button" role="menuitem" onClick={() => { setMenu(false); void window.goodbuddy.localInference.openSettings('document-parsing') }}>前往文档解析设置</button>
{!readOnly && <button type="button" role="menuitem" onClick={() => { setMenu(false); void window.goodbuddy.localInference.openSettings('document-parsing') }}>前往文档解析设置</button>}
</AnchoredMenu>}
{operation && <span role="status">正在处理附件<button type="button" className="secondary-button" onClick={() => void window.goodbuddy.context.cancelParsing(operation)}>取消解析</button></span>}
{error && <span role="alert">{error}</span>}
@@ -58,7 +58,6 @@ export function CapabilitiesAndToolsSettingsSection({
}
]}
value={activeTab}
variant="segmented"
/>
</div>
}
+82 -78
View File
@@ -1,5 +1,6 @@
import { ArrowDown, FileText, Sparkles } from "lucide-react";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
@@ -34,7 +35,7 @@ export type ChatScrollSnapshot = {
scrollTop: number;
};
export function ChatHistoryPane({
export const ChatHistoryPane = memo(function ChatHistoryPane({
onAddToNote,
noteMessageNavigation,
onOpenImageModelSettings,
@@ -103,6 +104,7 @@ export function ChatHistoryPane({
const { t } = useTranslation("app");
const headingId = `chat-heading-${conversation.id}`;
const scrollRef = useRef<HTMLElement>(null);
const contextRef = useRef<HTMLDivElement>(null);
const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true);
const latestScrollSnapshotRef = useRef(scrollSnapshot);
const restorePendingRef = useRef(true);
@@ -129,6 +131,29 @@ export function ChatHistoryPane({
);
const hiddenMessageCount = visibleMessageStartIndex;
useLayoutEffect(() => {
const context = contextRef.current;
const scrollContainer = scrollRef.current;
const content = scrollContainer?.querySelector<HTMLElement>('.chat-content');
if (!active || !context || !scrollContainer || !content) return;
const measure = (): void => {
// The fixed context strip also needs space inside the scrolling content.
context.parentElement?.style.setProperty(
'--chat-context-height', `${context.getBoundingClientRect().height}px`,
);
};
measure();
if (typeof ResizeObserver !== 'function') return;
const observer = new ResizeObserver(() => {
measure();
if (restorePendingRef.current || !pinnedToBottomRef.current) return;
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'auto' });
});
observer.observe(context);
observer.observe(content);
return () => observer.disconnect();
}, [active]);
useEffect(() => {
if (!active || noteMessageNavigation?.conversationId !== conversation.id) return;
const element = messageArticleRefs.current.get(noteMessageNavigation.messageId);
@@ -231,29 +256,6 @@ export function ChatHistoryPane({
}
}, [active, conversation.messages, scrollSnapshot, visibleMessageCount]);
useLayoutEffect(() => {
if (!active || typeof ResizeObserver !== "function") {
return;
}
const scrollContainer = scrollRef.current;
const messageList =
scrollContainer?.querySelector<HTMLElement>(".message-list");
if (!scrollContainer || !messageList) {
return;
}
const observer = new ResizeObserver(() => {
if (restorePendingRef.current || !pinnedToBottomRef.current) {
return;
}
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "auto",
});
});
observer.observe(messageList);
return () => observer.disconnect();
}, [active]);
useLayoutEffect(() => {
const previous = prependScrollPositionRef.current;
if (!previous) {
@@ -329,7 +331,7 @@ export function ChatHistoryPane({
<h1 className="sr-only" id={headingId}>
{t("chat.heading")}
</h1>
{taskStrip}
<div className="chat-history-context" ref={contextRef}>{taskStrip}</div>
<section
aria-labelledby={headingId}
className="chat"
@@ -337,59 +339,61 @@ export function ChatHistoryPane({
onScroll={updateScrollPosition}
ref={handleScrollRef}
>
{isUnusedConversation(conversation) && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
<div className="chat-content">
{isUnusedConversation(conversation) && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<h2>{t("chat.welcome.title")}</h2>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
onClick={() => onSetInput(action.prompt)}
type="button"
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
<h2>{t("chat.welcome.title")}</h2>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
onClick={() => onSetInput(action.prompt)}
type="button"
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
)}
<ChatTimeline
onOpenImageModelSettings={onOpenImageModelSettings}
onReselectImageSources={onReselectImageSources}
onEditImage={onEditImage}
artifactById={artifactById}
conversationId={conversation.id}
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
onOpenImage={onOpenImage}
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === "user"
? conversation.messages.at(-2)?.content
: undefined
}
totalMessageCount={conversation.messages.length}
/>
)}
<ChatTimeline
onOpenImageModelSettings={onOpenImageModelSettings}
onReselectImageSources={onReselectImageSources}
onEditImage={onEditImage}
artifactById={artifactById}
conversationId={conversation.id}
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
onOpenImage={onOpenImage}
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === "user"
? conversation.messages.at(-2)?.content
: undefined
}
totalMessageCount={conversation.messages.length}
/>
</div>
</section>
{active && showScrollToBottom && (
<button
@@ -405,4 +409,4 @@ export function ChatHistoryPane({
)}
</div>
);
}
});
+3 -3
View File
@@ -4,7 +4,7 @@ import {
CircleAlert,
ClockFading
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
@@ -31,7 +31,7 @@ type ConversationTaskStripProps = {
tasks: AssistantTask[]
}
export function ConversationTaskStrip({
export const ConversationTaskStrip = memo(function ConversationTaskStrip({
conversationMode,
locale,
onRemoveSchedule,
@@ -213,4 +213,4 @@ export function ConversationTaskStrip({
)}
</section>
)
}
})
@@ -74,6 +74,7 @@ const todo: MagicTodoItem = {
const otherTodo: MagicTodoItem = { ...todo, id: '00000000-0000-4000-8000-000000000604', noteId: secondNoteId, noteTitle: second.title, title: '准备演示' }
const settings: ApplicationSettings = {
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
@@ -1,9 +1,38 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { QueuedAttachmentsDialog } from './QueuedAttachmentsDialog'
import { DocumentConversationContext } from './DocumentConversationContext'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it.each([true, false])('previews queued images without parsing (full image available: %s)', async (fullImage) => {
const thumbnailUrl = 'data:image/png;base64,thumbnail'
const contentUrl = fullImage ? 'data:image/png;base64,full' : undefined
const openImage = vi.fn()
const getSnapshot = vi.fn()
const reparseDraft = vi.fn()
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
vi.stubGlobal('goodbuddy', {
conversationQueue: { getAttachments: vi.fn().mockResolvedValue([
{ id: 'image', resourceId: 'image', name: 'screenshot.png', kind: 'image', sendMode: 'image', thumbnailUrl, contentUrl }
]) },
documentParsing: { getSnapshot }, context: { reparseDraft }
})
render(<DocumentConversationContext.Provider value={{ openImage, create: vi.fn(), navigate: vi.fn(), notify: vi.fn() }}>
<QueuedAttachmentsDialog itemId="queued" onClose={vi.fn()} />
</DocumentConversationContext.Provider>)
expect(await screen.findByRole('img', { name: 'screenshot.png' })).toHaveAttribute('src', thumbnailUrl)
const trigger = screen.getByRole('button', { name: '查看图片 screenshot.png' })
fireEvent.click(trigger)
expect(openImage).toHaveBeenCalledWith(contentUrl ?? thumbnailUrl, 'screenshot.png', trigger)
fireEvent.click(screen.getByRole('button', { name: '更多附件操作:screenshot.png' }))
expect(screen.getAllByRole('menuitem')).toHaveLength(1)
expect(screen.getByRole('menuitem')).toHaveTextContent('打开原文件')
expect(screen.queryByRole('button', { name: /查看解析结果/ })).not.toBeInTheDocument()
expect(getSnapshot).not.toHaveBeenCalled()
expect(reparseDraft).not.toHaveBeenCalled()
})
it('opens snapshot guidance without hiding empty state or closing the dialog on Escape', async () => {
const onClose = vi.fn()
vi.stubGlobal('goodbuddy', { conversationQueue: { getAttachments: vi.fn().mockResolvedValue([]) } })
+7 -1
View File
@@ -1,13 +1,15 @@
import { useEffect, useRef, useState } from 'react'
import { useContext, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { ContextAttachment } from '../../shared/contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { AttachmentResultButton } from './AttachmentResultButton'
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
import { InlineHelp } from './InlineHelp'
import { DocumentConversationContext } from './DocumentConversationContext'
export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; onClose: () => void }): React.JSX.Element {
const close = useRef<HTMLButtonElement>(null)
const conversationContext = useContext(DocumentConversationContext)
const [items, setItems] = useState<ContextAttachment[]>()
const [error, setError] = useState('')
useEffect(() => activateModalFocus(() => close.current), [])
@@ -29,6 +31,10 @@ export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; o
{items?.length === 0 && <p>此输入没有附件。</p>}
<div className="document-result-panel">{items?.map((item) => <article key={item.id}>
<h3>{item.name}</h3>
{item.kind === 'image' && (item.thumbnailUrl || item.contentUrl) && <div className="document-result-image"><button
type="button" className="message-image-button" aria-label={`查看图片 ${item.name}`}
onClick={(event) => conversationContext?.openImage(item.contentUrl ?? item.thumbnailUrl!, item.name, event.currentTarget)}
><img src={item.thumbnailUrl ?? item.contentUrl} alt={item.name} loading="lazy" /></button></div>}
<div className="attachment-metadata"><AttachmentStatus attachment={item} /></div>
<div className="attachment-actions">
{item.resultId && <AttachmentResultButton resultId={item.resultId} name={item.name} readOnly />}
+3 -3
View File
@@ -1,5 +1,5 @@
import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } from 'lucide-react'
import { useId, useRef, useState } from 'react'
import { memo, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Message } from './ChatTimeline'
import { isCancelledMessage } from './message-terminal-status'
@@ -13,7 +13,7 @@ const statusIcons = {
cancelled: XCircle
}
export function RuntimeChecklistStrip({ messages, activeMessageId }: {
export const RuntimeChecklistStrip = memo(function RuntimeChecklistStrip({ messages, activeMessageId }: {
messages: readonly Message[]
activeMessageId?: string
}): React.JSX.Element | null {
@@ -26,7 +26,7 @@ export function RuntimeChecklistStrip({ messages, activeMessageId }: {
: messages.at(-1)
if (message?.role !== 'assistant' || !message.runtimeChecklist?.items.length) return null
return <Checklist key={message.id} message={message} />
}
})
function Checklist({ message }: { message: Message }): React.JSX.Element {
const { t } = useTranslation('app')
+46 -7
View File
@@ -520,6 +520,7 @@ const removeEmbeddingModel = vi.fn(
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -814,6 +815,7 @@ describe('SettingsPanel runtime files', () => {
applicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -1163,7 +1165,7 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: '上下文控制' })
).toHaveTextContent('直连模型的历史压缩与原文保留')
).toHaveTextContent(/^上下文控制$/)
const enabled = screen.getByRole('switch', {
name: '自动压缩较早的对话'
})
@@ -1549,6 +1551,36 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it.each([
['zh-CN', '外观', '透明磨砂特效', '应用于顶栏和展开的运行待办卡片,保存后立即生效。'],
['en-US', 'Appearance', 'Frosted glass effect', 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.']
] as const)('exposes the frosted glass Switch under Appearance in %s', async (locale, category, label, description) => {
await changeUiLocale(locale)
const onChange = vi.fn(async () => true)
const props = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn(),
onTransparentFrostedEffectEnabledChange: onChange
}
const { rerender } = render(<SettingsPanel {...props} />)
fireEvent.click(screen.getByRole('tab', { name: category }))
const toggle = screen.getByRole('switch', { name: label })
expect(toggle).not.toBeChecked()
expect(toggle).toHaveAccessibleDescription(description)
expect(toggle.closest('label')).toHaveClass('toggle-row')
fireEvent.click(toggle)
expect(onChange).toHaveBeenCalledExactlyOnceWith(true)
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled applicationSettingsPending />)
expect(toggle).toBeChecked()
expect(toggle).toBeDisabled()
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled />)
fireEvent.click(toggle)
expect(onChange).toHaveBeenLastCalledWith(false)
})
it.each([
['zh-CN', '平台功能', '桌面通知'],
['en-US', 'Platform features', 'Desktop notifications']
@@ -1796,15 +1828,22 @@ describe('SettingsPanel runtime files', () => {
expect(content.parentElement).toBe(navigation.parentElement)
expect(content).toHaveClass('settings-panel__content')
expect(
screen.getByText('管理模型、Runtime、平台能力、消息通道与本地数据。')
).toBeInTheDocument()
screen.queryByText('管理模型、Runtime、平台能力、消息通道与本地数据。')
).not.toBeInTheDocument()
const runtimeTab = screen.getByRole('tab', {
name: 'Agent Runtime'
})
expect(runtimeTab).toHaveTextContent('Agent Runtime')
expect(runtimeTab).toHaveTextContent(
'配置 Agent Runtime、默认工作区与原生能力'
)
expect(runtimeTab).toHaveTextContent(/^Agent Runtime$/)
expect(
Array.from(navigation.querySelectorAll('.settings-tabs__group-label'), node => node.textContent)
).toEqual(['常规', '模型与执行', '功能与扩展', '系统'])
expect(within(navigation).getAllByRole('tab').map(tab => tab.id)).toEqual([
'settings-tab-appearance', 'settings-tab-platform-features',
'settings-tab-model', 'settings-tab-context-control', 'settings-tab-runtime',
'settings-tab-ssh-hosts',
'settings-tab-document-parsing', 'settings-tab-channels', 'settings-tab-roles',
'settings-tab-capabilities', 'settings-tab-security', 'settings-tab-about'
])
})
it('omits the redundant close-only footer on passive settings pages', () => {
+66 -27
View File
@@ -10,6 +10,7 @@ import {
X
} from 'lucide-react'
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
@@ -234,6 +235,12 @@ type SettingsPanelProps = {
onExpertsChanged?: (experts: AssistantExpert[]) => void
onClearLocalData: () => Promise<void>
appearanceTheme?: AppearanceTheme
transparentFrostedEffectEnabled?: boolean
applicationSettingsPending?: boolean
applicationSettingsLocked?: boolean
applicationSettingsError?: string
onTransparentFrostedEffectEnabledChange?: (enabled: boolean) => Promise<boolean>
onRetryApplicationSettings?: () => void
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
brandingPreferences?: BrandingPreferences
brandingFallbackLogo?: string
@@ -647,6 +654,12 @@ export function SettingsPanel({
onClearLocalData,
onExpertsChanged = () => {},
appearanceTheme = 'system',
transparentFrostedEffectEnabled = false,
applicationSettingsPending = false,
applicationSettingsLocked = false,
applicationSettingsError,
onTransparentFrostedEffectEnabledChange,
onRetryApplicationSettings,
onAppearanceThemeChange = () => {},
brandingPreferences = defaultBrandingPreferences,
brandingFallbackLogo,
@@ -2124,7 +2137,6 @@ export function SettingsPanel({
<X aria-hidden="true" size={19} />
</button>
}
description={t('center.description')}
headingId="settings-title"
title={t('center.title')}
/>
@@ -2142,34 +2154,35 @@ export function SettingsPanel({
className="settings-tabs"
role="tablist"
>
{visibleSettingsCategories.map((category) => (
<button
aria-controls={`settings-panel-${category.id}`}
aria-label={t(
`categories.${category.translationKey}.label`
{visibleSettingsCategories.map((category, index) => (
<Fragment key={category.id}>
{visibleSettingsCategories[index - 1]?.group !== category.group && (
<span className="settings-tabs__group-label" aria-hidden="true">
{t(`categoryGroups.${category.group}`)}
</span>
)}
aria-selected={activeTab === category.id}
id={`settings-tab-${category.id}`}
key={category.id}
onClick={(event) => {
requestTabChange(category.id, event.currentTarget)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, category.id)
}
role="tab"
tabIndex={activeTab === category.id ? 0 : -1}
type="button"
>
<strong>
{t(`categories.${category.translationKey}.label`)}
</strong>
<small>
{t(
`categories.${category.translationKey}.navigationDescription`
<button
aria-controls={`settings-panel-${category.id}`}
aria-label={t(
`categories.${category.translationKey}.label`
)}
</small>
</button>
aria-selected={activeTab === category.id}
id={`settings-tab-${category.id}`}
onClick={(event) => {
requestTabChange(category.id, event.currentTarget)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, category.id)
}
role="tab"
tabIndex={activeTab === category.id ? 0 : -1}
type="button"
>
<strong>
{t(`categories.${category.translationKey}.label`)}
</strong>
</button>
</Fragment>
))}
</nav>
@@ -2339,6 +2352,32 @@ export function SettingsPanel({
)}
</div>
</div>
<div className="settings-section">
<label className="toggle-row">
<span>{t('appearance.frostedGlass.label')}</span>
<input
type="checkbox"
role="switch"
checked={transparentFrostedEffectEnabled}
disabled={applicationSettingsPending || applicationSettingsLocked || !onTransparentFrostedEffectEnabledChange}
aria-describedby="frosted-glass-description"
onChange={(event) => {
void onTransparentFrostedEffectEnabledChange?.(event.target.checked)
}}
/>
</label>
<p id="frosted-glass-description" className="settings-panel__description">
{t('appearance.frostedGlass.description')}
</p>
{applicationSettingsError && (
<div role="alert">
<p>{applicationSettingsError}</p>
<button className="secondary-button" disabled={applicationSettingsPending} onClick={onRetryApplicationSettings} type="button">
{t('appearance.frostedGlass.retry')}
</button>
</div>
)}
</div>
<div className="settings-section appearance-settings">
<div className="settings-section__title">
<span className="inline-help-label">
@@ -24,6 +24,7 @@ describe('UpdateSettingsSection', () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -105,6 +105,9 @@ describe('WorkspacePrimitives', () => {
expect(toggleRowStyles).toMatch(/align-items:\s*center;/u)
expect(toggleRowStyles).not.toMatch(/border-top:/u)
expect(toggleRowStyles).not.toMatch(/padding-top:/u)
expect(stylesheet).toMatch(
/\.toggle-row > input\[role='switch'\]\s*\{[^}]*order:\s*1;[^}]*margin-inline-start:\s*auto;/u
)
})
it('keeps Platform Features tabs visible in scrollable Settings', () => {
@@ -558,7 +561,7 @@ describe('WorkspacePrimitives', () => {
/\.settings-backdrop \.settings-tabs button strong\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.settings-backdrop \.settings-tabs button small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
/\.settings-tabs__group-label\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
})
+1 -1
View File
@@ -212,7 +212,7 @@
justify-content: center;
white-space: nowrap;
}
.sidebar-footer--applications > .nav-item > span {
.sidebar-footer--applications > .nav-item > span:nth-child(2) {
flex: 0 0 auto;
}
.sidebar-footer__divider {
@@ -21,6 +21,12 @@ export const settings = {
close: 'Close settings',
categoriesAriaLabel: 'Settings categories'
},
categoryGroups: {
general: 'General',
execution: 'Models and execution',
extensions: 'Features and extensions',
system: 'System'
},
categories: {
appearance: {
label: 'Appearance',
@@ -30,7 +36,7 @@ export const settings = {
platformFeatures: {
label: 'Platform features',
navigationDescription: 'Workspace features and navigation',
description: 'Choose which features appear in your GoodBuddy workspace'
description: 'Manage shortcuts, conversations, notifications, the default workspace, and remote projects'
},
model: {
label: 'Model connections',
@@ -56,7 +62,7 @@ export const settings = {
navigationDescription:
'Configure Agent Runtimes, the default workspace, and native capabilities',
description:
'Configure Agent Runtimes, the default workspace, and native capabilities'
'Configure Agent Runtimes, model sources, and native capabilities'
},
sshHosts: {
label: 'Hosts and remote execution',
@@ -885,6 +891,11 @@ export const settings = {
}
},
appearance: {
frostedGlass: {
label: 'Frosted glass effect',
description: 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.',
retry: 'Retry'
},
theme: {
title: 'Interface theme',
description: 'Applies immediately and is saved on this device',
@@ -895,7 +895,7 @@ export const settingsSections = {
diagnostics: {
label: 'Attach recent desktop diagnostics',
description:
'Off by default. When selected, this adds only timestamps, components, stages, stable error codes, error types, and fixed short messages from recent desktop diagnostics. It does not include conversations, prompts, credentials, file contents, paths, raw provider responses, or remote Agent logs.'
'Off by default. When selected, this adds timestamps, components, stages, error codes, error types, and fixed short messages from recent desktop diagnostics, plus tool connection failure categories, timing, HTTP status, attempt counts, tool kinds, and anonymous correlation IDs. It does not include conversations, prompts, credentials, file contents, paths, raw provider responses, or remote Agent logs.'
},
screenshot: {
title: 'Screenshot (optional)',
@@ -17,6 +17,12 @@ export const settings = {
close: '关闭设置',
categoriesAriaLabel: '设置分类'
},
categoryGroups: {
general: '常规',
execution: '模型与执行',
extensions: '功能与扩展',
system: '系统'
},
categories: {
appearance: {
label: '外观',
@@ -26,7 +32,7 @@ export const settings = {
platformFeatures: {
label: '平台功能',
navigationDescription: '功能入口与工作区能力',
description: '控制 GoodBuddy 工作区中显示的功能入口'
description: '管理快捷键、会话与通知、默认工作目录和远程项目'
},
model: {
label: '模型连接',
@@ -47,7 +53,7 @@ export const settings = {
runtime: {
label: 'Agent Runtime',
navigationDescription: '配置 Agent Runtime、默认工作区与原生能力',
description: '配置 Agent Runtime、默认工作区与原生能力'
description: '配置 Agent Runtime、模型来源与原生能力'
},
sshHosts: {
label: '主机与远程执行',
@@ -820,6 +826,11 @@ export const settings = {
}
},
appearance: {
frostedGlass: {
label: '透明磨砂特效',
description: '应用于顶栏和展开的运行待办卡片,保存后立即生效。',
retry: '重试'
},
theme: {
title: '界面主题',
description: '选择后立即应用,并保存在此设备',
@@ -840,7 +840,7 @@ export const settingsSections = {
diagnostics: {
label: '附加最近桌面诊断记录',
description:
'默认关闭。选中后仅附加最近桌面诊断的时间、组件、阶段、稳定错误码、错误类型和固定短消息;不包含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。'
'默认关闭。选中后附加最近桌面诊断的时间、组件、阶段、错误码、错误类型和固定短消息,以及工具连接失败的类别、耗时、HTTP 状态、尝试次数、工具类型和匿名关联标识;不包含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。'
},
screenshot: {
title: '截图(可选)',
+6
View File
@@ -74,6 +74,12 @@
font-size: var(--font-caption);
}
.runtime-checklist__content ol { margin: 0; padding: var(--space-2); list-style: none; }
:root[data-frosted-glass='true'] .runtime-checklist__content {
background: var(--surface-glass-panel);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
color: var(--text-primary);
}
.runtime-checklist__content li {
display: flex;
align-items: baseline;
+18 -6
View File
@@ -3,50 +3,62 @@ import i18n from './i18n'
export const settingsCategoryList = [
{
id: 'appearance',
group: 'general',
translationKey: 'appearance'
},
{
id: 'platform-features',
group: 'general',
translationKey: 'platformFeatures'
},
{
id: 'model',
group: 'execution',
translationKey: 'model'
},
{
id: 'context-control',
group: 'execution',
translationKey: 'contextControl'
},
{
id: 'document-parsing',
translationKey: 'documentParsing'
},
{
id: 'runtime',
group: 'execution',
translationKey: 'runtime'
},
{
id: 'ssh-hosts',
group: 'execution',
translationKey: 'sshHosts'
},
{
id: 'security',
translationKey: 'security'
id: 'document-parsing',
group: 'extensions',
translationKey: 'documentParsing'
},
{
id: 'channels',
group: 'extensions',
translationKey: 'channels'
},
{
id: 'roles',
group: 'extensions',
translationKey: 'roles'
},
{
id: 'capabilities',
group: 'extensions',
translationKey: 'capabilities'
},
{
id: 'security',
group: 'system',
translationKey: 'security'
},
{
id: 'about',
group: 'system',
translationKey: 'about'
}
] as const
+137 -53
View File
@@ -5,6 +5,9 @@
--surface-raised: #fff;
--surface-subtle: #f4f8fb;
--surface-muted: #e5edf3;
--surface-glass: rgb(255 255 255 / 65%);
--surface-glass-panel: rgb(255 255 255 / 75%);
--border-glass: rgb(116 138 159 / 16%);
--text-primary: #17263a;
--text-secondary: #4f6278;
--text-muted: #60748a;
@@ -1248,6 +1251,7 @@
}
.magic-notes-page {
--magic-notes-heading-height: 40px;
color-scheme: light;
display: flex;
min-width: 0;
@@ -1323,6 +1327,14 @@
gap: var(--space-2);
}
.magic-notes-workspace--notes .magic-notes-pane-heading {
min-height: var(--magic-notes-heading-height);
}
.magic-notes-workspace--notes .magic-notes-pane-heading :is(.icon-button, .magic-note-tag-manager-button) {
margin-block: 0;
}
.magic-notes-workspace--notes .magic-notes-card-grid {
gap: 2px;
margin-inline: calc(-1 * var(--space-1));
@@ -1929,7 +1941,16 @@
}
.magic-notes-ai-pane {
padding: var(--space-4);
padding: 0 var(--space-4) var(--space-4);
}
.magic-notes-ai-pane > .magic-notes-ai-controls {
padding-top: 0;
}
.magic-notes-ai-pane > .magic-notes-ai-controls .inline-help-label {
min-height: var(--magic-notes-heading-height);
margin-bottom: 0;
}
.magic-todo-source-entry {
@@ -2481,7 +2502,7 @@
z-index: 2;
top: 0;
display: flex;
min-height: 40px;
min-height: var(--magic-notes-heading-height);
align-items: center;
margin-inline: calc(-1 * var(--magic-note-stream-gutter, var(--space-4)));
padding: var(--space-1) var(--magic-note-stream-gutter, var(--space-4));
@@ -2752,9 +2773,17 @@
.magic-note-entry__editor {
min-width: 0;
}
.magic-note-entry--canvas-editing > .magic-note-entry__editor {
padding: var(--space-3);
}
.magic-note-entry__editor > .magic-note-editor,
.magic-note-composer > .magic-note-editor {
border-radius: 0;
}
.magic-note-entry__delete {
display: flex;
align-items: center;
@@ -2771,7 +2800,8 @@
}
.magic-note-entry__editor-actions {
margin-top: var(--space-2);
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--border-subtle);
}
.magic-note-editor {
@@ -4564,6 +4594,30 @@ textarea:focus-visible {
-webkit-app-region: no-drag;
}
.app-shell[data-frosted-glass='true'] .topbar {
position: relative;
z-index: 3;
background: var(--surface-glass);
border-bottom-color: var(--border-glass);
backdrop-filter: blur(18px);
}
/* Extend only the active chat; the workbar and other routes keep their grid row. */
.app-shell[data-frosted-glass='true'] .workspace:has(> [data-route='chat']:not([hidden]) .chat-history-pane[data-active='true']) {
margin-top: -58px;
}
.app-shell[data-frosted-glass='true'] .chat-history-context {
position: absolute;
z-index: 2;
top: 58px;
width: 100%;
}
.app-shell[data-frosted-glass='true'] .chat-content {
padding-top: calc(58px + var(--chat-context-height, 0px) + var(--page-gutter));
}
/*
* Overlays are portalled to document.body and cover the whole window, so they
* paint over the .brand and .topbar drag regions. Without no-drag the native
@@ -6189,10 +6243,16 @@ button > svg {
}
.chat-history-pane {
position: relative;
height: 100%;
min-height: 0;
}
.chat-history-context {
flex: 0 0 auto;
min-height: 0;
}
.chat-history-pane[hidden] {
display: none;
}
@@ -6226,6 +6286,14 @@ button > svg {
scrollbar-width: thin;
}
.chat-history-pane > .chat {
padding: 0;
}
.chat-content {
padding: var(--page-gutter) 0 10px;
}
.chat-scroll-to-bottom {
position: absolute;
z-index: 2;
@@ -6356,7 +6424,7 @@ button > svg {
line-height: 1.5;
}
.chat > .welcome,
.chat-content > .welcome,
.message-list {
/* Keep the scrollbar inside the outer gutter without adding trailing scroll padding. */
width: calc(100cqw - 2 * var(--chat-inline-padding));
@@ -7725,11 +7793,17 @@ button > svg {
cursor: pointer;
}
.context-chip > button:hover {
.context-chip > button:not(.message-image-button):hover {
background: var(--danger-subtle);
color: var(--danger);
}
.context-chip > .message-image-button {
width: auto;
height: auto;
cursor: zoom-in;
}
.attachment-metadata,
.attachment-actions {
display: flex;
@@ -8988,9 +9062,10 @@ button > svg {
}
.settings-panel__description {
margin: 6px 0 0;
color: var(--text-muted);
font-size: 10px;
margin: var(--space-2) 0 0;
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.6;
}
.settings-panel__body {
@@ -9005,15 +9080,12 @@ button > svg {
display: flex;
min-width: 0;
flex-direction: column;
gap: 18px;
gap: var(--space-6);
}
.settings-tabs {
display: grid;
padding: 3px;
border-radius: 9px;
background: var(--surface-muted);
grid-template-columns: repeat(10, 1fr);
border-radius: var(--radius-control);
background: var(--surface-subtle);
}
.settings-tabs button {
@@ -9022,23 +9094,31 @@ button > svg {
justify-content: center;
flex-direction: column;
min-height: 34px;
border-radius: 7px;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 11px;
font-weight: 650;
font-size: var(--font-body);
font-weight: 500;
}
.settings-tabs button strong {
font-size: 11px;
.settings-tabs button:hover {
background: var(--surface-muted);
color: var(--text-primary);
}
.settings-tabs button small {
display: none;
.settings-tabs__group-label {
flex: 0 0 auto;
margin-top: var(--space-3);
padding: var(--space-2) var(--space-3) var(--space-1);
color: var(--text-muted);
font-size: 9px;
font-weight: 400;
font-size: var(--font-caption);
font-weight: 600;
line-height: 1.5;
}
.settings-tabs__group-label:first-child {
margin-top: 0;
}
.settings-page .settings-panel__body,
@@ -9255,7 +9335,7 @@ button > svg {
display: flex;
min-height: 0;
max-height: 100%;
padding: 6px;
padding: var(--space-2);
overflow-y: auto;
align-self: stretch;
flex-direction: column;
@@ -9338,8 +9418,8 @@ button > svg {
.settings-page .settings-tabs button,
.settings-backdrop .settings-tabs button {
flex: 0 0 auto;
min-height: 58px;
padding: 10px 12px;
min-height: 40px;
padding: var(--space-2) var(--space-3);
align-items: flex-start;
justify-content: center;
text-align: left;
@@ -9348,20 +9428,14 @@ button > svg {
.settings-page .settings-tabs button strong,
.settings-backdrop .settings-tabs button strong {
font-size: var(--font-body);
line-height: 1.35;
}
.settings-page .settings-tabs button small,
.settings-backdrop .settings-tabs button small {
display: block;
margin-top: 3px;
font-size: var(--font-caption);
font-weight: inherit;
line-height: 1.35;
}
.settings-page .settings-tabs button[aria-selected='true'],
.settings-backdrop .settings-tabs button[aria-selected='true'] {
background: var(--accent-subtle);
font-weight: 650;
box-shadow: none;
}
@@ -9374,18 +9448,26 @@ button > svg {
.settings-section {
display: flex;
flex-direction: column;
padding: 16px;
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--surface-subtle);
gap: 13px;
border-radius: var(--radius-card);
background: var(--surface-raised);
gap: var(--space-4);
}
.settings-panel .settings-section .settings-section {
padding: var(--space-4) 0 0;
border: 0;
border-top: 1px solid var(--border-subtle);
border-radius: 0;
background: transparent;
}
.settings-section__title {
display: flex;
align-items: center;
color: var(--accent);
gap: 9px;
color: var(--text-secondary);
gap: var(--space-2);
}
.settings-section__title > div {
@@ -9407,12 +9489,14 @@ button > svg {
.settings-section__title strong {
color: var(--text-primary);
font-size: 12px;
font-size: var(--font-section-title);
}
.settings-section__title small {
color: var(--text-muted);
font-size: 9px;
margin-top: var(--space-1);
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.5;
}
.ssh-hosts-settings {
@@ -13092,6 +13176,11 @@ textarea.field-control {
gap: var(--space-3);
}
.toggle-row > input[role='switch'] {
order: 1;
margin-inline-start: auto;
}
.toggle-row input {
position: relative;
min-width: 38px;
@@ -17265,6 +17354,9 @@ textarea.field-control {
--surface-raised: #0d1d2c;
--surface-subtle: #12283a;
--surface-muted: #1a3448;
--surface-glass: rgb(7 16 31 / 70%);
--surface-glass-panel: rgb(7 16 31 / 75%);
--border-glass: rgb(143 166 185 / 18%);
--text-primary: #edf7ff;
--text-secondary: #b5c6d5;
--text-muted: #8fa6b9;
@@ -17752,15 +17844,11 @@ textarea.field-control {
}
.settings-page .settings-tabs button {
min-height: 52px;
min-height: 40px;
align-items: flex-start;
text-align: left;
}
.settings-page .settings-tabs button small {
display: block;
}
.heartbeat-center__metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -17808,10 +17896,6 @@ textarea.field-control {
padding: var(--space-2);
}
.settings-backdrop .settings-tabs button small {
display: none;
}
.settings-backdrop .settings-panel__header {
padding: var(--space-4);
}
@@ -17872,7 +17956,7 @@ textarea.field-control {
text-align: center;
}
.settings-page .settings-tabs button small {
.settings-page .settings-tabs__group-label {
display: none;
}
@@ -59,6 +59,7 @@ const applicationPreferencesSchema = z
.object({
checkUpdatesOnStartup: z.boolean(),
desktopNotificationsEnabled: z.boolean().default(true),
transparentFrostedEffectEnabled: z.boolean().default(false),
updateSource: updateSourceSchema,
modelDownloadSource: modelDownloadSourceSchema,
localToolEnvironment: localToolEnvironmentSettingsSchema,
@@ -90,6 +91,7 @@ export const applicationSettingsUpdateSchema = applicationPreferencesSchema
.extend({
applicationNavigation: applicationNavigationSchema.optional(),
desktopNotificationsEnabled: z.boolean().optional(),
transparentFrostedEffectEnabled: z.boolean().optional(),
localInferenceEnabled: z.boolean().optional(),
magicNotesEnabled: z.boolean().optional(),
heartbeatEnabled: z.boolean().optional(),
@@ -153,6 +153,7 @@ describe('GoodBuddy configuration contracts', () => {
application: {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
+47
View File
@@ -0,0 +1,47 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('scrolls real chat messages behind frosted glass while preserving controls, anchors and other routes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-frosted-glass-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'frosted-glass', configureServer(server) {
server.middlewares.use('/glass.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/glass.html', `<!doctype html>
<html><body><div id="root"></div>
<script type="module" src="/tests/support/frosted-glass-regression.tsx"></script></body></html>`))
})
} }],
optimizeDeps: { entries: ['tests/support/frosted-glass-regression.tsx'] },
server: { host: '127.0.0.1', port: 0, watch: null }
})
try {
await server.listen()
const driver = join(directory, 'driver.mjs')
await copyFile(resolve('tests/support/frosted-glass-driver.mjs'), driver)
const env: NodeJS.ProcessEnv = { ...process.env, GB_GLASS_DIRECTORY: directory,
GB_GLASS_URL: server.resolvedUrls!.local[0] + 'glass.html' }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
expect(code, output).toBe(0)
process.stdout.write(output.split(/\r?\n/).filter(line => line.startsWith('FrostedGlass')).join('\n') + '\n')
} finally { clearTimeout(timeout) }
} finally {
await server.close()
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
}
}, 90000)
+24
View File
@@ -30,6 +30,7 @@ it('resizes record columns with native Electron input and preserves desktop widt
const fs = require('node:fs');
const assert = require('node:assert/strict');
app.commandLine.appendSwitch('force-device-scale-factor', '1');
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true,
@@ -126,6 +127,21 @@ it('resizes record columns with native Electron input and preserves desktop widt
assert.equal((await rect(saveButton)).width, 0);
assert.equal(await js('document.querySelector(".magic-notes-ai-pane")?.checkVisibility() ?? false'), false, 'AI stays closed for notes without comments');
assert.equal((await rect('.magic-notes-index-pane')).width, 0, 'Record index starts collapsed');
await click('.magic-note-entry button[aria-label="Edit"]');
await wait('!!document.querySelector(".magic-note-entry__editor .ql-editor")');
const entryBox = await rect('.magic-note-entry');
const entryHeader = await rect('.magic-note-entry > header');
const editToolbar = await rect('.magic-note-entry__editor .ql-toolbar');
const editInput = await rect('.magic-note-entry__editor .ql-container');
for (const box of [editToolbar, editInput]) {
assert(Math.abs(box.x - entryBox.x - 1) <= 1 && Math.abs(box.width - entryBox.width + 2) <= 1, 'Editor fills the card inside its border: ' + JSON.stringify({ box, entryBox }));
}
assert(Math.abs(editToolbar.y - entryHeader.y - entryHeader.height) <= 1, 'Toolbar directly follows the entry header');
assert(Math.abs(editInput.y - editToolbar.y - editToolbar.height) <= 1, 'Input directly follows the toolbar');
await screenshot('magic-notes-entry-editor');
await click('.magic-note-entry__editor-actions .secondary-button');
await wait('!document.querySelector(".magic-note-entry__editor")');
await js('document.querySelector(".magic-notes-stream-pane").scrollTop = 0');
const focusComposer = async () => { await js('document.querySelector(' + JSON.stringify(editor) + ').focus()'); await settle(); };
await focusComposer();
const emptyHeight = (await rect(editor)).height;
@@ -141,6 +157,14 @@ it('resizes record columns with native Electron input and preserves desktop widt
const index = '.magic-notes-index-pane', handle = '.magic-notes-index-resize-handle', stream = '.magic-notes-stream-pane';
await click('#magic-notes-index-toggle');
await click('button[aria-controls="magic-notes-ai-pane"]');
const listHeading = await rect('#magic-library-panel-notes > .magic-notes-pane-heading');
const detailToolbar = await rect('.magic-note-detail-toolbar');
const aiHeading = await rect('.magic-notes-ai-pane .inline-help-label');
for (const heading of [listHeading, aiHeading]) {
assert(Math.abs(heading.y - detailToolbar.y) <= 1, 'Pane headings share a top edge: ' + JSON.stringify({ heading, detailToolbar }));
assert(Math.abs(heading.height - detailToolbar.height) <= 1, 'Pane headings share a row height: ' + JSON.stringify({ heading, detailToolbar }));
}
await screenshot('magic-notes-aligned-headings');
assert.equal((await rect(index)).width, 168);
const firstRecord = await rect('.magic-note-record');
assert.equal(firstRecord.x, (await rect(index)).x, 'Thumbnails align with the left pane edge');
+225
View File
@@ -0,0 +1,225 @@
import { app, BrowserWindow, screen } from 'electron'
import assert from 'node:assert/strict'
import { join } from 'node:path'
import process from 'node:process'
import console from 'node:console'
import { createRequire } from 'node:module'
import { setTimeout } from 'node:timers/promises'
app.setPath('userData', join(process.env.GB_GLASS_DIRECTORY, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, frame: false, width: 1280, height: 800, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, spellcheck: false, backgroundThrottling: false } })
win.webContents.on('console-message', event => console.log('Renderer:', event.message))
win.webContents.on('preload-error', (_event, _path, error) => console.error(error))
await win.loadURL(process.env.GB_GLASS_URL)
await win.webContents.executeJavaScript(`new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('Fixture did not mount: ' + document.body.innerText)), 15000);
const ready = () => {
const toggle = document.querySelector('.runtime-checklist__toggle');
if (!toggle) return requestAnimationFrame(ready);
toggle.click();
const expanded = () => document.querySelector('.runtime-checklist__content')
? resolve(true) : requestAnimationFrame(expanded);
expanded();
};
ready();
})`)
await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)')
let baselineLayout
for (const theme of ['light', 'dark']) {
let off
for (const state of [null, 'true', null, 'false']) {
const label = `${theme}/${state ?? 'absent'}`
const result = await win.webContents.executeJavaScript(`(() => {
document.documentElement.dataset.theme = ${JSON.stringify(theme)};
const shell = document.querySelector('.app-shell');
const state = ${JSON.stringify(state)};
for (const node of [shell, document.documentElement]) {
if (state === null) node.removeAttribute('data-frosted-glass');
else node.dataset.frostedGlass = state;
}
const panel = document.querySelector('.runtime-checklist__content');
if (shell.contains(panel) || !panel.closest('.floating-portal').matches(':popover-open'))
throw new Error('Checklist must render outside app-shell in the top layer');
const selectors = ['.app-shell', '.app-frame', '.topbar', '.workspace',
'.runtime-checklist', '.runtime-checklist__content', '[data-ordinary-menu]',
'.topbar button', '.topbar input', '.topbar select', '.topbar__actions', '.window-controls', '.window-control'];
return Object.fromEntries(selectors.map(selector => {
const e = document.querySelector(selector), s = getComputedStyle(e), r = e.getBoundingClientRect();
return [selector, { background: s.backgroundColor, image: s.backgroundImage,
blur: s.backdropFilter, color: s.color, opacity: s.opacity, region: s.getPropertyValue('-webkit-app-region'),
layout: [r.x, r.y, r.width, r.height, s.display, s.gridTemplateRows, s.padding, s.gap] }];
}));
})()`)
const topbar = result['.topbar']
assert.equal(topbar.region, 'drag', label)
for (const selector of ['.topbar button', '.topbar input', '.topbar select', '.topbar__actions', '.window-controls', '.window-control']) {
assert.equal(result[selector].region, 'no-drag', `${label} ${selector}`)
}
const layout = Object.fromEntries(Object.entries(result).filter(([selector]) => selector !== '.workspace').map(([selector, value]) => [selector, value.layout]))
baselineLayout ??= layout
assert.deepEqual(layout, baselineLayout, `${label} layout`)
assert.equal(topbar.layout[3], 58, `${label} topbar height`)
assert.ok(result['.workspace'].layout[3] > 0, `${label} workspace is visible`)
const alpha = Number(topbar.background.match(/^rgba?\((.*)\)$/)[1].split(',')[3] ?? 1)
if (state === 'true') {
assert.equal(alpha, theme === 'light' ? 0.65 : 0.7, `${label} alpha`)
assert.equal(topbar.background, theme === 'light' ? 'rgba(255, 255, 255, 0.65)' : 'rgba(7, 16, 31, 0.7)', label)
assert.equal(topbar.blur, 'blur(18px)', label)
assert.equal(result['.app-frame'].image, 'none', label)
assert.equal(result['.workspace'].layout[1], off['.workspace'].layout[1] - 58, `${label} chat extends behind topbar`)
assert.deepEqual(result['.runtime-checklist'], off['.runtime-checklist'], `${label} solid strip unchanged`)
assert.deepEqual(result['[data-ordinary-menu]'], off['[data-ordinary-menu]'], `${label} other menus unchanged`)
const panel = result['.runtime-checklist__content']
assert.equal(panel.background, theme === 'light' ? 'rgba(255, 255, 255, 0.75)' : 'rgba(7, 16, 31, 0.75)', label)
assert.equal(panel.blur, 'blur(18px)', label)
assert.equal(panel.opacity, '1', `${label} text stays opaque`)
const rgb = value => value.match(/[\d.]+/g).map(Number)
const luminance = values => values.map(value => {
const channel = value / 255
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
}).reduce((sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index], 0)
const text = luminance(rgb(panel.color))
const background = luminance(rgb(panel.background).slice(0, 3).map(value => value * 0.75 + (theme === 'light' ? 0 : 255) * 0.25))
assert.ok((Math.max(text, background) + 0.05) / (Math.min(text, background) + 0.05) >= 4.5, `${label} checklist text contrast`)
} else {
assert.equal(alpha, 1, `${label} opaque background`)
assert.equal(topbar.blur, 'none', label)
assert.equal(result['.app-frame'].image, 'none', label)
off ??= result
assert.deepEqual(result, off, `${label} restores original styles`)
}
console.log(`FrostedGlass ${label}: alpha=${alpha}, backdrop-filter=${topbar.blur}, drag regions and surrounding layout preserved`)
}
}
const evaluate = source => win.webContents.executeJavaScript(source)
const waitFor = source => evaluate(`new Promise((resolve, reject) => {
const deadline = performance.now() + 5000;
const check = () => { if (${source}) resolve(true);
else if (performance.now() > deadline) reject(new Error(${JSON.stringify(source)}));
else requestAnimationFrame(check); }; check(); })`)
const click = async selector => {
const point = await evaluate(`(() => { const e = document.querySelector(${JSON.stringify(selector)});
const r = e.getBoundingClientRect(); const x = r.x + r.width / 2, y = r.y + r.height / 2;
if (!e.contains(document.elementFromPoint(x, y))) throw new Error('Click target obscured: ' + ${JSON.stringify(selector)});
return { x: Math.round(x), y: Math.round(y) }; })()`)
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
}
win.focus()
console.log('FrostedGlass starting native interaction checks')
await waitFor('document.hasFocus()')
await click('.runtime-checklist__toggle')
await waitFor("!document.querySelector('.runtime-checklist__content')")
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 400`)
await waitFor("document.querySelector('.chat-scroll-to-bottom') !== null")
const toggleAnchor = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top`)
for (const enabled of [true, false]) {
await evaluate(`for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = '${enabled}'`)
await evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
assert.equal(await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top`), toggleAnchor, 'toggle preserves reader position')
}
for (const theme of ['light', 'dark']) {
await evaluate(`document.documentElement.dataset.theme = '${theme}'`)
for (const enabled of [false, true, false]) {
await evaluate(`for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = '${enabled}';
document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 0;`)
await waitFor("document.querySelector('.chat-scroll-to-bottom') !== null")
const geometry = await evaluate(`(() => {
const rect = selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { top:r.top, bottom:r.bottom, height:r.height }; };
return { topbar:rect('.topbar'), viewport:rect('.chat-history-pane[data-active="true"] .chat'),
first:rect('.chat-history-pane[data-active="true"] article'), strip:rect('.runtime-checklist'),
composer:rect('.composer-wrap'), workbar:rect('[data-workbar]'), padding:getComputedStyle(document.querySelector('.chat')).paddingTop };
})()`)
assert.ok(geometry.first.top >= geometry.strip.bottom, 'initial message clears both headers')
assert.equal(geometry.strip.top, 58, 'context strip stays below topbar')
assert.equal(geometry.workbar.top, 58, 'workbar stays in original row')
assert.equal(geometry.viewport.top, enabled ? 0 : geometry.strip.bottom, 'actual viewport extension')
assert.equal(geometry.padding, '0px', 'inset belongs to scrolling content')
await evaluate(`(() => { const chat = document.querySelector('.chat-history-pane[data-active="true"] .chat');
const message = chat.querySelector('article'); chat.scrollTop += message.getBoundingClientRect().top - 20; })()`)
const overlap = await evaluate(`(() => { const chat = document.querySelector('.chat-history-pane[data-active="true"] .chat');
const r = chat.querySelector('article').getBoundingClientRect(), v = chat.getBoundingClientRect(), t = document.querySelector('.topbar').getBoundingClientRect();
return Math.min(r.bottom, v.bottom, t.bottom) - Math.max(r.top, v.top, t.top); })()`)
assert.equal(overlap > 0, enabled, `${theme}/${enabled}: actual visible message/topbar intersection`)
const clicks = await evaluate(`Number(document.querySelector('[data-clicks]').dataset.clicks)`)
await click('.topbar button')
await waitFor(`Number(document.querySelector('[data-clicks]').dataset.clicks) === ${clicks + 1}`)
await click('.chat-scroll-to-bottom')
await waitFor(`(() => { const e = document.querySelector('.chat-history-pane[data-active="true"] .chat'); return e.scrollHeight - e.clientHeight - e.scrollTop < 1; })()`)
const bottom = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article:last-child').getBoundingClientRect().bottom`)
assert.ok(bottom <= geometry.composer.top, 'last message is above composer')
assert.equal(await evaluate(`document.querySelector('[aria-label="Composer"]').value`), 'Draft')
console.log(`FrostedGlass ${theme}/${enabled}: initial clearance, real message overlap=${overlap}, native click and jump-to-bottom passed`)
}
}
await evaluate(`for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = 'true';
document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 400;`)
await waitFor("document.querySelector('.chat-scroll-to-bottom') !== null")
await click('#switch-conversation')
await waitFor(`document.querySelector('.chat-history-pane[data-active="true"]').dataset.conversationId === 'b'`)
await click('#switch-conversation')
await waitFor(`document.querySelector('.chat-history-pane[data-active="true"]').dataset.conversationId === 'a'`)
assert.equal(await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop`), 400, 'keep-alive scroll position')
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 0`)
const anchor = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top`)
await click('.load-earlier-messages')
await waitFor(`document.querySelectorAll('.chat-history-pane[data-active="true"] article').length === 100`)
const restoredAnchor = await evaluate(`document.querySelectorAll('.chat-history-pane[data-active="true"] article')[20].getBoundingClientRect().top`)
assert.ok(Math.abs(restoredAnchor - anchor) < 1, 'prepend preserves message anchor')
await click('.runtime-checklist__toggle')
await waitFor(`!!document.querySelector('.runtime-checklist__content')`)
assert.equal(await evaluate(`(() => { const e = document.querySelector('.runtime-checklist__content'), r = e.getBoundingClientRect();
return e.closest('.floating-portal').matches(':popover-open') && e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)); })()`), true, 'checklist top layer remains above the overlay')
await click('.runtime-checklist__toggle')
await waitFor(`!document.querySelector('.runtime-checklist__content')`)
await click('#switch-context')
await waitFor(`!document.querySelector('.runtime-checklist')`)
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 0`)
await waitFor(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top >= 58`)
await click('#switch-route')
await waitFor(`!document.querySelector('[data-route="activity"]').hidden`)
assert.equal(await evaluate(`document.querySelector('.workspace').getBoundingClientRect().top`), 58, 'non-chat route keeps original layout')
await click('#switch-route')
await waitFor(`!document.querySelector('[data-route="chat"]').hidden`)
assert.equal(await evaluate(`document.querySelector('.workspace').getBoundingClientRect().top`), 0, 'chat overlay restored on route return')
if (process.platform === 'win32') {
const koffi = createRequire(join(process.cwd(), 'package.json'))('koffi')
const send = koffi.load('user32.dll').func('intptr_t __stdcall SendMessageW(void *hWnd, uint32_t Msg, uintptr_t wParam, intptr_t lParam)')
const hwnd = koffi.decode(win.getNativeWindowHandle(), 'void *')
const points = await evaluate(`(() => { const button = document.querySelector('.topbar button').getBoundingClientRect();
const last = document.querySelector('#short-chat').getBoundingClientRect(), actions = document.querySelector('.topbar__actions').getBoundingClientRect();
return { button: { x: button.x + button.width / 2, y: button.y + button.height / 2 },
caption: { x: (last.right + actions.left) / 2, y: 25 } }; })()`)
const hit = point => {
const bounds = win.getContentBounds()
const physical = screen.dipToScreenPoint({ x: Math.round(bounds.x + point.x), y: Math.round(bounds.y + point.y) })
return Number(send(hwnd, 0x84, 0, (physical.y << 16) | (physical.x & 0xffff)))
}
for (let i = 0; i < 40 && hit(points.caption) !== 2; i++) await setTimeout(25)
assert.equal(hit(points.caption), 2, 'native caption remains draggable')
assert.equal(hit(points.button), 1, 'native button remains HTCLIENT')
console.log('FrostedGlass Windows native hit test: HTCAPTION on titlebar, HTCLIENT on button')
}
await click('#short-chat')
await waitFor(`document.querySelector('.chat-history-pane[data-active="true"]').dataset.conversationId === 'short'`)
for (const theme of ['light', 'dark']) {
for (const enabled of [true, false]) {
win.setContentSize(960, 540)
await evaluate(`document.documentElement.dataset.theme = '${theme}';
for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = '${enabled}'`)
await waitFor(`(() => { const chat = document.querySelector('.chat-history-pane[data-active="true"] .chat');
const first = chat.querySelector('article').getBoundingClientRect(), footer = document.querySelector('.composer-wrap').getBoundingClientRect();
return innerHeight === 540 && first.top >= 58 && first.bottom <= footer.top && footer.bottom <= innerHeight && chat.scrollTop === 0; })()`)
}
}
console.log('FrostedGlass toggle anchor, keep-alive, prepend, top-layer hit, no-context, route isolation and short chat at 960x540 passed')
app.exit(0)
}).catch(error => {
console.error(error)
app.exit(1)
})
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { ChatHistoryPane } from '../../src/renderer/src/ChatHistoryPane'
import type { Conversation } from '../../src/renderer/src/chat-conversation'
import { RuntimeChecklistStrip } from '../../src/renderer/src/RuntimeChecklistStrip'
import { PageShell } from '../../src/renderer/src/WorkspacePrimitives'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import '../../src/renderer/src/styles.css'
const conversations: Conversation[] = ['a', 'b'].map(id => ({
id, title: id, updatedAt: 1,
messages: Array.from({ length: 100 }, (_, index) => ({
id: `${id}-${index}`, role: 'user', createdAt: index + 1, state: 'complete',
content: `Message ${id}-${index}: Scroll this actual chat content behind the title bar.\n\nSecond paragraph for a visible message surface.`,
})),
}))
conversations.push({ id: 'short', title: 'Short', updatedAt: 1, messages: [conversations[0].messages[0]] })
const noop = (): void => {}
const complete = async (): Promise<void> => {}
const artifacts = new Map()
function Fixture(): React.JSX.Element {
const [active, setActive] = useState('a')
const [route, setRoute] = useState('chat')
const [context, setContext] = useState(true)
const [counts, setCounts] = useState<Record<string, number>>({ a: 80, b: 100, short: 100 })
const [clicks, setClicks] = useState(0)
return <><div className="app-shell"><div className="app-frame">
<header className="topbar"><button data-clicks={clicks} onClick={() => setClicks(clicks + 1)}>Button</button>
<input aria-label="Input" /><select aria-label="Select"><option>Option</option></select>
<button id="switch-conversation" onClick={() => setActive(active === 'a' ? 'b' : 'a')}>Conversation</button>
<button id="switch-route" onClick={() => setRoute(route === 'chat' ? 'activity' : 'chat')}>Route</button>
<button id="switch-context" onClick={() => setContext(!context)}>Context</button>
<button id="short-chat" onClick={() => setActive('short')}>Short</button>
<div className="topbar__actions">Actions</div>
<div className="window-controls"><button className="window-control">X</button></div>
</header>
<div className="app-content"><main className="workspace">
<div className="workspace-route-cache" data-route="chat" hidden={route !== 'chat'}>
<PageShell variant="reading"><div className="chat-scroll-region">
{conversations.map(conversation => <ChatHistoryPane key={conversation.id}
conversation={conversation} active={route === 'chat' && active === conversation.id}
artifactById={artifacts} conversationHtmlRenderingEnabled={false} locale="en-US"
onOpenImageModelSettings={noop} onReselectImageSources={noop} onEditImage={noop}
onCopyMessage={async () => true} onDownloadImage={noop} onOpenCitationContext={complete}
onOpenCitationSource={complete} onOpenImage={noop} onRespondApproval={complete}
onRespondQuestion={complete} onRetry={noop} onScrollSnapshotChange={noop}
onSetInput={noop} quickActions={[]} visibleMessageCount={counts[conversation.id]}
onVisibleMessageCountChange={(id, count) => setCounts(current => ({ ...current, [id]: count }))}
taskStrip={context ? <div className="conversation-context-strips"><RuntimeChecklistStrip messages={[{
id: 'glass', role: 'assistant', content: '', createdAt: 1, state: 'streaming',
runtimeChecklist: { source: 'opencode', items: [
{ content: 'Inspect', status: 'completed' },
{ content: 'Implement', status: 'in_progress' },
{ content: 'Verify', status: 'pending' },
] },
}]} /></div> : undefined}
/>)}
</div><footer className="composer-wrap"><textarea aria-label="Composer" defaultValue="Draft" /></footer></PageShell>
</div>
<div className="workspace-route-cache" data-route="activity" hidden={route === 'chat'}><PageShell variant="dashboard">Activity</PageShell></div>
</main><aside data-workbar style={{ width: 300, flexShrink: 0 }}>Workbar</aside></div>
</div></div><div className="anchored-menu" data-ordinary-menu="true">Ordinary menu</div></>
}
createRoot(document.getElementById('root')!).render(<UiLocaleProvider><Fixture /></UiLocaleProvider>)
@@ -108,11 +108,34 @@ app.whenReady().then(async () => {
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
await wait('!!document.querySelector(".model-connection-detail select")')
await js('document.fonts.ready')
for (const [width, height, panelWidth] of [[680, 560], [720, 560], [721, 560], [740, 560], [960, 560], [1180, 760], [1180, 560, 740]]) {
const navigation = await js(`(() => {
const nav = document.querySelector('.settings-tabs');
return { ids: [...nav.querySelectorAll('[role=tab]')].map(e => e.id),
groups: nav.querySelectorAll('.settings-tabs__group-label').length,
descriptions: nav.querySelectorAll('small').length,
font: getComputedStyle(nav.querySelector('strong')).fontSize };
})()`)
assert.deepEqual(navigation.ids, ['appearance', 'platform-features', 'model', 'context-control', 'runtime',
'document-parsing', 'channels', 'roles', 'capabilities', 'security', 'about'].map(id => `settings-tab-${id}`))
assert.equal(navigation.groups, 4)
assert.equal(navigation.descriptions, 0)
assert.equal(navigation.font, '13px')
await js('document.querySelector("#settings-tab-model").focus()')
await key('Down')
await wait('document.activeElement.id === "settings-tab-context-control"')
await key('Up')
await wait('document.activeElement.id === "settings-tab-model" && !!document.querySelector(".model-connection-detail select")')
observations.push({ locale, theme, navigation })
for (const [width, height, panelWidth] of [[640, 420], [680, 560], [720, 640], [721, 560], [740, 560], [960, 720], [1280, 800], [1180, 560, 740]]) {
win.setContentSize(width, height)
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
await settle()
await js(`document.querySelector('.settings-panel').style.width=${JSON.stringify(panelWidth ? `${panelWidth}px` : '')}`)
if (width === 1280 || width === 640) {
await js("document.querySelector('.settings-panel__content').scrollTop=0")
await settle()
await screenshot(`${locale}-${theme}-settings-overview-${width}`)
}
await js("document.querySelector('.model-connection-detail select').scrollIntoView({block:'center'})")
const result = await js(`(() => {
const e=document.querySelector('.model-connection-detail select'),s=getComputedStyle(e),r=e.getBoundingClientRect();
@@ -136,6 +159,37 @@ app.whenReady().then(async () => {
await wait('document.querySelector(".model-connection-detail select").value==="openai-chat-completions"')
observations.push({ locale, theme, width, height, panelWidth, ...result })
await screenshot(`${locale}-${theme}-settings-${width}${panelWidth ? '-narrow-container' : ''}`)
const switchSelector = '.model-connection-detail .toggle-row input[role="switch"]'
await js(`document.querySelector(${JSON.stringify(switchSelector)}).scrollIntoView({block:'center'})`)
await settle()
const switchLayout = await js(`(() => {
const input=document.querySelector(${JSON.stringify(switchSelector)}),row=input.closest('.toggle-row'),
control=input.getBoundingClientRect(),label=row.querySelector('span').getBoundingClientRect(),bounds=row.getBoundingClientRect();
return {labelBeforeSwitch:label.right<=control.left,rightAligned:Math.abs(control.right-bounds.right)<1,
width:control.width,height:control.height};
})()`)
assert.deepEqual(switchLayout, { labelBeforeSwitch: true, rightAligned: true, width: 38, height: 22 })
await click(`${switchSelector} + span`)
await wait(`document.querySelector(${JSON.stringify(switchSelector)}).checked`)
await js(`document.querySelector(${JSON.stringify(switchSelector)}).focus()`)
await key('Space')
await wait(`!document.querySelector(${JSON.stringify(switchSelector)}).checked`)
observations.push({ locale, theme, width, height, panelWidth, switchLayout })
if (width === 640 || width === 1280) await screenshot(`${locale}-${theme}-settings-switch-${width}`)
if (width === 1280 || width === 640) {
await js("document.querySelector('.model-connection-detail details.settings-section > summary').scrollIntoView({block:'center'})")
await click('.model-connection-detail details.settings-section > summary')
await wait('document.querySelector(".model-connection-detail details.settings-section").open')
const nested = await js(`(() => {
const e=document.querySelector('.model-connection-detail details.settings-section'),s=getComputedStyle(e);
return {borderLeft:s.borderLeftWidth,background:s.backgroundColor,radius:s.borderRadius,
overflow:e.scrollWidth>e.clientWidth+1};
})()`)
assert.deepEqual(nested, { borderLeft: '0px', background: 'rgba(0, 0, 0, 0)', radius: '0px', overflow: false })
await screenshot(`${locale}-${theme}-settings-nested-${width}`)
await click('.model-connection-detail details.settings-section > summary')
observations.push({ locale, theme, width, nested })
}
}
}
}