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 {