Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35ea7692f | ||
|
|
5a77dfdb93 | ||
|
|
177ab2140e | ||
|
|
cbb6398851 | ||
|
|
b1ba14f98e |
+13
-7
@@ -21,7 +21,7 @@ otherwise.
|
||||
packages target compatible x64 and arm64 environments, including UOS, Kylin,
|
||||
Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification.
|
||||
LoongArch has a separate experimental loong64 preview outside standard
|
||||
releases and automatic updates; no preview is planned for 0.12.9. See the
|
||||
releases and automatic updates; no preview is planned for 0.12.10. See the
|
||||
[preview boundaries](./docs/development/loongarch-preview-build.md).
|
||||
- [x] **Configurable global shortcut**: Enable, disable, or record an Electron
|
||||
accelerator under Platform Features / General. The default remains
|
||||
@@ -95,8 +95,8 @@ otherwise.
|
||||
absolute, relative, or symbolic-link directories outside the workspace;
|
||||
the workspace stays the default and relative-path base. Ask remains read-only.
|
||||
- [x] **OpenCode and Continue**: Use isolated child processes, an environment
|
||||
variable allowlist, unified configuration, cancellation, total execution
|
||||
limits, bounded streaming output, and activity records. Shared process
|
||||
variable allowlist, unified configuration, cancellation, startup and control-request
|
||||
deadlines, bounded streaming output, and activity records. Shared process
|
||||
cleanup preserves complete Windows process-tree termination and terminates
|
||||
POSIX process groups when a child uses an independent process group. Chat
|
||||
status checks use a one-shot Runtime probe that is immediately cleaned up
|
||||
@@ -112,7 +112,12 @@ otherwise.
|
||||
requests in the same conversation ordered, while different conversations in
|
||||
the same project and different projects can run in parallel. Request-scoped
|
||||
dynamic MCP tools remain isolated through default wildcard disablement and
|
||||
explicit current-request enablement. OpenCode and Continue runs do not stop
|
||||
explicit current-request enablement. Managed local and remote OpenCode skip
|
||||
unused automatic Git snapshots to avoid synchronous diff stalls; file tools,
|
||||
subagents, and workspace Git diffs remain available. Continue continuously
|
||||
drains unused utility-host stdout so console output cannot fill its pipe.
|
||||
These changes apply to newly started Runtimes, not external OpenCode Servers.
|
||||
OpenCode and Continue runs do not stop
|
||||
at a fixed tool-call or activity count, and every observed tool and Subagent
|
||||
activity remains in the local conversation instead of being discarded while
|
||||
the native task continues.
|
||||
@@ -174,7 +179,7 @@ otherwise.
|
||||
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
|
||||
database. Successful tool START/END events appear exactly once, with no
|
||||
Prompt, provider, or tool replay observed. The current Agent source lock is
|
||||
`0.11.21`, while the current Desktop release candidate is `0.12.9`; formal
|
||||
`0.11.22`, while the current Desktop release candidate is `0.12.10`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels. Current macOS source has passed native package installation,
|
||||
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
|
||||
@@ -439,8 +444,9 @@ otherwise.
|
||||
cancelled states, and expose cited context or safely open its source.
|
||||
- [x] **Magic Notes**: A local-first notes and todo workbench with scope
|
||||
management, editing, filtering, and controlled AI comments. The todo view
|
||||
displays the selected item's source entry, with loading, retryable failure,
|
||||
and missing-source feedback. The left
|
||||
displays the source entry inside the selected item's details, without
|
||||
showing the previous item's source after a selection change. Failed reads
|
||||
use application notifications, and missing sources have an explicit fallback. The left
|
||||
navigation can show the incomplete-todo count, and create, save, and comment
|
||||
results use application-wide notifications.
|
||||
- [ ] **MCP Server Control Plane** (planned): Unified MCP lifecycle, health
|
||||
|
||||
+4
-4
@@ -14,7 +14,7 @@
|
||||
### 桌面基础、工作空间与上下文
|
||||
|
||||
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.12.9` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.12.10` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
|
||||
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
|
||||
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。编辑使用 multipart 上传附件图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。
|
||||
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [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 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 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、固定 Node 和 OpenCode 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。macOS 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证;这不等于新版本已经发布。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
|
||||
- [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 环境准备真实 Host 验收**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵仍待验收,候选 CI/原生打包与系统休眠唤醒也尚未验证;部分开发证据不代表已经发布。
|
||||
@@ -72,7 +72,7 @@
|
||||
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
|
||||
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
|
||||
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;待办视图可直接查看所选事项对应的来源记录,保留读取中、失败重试和来源缺失提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
|
||||
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
|
||||
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- 当前源码候选为 Desktop `0.12.9`、Agent `0.11.21`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
|
||||
- 当前源码候选为 Desktop `0.12.10`、Agent `0.11.22`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
|
||||
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
|
||||
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
|
||||
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。Windows 提供 NSIS 与 portable ZIP,macOS 仅 DMG,Linux 提供 AppImage、DEB 和 RPM,共 12 个安装包、20 个发布资产。
|
||||
|
||||
@@ -581,6 +581,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
### 13.1 聊天
|
||||
|
||||
- 使用 `reading` 壳层,消息流与输入区共享宽度。
|
||||
- 消息头像放在正文区域外侧:助手头像居左,用户头像居右。输入框左边缘与助手正文左边缘对齐,
|
||||
右边缘与用户气泡右边缘对齐;两侧为头像保留独立安全边距,滚动条出现或工作栏缩放不得改变对齐。
|
||||
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
|
||||
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
|
||||
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.21",
|
||||
"agentVersion": "0.11.22",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const { existsSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
function npmInvocation(projectDir, environment = process.env) {
|
||||
const npmCli = environment.npm_execpath;
|
||||
if (npmCli) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
prefixArgs: [npmCli],
|
||||
};
|
||||
}
|
||||
|
||||
const bundledNpmCli = join(
|
||||
projectDir,
|
||||
"node_modules",
|
||||
"npm",
|
||||
"bin",
|
||||
"npm-cli.js",
|
||||
);
|
||||
if (existsSync(bundledNpmCli)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
prefixArgs: [bundledNpmCli],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: process.platform === "win32" ? "npm.cmd" : "npm",
|
||||
prefixArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { npmInvocation };
|
||||
@@ -8,21 +8,11 @@ const {
|
||||
} = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { npmInvocation } = require("./npm-invocation.cjs");
|
||||
|
||||
const opencodeVersion = "1.18.29";
|
||||
const opencodePluginPackage = "@opencode-ai/plugin";
|
||||
|
||||
function npmInvocation() {
|
||||
const npmCli = process.env.npm_execpath;
|
||||
if (npmCli) {
|
||||
return { command: process.execPath, prefixArgs: [npmCli] };
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
throw new Error("npm_execpath is required to prepare OpenCode config");
|
||||
}
|
||||
return { command: "npm", prefixArgs: [] };
|
||||
}
|
||||
|
||||
function prepareBundledOpenCodeConfig(projectDir) {
|
||||
const targetDirectory = join(
|
||||
projectDir,
|
||||
@@ -68,7 +58,7 @@ function prepareBundledOpenCodeConfig(projectDir) {
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(join(stagingDirectory, ".gitignore"), "node_modules\n");
|
||||
const npm = npmInvocation();
|
||||
const npm = npmInvocation(projectDir);
|
||||
const environment = { ...process.env };
|
||||
delete environment.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||
const result = spawnSync(
|
||||
|
||||
+2
-18
@@ -17,6 +17,7 @@ const {
|
||||
opencodeVersion,
|
||||
prepareBundledOpenCodeConfig,
|
||||
} = require("./opencode-config.cjs");
|
||||
const { npmInvocation } = require("./npm-invocation.cjs");
|
||||
|
||||
const architectureNames = {
|
||||
1: "x64",
|
||||
@@ -48,23 +49,6 @@ async function lockedIntegrity(projectDir, packageName) {
|
||||
return entry.integrity;
|
||||
}
|
||||
|
||||
function npmInvocation() {
|
||||
const npmCli = process.env.npm_execpath;
|
||||
if (npmCli) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
prefixArgs: [npmCli],
|
||||
};
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
throw new Error("npm_execpath is required to prepare bundled runtimes");
|
||||
}
|
||||
return {
|
||||
command: "npm",
|
||||
prefixArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadPackage(projectDir, packageName, integrity) {
|
||||
const cacheDirectory = join(projectDir, ".runtime-resources", "cache");
|
||||
await mkdir(cacheDirectory, { recursive: true });
|
||||
@@ -80,7 +64,7 @@ async function downloadPackage(projectDir, packageName, integrity) {
|
||||
await rm(archivePath, { force: true });
|
||||
}
|
||||
|
||||
const npm = npmInvocation();
|
||||
const npm = npmInvocation(projectDir);
|
||||
const result = spawnSync(
|
||||
npm.command,
|
||||
[
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
|
||||
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互及消息底部请求状态的适配范围 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 |
|
||||
|
||||
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
|
||||
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-09 托管 Runtime 并行阻塞
|
||||
|
||||
- Windows 完整隔离测试程序通过界面发送 OpenCode 原生双子代理、直连与 Continue 文件
|
||||
任务;随后三路上游请求在 917 ms 内发出并重叠,全部完成。普通并发没有复现全局排队。
|
||||
- 在同一程序中,让真实模型执行一次 4,000 行、约 98 KB 文件的 CRLF → LF 转换,
|
||||
同时运行另一个会话。原生进程的无凭据存活探针两次延迟 3,301 ms、3,085 ms;
|
||||
原生热线程持续使用 CPU,而 Main 没有同步满载。操作不是模拟事件或直接调用
|
||||
Runtime 的脚本,模型请求来自实际输入框和发送按钮。
|
||||
- 托管 OpenCode 关闭未消费的自动 Git 快照后,完整程序重复相同操作和并行会话,
|
||||
90 秒观察中的最大原生探针延迟为 210 ms,没有超过 500 ms 的探针,两条请求均完成。
|
||||
该对照验证已复现的快照阻塞得到消除,不声称已经证明历史 90–125 秒事件的唯一原因。
|
||||
- Continue 的实际 Electron 原生宿主在不排空 stdout 时被注入的 128 KiB 输出阻塞;
|
||||
使用当前生产适配器后,输出在 5 ms 内完成,20 次状态读取全部成功,最大 5 ms。
|
||||
这证明管道修复有效,不代表普通启动约 640 字节的输出就是历史卡顿原因。
|
||||
- 修复版完整程序再次完成原生双子代理与 Continue 文件任务,实际请求重叠,三个页面
|
||||
都通过计数按钮功能检查。Windows 诊断与程序验证合计 40 次成功模型调用;
|
||||
另一次 UI 请求返回账户额度不足的 402,用户充值后才继续,没有把失败当成通过。
|
||||
- 当前源码 Linux x64 Agent 经桌面生产管理链路完成 Ask 读取和 Execute 换行转换,
|
||||
共 4 次成功模型调用;最终原生配置确认 `snapshot: false` 且权限仍为 `ask`/`allow`。
|
||||
此验证覆盖实际 Agent、模型桥、工具完成和关闭,不量化 Linux 提速,也未注入取消。
|
||||
- 定向回归先验证 7 项失败,再验证 124 项通过、5 项 Windows 平台跳过;
|
||||
全项目类型检查和修改文件 lint 已通过。首次全量回归为 3625 通过、1 失败、61 跳过;
|
||||
失败来自生命周期测试的包装流在底层 reader 关闭后仍计数为打开,已通过独立流实验
|
||||
复现并改为观察 `reader.closed`,没有因此修改生产取消逻辑。最终候选全量回归为
|
||||
3626 通过、0 失败、61 跳过,`npm run typecheck`、`npm run lint`、
|
||||
`npm run release:notes:verify`、差异空白与文档相对链接检查通过。本机和远端的
|
||||
测试进程已回收,安装版和正式 Host 环境未改动;正式发布仍需候选 CI 与资产核实。
|
||||
运行策略见
|
||||
[托管 Runtime 并行与输出](./runtime-interactions.md#托管-runtime-的并行与输出)。
|
||||
|
||||
## 2026-09-08 消息底部请求状态
|
||||
|
||||
- 已接入本机 OpenCode 父会话的原生重试等待及开始事件;直连模型把退避等待与实际发起
|
||||
|
||||
@@ -22,6 +22,20 @@ ACP 已提供相同业务问答接口。
|
||||
使用其非交互参数,或由用户在终端完成;不能把权限确认和业务决策混为一谈。Shell
|
||||
工具保留其现有取消与超时,不新增日志解析或通用交互代理。
|
||||
|
||||
## 托管 Runtime 的并行与输出
|
||||
|
||||
- 本机和 Agent 管理的 OpenCode 启动配置都显式设置 `snapshot: false`。GoodBuddy 不消费
|
||||
原生自动 Git 快照、快照差异摘要或快照撤销;关闭这条内部路径,避免大行级差异的同步
|
||||
计算阻塞同一原生进程中的其他会话。
|
||||
- 文件工具、Shell、原生子代理、工作区 Git 状态和单文件差异保持可用;不新增全局会话
|
||||
队列、请求重放或总执行截止时间。外部 OpenCode Server 的配置不由 GoodBuddy 改写,
|
||||
已运行的旧原生进程也不会热更新配置。
|
||||
- Continue 使用 HTTP 传递响应和状态;Electron UtilityProcess 启动后持续排空不用展示的
|
||||
stdout,避免控制台输出填满管道后阻塞宿主。stderr 的有界错误处理、请求取消与进程
|
||||
回收保持不变,不把诊断输出复制进聊天或持久日志。
|
||||
- 远端最终启动配置由[远程主机技术设计](../remote-host/technical-design.md)定义,
|
||||
模型桥生成最终配置时同样保留上述快照策略。
|
||||
|
||||
## 消息底部请求状态
|
||||
|
||||
- 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
本文记录截至 2026-09-05 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
|
||||
本文记录截至 2026-09-09 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
|
||||
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
|
||||
详细事务与验收边界见
|
||||
[SSH Host 远程环境准备与直连下载设计](./environment-provisioning-technical-design.md);
|
||||
@@ -11,8 +11,9 @@ Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Ho
|
||||
Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
|
||||
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
|
||||
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
|
||||
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前未发布
|
||||
Agent 源码 lock 为已发布的 `0.11.21`,Desktop 候选为 `0.12.8`;本次桌面打包修复不重发 Agent。
|
||||
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前源码
|
||||
候选为 Agent `0.11.22`、Desktop `0.12.10`,包含托管 OpenCode 自动快照策略修复。
|
||||
正式发布状态以 Agent 与 Desktop 独立发布渠道为准。
|
||||
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
|
||||
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
|
||||
|
||||
@@ -67,6 +68,10 @@ Detached GoodBuddy Agent
|
||||
- Host 地址、用户或 Host Key generation 变化时关闭旧连接,并定向退役依赖旧 Host identity 的 Workspace 和 Runtime 会话。下一次选择使用 Host 管理的当前连接记录解析 current Agent/Runtime,不读取项目中的旧 revision 或组件 identity;当前 registry、连接或 capability 无效时才要求显式修复。
|
||||
|
||||
## Agent 安装与生命周期
|
||||
- 托管 OpenCode 的直接 ACP profile 与模型桥最终配置都设置 `snapshot: false`,避免
|
||||
GoodBuddy 不使用的自动 Git 快照摘要阻塞原生执行;策略和保留的文件、Git、子代理能力
|
||||
见 [Runtime 并行与输出边界](../assistant-workbar/runtime-interactions.md#托管-runtime-的并行与输出)。
|
||||
此设置随新启动的 Agent Runtime 生效,不改写用户仓库或已运行进程的配置。
|
||||
|
||||
- Agent bundle 通过 manifest、Ed25519 签名、payload digest、平台和架构校验。
|
||||
- 外层 `agent-package.json` 描述符上限为 4 MiB,覆盖包含 OpenCode 离线依赖的实际包
|
||||
@@ -347,6 +352,14 @@ Execute 直接启动已签名 Runtime:
|
||||
|
||||
## Agent 开发期间的真实 Host 验证
|
||||
|
||||
2026-09-09 已在隔离 Linux x64 Host 验证当前源码的快照配置修复:桌面
|
||||
`ManagedRemoteExecutionServices` 经 managed ACP、真实 detached Agent 和模型桥启动
|
||||
OpenCode `1.18.29`,Ask 完成原生读取,Execute 完成 4,000 行文件的 CRLF → LF 转换。
|
||||
4 次实际 Provider 请求全部为 HTTP 200,最终原生配置在两种模式均为 `snapshot: false`,
|
||||
权限仍为 `ask`/`allow`,transcript ACK 分别为 15/15 和 12/12。测试 Agent、模型桥、
|
||||
Runtime 与 relay 均已回收,原有 Host registry 未改变。这是实际链路和关闭验证,
|
||||
不是 Linux 前后性能对照,也未注入取消;不代替正式发布资产校验。
|
||||
|
||||
任何会改变已部署 GoodBuddy Agent 或桌面到 Agent 生产链路的源码改动,都必须在开发期间
|
||||
使用共享 Linux x64 真实测试 Host 验证。适用范围包括 Agent daemon、Agent protocol
|
||||
及共享 contract、Agent 组包与安装、attach/update、Runtime 启动与进程归属、Workspace、
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.9",
|
||||
"version": "0.12.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.9",
|
||||
"version": "0.12.10",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.9",
|
||||
"version": "0.12.10",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.12.10",
|
||||
"releasedAt": "2026-09-09",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**OpenCode 并行执行更顺畅**:修复批量改写文件或转换换行符后,原生自动 Git 快照计算可能拖住其他会话的问题。GoodBuddy 托管的本机与远端 OpenCode 不再生成界面未使用的自动快照,文件工具、子代理和工作区 Git 差异仍然可用。",
|
||||
"**Continue 输出不再堵住宿主**:修复控制台输出较多时可能填满子进程管道、使请求无法继续的问题。现在会持续排空不用展示的输出,正常回复、工具进度和取消仍沿用原有通道。",
|
||||
"**聊天内容与输入框对齐**:头像移到消息正文外侧,消息边缘与输入框保持一致。滚动条空间留在外侧,避免聊天区域出现多余的横向滚动。",
|
||||
"**待办来源就在详情里**:魔法笔记把来源记录放入所选待办的详情区域,查看事项和原文不必再找底部面板。切换待办时不会混入上一项的来源内容,读取失败通过应用通知提示。",
|
||||
"**便携包构建**:修复 Windows 打包准备 Runtime 时依赖特定 npm 环境变量的问题。构建流程现在也能使用项目内 npm 或系统 npm,避免因此中断便携包构建。"
|
||||
],
|
||||
"notices": [
|
||||
"本机修复随升级并重启 Desktop 生效;远程项目请先升级 Desktop 至 0.12.10,再更新 Agent 至 0.11.22。已运行的旧 Runtime 不会热更新,外部 OpenCode Server 的配置不受此调整影响。",
|
||||
"本次使用 Windows、macOS、Linux 的 x64/arm64 六平台桌面发布矩阵,不构建 LoongArch 实验预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Smoother parallel OpenCode execution**: Fixed automatic native Git snapshot calculations that could stall other conversations after broad file rewrites or line-ending conversions. GoodBuddy-managed local and remote OpenCode no longer generate snapshots unused by the interface; file tools, subagents, and workspace Git diffs remain available.",
|
||||
"**Continue output no longer blocks its host**: Fixed larger console output filling a child-process pipe and preventing requests from progressing. Unused output is now continuously drained, while replies, tool progress, and cancellation keep using their existing channels.",
|
||||
"**Aligned messages and composer**: Avatars now sit outside message content, with message edges aligned to the composer. Scrollbar space stays in the outer gutter to avoid unnecessary horizontal scrolling in the chat area.",
|
||||
"**Todo sources inside their details**: Magic Notes now shows the source entry within the selected todo's detail area instead of a separate bottom panel. Switching todos cannot show the previous item's source content, and failed reads use application notifications.",
|
||||
"**Portable package builds**: Fixed Windows Runtime preparation depending on a particular npm environment variable. Builds can now also use the project's bundled npm or the system npm instead of failing portable packaging for that reason."
|
||||
],
|
||||
"notices": [
|
||||
"Local fixes take effect after upgrading and restarting Desktop. For remote projects, upgrade Desktop to 0.12.10 before updating Agent to 0.11.22. Already-running Runtimes are not hot-updated, and external OpenCode Server configuration is unchanged.",
|
||||
"This release uses the six standard Windows, macOS, and Linux x64/arm64 desktop targets, without a LoongArch experimental preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.12.9",
|
||||
"releasedAt": "2026-09-08",
|
||||
|
||||
@@ -48,6 +48,7 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
|
||||
|
||||
export type OpenCodeModelBridgeProviderConfig = {
|
||||
model: string
|
||||
snapshot: false
|
||||
permission: 'ask' | 'allow'
|
||||
agent: {
|
||||
title: {
|
||||
@@ -521,6 +522,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
const baseURL = `${origin}/v1`
|
||||
return {
|
||||
model: openCodeModelBridgeModelId(input.protocol, model),
|
||||
snapshot: false,
|
||||
permission: input.workMode === 'ask' ? 'ask' : 'allow',
|
||||
// GoodBuddy owns conversation titles. A first-prompt title request can
|
||||
// race the real ACP prompt, abandon its Provider response, and correctly
|
||||
|
||||
@@ -134,6 +134,7 @@ describe('model bridge loopback helper', () => {
|
||||
expect(options.env.NODE_OPTIONS).toBeUndefined()
|
||||
const config = JSON.parse(options.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.model).toBe('goodbuddy-anthropic/private-model')
|
||||
expect(config.snapshot).toBe(false)
|
||||
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
|
||||
expect(config.plugin).toHaveLength(1)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,19 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('OpenCode direct launch profile', () => {
|
||||
it.each(['ask', 'execute'] as const)(
|
||||
'disables unused automatic Git snapshots for %s',
|
||||
(workMode) => {
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
...createFixture(),
|
||||
workMode
|
||||
})
|
||||
const config = JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.snapshot).toBe(false)
|
||||
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
|
||||
}
|
||||
)
|
||||
|
||||
it('runs Ask directly in the Workspace', () => {
|
||||
const fixture = createFixture()
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
|
||||
@@ -146,6 +146,8 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
const environment = {
|
||||
...environmentBase,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
||||
// Native snapshot summaries are not consumed by GoodBuddy.
|
||||
snapshot: false,
|
||||
permission,
|
||||
agent: { build: { permission } },
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createContinueUtilityProcessChild,
|
||||
@@ -6,11 +7,12 @@ import {
|
||||
} from './continue-utility-process-adapter'
|
||||
import { waitForProcessExit } from './child-process-termination'
|
||||
|
||||
function createSource(): ContinueUtilityProcessSource & EventEmitter {
|
||||
function createSource(stdout?: PassThrough): ContinueUtilityProcessSource & EventEmitter {
|
||||
const emitter =
|
||||
new EventEmitter() as ContinueUtilityProcessSource & EventEmitter
|
||||
Object.defineProperties(emitter, {
|
||||
pid: { value: 42 },
|
||||
stdout: { value: stdout },
|
||||
stderr: { value: undefined }
|
||||
})
|
||||
emitter.kill = vi.fn(() => true)
|
||||
@@ -33,6 +35,17 @@ function createSource(): ContinueUtilityProcessSource & EventEmitter {
|
||||
}
|
||||
|
||||
describe('Continue utility process adapter', () => {
|
||||
it('drains unused stdout so native output cannot block the host', async () => {
|
||||
const stdout = new PassThrough({ highWaterMark: 1024 })
|
||||
createContinueUtilityProcessChild(createSource(stdout))
|
||||
|
||||
expect(stdout.readableFlowing).toBe(true)
|
||||
await new Promise<void>((resolve) => {
|
||||
stdout.end(Buffer.alloc(128 * 1024), resolve)
|
||||
})
|
||||
expect(stdout.readableLength).toBe(0)
|
||||
})
|
||||
|
||||
it('maps Electron exit to close and completes helper waits immediately', async () => {
|
||||
const source = createSource()
|
||||
const child = createContinueUtilityProcessChild(source)
|
||||
|
||||
@@ -8,6 +8,7 @@ type UtilityErrorListener = (
|
||||
|
||||
export type ContinueUtilityProcessSource = {
|
||||
readonly pid?: number
|
||||
readonly stdout?: Pick<NodeJS.ReadableStream, 'resume'> | null
|
||||
readonly stderr?: ContinueHostChild['stderr']
|
||||
kill(): boolean
|
||||
onExit(listener: (code: number) => void): void
|
||||
@@ -20,6 +21,8 @@ export type ContinueUtilityProcessSource = {
|
||||
export function createContinueUtilityProcessChild(
|
||||
utility: ContinueUtilityProcessSource
|
||||
): ContinueHostChild {
|
||||
// Responses arrive over HTTP, but an unread console pipe can block the host.
|
||||
utility.stdout?.resume()
|
||||
let exitCode: number | null = null
|
||||
let killed = false
|
||||
const closeListeners = new Map<
|
||||
|
||||
@@ -71,6 +71,8 @@ it.skipIf(!existsSync(binaryPath))(
|
||||
if (!closed) openEventBodies--
|
||||
closed = true
|
||||
}
|
||||
// The wrapper may stay full after fetch aborts, with no further pull.
|
||||
void reader.closed.then(close, close)
|
||||
return new Response(new ReadableStream({
|
||||
async pull(controller) {
|
||||
try {
|
||||
|
||||
@@ -288,6 +288,41 @@ async function collectRun(
|
||||
}
|
||||
|
||||
describe("OpenCodeRuntime embedded launcher", () => {
|
||||
it.each(["native", "profile"] as const)(
|
||||
"disables unused automatic Git snapshots with %s model configuration",
|
||||
async (configuration) => {
|
||||
const { deps, spawnMock } = dependencies(fakeChild());
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({
|
||||
modelProfile: configuration === "profile"
|
||||
? {
|
||||
id: "00000000-0000-4000-8000-000000000011",
|
||||
name: "Test model",
|
||||
baseUrl: "https://model.example/v1",
|
||||
modelName: "test-model",
|
||||
protocol: "openai-responses",
|
||||
authentication: "none",
|
||||
supportsImageInput: false,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
try {
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true,
|
||||
});
|
||||
const config = JSON.parse(
|
||||
spawnMock.mock.calls[0]?.[2]?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
|
||||
);
|
||||
expect(config.snapshot).toBe(false);
|
||||
expect(config.permission["*"]).toBe("allow");
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("forwards retry scheduling and resumes only on a matching native busy event", async () => {
|
||||
const next = Date.now() + 120_000;
|
||||
const setup = runClient([
|
||||
@@ -640,6 +675,7 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
|
||||
) as Record<string, unknown>;
|
||||
expect(config).toEqual({
|
||||
snapshot: false,
|
||||
skills: {
|
||||
paths: [join(configDirectory!, "skills")],
|
||||
urls: [],
|
||||
|
||||
@@ -1089,14 +1089,12 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
env.XDG_DATA_HOME = join(registration.root, "xdg-data");
|
||||
env.XDG_STATE_HOME = join(registration.root, "xdg-state");
|
||||
const skillConfig = createOpenCodeSkillConfig(registration, skillIds);
|
||||
env.OPENCODE_CONFIG_CONTENT = JSON.stringify(
|
||||
profile
|
||||
? {
|
||||
...createOpenCodeProviderConfig(profile),
|
||||
...skillConfig,
|
||||
}
|
||||
: skillConfig,
|
||||
);
|
||||
// GoodBuddy does not use native Git snapshots or their synchronous diffs.
|
||||
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
||||
...(profile ? createOpenCodeProviderConfig(profile) : {}),
|
||||
...skillConfig,
|
||||
snapshot: false,
|
||||
});
|
||||
if (!profile && this.options.configPath.trim()) {
|
||||
env.OPENCODE_CONFIG = resolve(this.options.configPath);
|
||||
}
|
||||
|
||||
@@ -1965,8 +1965,10 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('returns the latest 500 remote messages in chronological order', async () => {
|
||||
const database = await createDatabase()
|
||||
it('returns the latest 500 remote messages in chronological order', () => {
|
||||
// Exercise the query boundary without 502 disk-synchronized fixture writes.
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize('C:\\Workspace')
|
||||
const project = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
channelDefaultProfileId
|
||||
|
||||
@@ -389,6 +389,7 @@ const launchContinueHost: ContinueHostLauncher = (
|
||||
get pid() {
|
||||
return utilityChild.pid
|
||||
},
|
||||
stdout: utilityChild.stdout,
|
||||
stderr: utilityChild.stderr,
|
||||
kill: () => utilityChild.kill(),
|
||||
onExit: (listener) => {
|
||||
|
||||
@@ -621,8 +621,11 @@ describe('KnowledgeDatabase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lists every active task in addition to the terminal history limit', async () => {
|
||||
const { database } = await createDatabase()
|
||||
it('lists every active task in addition to the terminal history limit', () => {
|
||||
// This checks retention, not the disk latency of 502 independent writes.
|
||||
const database = new KnowledgeDatabase(':memory:')
|
||||
database.initialize()
|
||||
openDatabases.push(database)
|
||||
const library = database.createKnowledgeBase({
|
||||
name: 'Task listing',
|
||||
storageMode: 'reference'
|
||||
|
||||
@@ -116,11 +116,11 @@ describe('ChatTimeline', () => {
|
||||
expect(rule).not.toContain('contain-intrinsic')
|
||||
})
|
||||
|
||||
it('places message identity above the message body', () => {
|
||||
it.each(['assistant', 'user'] as const)('places the %s avatar outside the header and body', (role) => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: 'assistant-message',
|
||||
role: 'assistant',
|
||||
role,
|
||||
content: 'Answer',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete'
|
||||
@@ -145,9 +145,11 @@ describe('ChatTimeline', () => {
|
||||
const header = message?.querySelector('.message__header')
|
||||
const body = message?.querySelector('.message__body')
|
||||
|
||||
expect(header).toContainElement(message?.querySelector('.message__avatar') ?? null)
|
||||
const avatar = message?.querySelector('.message__avatar')
|
||||
|
||||
expect(avatar).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(header).toContainElement(message?.querySelector('.message__meta') ?? null)
|
||||
expect(Array.from(message?.children ?? [])).toEqual([header, body])
|
||||
expect(Array.from(message?.children ?? [])).toEqual([avatar, header, body])
|
||||
})
|
||||
|
||||
it('enables HTML rendering only for completed Agent output', () => {
|
||||
|
||||
@@ -553,14 +553,14 @@ function ChatMessageRowView({
|
||||
ref={(element) => onArticleRef(message.id, element)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div aria-hidden="true" className="message__avatar">
|
||||
{message.role === 'assistant' ? (
|
||||
<Bot size={18} />
|
||||
) : (
|
||||
<UserRound size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="message__header">
|
||||
<div className="message__avatar">
|
||||
{message.role === 'assistant' ? (
|
||||
<Bot size={18} />
|
||||
) : (
|
||||
<UserRound size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="message__meta">
|
||||
<strong>
|
||||
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
|
||||
|
||||
@@ -466,6 +466,10 @@ describe('MagicNotesWorkspace', () => {
|
||||
const sourceEntry = screen.getByRole('region', {
|
||||
name: '对应的笔记记录'
|
||||
})
|
||||
const todoDetail = screen.getByRole('region', { name: '待办详情' })
|
||||
const aiComments = screen.getByRole('complementary', { name: 'AI 评论' })
|
||||
expect(todoDetail).toContainElement(sourceEntry)
|
||||
expect(aiComments).not.toContainElement(sourceEntry)
|
||||
expect(within(sourceEntry).getByText('发布笔记')).toBeInTheDocument()
|
||||
expect(within(sourceEntry).getByText('记录正文')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -485,10 +485,6 @@ export function MagicNotesWorkspace({
|
||||
const [detail, setDetail] = useState<MagicNoteDetail>()
|
||||
const [todoSourceDetail, setTodoSourceDetail] =
|
||||
useState<MagicNoteDetail>()
|
||||
const [todoSourceError, setTodoSourceError] = useState<{
|
||||
message: string
|
||||
noteId: string
|
||||
}>()
|
||||
const [loadStatus, setLoadStatus] = useState<LoadStatus>('loading')
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [refreshError, setRefreshError] = useState('')
|
||||
@@ -1408,30 +1404,6 @@ export function MagicNotesWorkspace({
|
||||
const selectedTodoNoteId = selectedTodo?.noteId
|
||||
const selectedTodoEntryId = selectedTodo?.entryId
|
||||
|
||||
const loadTodoSource = useCallback(
|
||||
async (noteId: string): Promise<void> => {
|
||||
const requestId = ++todoSourceRequestRef.current
|
||||
try {
|
||||
const sourceDetail = await window.goodbuddy.magicNotes.get(noteId)
|
||||
if (todoSourceRequestRef.current === requestId) {
|
||||
setTodoSourceDetail(sourceDetail)
|
||||
setTodoSourceError(undefined)
|
||||
}
|
||||
} catch (sourceError) {
|
||||
if (todoSourceRequestRef.current === requestId) {
|
||||
setTodoSourceError({
|
||||
message: errorMessage(
|
||||
sourceError,
|
||||
tRef.current('errors.operationFailed')
|
||||
),
|
||||
noteId
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (libraryView !== 'todos' || !selectedTodoNoteId) {
|
||||
return
|
||||
@@ -1442,41 +1414,27 @@ export function MagicNotesWorkspace({
|
||||
.then((sourceDetail) => {
|
||||
if (todoSourceRequestRef.current === requestId) {
|
||||
setTodoSourceDetail(sourceDetail)
|
||||
setTodoSourceError(undefined)
|
||||
}
|
||||
})
|
||||
.catch((sourceError: unknown) => {
|
||||
if (todoSourceRequestRef.current === requestId) {
|
||||
setTodoSourceError({
|
||||
message: errorMessage(
|
||||
sourceError,
|
||||
tRef.current('errors.operationFailed')
|
||||
),
|
||||
noteId: selectedTodoNoteId
|
||||
})
|
||||
notifyError(sourceError)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
todoSourceRequestRef.current += 1
|
||||
}
|
||||
}, [libraryView, selectedTodoNoteId])
|
||||
}, [libraryView, notifyError, selectedTodoNoteId])
|
||||
|
||||
const selectedTodoSourceEntry = useMemo(
|
||||
() =>
|
||||
todoSourceDetail?.entries.find(
|
||||
(entry) => entry.id === selectedTodoEntryId
|
||||
),
|
||||
[selectedTodoEntryId, todoSourceDetail]
|
||||
todoSourceDetail && todoSourceDetail.id === selectedTodoNoteId
|
||||
? todoSourceDetail.entries.find(
|
||||
(entry) => entry.id === selectedTodoEntryId
|
||||
)
|
||||
: undefined,
|
||||
[selectedTodoEntryId, selectedTodoNoteId, todoSourceDetail]
|
||||
)
|
||||
const selectedTodoSourceError =
|
||||
todoSourceError && todoSourceError.noteId === selectedTodoNoteId
|
||||
? todoSourceError.message
|
||||
: ''
|
||||
const todoSourceLoading =
|
||||
Boolean(selectedTodoNoteId) &&
|
||||
todoSourceDetail?.id !== selectedTodoNoteId &&
|
||||
!selectedTodoSourceError
|
||||
|
||||
const reloadTodos = useCallback(async (): Promise<void> => {
|
||||
const snapshot = await window.goodbuddy.magicNotes.listTodos()
|
||||
setTodos(snapshot.todos)
|
||||
@@ -1574,7 +1532,6 @@ export function MagicNotesWorkspace({
|
||||
if (selectedTodoId === todo.id) {
|
||||
todoSourceRequestRef.current += 1
|
||||
setTodoSourceDetail(result.note)
|
||||
setTodoSourceError(undefined)
|
||||
}
|
||||
if (requestedNoteIdRef.current === result.note.id) {
|
||||
applyDetail(result.note)
|
||||
@@ -2767,6 +2724,31 @@ export function MagicNotesWorkspace({
|
||||
{t('actions.openSourceNote')}
|
||||
</button>
|
||||
</div>
|
||||
<section
|
||||
aria-label={t('todos.sourceEntryLabel')}
|
||||
className="magic-todo-source-entry"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{t('todos.sourceEntryHeading')}</strong>
|
||||
<small>{selectedTodo.noteTitle}</small>
|
||||
</div>
|
||||
{selectedTodoSourceEntry && (
|
||||
<time dateTime={selectedTodoSourceEntry.updatedAt}>
|
||||
{dateFormatter.format(
|
||||
new Date(selectedTodoSourceEntry.updatedAt)
|
||||
)}
|
||||
</time>
|
||||
)}
|
||||
</header>
|
||||
{selectedTodoSourceEntry ? (
|
||||
<MagicNoteContent content={selectedTodoSourceEntry.content} />
|
||||
) : (
|
||||
<p className="magic-notes-muted">
|
||||
{t('todos.sourceEntryMissing')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
@@ -2838,55 +2820,6 @@ export function MagicNotesWorkspace({
|
||||
<PanelRightClose aria-hidden="true" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{libraryView === 'todos' && selectedTodo && (
|
||||
<section
|
||||
aria-label={t('todos.sourceEntryLabel')}
|
||||
className="magic-todo-source-entry"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{t('todos.sourceEntryHeading')}</strong>
|
||||
<small>{selectedTodo.noteTitle}</small>
|
||||
</div>
|
||||
{selectedTodoSourceEntry && (
|
||||
<time dateTime={selectedTodoSourceEntry.updatedAt}>
|
||||
{dateFormatter.format(
|
||||
new Date(selectedTodoSourceEntry.updatedAt)
|
||||
)}
|
||||
</time>
|
||||
)}
|
||||
</header>
|
||||
{todoSourceLoading ? (
|
||||
<p className="magic-notes-muted">
|
||||
{t('todos.loadingSourceEntry')}
|
||||
</p>
|
||||
) : selectedTodoSourceError ? (
|
||||
<div className="magic-todo-source-entry__error" role="alert">
|
||||
<span>
|
||||
{t('todos.sourceEntryLoadFailed', {
|
||||
error: selectedTodoSourceError
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
setTodoSourceError(undefined)
|
||||
void loadTodoSource(selectedTodo.noteId)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : selectedTodoSourceEntry ? (
|
||||
<MagicNoteContent content={selectedTodoSourceEntry.content} />
|
||||
) : (
|
||||
<p className="magic-notes-muted">
|
||||
{t('todos.sourceEntryMissing')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<div className="magic-notes-ai-controls">
|
||||
<label>
|
||||
<span>{t('comments.directionLabel')}</span>
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('WorkspacePrimitives', () => {
|
||||
/@media \(max-width: 719px\)\s*\{(?:[^{}]|\{[^{}]*\})*?\.assistant-sidebar/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.composer-wrap\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*min-width:\s*0;[^}]*padding:\s*var\(--space-3\)\s*max\(var\(--page-gutter\),\s*calc\(\(100% - var\(--content-reading\)\) \/ 2\)\)\s*var\(--space-2\);/u
|
||||
/\.composer-wrap\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*min-width:\s*0;[^}]*padding:\s*var\(--space-3\)\s*var\(--chat-inline-padding\)\s*var\(--space-2\);/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.assistant-sidebar__tabs\s*\{[^}]*grid-template-columns:\s*repeat\(5,\s*minmax\(max-content,\s*1fr\)\);/u
|
||||
|
||||
@@ -117,9 +117,7 @@ export const magicNotes = {
|
||||
sourceNote: 'From note: {{title}}',
|
||||
sourceEntryLabel: 'Related note entry',
|
||||
sourceEntryHeading: 'Related entry',
|
||||
loadingSourceEntry: 'Loading the related entry…',
|
||||
sourceEntryMissing: 'The note entry for this to-do could not be found.',
|
||||
sourceEntryLoadFailed: 'Could not load the related entry: {{error}}',
|
||||
defaultInstructions: 'This to-do comes from a checklist in a note.',
|
||||
markComplete: 'Mark complete: {{title}}',
|
||||
markIncomplete: 'Mark incomplete: {{title}}'
|
||||
|
||||
@@ -110,9 +110,7 @@ export const magicNotes = {
|
||||
sourceNote: '来自笔记:{{title}}',
|
||||
sourceEntryLabel: '对应的笔记记录',
|
||||
sourceEntryHeading: '对应记录',
|
||||
loadingSourceEntry: '正在加载对应记录…',
|
||||
sourceEntryMissing: '未找到这条待办对应的笔记记录。',
|
||||
sourceEntryLoadFailed: '对应记录加载失败:{{error}}',
|
||||
defaultInstructions: '此待办来自笔记正文中的待办清单。',
|
||||
markComplete: '标记为已完成:{{title}}',
|
||||
markIncomplete: '标记为未完成:{{title}}'
|
||||
|
||||
+30
-23
@@ -1399,9 +1399,7 @@
|
||||
|
||||
.magic-todo-source-entry {
|
||||
display: grid;
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.magic-todo-source-entry > header {
|
||||
@@ -1438,11 +1436,9 @@
|
||||
}
|
||||
|
||||
.magic-todo-source-entry > .magic-note-content {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.magic-todo-source-entry > .magic-note-content .ql-editor {
|
||||
@@ -1453,18 +1449,6 @@
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.magic-todo-source-entry__error {
|
||||
display: grid;
|
||||
color: var(--danger);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.5;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-todo-source-entry__error .secondary-button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.magic-notes-ai-controls {
|
||||
display: grid;
|
||||
padding: var(--space-3) 0;
|
||||
@@ -5206,12 +5190,21 @@ button > svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat,
|
||||
.composer-wrap {
|
||||
--message-avatar-size: 29px;
|
||||
--chat-inline-padding: max(
|
||||
calc(var(--space-4) + var(--message-avatar-size) + var(--space-2)),
|
||||
calc((100cqw - var(--content-reading)) / 2)
|
||||
);
|
||||
}
|
||||
|
||||
.chat {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding:
|
||||
var(--page-gutter)
|
||||
max(var(--page-gutter), calc((100% - var(--content-reading)) / 2))
|
||||
0
|
||||
10px;
|
||||
overflow-y: auto;
|
||||
background: var(--surface-raised);
|
||||
@@ -5349,6 +5342,13 @@ button > svg {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat > .welcome,
|
||||
.message-list {
|
||||
/* Keep the scrollbar inside the outer gutter without adding trailing scroll padding. */
|
||||
width: calc(100cqw - 2 * var(--chat-inline-padding));
|
||||
margin-left: var(--chat-inline-padding);
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -5379,9 +5379,10 @@ button > svg {
|
||||
}
|
||||
|
||||
.message {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: 17px 8px;
|
||||
padding: 17px 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -5389,6 +5390,7 @@ button > svg {
|
||||
.message__header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: var(--message-avatar-size);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -5450,10 +5452,13 @@ button > svg {
|
||||
}
|
||||
|
||||
.message__avatar {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: calc(-1 * (var(--message-avatar-size) + var(--space-2)));
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
width: var(--message-avatar-size);
|
||||
height: var(--message-avatar-size);
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 9px;
|
||||
@@ -5462,6 +5467,8 @@ button > svg {
|
||||
}
|
||||
|
||||
.message--user .message__avatar {
|
||||
right: calc(-1 * (var(--message-avatar-size) + var(--space-2)));
|
||||
left: auto;
|
||||
border-color: var(--accent-selected);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
@@ -6527,7 +6534,7 @@ button > svg {
|
||||
min-width: 0;
|
||||
padding:
|
||||
var(--space-3)
|
||||
max(var(--page-gutter), calc((100% - var(--content-reading)) / 2))
|
||||
var(--chat-inline-padding)
|
||||
var(--space-2);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
interface NpmInvocationModule {
|
||||
npmInvocation(
|
||||
projectDir: string,
|
||||
environment?: NodeJS.ProcessEnv
|
||||
): {
|
||||
command: string
|
||||
prefixArgs: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { npmInvocation } = require(
|
||||
'../build/npm-invocation.cjs'
|
||||
) as NpmInvocationModule
|
||||
|
||||
let directory: string
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'goodbuddy-npm-invocation-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('npm invocation', () => {
|
||||
it('uses npm_execpath when the package manager provides it', () => {
|
||||
expect(
|
||||
npmInvocation(directory, { npm_execpath: 'provided-npm-cli.js' })
|
||||
).toEqual({
|
||||
command: process.execPath,
|
||||
prefixArgs: ['provided-npm-cli.js']
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the project npm CLI when npm_execpath is absent', () => {
|
||||
const npmCli = join(
|
||||
directory,
|
||||
'node_modules',
|
||||
'npm',
|
||||
'bin',
|
||||
'npm-cli.js'
|
||||
)
|
||||
mkdirSync(join(directory, 'node_modules', 'npm', 'bin'), {
|
||||
recursive: true
|
||||
})
|
||||
writeFileSync(npmCli, '')
|
||||
|
||||
expect(npmInvocation(directory, {})).toEqual({
|
||||
command: process.execPath,
|
||||
prefixArgs: [npmCli]
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user