Files
beaver_project/docs/superpowers/plans/2026-05-26-task-detail-live-execution.md
steven_li 6e9e74d1ee feat(engine): 添加运行时上下文支持并重构工具迭代限制
添加 RuntimeContext 类用于捕获模型运行时的日期时间信息,
包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。

同时增加最大上下文消息数和工具迭代次数的配置选项,
将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。

BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。

- 添加 RuntimeContext 类和相关渲染方法
- 增加 max_context_messages 和 max_tool_iterations 配置
- 移除 ValidationService 相关代码
- 更新消息记录中的验证状态字段
- 添加原始工具调用检测和回退处理
2026-05-26 11:18:35 +08:00

1538 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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?