From d08ba33c3e322546c26f9e20d81f660cbe0cdc65 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 6 Sep 2026 22:13:45 +0800 Subject: [PATCH] fix: complete subagent output and file workspace interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode child cards now show ordered text, reasoning and tool progress, with the final result displayed separately. Local SDK and remote ACP paths share child tracking, including progress retained across recovery and cancellation. Remote live progress requires an Agent package containing the event plugin; older Agent packages still expose final results without inventing missing history. Knowledge-library rows remain clickable when native label activation changes focus. File workspace changes use colored Git status letters and per-file staged/unstaged diffs, including deleted, renamed and untracked files. Refreshing reloads the selected diff and expanded directories, and more than 50 changes remain accessible through incremental loading. Validated with 3552 passing tests, typecheck, lint, desktop build, current-source Linux Host scenarios and isolated Windows desktop UI interactions. Release note: 展开 OpenCode 子代理卡片可查看执行过程、工具详情与独立最终结果;知识库支持整行勾选。工作区支持单文件暂存与未暂存差异,刷新会更新当前差异,更多变更可继续加载。远端实时过程需要更新 GoodBuddy Agent。 --- docs/features/assistant-workbar/README.md | 3 +- docs/features/assistant-workbar/prd.md | 16 ++ docs/features/assistant-workbar/progress.md | 29 ++++ docs/features/knowledge-base/progress.md | 6 + docs/features/knowledge-base/ui-design.md | 5 + docs/features/remote-host/technical-design.md | 26 +++- src/agent-daemon/model-bridge-helper.ts | 18 ++- .../opencode-subagent-plugin.test.ts | 42 +++++ src/agent-daemon/opencode-subagent-plugin.ts | 56 +++++++ .../workspace-git-service.test.ts | 26 ++++ src/agent-daemon/workspace-git-service.ts | 39 +++-- src/main/agent/acp-remote-runtime.test.ts | 52 +++++++ src/main/agent/acp-remote-runtime.ts | 48 +++++- src/main/agent/opencode-runtime.ts | 19 ++- src/main/agent/opencode-subagent.test.ts | 88 ++++++++++- src/main/agent/opencode-subagent.ts | 147 +++++++++++++++++- src/main/assistant/assistant-database.ts | 1 + .../workspace-changes-service.test.ts | 41 +++++ .../assistant/workspace-changes-service.ts | 11 +- src/main/ipc.test.ts | 1 + src/main/ipc.ts | 19 ++- src/main/workspace/local-workspace-access.ts | 61 +++++--- .../workspace/remote-workspace-access.test.ts | 20 +++ src/main/workspace/remote-workspace-access.ts | 20 ++- src/main/workspace/workspace-access.ts | 1 + src/preload/index.ts | 5 + src/renderer/src/App.test.tsx | 22 ++- src/renderer/src/App.tsx | 8 +- src/renderer/src/ChatTimeline.test.tsx | 40 +++++ src/renderer/src/ChatTimeline.tsx | 44 +++++- .../src/RightAssistantSidebar.resize.test.tsx | 1 + src/renderer/src/RightAssistantSidebar.tsx | 28 +--- src/renderer/src/WorkspaceFilesPanel.test.tsx | 93 ++++++++++- src/renderer/src/WorkspaceFilesPanel.tsx | 132 +++++++++++----- src/renderer/src/i18n/locales/en-US/app.ts | 2 + .../src/i18n/locales/en-US/workspace.ts | 7 +- src/renderer/src/i18n/locales/zh-CN/app.ts | 2 + .../src/i18n/locales/zh-CN/workspace.ts | 7 +- src/renderer/src/styles.css | 68 ++++++-- src/shared/assistant-contracts.ts | 2 + src/shared/contracts.ts | 1 + src/shared/ipc-channels.ts | 1 + src/shared/remote-agent-contracts.ts | 1 + 43 files changed, 1118 insertions(+), 141 deletions(-) create mode 100644 docs/features/assistant-workbar/progress.md create mode 100644 src/agent-daemon/opencode-subagent-plugin.test.ts create mode 100644 src/agent-daemon/opencode-subagent-plugin.ts diff --git a/docs/features/assistant-workbar/README.md b/docs/features/assistant-workbar/README.md index 26db55e7..2bb2d9bd 100644 --- a/docs/features/assistant-workbar/README.md +++ b/docs/features/assistant-workbar/README.md @@ -8,6 +8,7 @@ | --- | --- | | [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 | | [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 | +| [实现与验证进度](./progress.md) | 工作区改动的验证证据与剩余阻塞 | -当前尚未拆出独立 User Stories、功能逻辑、UI 设计、技术设计和进度文档;相关内容仍保留在 +当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在 两份现有 PRD 中,后续实际维护该功能时按职责拆分。 diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index 23130ac7..10b4ae9a 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -366,6 +366,22 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 - 显式打开、下载副本或在终端中打开。 - HTML 文件的源码与安全预览。 +当前项目目录的文件浏览与 Git 交互遵循以下规则: + +- 页签下不重复显示“项目工作区”标题,保留刷新按钮。 +- 未提交更改以彩色 Git 字母标识状态:M 修改、A 新增、D 删除、R 重命名、C 复制、 + U 未跟踪或冲突、T 类型变化;冲突用危险色区别于未跟踪文件,同时保留原始状态。 +- 点击更改项进入该文件 Diff,分别显示已暂存和未暂存差异,增删行使用对应状态色; + 删除文件也可查看。目录树中的文件仍打开内容预览。不再提供底部全仓库 Diff 入口。 +- 手动刷新或任务结束更新 Git 状态时,同步重读根目录和已展开目录,保留目录展开状态, + 清除未展开目录的旧缓存;正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。 + 无首次提交的仓库也应显示新增和暂存文件。 +- 未提交更改首批显示 50 项,可继续分批加载其余变更,不能使后续文件尤其是已删除 + 文件失去 Diff 入口。 +- 系统默认应用和资源管理器打开按钮仅用于本机项目,SSH 项目不显示这两个按钮。 + +目录切换、下载副本、终端打开及 HTML 安全预览仍属于后续能力,不能据此视为已实现。 + 本机和远程访问都必须由 Main 或远程 GoodBuddy Agent 在对应文件系统上执行路径规范化、相对路径和 符号链接边界检查。Renderer 只能提交受约束的相对路径和已授权范围 ID。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md new file mode 100644 index 00000000..c23962e9 --- /dev/null +++ b/docs/features/assistant-workbar/progress.md @@ -0,0 +1,29 @@ +# 工作栏实现与验证进度 + +## 2026-09-06 项目工作区 + +范围见 [PRD 工作区](./prd.md#76-工作区)。 + +当前源码已移除重复标题与底部完整 Diff 入口,使用彩色 Git 状态字母,点击更改项读取 +单文件 Diff。已暂存与未暂存差异分开展示;本机 Git 测试覆盖无首次提交、未跟踪、 +暂存后继续修改、重命名、删除及包含方括号的路径。Git 刷新驱动目录树重新读取,保留 +展开状态;SSH 项目不提供本机系统打开按钮。审查后补齐了当前 Diff 随刷新重读、 +旧请求结果失效,以及超过 50 项变更的继续加载入口。 + +验证记录: + +- 最终 `npm run typecheck`、`npm run lint`、`npm run build` 均通过。 +- 工作区专项 7 个测试文件复跑:317 项通过,3 项 Linux 专用测试在 Windows 跳过。 + 任务完成测试同时检查目录树出现新文件。 +- 最终 `npm test`:326 个测试文件、3552 项测试通过,9 个文件、58 项测试按既有条件 + 跳过。复核首轮曾有一项浏览器地址草稿测试超时,单独复跑及最终全量复跑均通过; + 更早的超时/失败记录不再作为最终验收结果。 +- 后续复核已通过既有凭据和固定 Host Key 经 VPN 连接共享 Linux x64 Host,使用当前 + 源码构建的隔离 Agent 包安装并启动。真实 `RemoteWorkspaceAccess → Agent git/status` + 与 `git/diff` 路径覆盖暂存后继续修改、删除、重命名、未跟踪和方括号路径;均通过。 + Git 验证未调用模型。 +- Windows 隔离桌面实例已通过真实点击验收:当前项目单文件暂存/工作树 Diff 分组与 + 增删颜色正确,修改测试文件后点击刷新可更新正在查看的 Diff,53 项变更经加载更多 + 全部可访问。截图留在本地验证目录,不纳入产品包。 + +目录分页、任意目录切换和 HTML 预览不在本轮改动范围内。 diff --git a/docs/features/knowledge-base/progress.md b/docs/features/knowledge-base/progress.md index 832d4e5b..9d00c4ed 100644 --- a/docs/features/knowledge-base/progress.md +++ b/docs/features/knowledge-base/progress.md @@ -26,6 +26,12 @@ 有效 Open XML DOCX 与两页原生文本 PDF 均通过生产 `KnowledgeService.importPaths` 导入、解析、分块和全文检索。 +2026-09-06 对话知识库选择补充验证: + +- `App.test.tsx` 覆盖点击名称、文档数量和行本身时切换选中状态,以及空目标失焦时 + 弹层不提前卸载。Windows 隔离桌面实际点击名称后选中、点击数量后取消,弹层保持打开。 +- 本轮全量校验结果见[工作栏验证记录](../assistant-workbar/progress.md);本项验证未调用模型。 + ## 剩余工作 - 外部 Dify、FastGPT 和 RAGFlow 知识库接入仍为规划中能力。 diff --git a/docs/features/knowledge-base/ui-design.md b/docs/features/knowledge-base/ui-design.md index d08e9aae..e71224ca 100644 --- a/docs/features/knowledge-base/ui-design.md +++ b/docs/features/knowledge-base/ui-design.md @@ -63,3 +63,8 @@ Conversation 的检索范围,返回对话并聚焦输入框。 知识库多选属于 Conversation。新对话默认空范围;切换对话恢复各自选择。创建新知识库 只加入当前 Conversation,不重新启用其他已取消项。发送请求时冻结当前选择和检索方式。 + +输入区的知识库选择弹层使用整行原生 Checkbox 标签,点击名称、文档数量或行内空白 +均切换一次选择;选中背景和整行焦点环保持可见。Tab 聚焦、Space 切换,Escape 关闭并 +返回触发按钮。点击行内非焦点元素时产生的空目标失焦不关闭弹层;点击外部或焦点明确 +移到外部时才关闭,避免原生标签完成勾选前被卸载。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index a42248c1..39bd96da 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -238,7 +238,20 @@ Execute 直接启动已签名 Runtime: - OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID 解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP 首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent - 状态卡,并持久化任务说明、终态与输出。 + 状态卡,并持久化任务说明、终态与输出。卡片展开后分为“执行过程”和“最终结果”: + 正文、折叠思考与可展开工具记录按收到的顺序显示,工具状态原位更新;完成后仅在最终 + 结果区显示与末尾正文完全相同的返回结果,避免重复。失败或取消保留已有过程,未终结 + 工具不再显示进行中。 +- 本地 SDK 通过 Task `metadata.sessionId` 关联子会话,复用稳定消息块 ID 保存过程。 + 固定 OpenCode 1.18.9 的 ACP 不转发子会话,因此 Agent 的模型桥 helper 在受管临时目录 + 写入事件插件,并通过既有 ACP `session/update` 的 `_meta.goodbuddySubagentEvent` + 转发到所属 Task;不新增端口,不修改签名 Runtime 包。插件文件随 helper 退出清理, + 事件继续经过既有 Agent transcript、ACK 和 Desktop 消息持久化路径。 +- ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的 + `` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属 + `toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。 + 远端实时过程需要包含该插件的 Agent;旧 Agent 的最终结果仍可查看,历史未采集过程 + 不会被补造。 - 本机 OpenCode SDK 路径允许不同会话并行,同一会话仍按请求顺序执行。每个请求独立拥有 SSE 事件订阅,并在正常完成、错误、取消或消费方结束迭代时主动关闭自己的响应流, 不关闭其他会话的订阅或共享 Server。取消导致事件迭代结束时保留原取消原因,不改报 @@ -408,6 +421,17 @@ bundle 构建通过;这组历史结果不替代完整 Host 支持改动后的 ### Linux 历史验收 +- 2026-09-06 使用当前源码隔离 Agent 验证原生子代理过程插件。最终场景收到 37 条 ACP + 插件事件,桌面侧从已保留活动重建跟踪器后仍接收后续文本与工具;子代理和父请求正常 + 完成,最终模型调用账本为 4 次 completed。此前成功场景另 4 次,两轮真实 Host 共 + 8 次模型请求。 +- 同日 Windows 隔离桌面实际发送 OpenCode 原生 Task,展开卡片可看到进行中的正文与 + read 工具,取消后已有过程保留且工具显示已取消;正确工作区场景完成后显示测试文件 + 内容,最终结果不重复留在过程末尾。本机有界代理共记录 10 次模型请求(含错误路径、 + 取消和成功场景);另外 2 次使用原生测试配置的请求返回 401,改选显式测试模型后通过。 + 未改动正常用户设置,也未发布 Agent 包。全量静态与测试结果见 + [本轮验证记录](../assistant-workbar/progress.md)。 + - 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0` 和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布 的双架构验收。Agent `0.11.10` 后续已由原生 workflow 发布并公开验证双架构工件; diff --git a/src/agent-daemon/model-bridge-helper.ts b/src/agent-daemon/model-bridge-helper.ts index 5cfe7f31..27fe8c20 100644 --- a/src/agent-daemon/model-bridge-helper.ts +++ b/src/agent-daemon/model-bridge-helper.ts @@ -1,5 +1,9 @@ import { randomBytes, randomUUID } from 'node:crypto' import { spawn as nodeSpawn } from 'node:child_process' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { pathToFileURL } from 'node:url' +import { openCodeSubagentPluginSource } from './opencode-subagent-plugin' import { createServer, type IncomingHttpHeaders, @@ -8,7 +12,7 @@ import { type ServerResponse } from 'node:http' import type { Socket } from 'node:net' -import { isAbsolute, resolve } from 'node:path' +import { isAbsolute, resolve, join } from 'node:path' import type { ModelBridgeModelProtocol } from '../shared/model-bridge-contracts' @@ -409,7 +413,11 @@ export async function runOpenCodeModelBridgeHelper(options: { const exchange = createUnixModelBridgeExchange({ socketPath }) const proxy = new ModelBridgeLoopbackProxy({ exchange }) const origin = await proxy.listen() + let pluginDirectory: string | undefined try { + pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-')) + const pluginPath = join(pluginDirectory, 'subagent.mjs') + await writeFile(pluginPath, openCodeSubagentPluginSource(), 'utf8') const config = createOpenCodeModelBridgeProviderConfig({ protocol: options.protocol, model: options.model, @@ -419,7 +427,10 @@ export async function runOpenCodeModelBridgeHelper(options: { }) const environment = credentialFreeHelperEnvironment( options.environment ?? process.env, - JSON.stringify(config) + JSON.stringify({ + ...config, + plugin: [pathToFileURL(pluginPath).href] + }) ) const child = (options.spawn ?? defaultHelperSpawn)( opencodeEntrypoint, @@ -442,6 +453,9 @@ export async function runOpenCodeModelBridgeHelper(options: { }) } finally { await proxy.close() + if (pluginDirectory) { + await rm(pluginDirectory, { recursive: true, force: true }) + } } } diff --git a/src/agent-daemon/opencode-subagent-plugin.test.ts b/src/agent-daemon/opencode-subagent-plugin.test.ts new file mode 100644 index 00000000..cfa17ba0 --- /dev/null +++ b/src/agent-daemon/opencode-subagent-plugin.test.ts @@ -0,0 +1,42 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' +import { openCodeSubagentPluginSource } from './opencode-subagent-plugin' + +describe('OpenCode ACP child event plugin', () => { + it('emits valid newline-delimited ACP updates only for owned child sessions', async () => { + const write = vi.fn() + const factory = runInNewContext( + `(${openCodeSubagentPluginSource().replace('export default ', '')})`, + { process: { stdout: { write } } } + ) + const hooks = await factory() + const event = { + type: 'message.part.updated', + properties: { + sessionID: 'parent', + part: { + type: 'tool', tool: 'task', callID: 'task', + state: { metadata: { sessionId: 'child' } } + } + } + } + await hooks.event({ event }) + await hooks.event({ event: { + type: 'message.part.delta', properties: { sessionID: 'child', delta: 'Inspecting' } + } }) + await hooks.event({ event: { + type: 'message.part.delta', properties: { sessionID: 'unrelated', delta: 'Private' } + } }) + expect(write).toHaveBeenCalledTimes(2) + for (const [line] of write.mock.calls) { + expect(line.endsWith('\n')).toBe(true) + expect(JSON.parse(line)).toMatchObject({ + method: 'session/update', + params: { sessionId: 'parent', update: { + sessionUpdate: 'tool_call_update', toolCallId: 'task', + _meta: { goodbuddySubagentEvent: { properties: expect.any(Object) } } + } } + }) + } + }) +}) diff --git a/src/agent-daemon/opencode-subagent-plugin.ts b/src/agent-daemon/opencode-subagent-plugin.ts new file mode 100644 index 00000000..2153e345 --- /dev/null +++ b/src/agent-daemon/opencode-subagent-plugin.ts @@ -0,0 +1,56 @@ +/** + * OpenCode's ACP adapter omits native child sessions. This plugin forwards + * their SDK events as ACP metadata on the owning Task, using the same stdio + * stream and prompt lifetime as the built-in adapter. + */ +export function openCodeSubagentPluginSource(): string { + return `export default ${plugin.toString()}` +} + +function plugin() { + const tasks = new Map() + return Promise.resolve({ + event: async ({ event }: { event: { + type: string + properties: { + sessionID?: string + part?: { + type: string + tool?: string + callID?: string + state?: { + metadata?: { sessionId?: string } + } + } + } + } }) => { + const properties = event.properties + const part = properties.part + let owner = properties.sessionID ? tasks.get(properties.sessionID) : undefined + if ( + !owner && event.type === 'message.part.updated' && + part?.type === 'tool' && part.tool === 'task' && + part.callID && properties.sessionID + ) { + owner = { sessionId: properties.sessionID, callId: part.callID } + const child = part.state?.metadata?.sessionId + if (child) tasks.set(child, owner) + } + if (!owner || ![ + 'message.updated', 'message.part.updated', 'message.part.delta' + ].includes(event.type)) return + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: owner.sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: owner.callId, + _meta: { goodbuddySubagentEvent: event } + } + } + }) + '\n') + } + }) +} diff --git a/src/agent-daemon/workspace-git-service.test.ts b/src/agent-daemon/workspace-git-service.test.ts index 8bf694fd..c2b796c0 100644 --- a/src/agent-daemon/workspace-git-service.test.ts +++ b/src/agent-daemon/workspace-git-service.test.ts @@ -127,6 +127,32 @@ describe('WorkspaceGitService', () => { code: 'git-unsafe' }) }) + + linuxIt('diffs unborn, untracked, staged, renamed and deleted files by literal path', async () => { + const root = temporaryDirectory() + const executable = await resolveVerifiedGitExecutable() + git(executable, root, 'init', '--quiet') + const service = new WorkspaceGitService({ gitExecutable: executable }) + const access = accessFor(root) + const diff = (relativePath: string, staged = false) => service.diff(access, { + workspaceId: 'workspace-a', generation: 1, relativePath, staged, maximumBytes: 4096 + }) + writeFileSync(join(root, '[new].txt'), 'original\n') + expect((await diff('[new].txt')).patch).toContain('+original') + expect((await diff('[new].txt', true)).patch).toBe('') + git(executable, root, 'add', '.') + expect((await diff('[new].txt', true)).patch).toContain('+original') + writeFileSync(join(root, '[new].txt'), 'edited\n') + expect((await diff('[new].txt')).patch).toContain('+edited') + git(executable, root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial') + git(executable, root, 'mv', '[new].txt', 'renamed file.txt') + expect((await diff('renamed file.txt', true)).patch).toContain('rename from [new].txt') + expect((await diff('renamed file.txt')).patch).toContain('+edited') + rmSync(join(root, 'renamed file.txt')) + expect((await diff('renamed file.txt')).patch).toContain('-original') + git(executable, root, 'add', '-u') + expect((await diff('[new].txt', true)).patch).toContain('-original') + }) }) function git(executable: string, cwd: string, ...args: string[]): void { diff --git a/src/agent-daemon/workspace-git-service.ts b/src/agent-daemon/workspace-git-service.ts index f65c4602..0393f596 100644 --- a/src/agent-daemon/workspace-git-service.ts +++ b/src/agent-daemon/workspace-git-service.ts @@ -184,8 +184,23 @@ export class WorkspaceGitService { options: WorkspaceIoOptions = {} ): Promise { const repository = await this.#repository(accessService, options) - if (request.relativePath !== undefined && request.relativePath !== '') { - await accessService.stat(request.relativePath, options) + const selectedPath = request.relativePath || undefined + const status = selectedPath === undefined ? undefined : await this.status( + accessService, + { workspaceId: request.workspaceId, generation: request.generation, + includeIgnored: false, maximumEntries: 2000 }, + options + ) + const file = status?.entries.find((entry) => entry.relativePath === selectedPath) + if (selectedPath !== undefined && !file) { + throw new WorkspaceServiceError('File is no longer in the Git changes list', 'git-failed') + } + const untracked = file?.index === 'untracked' + if (untracked && request.staged) { + return { repositoryIdentity: repository.repositoryIdentity, patch: '', byteLength: 0, truncated: false } + } + if (untracked) { + await accessService.stat(selectedPath!, options) } const cursor = parseCursor(request.cursor) if (cursor > MAXIMUM_GIT_PROCESS_BYTES) { @@ -205,13 +220,14 @@ export class WorkspaceGitService { '--no-ext-diff', '--no-textconv', '--no-color', - '--no-renames', '--ignore-submodules=all', - ...(request.staged ? ['--cached'] : []), - ...(request.relativePath === undefined || - request.relativePath === '' - ? [] - : ['--', request.relativePath]) + ...(untracked ? ['--no-index', '--', NULL_DEVICE, selectedPath!] : [ + ...(request.staged ? ['--cached'] : []), + ...(selectedPath === undefined ? [] : ['--', + ...[...new Set([file?.originalRelativePath, selectedPath])] + .filter((path): path is string => path !== undefined) + .map((path) => `:(literal)${path}`)]) + ]) ] if (cursor > 0) { const cached = this.#diffSnapshots.get(snapshotKey) @@ -228,7 +244,7 @@ export class WorkspaceGitService { cached === undefined || cached.repositoryIdentity !== repository.repositoryIdentity || current === undefined || - current.exitCode !== 0 || + (current.exitCode !== 0 && !(untracked && current.exitCode === 1)) || current.truncated || !current.stdout.equals(cached.bytes) || cursor > cached.bytes.byteLength @@ -254,7 +270,7 @@ export class WorkspaceGitService { options, MAXIMUM_GIT_PROCESS_BYTES ) - if (result.exitCode !== 0) { + if (result.exitCode !== 0 && !(untracked && result.exitCode === 1)) { throw gitFailure(result) } const middle = await repositorySnapshot(repository, accessService) @@ -266,7 +282,7 @@ export class WorkspaceGitService { MAXIMUM_GIT_PROCESS_BYTES ) if ( - verification.exitCode !== 0 || + (verification.exitCode !== 0 && !(untracked && verification.exitCode === 1)) || verification.truncated !== result.truncated || !verification.stdout.equals(result.stdout) ) { @@ -876,6 +892,7 @@ function parseStatus( continue } entries.push({ + statusCode, relativePath: remoteRelativePathSchema.parse(relativePath), index: mapStatus(statusCode[0]!, statusCode), worktree: mapStatus(statusCode[1]!, statusCode), diff --git a/src/main/agent/acp-remote-runtime.test.ts b/src/main/agent/acp-remote-runtime.test.ts index ee9246c8..851796c0 100644 --- a/src/main/agent/acp-remote-runtime.test.ts +++ b/src/main/agent/acp-remote-runtime.test.ts @@ -1544,6 +1544,58 @@ describe('AcpRemoteRuntime', () => { }) }) + it('keeps ACP content results and bridged child progress in the same card', async () => { + const client: { value?: AgentSideConnection } = {} + const input = { subagent_type: 'general', prompt: 'Read seed' } + const server = fakeServer({ + prompt: async ({ sessionId }) => { + for (const event of [ + { type: 'message.part.updated', properties: { + sessionID: sessionId, part: { + id: 'task', callID: 'task', type: 'tool', tool: 'task', + state: { status: 'running', input, metadata: { sessionId: 'child' } } + } + } }, + { type: 'message.part.delta', properties: { + sessionID: 'child', partID: 'text', field: 'text', delta: 'Inspecting seed' + } }, + { type: 'message.part.updated', properties: { + sessionID: 'child', part: { + id: 'read', callID: 'read', type: 'tool', tool: 'read', + state: { status: 'completed', input: { path: 'seed.txt' }, output: 'seed' } + } + } } + ]) { + await client.value!.sessionUpdate({ + sessionId, update: { + sessionUpdate: 'tool_call_update', toolCallId: 'task', + _meta: { goodbuddySubagentEvent: event } + } + }) + } + await client.value!.sessionUpdate({ + sessionId, update: { + sessionUpdate: 'tool_call', toolCallId: 'task', title: 'Read seed', rawInput: input, + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: '**Final seed**' } }] + } + }) + return { stopReason: 'end_turn' } + } + }) + client.value = server.client + const events = await collect(runtime(server).run(request, new AbortController().signal)) + const children = events.filter(event => event.type === 'subagent') + expect(children.at(-1)).toMatchObject({ + state: 'completed', output: '**Final seed**', + progress: [ + { type: 'text', content: 'Inspecting seed' }, + { type: 'tool', tool: { name: 'read', state: 'completed', output: 'seed' } } + ] + }) + expect(events.some(event => event.type === 'text')).toBe(false) + }) + it('bounds native subagent output per event without a request-wide stop', async () => { const client: { value?: AgentSideConnection } = {} const server = fakeServer({ diff --git a/src/main/agent/acp-remote-runtime.ts b/src/main/agent/acp-remote-runtime.ts index 2c9d8383..b2914d78 100644 --- a/src/main/agent/acp-remote-runtime.ts +++ b/src/main/agent/acp-remote-runtime.ts @@ -60,6 +60,7 @@ import { } from './remote-runtime-channel' import type { RuntimeSessionBindingStore } from './runtime-session-binding-store' import { + OpenCodeSubagentProgress, parseOpenCodeSubagentInput, toOpenCodeSubagentEvent } from './opencode-subagent' @@ -136,6 +137,7 @@ export class RemotePromptRecoveryUnavailableError extends Error { } type ActivePrompt = { + subagentProgress?: OpenCodeSubagentProgress requestId: string operationId: string sessionId: string @@ -153,6 +155,7 @@ type ActivePrompt = { status?: 'pending' | 'in_progress' | 'completed' | 'failed' rawInput?: unknown rawOutput?: unknown + contentOutput?: string retainedInput?: string retainedOutput?: string retainedError?: string @@ -2067,6 +2070,34 @@ export class AcpRemoteRuntime implements AgentRuntime { prompt: ActivePrompt, update: SessionUpdate ): RuntimePublicEvent | undefined { + if (this.options.runtimeId === 'opencode') { + if (!prompt.subagentProgress) { + prompt.subagentProgress = new OpenCodeSubagentProgress( + prompt.requestId, prompt.sessionId + ) + for (const tool of prompt.toolCalls.values()) { + if (tool.retainedSubagent) { + prompt.subagentProgress.retain({ + ...tool.retainedSubagent, + requestId: prompt.requestId, type: 'subagent' + }) + } + } + } + const childEvent = update._meta?.goodbuddySubagentEvent + if (childEvent !== undefined) { + const event = prompt.subagentProgress.update( + childEvent, 'toolCallId' in update ? update.toolCallId : undefined + ) + if (event?.runtimeCallId) { + const previous = prompt.toolCalls.get(event.runtimeCallId) + prompt.toolCalls.set(event.runtimeCallId, { + ...previous, retainedSubagent: event + }) + } + return event + } + } if ( (update.sessionUpdate === 'agent_message_chunk' || update.sessionUpdate === 'agent_thought_chunk') && @@ -2118,6 +2149,13 @@ export class AcpRemoteRuntime implements AgentRuntime { update.rawOutput, retainedMaximum ), + contentOutput: + update.content === undefined || update.content === null + ? previous?.contentOutput + : update.content.flatMap((entry) => + entry.type === 'content' && entry.content.type === 'text' + ? [entry.content.text] : [] + ).join('\n').slice(0, retainedMaximum), retainedInput: update.rawInput === undefined ? previous?.retainedInput @@ -2148,22 +2186,22 @@ export class AcpRemoteRuntime implements AgentRuntime { callId, state: toolCall.status ?? 'pending', input: toolCall.rawInput, - output: toolCall.rawOutput + output: toolCall.contentOutput || toolCall.rawOutput }) : undefined if (subagent) { - return subagent + return prompt.subagentProgress?.retain(subagent) ?? subagent } if (toolCall.retainedSubagent) { const retained = toolCall.retainedSubagent const state = toolCall.status === 'pending' - ? ('queued' as const) + ? retained.state : toolCall.status === 'in_progress' ? ('running' as const) : toolCall.status ?? retained.state const detail = safeStringify( - toolCall.rawOutput, + toolCall.contentOutput || toolCall.rawOutput, retainedMaximum ) return { @@ -2191,7 +2229,7 @@ export class AcpRemoteRuntime implements AgentRuntime { retainedMaximum ) ?? toolCall.retainedInput const output = safeStringify( - toolCall.rawOutput, + toolCall.contentOutput || toolCall.rawOutput, retainedMaximum ) ?? toolCall.retainedOutput return { diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index ab71a300..ed130305 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -63,7 +63,7 @@ import { requestProcessTreeTermination, waitForProcessExit } from './child-process-termination' -import { toOpenCodeSubagentEvent } from './opencode-subagent' +import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent' import { promptWithUntrustedConversationHistory } from './runtime-conversation-history' const STARTUP_TIMEOUT_MS = 30_000 @@ -2217,6 +2217,7 @@ export class OpenCodeRuntime implements AgentRuntime { } >() const reasoningPartIds = new Set() + const subagentProgress = new OpenCodeSubagentProgress(request.requestId, sessionId) const reportedQuestionIds = new Map() let aggregateOutputBytes = 0 let outputTruncated = false @@ -2315,6 +2316,18 @@ export class OpenCodeRuntime implements AgentRuntime { const repliedPermissionIds = new Set() const reportedMessageIds = new Set() for await (const event of subscription.stream) { + const childProgress = subagentProgress.update(event) + if ( + childProgress && + 'sessionID' in event.properties && + event.properties.sessionID !== sessionId + ) { + const task = childProgress.runtimeCallId + ? toolStates.get(childProgress.runtimeCallId) + : undefined + if (task) task.subagent = childProgress + yield childProgress + } if ( event.type === 'message.updated' && event.properties.sessionID === sessionId && @@ -2424,7 +2437,9 @@ export class OpenCodeRuntime implements AgentRuntime { ...(subagent ? { subagent } : {}) }) if (subagent) { - yield subagent + const retained = subagentProgress.retain(subagent) + toolStates.get(callId)!.subagent = retained + yield retained } else { yield { requestId: request.requestId, diff --git a/src/main/agent/opencode-subagent.test.ts b/src/main/agent/opencode-subagent.test.ts index 23e9e08a..67bca765 100644 --- a/src/main/agent/opencode-subagent.test.ts +++ b/src/main/agent/opencode-subagent.test.ts @@ -1,9 +1,76 @@ import { describe, expect, it } from 'vitest' -import { toOpenCodeSubagentEvent } from './opencode-subagent' +import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent' const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef' describe('OpenCode native subagent events', () => { + it('extracts ACP output objects without displaying protocol metadata', () => { + const event = toOpenCodeSubagentEvent({ + requestId, callId: 'task', state: 'completed', + input: { subagent_type: 'general', prompt: 'Inspect seed' }, + output: { + output: '\n\n**Done**\n\n', + metadata: { sessionId: 'child' } + } + }) + expect(event?.output).toBe('**Done**') + }) + + it('keeps ordered child text, reasoning and tools separate from the final result', () => { + const tracker = new OpenCodeSubagentProgress(requestId, 'parent') + const task = (status: string) => ({ + type: 'message.part.updated', + properties: { + sessionID: 'parent', + part: { + id: 'task-part', callID: 'task', type: 'tool', tool: 'task', + state: { + status, input: { subagent_type: 'general', prompt: 'Inspect seed' }, + metadata: { sessionId: 'child' }, + ...(status === 'completed' ? { output: 'Final result' } : {}) + } + } + } + }) + tracker.update(task('running')) + tracker.update({ + type: 'message.updated', + properties: { sessionID: 'child', info: { id: 'user', role: 'user' } } + }) + expect(tracker.update({ + type: 'message.part.updated', + properties: { sessionID: 'child', part: { + id: 'prompt', messageID: 'user', type: 'text', text: 'Do not display prompt' + } } + })).toBeUndefined() + const delta = (sessionID: string, partID: string, text: string) => ({ + type: 'message.part.delta', + properties: { sessionID, partID, field: 'text', delta: text } + }) + expect(tracker.update(delta('unrelated', 'text', 'Not ours'))).toBeUndefined() + tracker.update(delta('child', 'text', 'Inspecting ')) + const first = tracker.update(delta('child', 'text', 'seed')) + expect(first?.progress?.[0]).toMatchObject({ type: 'text', content: 'Inspecting seed' }) + tracker.update({ + type: 'message.part.updated', properties: { sessionID: 'child', part: { + id: 'thought', type: 'reasoning', text: '' + } } + }) + tracker.update(delta('child', 'thought', 'Check contents')) + for (const status of ['running', 'completed']) { + tracker.update({ + type: 'message.part.updated', properties: { sessionID: 'child', part: { + id: 'read', callID: 'read', type: 'tool', tool: 'read', + state: { status, input: { path: 'seed.txt' }, output: 'seed contents' } + } } + }) + } + const done = tracker.update(task('completed')) + expect(done?.output).toBe('Final result') + expect(done?.progress?.map(b => b.type)).toEqual(['text', 'reasoning', 'tool']) + expect(done?.progress?.[2]).toMatchObject({ tool: { state: 'completed', output: 'seed contents' } }) + expect(first?.progress).toHaveLength(1) + }) it('maps Task tool updates to stable bounded subagent identities', () => { const running = toOpenCodeSubagentEvent({ requestId, @@ -57,4 +124,23 @@ describe('OpenCode native subagent events', () => { }) ).toBeUndefined() }) + + it('routes recovered live child events by the ACP owning Task without new metadata', () => { + const tracker = new OpenCodeSubagentProgress(requestId, 'parent') + tracker.retain({ + ...toOpenCodeSubagentEvent({ + requestId, callId: 'task', state: 'running', + input: { subagent_type: 'general', prompt: 'Read seed' } + })!, + progress: [] + }) + const event = { + type: 'message.part.delta', + properties: { sessionID: 'child', partID: 'text', field: 'text', delta: 'Resumed progress' } + } + expect(tracker.update(event)).toBeUndefined() + expect(tracker.update(event, 'task')).toMatchObject({ + state: 'running', progress: [{ type: 'text', content: 'Resumed progress' }] + }) + }) }) diff --git a/src/main/agent/opencode-subagent.ts b/src/main/agent/opencode-subagent.ts index 131347fc..3c9afc69 100644 --- a/src/main/agent/opencode-subagent.ts +++ b/src/main/agent/opencode-subagent.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import type { SubagentEvent } from '../../shared/contracts' +import type { ConversationMessageBlock } from '../../shared/assistant-contracts' import { boundedToolDetail } from './approval-summary' type OpenCodeSubagentState = @@ -113,11 +114,19 @@ export function toOpenCodeSubagentEvent( ? 'completed' : 'failed' const failed = state === 'failed' + const detail = isRecord(update.output) + ? update.output.output ?? update.output.error + : update.output + const result = typeof detail === 'string' + ? detail.match( + /^\s*]*>\s*\s*([\s\S]*?)\s*<\/task_result>\s*<\/task>\s*$/u + )?.[1] ?? detail + : detail const output = failed ? undefined - : boundedDetail(update.output, 16_000) + : boundedDetail(result, 16_000) const error = failed - ? boundedDetail(update.error ?? update.output, 1_000) + ? boundedDetail(update.error ?? detail, 1_000) : undefined return { @@ -138,3 +147,137 @@ export function toOpenCodeSubagentEvent( ...(error ? { error } : {}) } } + +/** + * Tracks only sessions explicitly linked by a Task's metadata.sessionId. + * The same snapshots travel through the local SDK and the remote ACP bridge. + */ +export class OpenCodeSubagentProgress { + private readonly roles = new Map() + private readonly reasoning = new Set() + private readonly tasks = new Map() + + constructor( + private readonly requestId: string, + private readonly parentSessionId?: string + ) {} + + update(value: unknown, ownerCallId?: string): SubagentEvent | undefined { + if (!isRecord(value) || !isRecord(value.properties)) return undefined + const properties = value.properties + const part = properties.part + if ( + value.type === 'message.part.updated' && + isRecord(part) && part.type === 'tool' && + part.tool === 'task' && isRecord(part.state) && + (!this.parentSessionId || properties.sessionID === this.parentSessionId) && + ![...this.tasks.values()].some( + (task) => task.sessionId === properties.sessionID + ) + ) { + const callId = boundedText(part.callID ?? part.id, 256) + if (!callId) return undefined + const event = toOpenCodeSubagentEvent({ + requestId: this.requestId, + callId, + input: part.state.input, + state: part.state.status as OpenCodeSubagentState, + output: part.state.output, + error: part.state.error + }) + if (!event) return undefined + const previous = this.tasks.get(callId) + const metadata = part.state.metadata + const task = { + event, + sessionId: (isRecord(metadata) + ? boundedText(metadata.sessionId, 256) + : undefined) ?? previous?.sessionId, + blocks: previous?.blocks ?? [] + } + this.tasks.set(callId, task) + return { ...event, progress: [...task.blocks] } + } + const task = [...this.tasks.values()].find( + (candidate) => candidate.sessionId === properties.sessionID + ) ?? (ownerCallId ? this.tasks.get(ownerCallId) : undefined) + if (!task) return undefined + if (value.type === 'message.updated' && isRecord(properties.info)) { + this.roles.set(String(properties.info.id), String(properties.info.role)) + return undefined + } + const messageId = isRecord(part) ? part.messageID : properties.messageID + if (this.roles.get(String(messageId)) === 'user') return undefined + const partId = isRecord(part) ? part.id : properties.partID + if (typeof partId !== 'string') return undefined + const id = deterministicUuid(`${task.event.childTaskId}\0${partId}`) + const index = task.blocks.findIndex((block) => block.id === id) + const previous = task.blocks[index] + let block: ConversationMessageBlock | undefined + if (value.type === 'message.part.updated' && isRecord(part)) { + if (part.type === 'reasoning') this.reasoning.add(id) + if ( + (part.type === 'text' || part.type === 'reasoning') && + typeof part.text === 'string' && part.text && + part.ignored !== true && part.synthetic !== true + ) { + block = { id, type: part.type, content: part.text.slice(0, 16_000) } + } else if (part.type === 'tool' && isRecord(part.state)) { + block = { + id, + type: 'tool', + tool: { + callId: String(part.callID ?? partId).slice(0, 256), + name: String(part.tool).slice(0, 200), + summary: boundedText(part.state.title, 240) ?? String(part.tool), + state: part.state.status === 'error' + ? 'failed' + : part.state.status as 'pending' | 'running' | 'completed', + input: boundedDetail(part.state.input, 4_000), + output: boundedDetail(part.state.output, 16_000), + error: boundedDetail(part.state.error, 1_000) + } + } + } + } else if ( + value.type === 'message.part.delta' && + typeof properties.delta === 'string' && properties.delta && + (properties.field === 'text' || properties.field === 'reasoning') + ) { + block = { + id, + type: this.reasoning.has(id) || previous?.type === 'reasoning' || properties.field === 'reasoning' + ? 'reasoning' + : 'text', + content: ( + (previous?.type === 'text' || previous?.type === 'reasoning' + ? previous.content : '') + properties.delta + ).slice(0, 16_000) + } + } + if (!block) return undefined + if (index >= 0) task.blocks[index] = block + else task.blocks.push(block) + return { ...task.event, progress: [...task.blocks] } + } + + retain(event: SubagentEvent): SubagentEvent { + const task = event.runtimeCallId + ? this.tasks.get(event.runtimeCallId) + : undefined + if (!task) { + if (event.runtimeCallId && event.progress) { + this.tasks.set(event.runtimeCallId, { + event, blocks: [...event.progress] + }) + } + return event + } + task.event = event + return { ...event, progress: [...task.blocks] } + } +} diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index eb64e3af..ef702a76 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -1394,6 +1394,7 @@ function reduceRecoveredAgentEvent( workMode: event.workMode, state: event.state, reason: event.reason, + progress: event.progress, output: event.output, error: event.error } diff --git a/src/main/assistant/workspace-changes-service.test.ts b/src/main/assistant/workspace-changes-service.test.ts index d3a3e7ec..2e2e65d0 100644 --- a/src/main/assistant/workspace-changes-service.test.ts +++ b/src/main/assistant/workspace-changes-service.test.ts @@ -23,6 +23,47 @@ afterEach(async () => { }) describe('getWorkspaceChanges', () => { + it('reads unborn repositories, nested untracked files, index and worktree diffs separately', async () => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-')) + temporaryDirectories.push(directory) + await execute('git', ['init'], { cwd: directory }) + expect(await getWorkspaceChanges(directory)).toMatchObject({ available: true, files: [], patch: '' }) + await mkdir(join(directory, 'new directory')) + const path = 'new directory/[file].txt' + await writeFile(join(directory, path), 'index\n') + expect((await getWorkspaceChanges(directory)).files).toContainEqual({ path, status: '??' }) + expect((await getWorkspaceChanges(directory, path)).patch).toContain('+index') + await execute('git', ['add', '--', path], { cwd: directory }) + await writeFile(join(directory, path), 'worktree\n') + const diff = await getWorkspaceChanges(directory, path) + expect(diff).toMatchObject({ available: true }) + expect(diff.stagedPatch).toContain('+index') + expect(diff.patch).toContain('-index') + expect(diff.patch).toContain('+worktree') + }) + + it('reads deleted and renamed paths literally without including unrelated changes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-')) + temporaryDirectories.push(directory) + const git = (...args: string[]) => execute('git', args, { cwd: directory }) + await git('init') + await writeFile(join(directory, 'old name.txt'), 'original\n') + await writeFile(join(directory, 'other.txt'), 'other\n') + await git('add', '.') + await git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial') + await git('mv', 'old name.txt', '[new].txt') + await writeFile(join(directory, '[new].txt'), 'edited\n') + await writeFile(join(directory, 'other.txt'), 'unrelated\n') + const renamed = await getWorkspaceChanges(directory, '[new].txt') + expect(renamed.stagedPatch).toContain('rename from old name.txt') + expect(renamed.stagedPatch).toContain('rename to [new].txt') + expect(renamed.patch).toContain('+edited') + expect(renamed.patch).not.toContain('unrelated') + await rm(join(directory, '[new].txt')) + expect((await getWorkspaceChanges(directory, '[new].txt')).patch).toContain('-original') + await git('add', '-u') + expect((await getWorkspaceChanges(directory, 'old name.txt')).stagedPatch).toContain('-original') + }) it('returns tracked and untracked Git workspace changes', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-')) temporaryDirectories.push(directory) diff --git a/src/main/assistant/workspace-changes-service.ts b/src/main/assistant/workspace-changes-service.ts index 27cfc776..d35b89d0 100644 --- a/src/main/assistant/workspace-changes-service.ts +++ b/src/main/assistant/workspace-changes-service.ts @@ -59,8 +59,8 @@ const previewFileNames = new Set([ export class WorkspaceChangesService { constructor(private readonly workspace: WorkspaceAccess) {} - getChanges(signal?: AbortSignal): Promise { - return this.workspace.getChanges({ signal }) + getChanges(signal?: AbortSignal, path?: string): Promise { + return this.workspace.getChanges({ signal, path }) } async listDirectory( @@ -158,13 +158,14 @@ export async function resolveWorkspaceEntryPath( } export async function getWorkspaceChanges( - workspace: string | WorkspaceAccess + workspace: string | WorkspaceAccess, + path?: string ): Promise { if (typeof workspace !== 'string') { - return new WorkspaceChangesService(workspace).getChanges() + return new WorkspaceChangesService(workspace).getChanges(undefined, path) } return withLocalWorkspace(workspace, (access) => - new WorkspaceChangesService(access).getChanges() + new WorkspaceChangesService(access).getChanges(undefined, path) ) } diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index f03db089..8b783a7d 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -4157,6 +4157,7 @@ describe('registerIpcHandlers workspace files', () => { for (const [channel, input] of [ [ipcChannels.workspaceChangesGet, projectId], + [ipcChannels.workspaceFileDiff, { projectId, path: 'deleted.txt' }], [ ipcChannels.workspaceDirectoryList, { projectId, path: '' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index c15fb4d5..dfa6b27d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6601,6 +6601,23 @@ export function registerIpcHandlers( } } ) + registerHandler( + ipcChannels.workspaceFileDiff, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const value = workspaceFileRequestSchema.omit({ offsetBytes: true }).parse(input) + const project = assistantDatabase.getProject(value.projectId) + if (project.executionSpace?.kind === 'ssh') { + await requireRemoteProjectsEnabled() + } + const executionSpace = spaceResolver.resolveProject(project) + try { + return await getWorkspaceChanges(executionSpace.workspaceAccess, value.path) + } finally { + await executionSpace.workspaceAccess.dispose() + } + } + ) registerHandler( ipcChannels.workspaceDirectoryList, async (event, input: unknown) => { @@ -6652,9 +6669,9 @@ export function registerIpcHandlers( await requireRemoteProjectsEnabled() } const executionSpace = spaceResolver.resolveProject(project) - spaceResolver.assertLocal(executionSpace) let targetPath: string try { + spaceResolver.assertLocal(executionSpace) targetPath = await resolveWorkspaceEntryPath( executionSpace.rootPath, value.path, diff --git a/src/main/workspace/local-workspace-access.ts b/src/main/workspace/local-workspace-access.ts index 4b8636e1..b34e7dec 100644 --- a/src/main/workspace/local-workspace-access.ts +++ b/src/main/workspace/local-workspace-access.ts @@ -775,53 +775,76 @@ export class LocalWorkspaceAccess implements WorkspaceAccess { canonicalRoot, input.signal ) - const [statusResult, patchResult] = await Promise.all([ - runGit( + const git = (args: string[]): Promise => + runGit(canonicalRoot, withSafeGitArguments(args), input.signal, filterOverrides) + const statusResult = await runGit( canonicalRoot, withSafeGitArguments([ 'status', '--porcelain=v1', '-z', - '--untracked-files=normal' - ]), - input.signal, - filterOverrides - ), - runGit( - canonicalRoot, - withSafeGitArguments([ - 'diff', - '--no-ext-diff', - '--no-textconv', - '--no-color', - 'HEAD' + '--untracked-files=all' ]), input.signal, filterOverrides ) - ]) - if (statusResult.code !== 0 || patchResult.code !== 0) { - const detail = statusResult.stderr || patchResult.stderr + if (statusResult.code !== 0) { + const detail = statusResult.stderr return { rootPath: this.rootPath, available: false, status: '', patch: '', files: [], - truncated: statusResult.truncated || patchResult.truncated, + truncated: statusResult.truncated, error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区' } } const changedFiles = parseChangedFiles(statusResult.stdout) + const diffArgs = ['diff', '--no-ext-diff', '--no-textconv', '--no-color'] + let patchResult: CommandResult + let stagedResult: CommandResult | undefined + if (input.path !== undefined) { + const path = normalizeRelativePath(input.path, false).path + const file = changedFiles.files.find((entry) => entry.path === path) + if (!file) { + throw new Error('File is no longer in the Git changes list') + } + const paths = [...new Set([file.previousPath, path].filter((value): value is string => value !== undefined))] + .map((value) => `:(literal)${value}`) + if (file.status === '??') { + await this.resolveEntryPath(path, 'file') + patchResult = await git([...diffArgs, '--no-index', '--', NULL_DEVICE, path]) + if (patchResult.code !== 0 && patchResult.code !== 1) { + throw new Error(patchResult.stderr || 'Git diff failed') + } + } else { + ;[stagedResult, patchResult] = await Promise.all([ + git([...diffArgs, '--cached', '--', ...paths]), + git([...diffArgs, '--', ...paths]) + ]) + if (stagedResult.code !== 0 || patchResult.code !== 0) { + throw new Error(stagedResult.stderr || patchResult.stderr || 'Git diff failed') + } + } + } else { + const head = await git(['rev-parse', '--verify', '--quiet', 'HEAD']) + patchResult = await git([...diffArgs, ...(head.code === 0 ? ['HEAD'] : ['--cached'])]) + } return { rootPath: this.rootPath, available: true, status: formatChangedFiles(changedFiles.files), patch: patchResult.stdout, + ...(patchResult.code !== 0 && input.path === undefined + ? { error: patchResult.stderr.trim().slice(0, 2000) || 'Git diff failed' } + : {}), + ...(stagedResult ? { stagedPatch: stagedResult.stdout } : {}), files: changedFiles.files, truncated: statusResult.truncated || patchResult.truncated || + stagedResult?.truncated === true || changedFiles.truncated } } catch (error) { diff --git a/src/main/workspace/remote-workspace-access.test.ts b/src/main/workspace/remote-workspace-access.test.ts index 88fe4f4c..9838343d 100644 --- a/src/main/workspace/remote-workspace-access.test.ts +++ b/src/main/workspace/remote-workspace-access.test.ts @@ -113,6 +113,26 @@ function createAccess(lease: RemoteWorkspaceTransportLease): { } describe('RemoteWorkspaceAccess', () => { + it('requests both single-file diff layers and preserves raw Git status', async () => { + const lease = createLease({ + getGitStatus: vi.fn(async () => ({ + repositoryIdentity: 'repository-1', branch: 'main', truncated: false, + entries: [{ relativePath: '[file].txt', index: 'modified' as const, worktree: 'unmodified' as const, statusCode: 'T.' }] + })), + getGitDiff: vi.fn(async (request) => ({ + repositoryIdentity: 'repository-1', patch: request.staged ? 'staged' : 'worktree', byteLength: request.staged ? 6 : 8, truncated: request.staged + })) + }) + const { access } = createAccess(lease) + expect(await access.getChanges({ path: '[file].txt' })).toMatchObject({ + available: true, patch: 'worktree', stagedPatch: 'staged', truncated: true, + files: [{ path: '[file].txt', status: 'T ' }] + }) + expect(lease.getGitDiff).toHaveBeenCalledWith(expect.objectContaining({ relativePath: '[file].txt', staged: true }), undefined) + expect(lease.getGitDiff).toHaveBeenCalledWith(expect.objectContaining({ relativePath: '[file].txt', staged: false }), undefined) + expect(lease.statWorkspace).not.toHaveBeenCalled() + await access.dispose() + }) it('lazily validates once and maps bounded workspace methods', async () => { const lease = createLease({ listWorkspace: vi.fn(async () => ({ diff --git a/src/main/workspace/remote-workspace-access.ts b/src/main/workspace/remote-workspace-access.ts index 1050316b..8fb79f0c 100644 --- a/src/main/workspace/remote-workspace-access.ts +++ b/src/main/workspace/remote-workspace-access.ts @@ -247,7 +247,7 @@ function mapChangedFiles( : `${gitStatusCharacter(entry.index)}${gitStatusCharacter(entry.worktree)}` return { path: entry.relativePath, - status, + status: entry.statusCode?.replaceAll('.', ' ') ?? status, ...(entry.originalRelativePath ? { previousPath: entry.originalRelativePath } : {}) @@ -577,8 +577,7 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess { for (let attempt = 0; attempt < 2; attempt += 1) { input.signal?.throwIfAborted() const reference = this.reference(opened) - const [status, diff] = await Promise.all([ - opened.lease + const status = await opened.lease .getGitStatus( remoteGitStatusRequestSchema.parse({ ...reference, @@ -590,12 +589,13 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess { ) .then((result) => remoteGitStatusResultSchema.parse(result) - ), - opened.lease + ) + const readDiff = (staged: boolean) => opened.lease .getGitDiff( remoteGitDiffRequestSchema.parse({ ...reference, - staged: false, + staged, + ...(input.path === undefined ? {} : { relativePath: input.path }), maximumBytes: REMOTE_WORKSPACE_LIMITS.maximumGitDiffBytes }), @@ -604,8 +604,12 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess { .then((result) => remoteGitDiffResultSchema.parse(result) ) + const [diff, stagedDiff] = await Promise.all([ + readDiff(false), + input.path === undefined ? undefined : readDiff(true) ]) - if (status.repositoryIdentity !== diff.repositoryIdentity) { + if (status.repositoryIdentity !== diff.repositoryIdentity || + (stagedDiff && status.repositoryIdentity !== stagedDiff.repositoryIdentity)) { if (attempt === 0) { continue } @@ -617,10 +621,12 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess { available: true, status: formatChangedFiles(files), patch: diff.patch, + ...(stagedDiff ? { stagedPatch: stagedDiff.patch } : {}), files, truncated: status.truncated || diff.truncated || + stagedDiff?.truncated === true || diff.nextCursor !== undefined } } diff --git a/src/main/workspace/workspace-access.ts b/src/main/workspace/workspace-access.ts index d40d5f63..63dd21c4 100644 --- a/src/main/workspace/workspace-access.ts +++ b/src/main/workspace/workspace-access.ts @@ -88,6 +88,7 @@ export type SearchPage = { } export type WorkspaceChangesInput = { + path?: string signal?: AbortSignal } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b33db6b..ce27c12f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1089,6 +1089,11 @@ const desktopApi: DesktopApi = { } }, workspace: { + getFileDiff: (projectId: string, path: string) => + ipcRenderer.invoke(ipcChannels.workspaceFileDiff, { + projectId, + path + }) as Promise, getChanges: (projectId: string) => ipcRenderer.invoke( ipcChannels.workspaceChangesGet, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index dab07db5..61702d9d 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -523,6 +523,7 @@ const api: DesktopApi = { }) }, workspace: { + getFileDiff: vi.fn(), getChanges: vi.fn(async () => ({ rootPath: 'C:\\Workspace', available: true, @@ -4775,7 +4776,14 @@ describe('App', () => { name: /产品知识/u }) await waitFor(() => expect(scopeCheckbox).toHaveFocus()) - fireEvent.click(scopeCheckbox) + fireEvent.focusOut(scopeCheckbox, { relatedTarget: null }) + expect(knowledgeScopeTrigger).toHaveAttribute('aria-expanded', 'true') + fireEvent.click(screen.getByText('产品知识', { selector: 'span' })) + expect(scopeCheckbox).toBeChecked() + fireEvent.click(scopeCheckbox.closest('label')!) + expect(scopeCheckbox).not.toBeChecked() + fireEvent.click(scopeCheckbox.closest('label')!.querySelector('small')!) + expect(scopeCheckbox).toBeChecked() const composerInput = screen.getByLabelText('向 GoodBuddy 提问') fireEvent.focusOut(scopeCheckbox, { relatedTarget: composerInput @@ -5815,6 +5823,13 @@ describe('App', () => { }) it('refreshes generated workspace files when a run completes', async () => { + vi.mocked(api.workspace.listDirectory).mockImplementation(async (_projectId, path) => ({ + path, + entries: vi.mocked(api.workspace.getChanges).mock.calls.length >= 2 + ? [{ name: 'tree-only.txt', path: 'tree-only.txt', type: 'file' as const }] + : [], + truncated: false + })) vi.mocked(api.workspace.getChanges) .mockResolvedValueOnce({ rootPath: project.rootPath, @@ -5852,6 +5867,7 @@ describe('App', () => { }) expect(await screen.findByText('generated.md')).toBeInTheDocument() + expect(await screen.findByText('tree-only.txt')).toBeInTheDocument() }) it('ignores stale Git changes after switching projects', async () => { @@ -10246,8 +10262,8 @@ describe('App', () => { screen.getByRole('tab', { name: '工作区' }) ).toHaveAttribute('title', '浏览项目文件、Git 变更与文件内容') expect( - screen.getByRole('heading', { name: '项目工作区' }) - ).toBeInTheDocument() + screen.queryByRole('heading', { name: '项目工作区' }) + ).not.toBeInTheDocument() expect( screen.getByRole('button', { name: '刷新工作区文件' }) ).toBeInTheDocument() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 893d69b2..95b3aaf9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4740,6 +4740,7 @@ function App(): React.JSX.Element { workMode: event.workMode, state: event.state, reason: event.reason, + progress: event.progress, output: event.output, error: event.error } @@ -5886,6 +5887,10 @@ function App(): React.JSX.Element { }, [activeProjectId] ) + const loadWorkspaceDiff = useCallback(async (path: string) => { + if (!activeProjectId) throw new Error(tRef.current('notices.selectProject')) + return window.goodbuddy.workspace.getFileDiff(activeProjectId, path) + }, [activeProjectId]) const openWorkspaceEntry = useCallback( async ( path: string, @@ -9787,7 +9792,7 @@ function App(): React.JSX.Element { className="knowledge-scope" onBlurCapture={(event) => { if ( - !(event.relatedTarget instanceof Node) || + event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget) ) { setKnowledgeScopeOpen(false) @@ -11228,6 +11233,7 @@ function App(): React.JSX.Element { onListWorkspaceDirectory={listWorkspaceDirectory} onLoadWorkspaceFile={loadWorkspaceFile} onOpenWorkspaceEntry={openWorkspaceEntry} + onLoadWorkspaceDiff={loadWorkspaceDiff} onRefreshChanges={refreshWorkspaceChanges} onTabChange={setAssistantSidebarTab} open={assistantSidebarOpen} diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index fd2df220..52c7131f 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -408,6 +408,46 @@ describe('ChatTimeline', () => { } }) + it('renders live child progress in order and keeps the final result separate', () => { + const child = { + childTaskId: '00000000-0000-4000-8000-000000000111', + expertId: '00000000-0000-4000-8000-000000000211', + expertName: 'general', routingMode: 'native' as const, + state: 'running' as const, + progress: [ + { id: 'text', type: 'text' as const, content: 'Inspecting seed' }, + { id: 'tool', type: 'tool' as const, tool: { + callId: 'read', name: 'read', state: 'completed' as const, + summary: 'Read seed', output: 'seed contents' + } }, + { id: 'reasoning', type: 'reasoning' as const, content: 'Check the result' } + ] + } + const props = { + artifactById: new Map(), conversationId: 'conversation-1', + hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const, + messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1 + } + const message: Message = { + id: 'assistant', role: 'assistant', content: '', createdAt: 0, + state: 'streaming', subagents: [child] + } + const view = render() + expect(screen.queryByText('Inspecting seed')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('general', { selector: 'strong' })) + fireEvent(screen.getByText('general', { selector: 'strong' }).closest('details')!, new Event('toggle')) + const progress = screen.getByRole('region', { name: '执行过程' }) + expect(within(progress).getByText('Inspecting seed')).toBeInTheDocument() + expect(within(progress).getByText('Check the result').closest('details')).not.toHaveAttribute('open') + expect(screen.queryByRole('region', { name: '最终结果' })).not.toBeInTheDocument() + view.rerender() + expect(screen.getByRole('region', { name: '最终结果' })).toHaveTextContent('Final seed') + expect(screen.getByRole('region', { name: '执行过程' })).not.toHaveTextContent('Final seed') + }) + it('labels native OpenCode subagents and shows their task description', () => { const messages: Message[] = [ { diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 9763650e..5cab806d 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -292,6 +292,14 @@ const SubagentStatusCard = memo(function SubagentStatusCard({ actor.kind === 'direct-model' && subagent.workMode ? `${source} · ${subagent.workMode === 'execute' ? 'Execute' : 'Ask'}` : source + const progress = subagent.progress?.filter((block, index, blocks) => + !( + subagent.state === 'completed' && + index === blocks.length - 1 && + block.type === 'text' && + block.content.trim() === subagent.output?.trim() + ) + ) return (
{expanded && (
+ {progress && progress.length > 0 && ( +
+ {t('chat.subagents.progress')} +
+ {groupMessageBlocks(progress).map((item) => + item.kind === 'tools' ? ( + + (tool.state === 'pending' || tool.state === 'running') && + subagent.state !== 'queued' && subagent.state !== 'running' + ? { ...tool, state: subagent.state === 'cancelled' ? 'cancelled' : 'interrupted' } + : tool + )} /> + ) : item.kind === 'subagents' ? null + : item.block.type === 'reasoning' ? ( + + ) : item.block.type === 'text' ? ( +
+ {item.block.content} +
+ ) : null + )} +
+
+ )} {subagent.output ? ( -
- {t('chat.subagents.output')} +
+ {t('chat.subagents.finalOutput')}
- ) : ( + ) : !subagent.progress?.length ? (

{t('chat.subagents.noOutput')}

- )} + ) : null} {subagent.error && (subagent.state === 'failed' || subagent.state === 'cancelled') && ( diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index c78a2008..4d24fc49 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -76,6 +76,7 @@ function renderSidebar({ onListWorkspaceDirectory={onListWorkspaceDirectory} onLoadArtifact={onLoadArtifact} onLoadWorkspaceFile={onLoadWorkspaceFile} + onLoadWorkspaceDiff={vi.fn()} onOpenWorkspaceEntry={vi.fn(async () => undefined)} onInteractBrowser={vi.fn(async () => undefined)} onRefreshChanges={vi.fn(async () => undefined)} diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index b23b4e84..d4687764 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -6,7 +6,6 @@ import { CircleAlert, ExternalLink, FileText, - FolderTree, ClockFading, Monitor, Plus, @@ -117,6 +116,7 @@ type RightAssistantSidebarProps = { onImportArtifacts: () => Promise onLoadArtifact: (artifactId: string) => Promise onRefreshChanges: () => Promise + onLoadWorkspaceDiff: (path: string) => Promise onListWorkspaceDirectory: ( path: string ) => Promise @@ -500,6 +500,7 @@ export function RightAssistantSidebar({ onImportArtifacts, onLoadArtifact, onRefreshChanges, + onLoadWorkspaceDiff, onListWorkspaceDirectory, onLoadWorkspaceFile, onOpenWorkspaceEntry, @@ -629,7 +630,6 @@ export function RightAssistantSidebar({ useState(false) const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] = useState('') - const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0) const [taskFilter, setTaskFilter] = useState< 'attention' | 'active' | 'paused' | 'finished' >('active') @@ -1583,15 +1583,12 @@ export function RightAssistantSidebar({
) : (
-

- - {t('sidebar.workspace.projectTitle')} +
-

+
@@ -1618,17 +1617,6 @@ export function RightAssistantSidebar({ })}

)} - {workspaceChanges?.patch && ( -
- {t('sidebar.workspace.fullDiff')} -
-                    {workspaceChanges.patch}
-                    {workspaceChanges.truncated
-                      ? t('sidebar.workspace.truncatedDiff')
-                      : ''}
-                  
-
- )} ) )} diff --git a/src/renderer/src/WorkspaceFilesPanel.test.tsx b/src/renderer/src/WorkspaceFilesPanel.test.tsx index a312bd18..f896bd8c 100644 --- a/src/renderer/src/WorkspaceFilesPanel.test.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.test.tsx @@ -1,7 +1,7 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { changeUiLocale } from './i18n' -import { WorkspaceFilesPanel } from './WorkspaceFilesPanel' +import { gitStatusLetter, WorkspaceFilesPanel } from './WorkspaceFilesPanel' afterEach(async () => { cleanup() @@ -9,6 +9,9 @@ afterEach(async () => { }) describe('WorkspaceFilesPanel', () => { + it.each([['??', 'U'], [' M', 'M'], ['A ', 'A'], [' D', 'D'], ['R ', 'R'], ['C ', 'C'], ['UU', 'U'], ['AM', 'AM'], [' T', 'T']])('maps %s to %s without calling every new file added', (status, letter) => { + expect(gitStatusLetter(status)).toBe(letter) + }) it('lists the project tree, expands directories, and opens files', async () => { const onListDirectory = vi.fn(async (path: string) => path @@ -45,6 +48,7 @@ describe('WorkspaceFilesPanel', () => { render( { }) ) expect(onOpenEntry).toHaveBeenCalledWith('docs', 'directory') - expect(screen.getAllByText('修改')).not.toHaveLength(0) + expect(screen.getAllByText('M')).not.toHaveLength(0) }) it('ignores stale directory results after the active project changes', async () => { @@ -100,6 +104,7 @@ describe('WorkspaceFilesPanel', () => { }) const { rerender } = render( undefined)} @@ -111,6 +116,7 @@ describe('WorkspaceFilesPanel', () => { await waitFor(() => expect(onListDirectory).toHaveBeenCalledOnce()) rerender( undefined)} @@ -133,6 +139,7 @@ describe('WorkspaceFilesPanel', () => { render( undefined)} @@ -149,7 +156,85 @@ describe('WorkspaceFilesPanel', () => { await screen.findByText('Current workspace') ).toBeInTheDocument() expect(screen.getByText('Uncommitted changes')).toBeInTheDocument() - expect(screen.getByText('Modified')).toBeInTheDocument() + expect(screen.getByText('M')).toBeInTheDocument() expect(onListDirectory).toHaveBeenCalledOnce() }) + it('refreshes expanded directories and drops collapsed caches without remounting', async () => { + let version = 0 + const onListDirectory = vi.fn(async (path: string) => ({ + path, + entries: path ? [{ name: `file-${version}.txt`, path: `${path}/file-${version}.txt`, type: 'file' as const }] + : [{ name: 'docs', path: 'docs', type: 'directory' as const }], + truncated: false + })) + const props = { projectId: 'project', changedFiles: [], onListDirectory, onLoadDiff: vi.fn(), onOpenFile: vi.fn() } + const { rerender } = render() + fireEvent.click(await screen.findByRole('button', { name: 'docs' })) + await screen.findByText('file-0.txt') + version += 1 + rerender() + await screen.findByText('file-1.txt') + expect(screen.queryByText('file-0.txt')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-expanded', 'true') + fireEvent.click(screen.getByRole('button', { name: 'docs' })) + version += 1 + rerender() + await waitFor(() => expect(onListDirectory).toHaveBeenCalledTimes(5)) + fireEvent.click(screen.getByRole('button', { name: 'docs' })) + await screen.findByText('file-2.txt') + expect(screen.queryByRole('button', { name: /默认应用|资源管理器/ })).not.toBeInTheDocument() + }) + + it('opens a deleted file diff rather than reading the missing file', async () => { + const onLoadDiff = vi.fn(async () => ({ rootPath: '/project', available: true, files: [], status: '', patch: '-deleted content', stagedPatch: '-staged content', truncated: true })) + const onOpenFile = vi.fn() + render( ({ path: '', entries: [], truncated: false }))} + onLoadDiff={onLoadDiff} onOpenFile={onOpenFile} />) + fireEvent.click(screen.getByRole('button', { name: /deleted.txt/ })) + await screen.findByText('-deleted content') + expect(screen.getByText('-staged content')).toBeInTheDocument() + expect(screen.getByText('差异内容已截断。')).toBeInTheDocument() + expect(onLoadDiff).toHaveBeenCalledWith('deleted.txt') + expect(onOpenFile).not.toHaveBeenCalled() + }) + + it('reloads the selected diff on refresh and ignores an older pending response', async () => { + let resolveOld!: (value: Awaited>) => void + const value = { rootPath: '/project', available: true, files: [], status: '', patch: '+fresh', stagedPatch: '', truncated: false } + const onLoadDiff = vi.fn<() => Promise>() + .mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) + .mockResolvedValue(value) + const props = { + projectId: 'project', changedFiles: [{ path: 'seed.txt', status: ' M' }], + onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })), + onLoadDiff, onOpenFile: vi.fn() + } + const view = render() + await waitFor(() => expect(props.onListDirectory).toHaveBeenCalledOnce()) + fireEvent.click(screen.getByRole('button', { name: /seed.txt/ })) + view.rerender() + await screen.findByText('+fresh') + await act(async () => resolveOld({ ...value, patch: '+stale' })) + expect(screen.queryByText('+stale')).not.toBeInTheDocument() + expect(onLoadDiff).toHaveBeenCalledTimes(2) + }) + + it('loads more changed files so deleted files after the first batch have a diff entry', async () => { + const onLoadDiff = vi.fn(async () => ({ + rootPath: '/project', available: true, files: [], status: '', + patch: '-last deleted', stagedPatch: '', truncated: false + })) + render( ({ path: `deleted-${index}.txt`, status: ' D' }))} + onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))} + onLoadDiff={onLoadDiff} onOpenFile={vi.fn()} + />) + expect(screen.queryByRole('button', { name: /deleted-50.txt/ })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '加载更多变更(剩余 1 个)' })) + fireEvent.click(screen.getByRole('button', { name: /deleted-50.txt/ })) + await screen.findByText('-last deleted') + expect(onLoadDiff).toHaveBeenCalledWith('deleted-50.txt') + }) }) diff --git a/src/renderer/src/WorkspaceFilesPanel.tsx b/src/renderer/src/WorkspaceFilesPanel.tsx index 187e18dc..bc15b9ec 100644 --- a/src/renderer/src/WorkspaceFilesPanel.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.tsx @@ -1,6 +1,7 @@ import { ChevronDown, ChevronRight, + ChevronLeft, FileSearch, FileText, Folder, @@ -9,6 +10,7 @@ import { import { useCallback, useEffect, + useEffectEvent, useMemo, useRef, useState @@ -16,6 +18,7 @@ import { import { useTranslation } from 'react-i18next' import type { WorkspaceChangedFile, + WorkspaceChanges, WorkspaceDirectoryEntry, WorkspaceDirectoryListing } from '../../shared/assistant-contracts' @@ -23,36 +26,27 @@ import type { type WorkspaceFilesPanelProps = { projectId?: string changedFiles: WorkspaceChangedFile[] + refreshToken?: unknown + onLoadDiff: (path: string) => Promise onListDirectory: (path: string) => Promise onOpenFile: (path: string) => void - onOpenEntry: ( + onOpenEntry?: ( path: string, type: WorkspaceDirectoryEntry['type'] ) => Promise } -function statusKey( - status: string -): 'added' | 'deleted' | 'renamed' | 'modified' { - const value = status.trim() - if (value === '??') { - return 'added' - } - if (value.includes('D')) { - return 'deleted' - } - if (value.includes('R')) { - return 'renamed' - } - if (value.includes('A')) { - return 'added' - } - return 'modified' +export function gitStatusLetter(status: string): string { + if (status === '??') return 'U' + if (['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(status)) return 'U' + return status.trim().replaceAll('.', '').split('').filter((value, index, values) => values.indexOf(value) === index).join('') } export function WorkspaceFilesPanel({ projectId, changedFiles, + refreshToken, + onLoadDiff, onListDirectory, onOpenFile, onOpenEntry @@ -80,6 +74,18 @@ export function WorkspaceFilesPanel({ }>({}) const requestGeneration = useRef(0) const inFlightPaths = useRef(new Set()) + const [diff, setDiff] = useState<{ path: string; value?: WorkspaceChanges; error?: string }>() + const diffRequest = useRef(0) + const [visibleChangeCount, setVisibleChangeCount] = useState(50) + const openDiff = (path: string): void => { + const request = ++diffRequest.current + setDiff({ path }) + void onLoadDiff(path).then((value) => { + if (request === diffRequest.current) setDiff({ path, value, error: value.error }) + }).catch((reason: unknown) => { + if (request === diffRequest.current) setDiff({ path, error: reason instanceof Error ? reason.message : t('files.errors.read') }) + }) + } const loadDirectory = useCallback( async (path: string, generation: number): Promise => { @@ -118,8 +124,8 @@ export function WorkspaceFilesPanel({ }) } } finally { - inFlightPaths.current.delete(path) if (requestGeneration.current === generation) { + inFlightPaths.current.delete(path) setLoadingState((current) => { const next = new Set( current.projectId === projectId ? current.value : [] @@ -133,6 +139,14 @@ export function WorkspaceFilesPanel({ [onListDirectory, projectId] ) + const refreshDirectories = useEffectEvent((generation: number) => { + const paths = expandedState.projectId === projectId ? [...expandedState.value] : [] + setListingState({ projectId, value: {} }) + setLoadingState({ projectId, value: new Set() }) + void loadDirectory('', generation) + for (const path of paths) void loadDirectory(path, generation) + if (diff) openDiff(diff.path) + }) useEffect(() => { requestGeneration.current += 1 const generation = requestGeneration.current @@ -141,10 +155,14 @@ export function WorkspaceFilesPanel({ return } const timeout = setTimeout(() => { - void loadDirectory('', generation) + refreshDirectories(generation) }, 0) - return () => clearTimeout(timeout) - }, [loadDirectory, projectId]) + return () => { + clearTimeout(timeout) + requestGeneration.current += 1 + diffRequest.current += 1 + } + }, [loadDirectory, projectId, refreshToken]) const changedByPath = useMemo( () => @@ -190,7 +208,7 @@ export function WorkspaceFilesPanel({ const openEntry = (entry: WorkspaceDirectoryEntry): void => { setErrorState({ projectId }) - void onOpenEntry(entry.path, entry.type).catch((reason: unknown) => { + void onOpenEntry?.(entry.path, entry.type).catch((reason: unknown) => { setErrorState({ projectId, value: @@ -227,7 +245,7 @@ export function WorkspaceFilesPanel({ {expanded ? : } {entry.name} - + } {expanded && (
@@ -271,12 +289,12 @@ export function WorkspaceFilesPanel({ {entry.name} {changed && ( - - {t(`files.statuses.${statusKey(changed.status)}`)} + + {gitStatusLetter(changed.status)} )} - + }
) } @@ -300,34 +318,68 @@ export function WorkspaceFilesPanel({ } const root = listings[''] + const renderPatch = (patch: string): React.JSX.Element => ( +
+      {patch.split('\n').map((line, index) => (
+        {line}{'\n'}
+      ))}
+    
+ ) + if (diff) { + return
+
+ + {diff.path} +
+ {diff.error ?

{diff.error}

+ : !diff.value ?

{t('files.reading')}

: <> + {diff.value.stagedPatch && <>

{t('files.staged')}

{renderPatch(diff.value.stagedPatch)}} + {diff.value.patch && <>

{t('files.unstaged')}

{renderPatch(diff.value.patch)}} + {!diff.value.patch && !diff.value.stagedPatch &&

{t('files.noDiff')}

} + {diff.value.truncated &&

{t('files.diffTruncated')}

} + } +
+ } return (
{changedFiles.length > 0 && (
{t('files.changedTitle')} - {changedFiles.slice(0, 50).map((file) => { - const deleted = file.status.includes('D') + {changedFiles.slice(0, visibleChangeCount).map((file) => { return ( ) })} - {changedFiles.length > 50 && ( -

- {t('files.changesTruncated')} -

+ {changedFiles.length > visibleChangeCount && ( + )}
)} diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index d3908397..efaed2c1 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -376,6 +376,8 @@ export const app = { fallbackTask: '{{name}} subagent task', task: 'Task', output: 'Subagent output', + progress: 'Execution progress', + finalOutput: 'Final result', error: 'Execution details', noOutput: 'This subagent has no output to display yet.', incomplete: diff --git a/src/renderer/src/i18n/locales/en-US/workspace.ts b/src/renderer/src/i18n/locales/en-US/workspace.ts index c127df8c..a0214cdb 100644 --- a/src/renderer/src/i18n/locales/en-US/workspace.ts +++ b/src/renderer/src/i18n/locales/en-US/workspace.ts @@ -432,6 +432,11 @@ export const workspace = { taskList: 'Task list' }, files: { + staged: 'Staged changes', + unstaged: 'Working tree changes', + noDiff: 'No textual differences (the file may be empty or only its status changed).', + diffTruncated: 'Diff output truncated.', + retry: 'Retry', statuses: { added: 'Added', deleted: 'Deleted', @@ -452,7 +457,7 @@ export const workspace = { 'This folder has more than 500 items. Only the first 500 are shown.', selectProject: 'Select a project to browse its workspace.', changedTitle: 'Uncommitted changes', - changesTruncated: 'Only the first 50 uncommitted changes are shown.', + loadMoreChanges: 'Load more changes ({{count}} remaining)', currentWorkspace: 'Current workspace', readingWorkspace: 'Reading workspace…', rootTruncated: diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 7881f668..c7805b9c 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -358,6 +358,8 @@ export const app = { fallbackTask: '{{name}} 子专家任务', task: '任务', output: '子代理输出', + progress: '执行过程', + finalOutput: '最终结果', error: '执行说明', noOutput: '子代理暂时没有可显示的输出。', incomplete: '父请求已结束,但子 Agent 未报告完成状态。', diff --git a/src/renderer/src/i18n/locales/zh-CN/workspace.ts b/src/renderer/src/i18n/locales/zh-CN/workspace.ts index 3cea7dd7..98e1c3cd 100644 --- a/src/renderer/src/i18n/locales/zh-CN/workspace.ts +++ b/src/renderer/src/i18n/locales/zh-CN/workspace.ts @@ -422,6 +422,11 @@ export const workspace = { taskList: '任务列表' }, files: { + staged: '已暂存更改', + unstaged: '工作树更改', + noDiff: '没有文本差异(文件可能为空,或仅状态发生变化)。', + diffTruncated: '差异内容已截断。', + retry: '重试', statuses: { added: '新增', deleted: '删除', @@ -441,7 +446,7 @@ export const workspace = { directoryTruncated: '目录项目超过 500 项,仅显示前 500 项。', selectProject: '选择项目后可浏览项目工作区。', changedTitle: '未提交更改', - changesTruncated: '仅显示前 50 个未提交更改。', + loadMoreChanges: '加载更多变更(剩余 {{count}} 个)', currentWorkspace: '当前工作区', readingWorkspace: '正在读取工作区…', rootTruncated: '根目录项目超过 500 项,仅显示前 500 项。', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index f2cc376a..2721694a 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -4283,16 +4283,6 @@ button > svg { word-break: break-word; } -.assistant-sidebar__diff-details { - color: var(--text-secondary); - font-size: var(--font-caption); -} - -.assistant-sidebar__diff-details summary { - padding: var(--space-2) 0; - cursor: pointer; -} - .workspace-files { display: flex; min-width: 0; @@ -4300,6 +4290,46 @@ button > svg { gap: var(--space-2); } +.workspace-files__toolbar { + display: flex; + justify-content: flex-end; +} + +.workspace-files__diff [data-line="added"] { + color: var(--success); + background: var(--success-subtle); +} + +.workspace-files__diff [data-line="deleted"] { + color: var(--danger); + background: var(--danger-subtle); +} + +.workspace-files__diff [data-line="hunk"] { + color: var(--accent); +} + +.workspace-files [data-status] { + color: var(--warning); + font-weight: 600; + flex-shrink: 0; +} + +.workspace-files [data-status="A"], +.workspace-files [data-status="U"] { + color: var(--success); +} + +.workspace-files [data-status="D"], +.workspace-files [data-conflict="true"] { + color: var(--danger); +} + +.workspace-files [data-status="R"], +.workspace-files [data-status="C"] { + color: var(--accent); +} + .workspace-files__heading, .workspace-files__changed > strong { color: var(--text-secondary); @@ -6226,6 +6256,14 @@ button > svg { gap: var(--space-3); } +.subagent-status-card__progress { + display: grid; + min-width: 0; + gap: var(--space-3); + padding-left: var(--space-3); + border-left: 1px solid var(--border-default); +} + .subagent-status-card__details section { display: grid; gap: var(--space-2); @@ -11960,6 +11998,16 @@ details.settings-section > :not(summary) + :not(summary) { color: var(--accent); } +.knowledge-scope__popover label:has(input:checked) { + background: var(--accent-subtle); + color: var(--accent); +} + +.knowledge-scope__popover label:focus-within { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + .knowledge-scope__popover label input { accent-color: var(--accent-solid); } diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 6d62a4ee..6e3aff37 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -266,6 +266,7 @@ const conversationSubagentActivityBaseSchema = z.object({ 'cancelled' ]), reason: z.string().trim().min(1).max(240).optional(), + progress: conversationMessageBlocksSchema.optional(), output: z.string().optional(), error: z.string().trim().min(1).max(1_000).optional() }) @@ -573,6 +574,7 @@ export type WorkspaceChanges = { available: boolean status: string patch: string + stagedPatch?: string files: WorkspaceChangedFile[] truncated: boolean error?: string diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 6134c547..4def7cdb 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1893,6 +1893,7 @@ export type DesktopApi = { } workspace: { getChanges: (projectId: string) => Promise + getFileDiff: (projectId: string, path: string) => Promise listDirectory: ( projectId: string, path: string diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 064ea340..035f91b7 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -180,6 +180,7 @@ export const ipcChannels = { conversationQueueChanged: 'conversation-queue:changed', conversationQueueDispatch: 'conversation-queue:dispatch', workspaceChangesGet: 'workspace:changes:get', + workspaceFileDiff: 'workspace:file:diff', workspaceDirectoryList: 'workspace:directory:list', workspaceFileRead: 'workspace:file:read', workspacePathOpen: 'workspace:path:open', diff --git a/src/shared/remote-agent-contracts.ts b/src/shared/remote-agent-contracts.ts index 5a5d4723..3e7d3ff7 100644 --- a/src/shared/remote-agent-contracts.ts +++ b/src/shared/remote-agent-contracts.ts @@ -802,6 +802,7 @@ export const remoteGitStatusRequestSchema = z export const remoteGitFileStatusSchema = z .object({ + statusCode: z.string().regex(/^[ .AMDRCUT?!]{2}$/u).optional(), relativePath: remoteRelativePathSchema, index: z.enum([ 'unmodified',