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',