添加 RuntimeContext 类用于捕获模型运行时的日期时间信息, 包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。 同时增加最大上下文消息数和工具迭代次数的配置选项, 将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。 BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。 - 添加 RuntimeContext 类和相关渲染方法 - 增加 max_context_messages 和 max_tool_iterations 配置 - 移除 ValidationService 相关代码 - 更新消息记录中的验证状态字段 - 添加原始工具调用检测和回退处理
1538 lines
54 KiB
Markdown
1538 lines
54 KiB
Markdown
# Task Detail Live Execution Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a timeline-first task detail page where ordinary users can watch Beaver select skills, call tools, coordinate agent teams, produce artifacts, and reach acceptance in real time.
|
||
|
||
**Architecture:** Keep backend task detail as the durable source of truth, enrich process projection with clearer user-facing event metadata, then normalize backend and live WebSocket process events into a frontend `TaskTimelineCard` model. Refactor the task detail route into focused components: persistent header, chronological card feed, side rail, artifact list, and final acceptance surface.
|
||
|
||
**Tech Stack:** FastAPI/Python backend, Next.js 13 app router, React, TypeScript, Zustand store, Vitest, pytest.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
- Create: `app-instance/frontend/lib/task-timeline.ts`
|
||
- Converts `BackendTask`, `ProcessRun`, `ProcessEvent`, and `ProcessArtifact` records into stable user-facing timeline cards.
|
||
- Create: `app-instance/frontend/lib/task-timeline.test.ts`
|
||
- Covers skill cards, tool cards, agent team cards, artifact cards, result cards, acceptance cards, ordering, and fallback behavior.
|
||
- Create: `app-instance/frontend/components/task-detail/TaskLiveHeader.tsx`
|
||
- Sticky task status header.
|
||
- Create: `app-instance/frontend/components/task-detail/TaskTimeline.tsx`
|
||
- Main timeline renderer.
|
||
- Create: `app-instance/frontend/components/task-detail/TaskTimelineCard.tsx`
|
||
- Card renderer for each timeline card type.
|
||
- Create: `app-instance/frontend/components/task-detail/TaskSideRail.tsx`
|
||
- Agent team map, current activity, artifacts, warnings, and acceptance state.
|
||
- Create: `app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx`
|
||
- Accept, revise, abandon UI extracted from the current task detail page and adapted for final-result timeline cards.
|
||
- Create: `app-instance/frontend/components/task-detail/index.ts`
|
||
- Barrel export for task detail components.
|
||
- Modify: `app-instance/frontend/types/index.ts`
|
||
- Add task timeline card types and optional explicit process event kinds.
|
||
- Modify: `app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx`
|
||
- Replace phase-group-first layout with timeline-first layout.
|
||
- Modify: `app-instance/backend/beaver/services/process_service.py`
|
||
- Emit clearer event kinds and user-facing metadata from persisted task lifecycle records.
|
||
- Modify: `app-instance/backend/tests/unit/test_process_projection.py`
|
||
- Add coverage for skill selection, team, result-ready, and acceptance event projection.
|
||
|
||
## Existing Context
|
||
|
||
- `GET /api/tasks/{task_id}` already returns task metadata plus `events`, `runs`, `process_runs`, `process_events`, and `process_artifacts`.
|
||
- `AppRuntimeBridge` already ingests WebSocket `process_*` events into `useChatStore`.
|
||
- `useChatStore.setSessionProcess()` can merge a persisted projection into the live process state.
|
||
- The current `tasks/[taskId]/page.tsx` has useful acceptance and artifact UI, but the hierarchy is phase-group-first instead of timeline-first.
|
||
- The current `SessionProcessProjector` maps task lifecycle events into generic process runs/events; this plan keeps that path but adds clearer `metadata.timeline_type` and more explicit user-facing `text`.
|
||
|
||
## Task 1: Timeline View Model
|
||
|
||
**Files:**
|
||
- Create: `app-instance/frontend/lib/task-timeline.ts`
|
||
- Create: `app-instance/frontend/lib/task-timeline.test.ts`
|
||
- Modify: `app-instance/frontend/types/index.ts`
|
||
|
||
- [ ] **Step 1: Add the failing timeline tests**
|
||
|
||
Create `app-instance/frontend/lib/task-timeline.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||
|
||
const baseTask: BackendTask = {
|
||
task_id: 'task-1',
|
||
session_id: 'web:default',
|
||
description: '整理后端产品能力展示',
|
||
short_title: '后端展示',
|
||
goal: '整理后端产品能力展示',
|
||
constraints: [],
|
||
priority: 0,
|
||
status: 'running',
|
||
creator: 'main-agent',
|
||
created_at: '2026-05-26T10:00:00.000Z',
|
||
updated_at: '2026-05-26T10:03:00.000Z',
|
||
run_ids: ['run-main'],
|
||
skill_names: ['backend-review'],
|
||
feedback: [],
|
||
metadata: { short_title: '后端展示' },
|
||
};
|
||
|
||
describe('buildTaskTimelineCards', () => {
|
||
it('builds ordered user-facing cards from task process data', () => {
|
||
const processRuns: ProcessRun[] = [
|
||
{
|
||
run_id: 'task:task-1:attempt:1',
|
||
parent_run_id: null,
|
||
session_id: 'web:default',
|
||
actor_type: 'system',
|
||
actor_id: 'task',
|
||
actor_name: 'Task Planner',
|
||
title: 'team plan: research_then_summarize',
|
||
source: 'task_mode',
|
||
status: 'running',
|
||
started_at: '2026-05-26T10:00:01.000Z',
|
||
summary: '分为调研、复核、汇总三个阶段',
|
||
metadata: {
|
||
task_id: 'task-1',
|
||
plan_mode: 'team',
|
||
strategy: 'research_then_summarize',
|
||
selected_skill_names: ['backend-review'],
|
||
},
|
||
},
|
||
{
|
||
run_id: 'run-research',
|
||
parent_run_id: 'task:task-1:attempt:1',
|
||
session_id: 'web:default',
|
||
actor_type: 'agent',
|
||
actor_id: 'research',
|
||
actor_name: 'Research Agent',
|
||
title: '阅读后端文档',
|
||
source: 'task_team',
|
||
status: 'done',
|
||
started_at: '2026-05-26T10:00:20.000Z',
|
||
finished_at: '2026-05-26T10:02:00.000Z',
|
||
summary: '已完成资料阅读',
|
||
metadata: { task_id: 'task-1', node_id: 'research' },
|
||
},
|
||
];
|
||
|
||
const processEvents: ProcessEvent[] = [
|
||
{
|
||
event_id: 'evt-plan',
|
||
run_id: 'task:task-1:attempt:1',
|
||
parent_run_id: null,
|
||
kind: 'task_planned',
|
||
actor_type: 'system',
|
||
actor_id: 'task',
|
||
actor_name: 'Task Planner',
|
||
text: 'Beaver planned a team execution.',
|
||
status: 'running',
|
||
created_at: '2026-05-26T10:00:01.000Z',
|
||
metadata: {
|
||
task_id: 'task-1',
|
||
timeline_type: 'plan',
|
||
plan_mode: 'team',
|
||
strategy: 'research_then_summarize',
|
||
selected_skill_names: ['backend-review'],
|
||
},
|
||
},
|
||
{
|
||
event_id: 'evt-skill',
|
||
run_id: 'task:task-1:attempt:1',
|
||
parent_run_id: null,
|
||
kind: 'skill_selected',
|
||
actor_type: 'system',
|
||
actor_id: 'skill-selector',
|
||
actor_name: 'Skill Selector',
|
||
text: 'Selected backend-review to guide backend capability analysis.',
|
||
status: 'done',
|
||
created_at: '2026-05-26T10:00:02.000Z',
|
||
metadata: {
|
||
task_id: 'task-1',
|
||
timeline_type: 'skill',
|
||
skill_names: ['backend-review'],
|
||
reason: 'Matches backend review and product roadshow documents.',
|
||
},
|
||
},
|
||
{
|
||
event_id: 'evt-tool-start',
|
||
run_id: 'run-research',
|
||
parent_run_id: 'task:task-1:attempt:1',
|
||
kind: 'tool_call_started',
|
||
actor_type: 'tool',
|
||
actor_id: 'filesystem',
|
||
actor_name: 'Filesystem',
|
||
text: 'Reading backend product documents.',
|
||
status: 'running',
|
||
created_at: '2026-05-26T10:00:30.000Z',
|
||
metadata: {
|
||
task_id: 'task-1',
|
||
timeline_type: 'tool_call',
|
||
tool_name: 'filesystem',
|
||
action: 'read_files',
|
||
},
|
||
},
|
||
{
|
||
event_id: 'evt-tool-finish',
|
||
run_id: 'run-research',
|
||
parent_run_id: 'task:task-1:attempt:1',
|
||
kind: 'tool_call_finished',
|
||
actor_type: 'tool',
|
||
actor_id: 'filesystem',
|
||
actor_name: 'Filesystem',
|
||
text: 'Read 2 backend review documents.',
|
||
status: 'done',
|
||
created_at: '2026-05-26T10:00:40.000Z',
|
||
metadata: {
|
||
task_id: 'task-1',
|
||
timeline_type: 'tool_result',
|
||
tool_name: 'filesystem',
|
||
result_summary: '2 documents read successfully.',
|
||
},
|
||
},
|
||
];
|
||
|
||
const processArtifacts: ProcessArtifact[] = [
|
||
{
|
||
artifact_id: 'artifact-summary',
|
||
run_id: 'run-research',
|
||
actor_type: 'agent',
|
||
actor_id: 'research',
|
||
actor_name: 'Research Agent',
|
||
title: '后端能力摘要',
|
||
artifact_type: 'markdown',
|
||
content: '# 后端能力摘要',
|
||
created_at: '2026-05-26T10:02:10.000Z',
|
||
metadata: { task_id: 'task-1' },
|
||
},
|
||
];
|
||
|
||
const cards = buildTaskTimelineCards({
|
||
task: baseTask,
|
||
processRuns,
|
||
processEvents,
|
||
processArtifacts,
|
||
});
|
||
|
||
expect(cards.map((card) => card.type)).toEqual([
|
||
'task_created',
|
||
'plan',
|
||
'skill',
|
||
'tool_call',
|
||
'tool_result',
|
||
'agent_progress',
|
||
'artifact',
|
||
]);
|
||
expect(cards[1].title).toBe('执行计划');
|
||
expect(cards[2].title).toBe('选择 Skill');
|
||
expect(cards[4].summary).toBe('2 documents read successfully.');
|
||
expect(cards[6].relatedArtifactIds).toEqual(['artifact-summary']);
|
||
});
|
||
|
||
it('adds result and acceptance cards from final task state and feedback', () => {
|
||
const cards = buildTaskTimelineCards({
|
||
task: {
|
||
...baseTask,
|
||
status: 'closed',
|
||
updated_at: '2026-05-26T10:05:00.000Z',
|
||
feedback: [
|
||
{
|
||
acceptance_type: 'accept',
|
||
comment: '可以',
|
||
created_at: '2026-05-26T10:05:00.000Z',
|
||
run_id: 'run-main',
|
||
},
|
||
],
|
||
},
|
||
processRuns: [],
|
||
processEvents: [],
|
||
processArtifacts: [],
|
||
});
|
||
|
||
expect(cards.at(-2)?.type).toBe('result');
|
||
expect(cards.at(-1)?.type).toBe('acceptance');
|
||
expect(cards.at(-1)?.summary).toContain('可以');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the failing test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd app-instance/frontend
|
||
npm test -- task-timeline.test.ts
|
||
```
|
||
|
||
Expected: FAIL because `@/lib/task-timeline` does not exist.
|
||
|
||
- [ ] **Step 3: Add timeline types**
|
||
|
||
Modify `app-instance/frontend/types/index.ts` after the existing `ProcessArtifact` interface:
|
||
|
||
```ts
|
||
export type TaskTimelineCardType =
|
||
| 'task_created'
|
||
| 'plan'
|
||
| 'skill'
|
||
| 'tool_call'
|
||
| 'tool_result'
|
||
| 'next_step'
|
||
| 'agent_team'
|
||
| 'agent_progress'
|
||
| 'agent_handoff'
|
||
| 'artifact'
|
||
| 'error'
|
||
| 'result'
|
||
| 'acceptance';
|
||
|
||
export interface TaskTimelineCard {
|
||
id: string;
|
||
taskId: string;
|
||
runId?: string | null;
|
||
parentRunId?: string | null;
|
||
type: TaskTimelineCardType;
|
||
title: string;
|
||
summary?: string;
|
||
actorName?: string;
|
||
status?: string;
|
||
createdAt: string;
|
||
relatedArtifactIds?: string[];
|
||
details?: Record<string, unknown>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `buildTaskTimelineCards`**
|
||
|
||
Create `app-instance/frontend/lib/task-timeline.ts`:
|
||
|
||
```ts
|
||
import type {
|
||
BackendTask,
|
||
ProcessArtifact,
|
||
ProcessEvent,
|
||
ProcessRun,
|
||
TaskTimelineCard,
|
||
TaskTimelineCardType,
|
||
} from '@/types';
|
||
|
||
type BuildTaskTimelineCardsInput = {
|
||
task: BackendTask;
|
||
processRuns?: ProcessRun[];
|
||
processEvents?: ProcessEvent[];
|
||
processArtifacts?: ProcessArtifact[];
|
||
};
|
||
|
||
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
|
||
|
||
function asString(value: unknown): string | null {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
function asStringArray(value: unknown): string[] {
|
||
return Array.isArray(value)
|
||
? value.map((item) => String(item || '').trim()).filter(Boolean)
|
||
: [];
|
||
}
|
||
|
||
function eventType(event: ProcessEvent): TaskTimelineCardType | null {
|
||
const explicit = asString(event.metadata?.timeline_type);
|
||
if (explicit) return explicit as TaskTimelineCardType;
|
||
|
||
if (event.kind === 'task_planned' || event.kind === 'run_started') return 'plan';
|
||
if (event.kind === 'skill_selected') return 'skill';
|
||
if (event.kind === 'tool_call_started') return 'tool_call';
|
||
if (event.kind === 'tool_call_finished') return 'tool_result';
|
||
if (event.kind === 'agent_team_created') return 'agent_team';
|
||
if (event.kind === 'agent_handoff') return 'agent_handoff';
|
||
if (event.kind === 'run_progress' || event.kind === 'run_finished') return 'agent_progress';
|
||
if (event.kind === 'task_result_ready') return 'result';
|
||
if (event.kind === 'task_acceptance_recorded') return 'acceptance';
|
||
if (event.kind === 'task_error' || event.status === 'error') return 'error';
|
||
return null;
|
||
}
|
||
|
||
function titleForCard(type: TaskTimelineCardType, event?: ProcessEvent): string {
|
||
if (type === 'task_created') return '任务已创建';
|
||
if (type === 'plan') return '执行计划';
|
||
if (type === 'skill') return '选择 Skill';
|
||
if (type === 'tool_call') return `调用工具${event?.actor_name ? `:${event.actor_name}` : ''}`;
|
||
if (type === 'tool_result') return `工具结果${event?.actor_name ? `:${event.actor_name}` : ''}`;
|
||
if (type === 'next_step') return '下一步';
|
||
if (type === 'agent_team') return '启动 Agent Team';
|
||
if (type === 'agent_progress') return event?.actor_name || 'Agent 进展';
|
||
if (type === 'agent_handoff') return 'Agent 交接';
|
||
if (type === 'artifact') return '生成产物';
|
||
if (type === 'error') return '执行遇到问题';
|
||
if (type === 'result') return '本轮结果';
|
||
if (type === 'acceptance') return '任务验收';
|
||
return event?.actor_name || '任务事件';
|
||
}
|
||
|
||
function eventSummary(event: ProcessEvent): string | undefined {
|
||
const metadataSummary =
|
||
asString(event.metadata?.result_summary) ||
|
||
asString(event.metadata?.reason) ||
|
||
asString(event.metadata?.action_summary);
|
||
return metadataSummary || event.text || undefined;
|
||
}
|
||
|
||
function buildTaskCreatedCard(task: BackendTask): TaskTimelineCard {
|
||
return {
|
||
id: `task-created:${task.task_id}`,
|
||
taskId: task.task_id,
|
||
type: 'task_created',
|
||
title: '任务已创建',
|
||
summary: task.goal || task.description || task.short_title || task.task_id,
|
||
status: task.status,
|
||
createdAt: task.created_at,
|
||
details: {
|
||
session_id: task.session_id,
|
||
creator: task.creator,
|
||
constraints: task.constraints,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildEventCard(task: BackendTask, event: ProcessEvent): TaskTimelineCard | null {
|
||
const type = eventType(event);
|
||
if (!type) return null;
|
||
const skillNames = asStringArray(event.metadata?.skill_names).concat(asStringArray(event.metadata?.selected_skill_names));
|
||
return {
|
||
id: event.event_id,
|
||
taskId: task.task_id,
|
||
runId: event.run_id,
|
||
parentRunId: event.parent_run_id ?? null,
|
||
type,
|
||
title: titleForCard(type, event),
|
||
summary: eventSummary(event),
|
||
actorName: event.actor_name,
|
||
status: event.status || undefined,
|
||
createdAt: event.created_at,
|
||
details: {
|
||
...(event.metadata ?? {}),
|
||
skill_names: skillNames.length > 0 ? Array.from(new Set(skillNames)) : undefined,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildRunCards(task: BackendTask, processRuns: ProcessRun[], existingCardIds: Set<string>): TaskTimelineCard[] {
|
||
return processRuns
|
||
.filter((run) => run.parent_run_id && !existingCardIds.has(`run:${run.run_id}`))
|
||
.map((run) => ({
|
||
id: `run:${run.run_id}`,
|
||
taskId: task.task_id,
|
||
runId: run.run_id,
|
||
parentRunId: run.parent_run_id ?? null,
|
||
type: 'agent_progress' as const,
|
||
title: run.title || run.actor_name,
|
||
summary: run.summary || undefined,
|
||
actorName: run.actor_name,
|
||
status: run.status,
|
||
createdAt: run.finished_at || run.started_at,
|
||
details: run.metadata,
|
||
}));
|
||
}
|
||
|
||
function buildArtifactCards(task: BackendTask, artifacts: ProcessArtifact[]): TaskTimelineCard[] {
|
||
return artifacts.map((artifact) => ({
|
||
id: `artifact:${artifact.artifact_id}`,
|
||
taskId: task.task_id,
|
||
runId: artifact.run_id,
|
||
type: 'artifact' as const,
|
||
title: artifact.title || '生成产物',
|
||
summary: artifact.content ? artifact.content.slice(0, 180) : undefined,
|
||
actorName: artifact.actor_name,
|
||
status: 'done',
|
||
createdAt: artifact.created_at,
|
||
relatedArtifactIds: [artifact.artifact_id],
|
||
details: {
|
||
artifact_type: artifact.artifact_type,
|
||
file_id: artifact.file_id,
|
||
url: artifact.url,
|
||
metadata: artifact.metadata,
|
||
},
|
||
}));
|
||
}
|
||
|
||
function buildResultCard(task: BackendTask, artifacts: ProcessArtifact[]): TaskTimelineCard | null {
|
||
if (!TERMINAL_TASK_STATUSES.has(task.status) && task.status !== 'awaiting_acceptance') return null;
|
||
return {
|
||
id: `result:${task.task_id}:${task.updated_at}`,
|
||
taskId: task.task_id,
|
||
runId: task.run_ids.at(-1) ?? null,
|
||
type: 'result',
|
||
title: '本轮结果',
|
||
summary: task.status === 'awaiting_acceptance' ? '结果已准备好,请验收。' : '任务已结束。',
|
||
status: task.status,
|
||
createdAt: task.updated_at,
|
||
relatedArtifactIds: artifacts.map((artifact) => artifact.artifact_id),
|
||
details: {
|
||
validation_result: task.validation_result,
|
||
close_reason: task.close_reason,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildAcceptanceCards(task: BackendTask): TaskTimelineCard[] {
|
||
return (task.feedback || []).map((item, index) => {
|
||
const acceptanceType = asString(item.acceptance_type) || asString(item.feedback_type) || 'feedback';
|
||
const comment = asString(item.comment);
|
||
return {
|
||
id: `acceptance:${task.task_id}:${String(item.created_at || index)}`,
|
||
taskId: task.task_id,
|
||
runId: asString(item.run_id),
|
||
type: 'acceptance' as const,
|
||
title: '任务验收',
|
||
summary: comment ? `${acceptanceType}: ${comment}` : acceptanceType,
|
||
actorName: 'User Acceptance',
|
||
status: 'done',
|
||
createdAt: asString(item.created_at) || task.updated_at,
|
||
details: { ...item },
|
||
};
|
||
});
|
||
}
|
||
|
||
function compareCards(a: TaskTimelineCard, b: TaskTimelineCard): number {
|
||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||
}
|
||
|
||
export function buildTaskTimelineCards({
|
||
task,
|
||
processRuns = [],
|
||
processEvents = [],
|
||
processArtifacts = [],
|
||
}: BuildTaskTimelineCardsInput): TaskTimelineCard[] {
|
||
const eventCards = processEvents
|
||
.map((event) => buildEventCard(task, event))
|
||
.filter((card): card is TaskTimelineCard => Boolean(card));
|
||
const eventCardIds = new Set(eventCards.map((card) => card.id));
|
||
const resultCard = buildResultCard(task, processArtifacts);
|
||
return [
|
||
buildTaskCreatedCard(task),
|
||
...eventCards,
|
||
...buildRunCards(task, processRuns, eventCardIds),
|
||
...buildArtifactCards(task, processArtifacts),
|
||
...(resultCard ? [resultCard] : []),
|
||
...buildAcceptanceCards(task),
|
||
].sort(compareCards);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the timeline tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd app-instance/frontend
|
||
npm test -- task-timeline.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app-instance/frontend/types/index.ts app-instance/frontend/lib/task-timeline.ts app-instance/frontend/lib/task-timeline.test.ts
|
||
git commit -m "feat: add task timeline model"
|
||
```
|
||
|
||
## Task 2: Backend Projection Event Enrichment
|
||
|
||
**Files:**
|
||
- Modify: `app-instance/backend/beaver/services/process_service.py`
|
||
- Modify: `app-instance/backend/tests/unit/test_process_projection.py`
|
||
|
||
- [ ] **Step 1: Add failing projection tests**
|
||
|
||
Append these tests to `app-instance/backend/tests/unit/test_process_projection.py`. If the file already has fixtures for session records and run records, reuse the local helper style but keep the assertions below:
|
||
|
||
```py
|
||
def test_task_projection_emits_timeline_metadata_for_plan_and_skills(session_manager, run_memory_store):
|
||
projector = SessionProcessProjector(session_manager, run_memory_store)
|
||
|
||
projection = projector.project("web:default")
|
||
|
||
plan_events = [event for event in projection["events"] if event["kind"] == "task_planned"]
|
||
assert plan_events
|
||
assert plan_events[0]["metadata"]["timeline_type"] == "plan"
|
||
assert "selected_skill_names" in plan_events[0]["metadata"]
|
||
|
||
skill_events = [event for event in projection["events"] if event["kind"] == "skill_selected"]
|
||
assert skill_events
|
||
assert skill_events[0]["metadata"]["timeline_type"] == "skill"
|
||
assert skill_events[0]["metadata"]["skill_names"]
|
||
|
||
|
||
def test_task_projection_emits_result_ready_and_acceptance_cards(session_manager, run_memory_store):
|
||
projector = SessionProcessProjector(session_manager, run_memory_store)
|
||
|
||
projection = projector.project("web:default")
|
||
|
||
result_events = [event for event in projection["events"] if event["kind"] == "task_result_ready"]
|
||
assert result_events
|
||
assert result_events[0]["metadata"]["timeline_type"] == "result"
|
||
assert result_events[0]["status"] == "done"
|
||
|
||
acceptance_events = [event for event in projection["events"] if event["kind"] == "task_acceptance_recorded"]
|
||
assert acceptance_events
|
||
assert acceptance_events[0]["metadata"]["timeline_type"] == "acceptance"
|
||
```
|
||
|
||
If `test_process_projection.py` does not expose `session_manager` and `run_memory_store` fixtures, create local fake classes in that file:
|
||
|
||
```py
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from beaver.services.process_service import SessionProcessProjector
|
||
|
||
|
||
@dataclass
|
||
class FakeEventRecord:
|
||
message_id: int
|
||
timestamp: float
|
||
run_id: str | None
|
||
event_type: str
|
||
event_payload: dict[str, Any]
|
||
|
||
|
||
@dataclass
|
||
class FakeRunRecord:
|
||
run_id: str
|
||
session_id: str
|
||
started_at: str
|
||
ended_at: str | None
|
||
success: bool
|
||
task_text: str
|
||
|
||
|
||
class FakeSessionManager:
|
||
def __init__(self, records: list[FakeEventRecord]) -> None:
|
||
self.records = records
|
||
|
||
def get_event_records(self, session_id: str) -> list[FakeEventRecord]:
|
||
return self.records
|
||
|
||
|
||
class FakeRunMemoryStore:
|
||
def __init__(self, records: list[FakeRunRecord]) -> None:
|
||
self.records = records
|
||
|
||
def list_runs(self) -> list[FakeRunRecord]:
|
||
return self.records
|
||
```
|
||
|
||
- [ ] **Step 2: Run the failing backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd app-instance/backend
|
||
uv run pytest tests/unit/test_process_projection.py -q
|
||
```
|
||
|
||
Expected: FAIL because projection events are still generic `run_started` / `run_status` and do not include `timeline_type`.
|
||
|
||
- [ ] **Step 3: Enrich `task_execution_planned` projection**
|
||
|
||
Modify the `task_execution_planned` branch in `app-instance/backend/beaver/services/process_service.py` so the existing `add_event(...)` call uses:
|
||
|
||
```py
|
||
kind="task_planned",
|
||
text=f"Beaver planned {payload.get('plan_mode', 'single')} execution via {strategy}. {payload.get('reason') or ''}".strip(),
|
||
metadata={
|
||
**root["metadata"],
|
||
"timeline_type": "plan",
|
||
"user_summary": f"Beaver will use {payload.get('plan_mode', 'single')} execution for this task.",
|
||
},
|
||
```
|
||
|
||
Immediately after that `add_event(...)`, add a second event when skills are present:
|
||
|
||
```py
|
||
selected_skill_names = [str(item) for item in payload.get("selected_skill_names") or [] if str(item).strip()]
|
||
if selected_skill_names:
|
||
add_event(
|
||
event_id=_event_id(record, "skills"),
|
||
run_id=root_run_id,
|
||
kind="skill_selected",
|
||
actor_type="system",
|
||
actor_id="skill-selector",
|
||
actor_name="Skill Selector",
|
||
text=f"Selected skill guidance: {', '.join(selected_skill_names)}.",
|
||
created_at=created_at,
|
||
status="done",
|
||
metadata={
|
||
"task_id": task_id,
|
||
"attempt_index": attempt_index,
|
||
"timeline_type": "skill",
|
||
"skill_names": selected_skill_names,
|
||
"reason": payload.get("reason") or "Selected from task planning context.",
|
||
},
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Enrich team and node events**
|
||
|
||
In the `task_team_run_completed` / `task_team_run_failed` branch, change the team-level event to:
|
||
|
||
```py
|
||
kind="agent_team_created",
|
||
text=payload.get("error") or ("Agent team completed" if team_success else "Agent team completed with failed nodes"),
|
||
metadata={
|
||
**dict(payload),
|
||
"timeline_type": "agent_team",
|
||
"team_run_ids": payload.get("team_run_ids") or [],
|
||
},
|
||
```
|
||
|
||
Change each node result event to use:
|
||
|
||
```py
|
||
kind="agent_finished",
|
||
metadata={
|
||
**dict(item),
|
||
"task_id": task_id,
|
||
"timeline_type": "agent_progress",
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 5: Enrich result and acceptance events**
|
||
|
||
In the `task_evidence_recorded` branch, change the event to:
|
||
|
||
```py
|
||
kind="task_result_ready",
|
||
text="The task result is ready for user acceptance.",
|
||
metadata={
|
||
**dict(payload),
|
||
"timeline_type": "result",
|
||
},
|
||
```
|
||
|
||
In the `task_acceptance_recorded` branch, change the event to:
|
||
|
||
```py
|
||
kind="task_acceptance_recorded",
|
||
text=f"User acceptance recorded: {acceptance_type or 'unknown'}.",
|
||
metadata={
|
||
**dict(payload),
|
||
"timeline_type": "acceptance",
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 6: Run backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd app-instance/backend
|
||
uv run pytest tests/unit/test_process_projection.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app-instance/backend/beaver/services/process_service.py app-instance/backend/tests/unit/test_process_projection.py
|
||
git commit -m "feat: enrich task process timeline events"
|
||
```
|
||
|
||
## Task 3: Task Detail Components
|
||
|
||
**Files:**
|
||
- Create: `app-instance/frontend/components/task-detail/TaskLiveHeader.tsx`
|
||
- Create: `app-instance/frontend/components/task-detail/TaskTimelineCard.tsx`
|
||
- Create: `app-instance/frontend/components/task-detail/TaskTimeline.tsx`
|
||
- Create: `app-instance/frontend/components/task-detail/TaskSideRail.tsx`
|
||
- Create: `app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx`
|
||
- Create: `app-instance/frontend/components/task-detail/index.ts`
|
||
|
||
- [ ] **Step 1: Create the acceptance component**
|
||
|
||
Create `app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import React from 'react';
|
||
import { CheckCircle2, Loader2, RefreshCw, ThumbsUp, XCircle } from 'lucide-react';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||
import { pickAppText } from '@/lib/i18n/core';
|
||
import { useAppI18n } from '@/lib/i18n/provider';
|
||
|
||
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
|
||
|
||
export type TaskFeedbackItem = {
|
||
acceptance_type?: unknown;
|
||
feedback_type?: unknown;
|
||
comment?: unknown;
|
||
created_at?: unknown;
|
||
run_id?: unknown;
|
||
};
|
||
|
||
type Props = {
|
||
sessionId: string;
|
||
runId: string | null;
|
||
taskStatus: string;
|
||
feedbackItems: TaskFeedbackItem[];
|
||
actionBusy: string | null;
|
||
revision?: string;
|
||
onRevisionChange?: (value: string) => void;
|
||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
|
||
};
|
||
|
||
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
|
||
if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted');
|
||
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
|
||
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
|
||
return type || pickAppText(locale, '验收', 'Acceptance');
|
||
}
|
||
|
||
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
||
if (!runId) return null;
|
||
const ordered = [...items].reverse();
|
||
return ordered.find((item) => String(item.run_id || '') === runId) ?? null;
|
||
}
|
||
|
||
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
||
return [...items].reverse()[0] ?? null;
|
||
}
|
||
|
||
function FeedbackButton({
|
||
type,
|
||
icon,
|
||
label,
|
||
actionBusy,
|
||
disabled,
|
||
onClick,
|
||
}: {
|
||
type: TaskFeedbackType;
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
actionBusy: string | null;
|
||
disabled: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
const isBusy = Boolean(actionBusy?.endsWith(type));
|
||
return (
|
||
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
||
{label}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
export function TaskAcceptanceCard({
|
||
sessionId,
|
||
runId,
|
||
taskStatus,
|
||
feedbackItems,
|
||
actionBusy,
|
||
revision,
|
||
onRevisionChange,
|
||
onSubmit,
|
||
}: Props) {
|
||
const { locale } = useAppI18n();
|
||
const [localComment, setLocalComment] = React.useState('');
|
||
const comment = revision ?? localComment;
|
||
const setComment = onRevisionChange ?? setLocalComment;
|
||
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
||
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
||
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && !actionBusy;
|
||
|
||
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||
if (!runId || !canSubmit) return;
|
||
void onSubmit(feedbackType, nextComment);
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{recordedFeedback ? (
|
||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||
<div className="flex items-center gap-2 font-medium">
|
||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
||
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(String(recordedFeedback.acceptance_type || recordedFeedback.feedback_type || ''), locale)}
|
||
</div>
|
||
{recordedFeedback.comment ? <p className="mt-2 text-muted-foreground">{String(recordedFeedback.comment)}</p> : null}
|
||
{recordedFeedback.created_at ? <p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p> : null}
|
||
</div>
|
||
) : isFinalized ? (
|
||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
|
||
</div>
|
||
) : !runId ? (
|
||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
||
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid gap-2 sm:grid-cols-3">
|
||
<FeedbackButton type="accept" icon={<ThumbsUp className="mr-2 h-4 w-4" />} label={pickAppText(locale, '接受', 'Accept')} actionBusy={actionBusy} disabled={!canSubmit} onClick={() => submit('accept', comment.trim() || undefined)} />
|
||
<FeedbackButton type="revise" icon={<RefreshCw className="mr-2 h-4 w-4" />} label={pickAppText(locale, '需要修改', 'Needs revision')} actionBusy={actionBusy} disabled={!canSubmit || !comment.trim()} onClick={() => submit('revise', comment.trim())} />
|
||
<FeedbackButton type="abandon" icon={<XCircle className="mr-2 h-4 w-4" />} label={pickAppText(locale, '放弃', 'Abandon')} actionBusy={actionBusy} disabled={!canSubmit} onClick={() => submit('abandon', comment.trim() || undefined)} />
|
||
</div>
|
||
|
||
<Textarea
|
||
value={comment}
|
||
onChange={(event) => setComment(event.target.value)}
|
||
disabled={Boolean(recordedFeedback) || isFinalized || Boolean(actionBusy)}
|
||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||
/>
|
||
<div className="text-xs text-muted-foreground">
|
||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||
<span className="font-mono">{runId || '-'}</span>
|
||
<span className="mx-1">·</span>
|
||
{pickAppText(locale, '会话:', 'Session: ')}
|
||
<span className="font-mono">{sessionId}</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create header component**
|
||
|
||
Create `app-instance/frontend/components/task-detail/TaskLiveHeader.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { ArrowLeft, MessageSquare } from 'lucide-react';
|
||
|
||
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { pickAppText } from '@/lib/i18n/core';
|
||
import { useAppI18n } from '@/lib/i18n/provider';
|
||
import type { BackendTask } from '@/types';
|
||
|
||
type Props = {
|
||
task: BackendTask;
|
||
activeLabel: string;
|
||
durationMs: number | null;
|
||
};
|
||
|
||
export function TaskLiveHeader({ task, activeLabel, durationMs }: Props) {
|
||
const { locale } = useAppI18n();
|
||
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
|
||
|
||
return (
|
||
<Card className="sticky top-0 z-20 border-x-0 border-t-0 rounded-none bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||
<CardContent className="mx-auto flex max-w-7xl flex-col gap-3 px-6 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||
<Button asChild variant="outline" size="sm">
|
||
<Link href="/tasks">
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||
</Link>
|
||
</Button>
|
||
<Button asChild variant="ghost" size="sm">
|
||
<Link href="/">
|
||
<MessageSquare className="mr-2 h-4 w-4" />
|
||
{pickAppText(locale, '对话', 'Chat')}
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||
<h1 className="truncate text-xl font-semibold tracking-normal">{title}</h1>
|
||
<TaskRuntimeStatusBadge status={task.status as any} />
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||
<span>{pickAppText(locale, '当前', 'Now')}: {activeLabel}</span>
|
||
<span>{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}</span>
|
||
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(durationMs, locale)}</span>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create timeline card component**
|
||
|
||
Create `app-instance/frontend/components/task-detail/TaskTimelineCard.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { AlertCircle, Bot, Boxes, CheckCircle2, FileText, Hammer, Lightbulb, Network, PackageCheck, Route, Sparkles, UserCheck } from 'lucide-react';
|
||
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||
import { pickAppText } from '@/lib/i18n/core';
|
||
import { useAppI18n } from '@/lib/i18n/provider';
|
||
import type { TaskTimelineCard as TaskTimelineCardView } from '@/types';
|
||
|
||
function iconFor(type: TaskTimelineCardView['type']) {
|
||
if (type === 'task_created') return Sparkles;
|
||
if (type === 'plan') return Route;
|
||
if (type === 'skill') return Lightbulb;
|
||
if (type === 'tool_call' || type === 'tool_result') return Hammer;
|
||
if (type === 'agent_team') return Network;
|
||
if (type === 'agent_progress' || type === 'agent_handoff') return Bot;
|
||
if (type === 'artifact') return FileText;
|
||
if (type === 'result') return PackageCheck;
|
||
if (type === 'acceptance') return UserCheck;
|
||
if (type === 'error') return AlertCircle;
|
||
return Boxes;
|
||
}
|
||
|
||
export function TaskTimelineCard({ card }: { card: TaskTimelineCardView }) {
|
||
const { locale } = useAppI18n();
|
||
const Icon = iconFor(card.type);
|
||
const detailEntries = Object.entries(card.details || {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
|
||
|
||
return (
|
||
<Card className={card.type === 'error' ? 'border-destructive' : undefined}>
|
||
<CardContent className="p-4">
|
||
<div className="flex gap-3">
|
||
<div className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="font-medium">{card.title}</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
{card.actorName ? `${card.actorName} · ` : ''}
|
||
{formatTaskRuntimeTime(card.createdAt, locale)}
|
||
</div>
|
||
</div>
|
||
{card.status ? <Badge variant={card.status === 'error' ? 'destructive' : 'secondary'}>{card.status}</Badge> : null}
|
||
</div>
|
||
{card.summary ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{card.summary}</p> : null}
|
||
{detailEntries.length > 0 ? (
|
||
<details className="mt-3">
|
||
<summary className="cursor-pointer text-xs text-muted-foreground">{pickAppText(locale, '显示详情', 'Show details')}</summary>
|
||
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted/40 p-3 text-xs">{JSON.stringify(card.details, null, 2)}</pre>
|
||
</details>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create timeline and side rail components**
|
||
|
||
Create `app-instance/frontend/components/task-detail/TaskTimeline.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { Loader2 } from 'lucide-react';
|
||
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { pickAppText } from '@/lib/i18n/core';
|
||
import { useAppI18n } from '@/lib/i18n/provider';
|
||
import type { TaskTimelineCard as TaskTimelineCardView } from '@/types';
|
||
|
||
import { TaskTimelineCard } from './TaskTimelineCard';
|
||
|
||
export function TaskTimeline({ cards, isLive }: { cards: TaskTimelineCardView[]; isLive: boolean }) {
|
||
const { locale } = useAppI18n();
|
||
if (cards.length === 0) {
|
||
return (
|
||
<Card className="border-dashed">
|
||
<CardContent className="flex items-center gap-2 p-5 text-sm text-muted-foreground">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
{pickAppText(locale, 'Beaver 正在准备第一步。', 'Beaver is preparing the first step.')}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{isLive ? (
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span className="h-2 w-2 rounded-full bg-[#657162]" />
|
||
{pickAppText(locale, '实时更新中', 'Live updates')}
|
||
</div>
|
||
) : null}
|
||
{cards.map((card) => <TaskTimelineCard key={card.id} card={card} />)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Create `app-instance/frontend/components/task-detail/TaskSideRail.tsx`:
|
||
|
||
```tsx
|
||
'use client';
|
||
|
||
import { Download, FileText } from 'lucide-react';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { getFileUrl } from '@/lib/api';
|
||
import { pickAppText } from '@/lib/i18n/core';
|
||
import { useAppI18n } from '@/lib/i18n/provider';
|
||
import type { BackendTask, ProcessArtifact, ProcessRun, TaskTimelineCard } from '@/types';
|
||
|
||
type Props = {
|
||
task: BackendTask;
|
||
runs: ProcessRun[];
|
||
artifacts: ProcessArtifact[];
|
||
cards: TaskTimelineCard[];
|
||
};
|
||
|
||
export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||
const { locale } = useAppI18n();
|
||
const activeRuns = runs.filter((run) => !['done', 'error', 'cancelled'].includes(run.status));
|
||
const latestWarning = [...cards].reverse().find((card) => card.type === 'error');
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">{pickAppText(locale, '当前状态', 'Current status')}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 text-sm">
|
||
<div>{pickAppText(locale, '任务', 'Task')}: {task.status}</div>
|
||
<div>{pickAppText(locale, '活跃执行', 'Active runs')}: {activeRuns.length}</div>
|
||
{activeRuns.map((run) => <div key={run.run_id} className="text-muted-foreground">{run.actor_name}: {run.title}</div>)}
|
||
{latestWarning ? <div className="text-destructive">{latestWarning.summary || latestWarning.title}</div> : null}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">{pickAppText(locale, 'Agent Team', 'Agent Team')}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{runs.filter((run) => run.parent_run_id).length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无子 Agent', 'No sub-agents yet')}</p>
|
||
) : (
|
||
runs.filter((run) => run.parent_run_id).map((run) => (
|
||
<div key={run.run_id} className="rounded-md border border-border p-2 text-sm">
|
||
<div className="font-medium">{run.actor_name}</div>
|
||
<div className="text-xs text-muted-foreground">{run.title}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">{pickAppText(locale, '产物', 'Artifacts')}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
{artifacts.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
|
||
) : (
|
||
artifacts.map((artifact) => (
|
||
<div key={artifact.artifact_id} className="flex items-center justify-between gap-2 rounded-md border border-border p-2">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 text-sm font-medium">
|
||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||
<span className="truncate">{artifact.title}</span>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">{artifact.actor_name || artifact.actor_id}</div>
|
||
</div>
|
||
{artifact.url || artifact.file_id ? (
|
||
<Button asChild size="sm" variant="outline">
|
||
<a href={artifact.url || getFileUrl(artifact.file_id!)} target="_blank" rel="noopener noreferrer">
|
||
<Download className="h-3.5 w-3.5" />
|
||
</a>
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create barrel export**
|
||
|
||
Create `app-instance/frontend/components/task-detail/index.ts`:
|
||
|
||
```ts
|
||
export { TaskAcceptanceCard, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||
export { TaskLiveHeader } from './TaskLiveHeader';
|
||
export { TaskSideRail } from './TaskSideRail';
|
||
export { TaskTimeline } from './TaskTimeline';
|
||
export { TaskTimelineCard } from './TaskTimelineCard';
|
||
```
|
||
|
||
- [ ] **Step 6: Typecheck components**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd app-instance/frontend
|
||
npm run typecheck
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app-instance/frontend/components/task-detail
|
||
git commit -m "feat: add task detail timeline components"
|
||
```
|
||
|
||
## Task 4: Timeline-First Task Detail Page
|
||
|
||
**Files:**
|
||
- Modify: `app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx`
|
||
|
||
- [ ] **Step 1: Refactor imports**
|
||
|
||
In `app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx`, replace the current task runtime UI imports and local acceptance imports with:
|
||
|
||
```tsx
|
||
import Link from 'next/link';
|
||
import { useParams, useRouter } from 'next/navigation';
|
||
import React, { useMemo, useState } from 'react';
|
||
import { AlertCircle, ArrowLeft, Trash2 } from 'lucide-react';
|
||
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
TaskAcceptanceCard,
|
||
TaskLiveHeader,
|
||
TaskSideRail,
|
||
TaskTimeline,
|
||
type TaskFeedbackItem,
|
||
type TaskFeedbackType,
|
||
} from '@/components/task-detail';
|
||
import { deleteBackendTask, getBackendTask, submitChatFeedback } from '@/lib/api';
|
||
import { pickAppText } from '@/lib/i18n/core';
|
||
import { useAppI18n } from '@/lib/i18n/provider';
|
||
import { useChatStore } from '@/lib/store';
|
||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||
```
|
||
|
||
- [ ] **Step 2: Add task detail polling**
|
||
|
||
Inside `TaskDetailPage`, keep the existing `backendTask` state and add:
|
||
|
||
```tsx
|
||
const setSessionProcess = useChatStore((state) => state.setSessionProcess);
|
||
const wsStatus = useChatStore((state) => state.wsStatus);
|
||
|
||
const isTaskLive = backendTask
|
||
? !['closed', 'abandoned', 'cancelled', 'error'].includes(backendTask.status)
|
||
: false;
|
||
|
||
const loadBackendTask = React.useCallback(async () => {
|
||
if (!taskId) return;
|
||
setBackendTaskLoading(true);
|
||
try {
|
||
const item = await getBackendTask(taskId);
|
||
setBackendTask(item);
|
||
setSessionProcess(item.session_id, {
|
||
runs: item.process_runs ?? [],
|
||
events: item.process_events ?? [],
|
||
artifacts: item.process_artifacts ?? [],
|
||
});
|
||
} catch {
|
||
setBackendTask(null);
|
||
} finally {
|
||
setBackendTaskLoading(false);
|
||
}
|
||
}, [setSessionProcess, taskId]);
|
||
|
||
React.useEffect(() => {
|
||
void loadBackendTask();
|
||
}, [loadBackendTask]);
|
||
|
||
React.useEffect(() => {
|
||
if (!isTaskLive || wsStatus === 'connected') return;
|
||
const id = window.setInterval(() => {
|
||
void loadBackendTask();
|
||
}, 4000);
|
||
return () => window.clearInterval(id);
|
||
}, [isTaskLive, loadBackendTask, wsStatus]);
|
||
```
|
||
|
||
- [ ] **Step 3: Build timeline inputs**
|
||
|
||
Replace the old `task = buildTaskRuntimeView(...)` derived view with backend-task-first derived values:
|
||
|
||
```tsx
|
||
const taskRunIds = useMemo(() => {
|
||
const ids = new Set<string>();
|
||
for (const run of backendTask?.process_runs ?? []) ids.add(run.run_id);
|
||
for (const runId of backendTask?.run_ids ?? []) ids.add(runId);
|
||
return ids;
|
||
}, [backendTask]);
|
||
|
||
const liveRuns = useMemo(
|
||
() => processRuns.filter((run) => taskRunIds.has(run.run_id) || run.metadata?.task_id === taskId),
|
||
[processRuns, taskId, taskRunIds]
|
||
);
|
||
|
||
const liveEvents = useMemo(
|
||
() => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId),
|
||
[processEvents, taskId, taskRunIds]
|
||
);
|
||
|
||
const liveArtifacts = useMemo(
|
||
() => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId),
|
||
[processArtifacts, taskId, taskRunIds]
|
||
);
|
||
|
||
const timelineCards = useMemo(
|
||
() =>
|
||
backendTask
|
||
? buildTaskTimelineCards({
|
||
task: backendTask,
|
||
processRuns: liveRuns.length > 0 ? liveRuns : backendTask.process_runs ?? [],
|
||
processEvents: liveEvents.length > 0 ? liveEvents : backendTask.process_events ?? [],
|
||
processArtifacts: liveArtifacts.length > 0 ? liveArtifacts : backendTask.process_artifacts ?? [],
|
||
})
|
||
: [],
|
||
[backendTask, liveArtifacts, liveEvents, liveRuns]
|
||
);
|
||
|
||
const activeLabel = [...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
|
||
const durationMs = backendTask
|
||
? new Date(backendTask.closed_at || backendTask.updated_at).getTime() - new Date(backendTask.created_at).getTime()
|
||
: null;
|
||
const feedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
||
```
|
||
|
||
- [ ] **Step 4: Replace the main render**
|
||
|
||
Replace the existing `if (!task && backendTask)` and runtime-detail render branches with:
|
||
|
||
```tsx
|
||
if (backendTask) {
|
||
const artifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask.process_artifacts ?? [];
|
||
const runs = liveRuns.length > 0 ? liveRuns : backendTask.process_runs ?? [];
|
||
const feedbackItems = backendTask.feedback || [];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} />
|
||
|
||
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||
<div className="space-y-4">
|
||
{actionError ? (
|
||
<Card className="border-destructive">
|
||
<CardContent className="flex items-center gap-2 p-4 text-sm text-destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
{actionError}
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
|
||
<TaskTimeline cards={timelineCards} isLive={isTaskLive && wsStatus === 'connected'} />
|
||
|
||
<TaskAcceptanceCard
|
||
sessionId={backendTask.session_id}
|
||
runId={feedbackRunId}
|
||
taskStatus={backendTask.status}
|
||
feedbackItems={feedbackItems as TaskFeedbackItem[]}
|
||
actionBusy={actionBusy}
|
||
revision={revision}
|
||
onRevisionChange={setRevision}
|
||
onSubmit={(feedbackType: TaskFeedbackType, comment?: string) =>
|
||
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||
await submitChatFeedback({
|
||
sessionId: backendTask.session_id,
|
||
runId: feedbackRunId!,
|
||
feedbackType,
|
||
comment,
|
||
});
|
||
setRevision('');
|
||
await loadBackendTask();
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<TaskSideRail task={backendTask} runs={runs} artifacts={artifacts} cards={timelineCards} />
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Keep the existing "task not found" branch, but remove references to the old `task` runtime view.
|
||
|
||
- [ ] **Step 5: Keep delete and feedback helpers**
|
||
|
||
Keep these helper functions at the bottom of the page:
|
||
|
||
```tsx
|
||
function pickFeedbackRunId(task: BackendTask): string | null {
|
||
const runIds = task.run_ids.filter(Boolean);
|
||
if (runIds.length > 0) return runIds[runIds.length - 1];
|
||
const runs = task.runs ?? [];
|
||
if (runs.length > 0) return runs[runs.length - 1].run_id;
|
||
return null;
|
||
}
|
||
```
|
||
|
||
Remove the old local `TaskFeedbackPanel`, `FeedbackButton`, `BackendExecutionStages`, `BackendProcessRun`, `BackendRunConversation`, `Metric`, `humanFeedback`, `humanFinishReason`, `feedbackForRun`, and `latestFeedback` functions after their behavior has been moved into task-detail components or dropped from V1.
|
||
|
||
- [ ] **Step 6: Typecheck**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd app-instance/frontend
|
||
npm run typecheck
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app-instance/frontend/app/'(app)'/tasks/'[taskId]'/page.tsx
|
||
git commit -m "feat: make task detail timeline first"
|
||
```
|
||
|
||
## Task 5: End-to-End Verification and Regression Coverage
|
||
|
||
**Files:**
|
||
- Modify: `app-instance/frontend/lib/task-timeline.test.ts`
|
||
- Modify: `app-instance/frontend/lib/store.test.ts` if live event merge behavior regresses
|
||
|
||
- [ ] **Step 1: Add a live event merge regression test if needed**
|
||
|
||
If the task page cannot see live events after loading persisted task detail, add this test to `app-instance/frontend/lib/store.test.ts`:
|
||
|
||
```ts
|
||
it('keeps live task events after persisted session projection is merged', () => {
|
||
const store = useChatStore.getState();
|
||
store.setSessionId('web:default');
|
||
store.ingestProcessEvent({
|
||
type: 'process_run_progress',
|
||
session_id: 'web:default',
|
||
run_id: 'run-live',
|
||
parent_run_id: null,
|
||
actor_type: 'agent',
|
||
actor_id: 'main-agent',
|
||
actor_name: 'Main Agent',
|
||
text: '正在调用工具',
|
||
metadata: { task_id: 'task-live', timeline_type: 'tool_call' },
|
||
created_at: '2026-05-26T10:00:00.000Z',
|
||
});
|
||
|
||
store.setSessionProcess('web:default', {
|
||
runs: [],
|
||
events: [],
|
||
artifacts: [],
|
||
});
|
||
|
||
expect(useChatStore.getState().processEvents.some((event) => event.run_id === 'run-live')).toBe(true);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run frontend unit tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd app-instance/frontend
|
||
npm test -- task-timeline.test.ts store.test.ts task-runtime.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 3: Run frontend typecheck**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd app-instance/frontend
|
||
npm run typecheck
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Run backend projection tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd app-instance/backend
|
||
uv run pytest tests/unit/test_process_projection.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Run a focused manual task detail check**
|
||
|
||
Start the frontend and backend using the repository's normal local workflow. Then:
|
||
|
||
1. Create a task from chat that uses at least one tool.
|
||
2. Open `/tasks/<task_id>`.
|
||
3. Confirm cards appear before the final answer.
|
||
4. Confirm skill, tool, agent/team, artifact, result, and acceptance cards render.
|
||
5. Disconnect WebSocket or simulate disconnected state and confirm polling refreshes active tasks.
|
||
6. Submit "Needs revision" and confirm the same timeline receives new attempt cards.
|
||
|
||
- [ ] **Step 6: Commit verification fixes**
|
||
|
||
If Task 5 changed tests or store behavior, commit:
|
||
|
||
```bash
|
||
git add app-instance/frontend/lib/task-timeline.test.ts app-instance/frontend/lib/store.test.ts app-instance/frontend/lib/store.ts
|
||
git commit -m "test: cover task detail live timeline updates"
|
||
```
|
||
|
||
If no files changed, do not create an empty commit.
|
||
|
||
## Self-Review
|
||
|
||
- Spec coverage:
|
||
- Persistent header: Task 3 and Task 4.
|
||
- Live chronological card feed: Task 1, Task 3, and Task 4.
|
||
- Skill cards: Task 1 and Task 2.
|
||
- Tool call and result cards: Task 1 and Task 3.
|
||
- Agent team and sub-agent progress cards: Task 1, Task 2, and Task 3.
|
||
- Artifact cards: Task 1 and Task 3.
|
||
- Final result and acceptance card: Task 1, Task 3, and Task 4.
|
||
- WebSocket-first updates with polling fallback: Task 4 and Task 5.
|
||
- Collapsed raw details: Task 3.
|
||
- Placeholder scan: no unfinished placeholder markers or unspecified edge handling remains.
|
||
- Type consistency:
|
||
- `TaskTimelineCard` type is defined in `types/index.ts`.
|
||
- `buildTaskTimelineCards` consumes `BackendTask`, `ProcessRun`, `ProcessEvent`, and `ProcessArtifact`.
|
||
- Page imports task detail components from `components/task-detail/index.ts`.
|
||
- Feedback types are exported from `TaskAcceptanceCard`.
|
||
|
||
## Execution Handoff
|
||
|
||
Plan complete and saved to `docs/superpowers/plans/2026-05-26-task-detail-live-execution.md`. Two execution options:
|
||
|
||
1. **Subagent-Driven (recommended)** - Dispatch a fresh subagent per task, review between tasks, fast iteration.
|
||
2. **Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints.
|
||
|
||
Which approach?
|