diff --git a/AGENTS.md b/AGENTS.md index be0a050..463fe89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,12 @@ Keep Electron security boundaries intact: - Reuse installed libraries and shared contracts before adding dependencies. - Keep changes focused. Do not add unrelated refactors or documentation. - Add or update focused tests for behavioral changes and regressions. +- After completing any functional change, inspect the affected product, + architecture, design, feature, setup, and operational documentation and + update every relevant document to match the implemented behavior. Treat the + final code and validated runtime behavior as the source of truth: correct + stale documentation rather than preserving outdated intent. Avoid + documentation churn only when the change has no documented impact. - Avoid broad catches that erase HTTP status, cancellation, or provider error context. - Keep UI accessible with labels, keyboard behavior, semantic roles, and visible diff --git a/UI-DESIGN.md b/UI-DESIGN.md index d59bc87..ef3d28e 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -505,7 +505,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 使用 `dashboard` 壳层,并通过 `PageTabs` 提供“任务与会话 / 活动时间线 / 用量统计”三个同级视图。 - 默认视图按“项目 → 任务或会话 → 活动详情”组织,项目范围持续可见,任务或会话详情可以折叠。 -- 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系;选择节点后显示所属会话、不可变范围快照和完整详情。 +- 任务或会话的综合状态以最近一次顶层请求对应的最终 Agent 结果为准;最终结果尚未产生时使用该请求的当前状态。中间工具或子专家的失败、取消和中断保留在活动详情中,但不得覆盖最终成功状态。 +- 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系。节点内使用 `U / G / S / T / A` 拉丁字母简称,节点下显示完整身份或名称;选择节点后显示所属会话、不可变范围快照和完整详情。 - 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度,宽表格在独立容器内横向滚动。 - 活动状态筛选使用 `SegmentedControl`,不与页面页签混合。清空历史遵循破坏性操作政策。 diff --git a/docs/features/wechat-clawbot-channel-project-prd.md b/docs/features/wechat-clawbot-channel-project-prd.md index 47d3858..7856b32 100644 --- a/docs/features/wechat-clawbot-channel-project-prd.md +++ b/docs/features/wechat-clawbot-channel-project-prd.md @@ -26,7 +26,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入 - 通道项目标识平台并确定默认工作目录、处理后端和默认模式。 - 远程会话标识具体发送者或群聊。 - 消息记录具体发送者和本次实际使用的模式。 -- 任务与活动记录执行、工具调用和结果。 +- 运行记录覆盖任务执行、工具调用和结果。 ## 2. 已确认的产品决策 @@ -119,7 +119,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入 | 通道项目 | 平台、连接状态、默认根目录、处理后端、默认模式 | | 远程会话 | 平台账号、私聊用户或群聊、连续上下文 | | 消息 | 具体发送者、本次实际模式、正文、时间和处理状态 | -| 任务与活动 | Runtime、工具调用、执行结果和错误 | +| 运行记录 | Runtime、工具调用、执行结果和错误 | ### 5.3 会话命名 @@ -268,7 +268,7 @@ Execute 启动前检查解析后的后端是否支持工具执行,并返回可 - 尚无远程会话时显示等待首条客户端消息的空状态和设置入口。 - 旧版本误建在通道项目中的普通本地会话不参与通道会话列表,但保留其数据。 - 远程会话底部说明客户端联动方式,只显示历史、任务和执行结果,不再提及已移除的审批流程。 -- “任务与活动”页面按会话分组显示远程任务;所有分组首次进入时默认收起,包括进行中、失败和已完成状态,用户可通过原生展开控件查看明细。 +- “运行记录”的“任务与会话”视图按“项目 → 任务或会话 → 活动详情”显示远程任务;所有任务或会话首次进入时默认收起,用户可通过原生展开控件查看明细。综合状态以最近一次顶层请求对应的最终 Agent 结果为准,最终结果尚未产生时使用请求当前状态;中间工具或子专家的失败、取消和中断不得覆盖最终成功状态。 ### 7.6 微信扫码绑定 @@ -672,7 +672,7 @@ Renderer 快照只返回是否已配置和脱敏标识。 - [ ] 切换到通道项目不会创建普通本地会话。 - [ ] 通道项目隐藏“新建对话”和 `Ctrl+N`,全局快捷命令也不创建会话。 - [ ] 没有远程会话时显示等待客户端首条消息的空状态。 -- [ ] “任务与活动”中的会话分组默认收起,进行中、失败和已完成状态行为一致。 +- [ ] “运行记录”的任务或会话分组默认收起;综合状态使用最近一次顶层请求的最终 Agent 结果,中间工具或子专家失败不得覆盖最终成功状态。 - [ ] 微信图片和文件显示在对应远程消息中,附件消息无需附带文字。 - [ ] 支持的附件进入所选后端现有图片或文档上下文;不支持和超限附件返回明确提示。 diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index ffd284d..f30cc42 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -162,6 +162,58 @@ describe('ActivityPanel', () => { ).toHaveLength(2) }) + it('uses the latest top-level Agent result for conversation status', () => { + const request: ActivityRecord = { + ...makeRecord(1, 'running'), + conversationId: 'conversation-status', + requestId: 'request-status', + kind: 'request', + title: '最终成功的任务' + } + const failedTool: ActivityRecord = { + ...makeRecord(2, 'failed'), + conversationId: request.conversationId, + requestId: request.requestId, + kind: 'tool', + title: '中间失败的工具' + } + const cancelledSubagent: ActivityRecord = { + ...makeRecord(3, 'cancelled'), + conversationId: request.conversationId, + requestId: request.requestId, + kind: 'subagent', + title: '已取消的子专家' + } + const result: ActivityRecord = { + ...makeRecord(4, 'completed'), + conversationId: request.conversationId, + requestId: request.requestId, + kind: 'result', + title: '任务执行完成' + } + render( + + ) + + const conversation = screen + .getByText('对话:最终成功的任务') + .closest('summary') + expect(conversation).not.toBeNull() + if (!conversation) { + return + } + expect(within(conversation).getByText('已完成')).toBeInTheDocument() + expect(within(conversation).queryByText('失败')).not.toBeInTheDocument() + expect( + within(conversation).queryByText('已取消') + ).not.toBeInTheDocument() + }) + it('filters active and exceptional activity and opens its conversation', () => { const onOpenConversation = vi.fn() render( diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index 0215693..d0b28c0 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -127,9 +127,32 @@ function activityScopeKey(scope: ActivityRecord['scope']): string { function conversationStatus( records: readonly ActivityRecord[] ): ActivityRecord['status'] { + const latestRecord = ( + candidates: readonly ActivityRecord[] + ): ActivityRecord | undefined => + candidates.reduce( + (latest, record) => + !latest || record.createdAt > latest.createdAt ? record : latest, + undefined + ) + const latestRequest = latestRecord( + records.filter((record) => record.kind === 'request') + ) + if (latestRequest) { + const latestResult = latestRecord( + records.filter( + (record) => + record.kind === 'result' && + record.requestId === latestRequest.requestId + ) + ) + return latestResult?.status ?? latestRequest.status + } + return ( - records.find(isActive)?.status ?? - records.find(isFailed)?.status ?? + latestRecord(records.filter((record) => record.kind === 'result')) + ?.status ?? + latestRecord(records)?.status ?? 'completed' ) } @@ -154,6 +177,13 @@ function groupActivityRecordsByProject( allRecords: readonly ActivityRecord[] ): ProjectActivityGroup[] { const conversationTitles = getConversationTitles(allRecords) + const allConversationRecords = new Map() + for (const record of allRecords) { + const conversationRecords = + allConversationRecords.get(record.conversationId) ?? [] + conversationRecords.push(record) + allConversationRecords.set(record.conversationId, conversationRecords) + } const projectGroups = new Map< string, { @@ -186,7 +216,9 @@ function groupActivityRecordsByProject( conversationTitles.get(conversationId) ?? items[0]!.title, records: items, latestAt: Math.max(...items.map((record) => record.createdAt)), - status: conversationStatus(items) + status: conversationStatus( + allConversationRecords.get(conversationId) ?? items + ) }) ) return {