添加 RuntimeContext 类用于捕获模型运行时的日期时间信息, 包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。 同时增加最大上下文消息数和工具迭代次数的配置选项, 将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。 BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。 - 添加 RuntimeContext 类和相关渲染方法 - 增加 max_context_messages 和 max_tool_iterations 配置 - 移除 ValidationService 相关代码 - 更新消息记录中的验证状态字段 - 添加原始工具调用检测和回退处理
54 KiB
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, andProcessArtifactrecords into stable user-facing timeline cards.
- Converts
- 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 plusevents,runs,process_runs,process_events, andprocess_artifacts.AppRuntimeBridgealready ingests WebSocketprocess_*events intouseChatStore.useChatStore.setSessionProcess()can merge a persisted projection into the live process state.- The current
tasks/[taskId]/page.tsxhas useful acceptance and artifact UI, but the hierarchy is phase-group-first instead of timeline-first. - The current
SessionProcessProjectormaps task lifecycle events into generic process runs/events; this plan keeps that path but adds clearermetadata.timeline_typeand more explicit user-facingtext.
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:
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:
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:
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:
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:
cd app-instance/frontend
npm test -- task-timeline.test.ts
Expected: PASS.
- Step 6: Commit
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:
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:
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:
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_plannedprojection
Modify the task_execution_planned branch in app-instance/backend/beaver/services/process_service.py so the existing add_event(...) call uses:
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:
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:
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:
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:
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:
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:
cd app-instance/backend
uv run pytest tests/unit/test_process_projection.py -q
Expected: PASS.
- Step 7: Commit
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:
'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:
'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:
'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:
'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:
'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:
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:
cd app-instance/frontend
npm run typecheck
Expected: PASS.
- Step 7: Commit
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:
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:
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:
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:
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:
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:
cd app-instance/frontend
npm run typecheck
Expected: PASS.
- Step 7: Commit
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.tsif 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:
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:
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:
cd app-instance/frontend
npm run typecheck
Expected: PASS.
- Step 4: Run backend projection tests
Run:
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:
- Create a task from chat that uses at least one tool.
- Open
/tasks/<task_id>. - Confirm cards appear before the final answer.
- Confirm skill, tool, agent/team, artifact, result, and acceptance cards render.
- Disconnect WebSocket or simulate disconnected state and confirm polling refreshes active tasks.
- 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:
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:
TaskTimelineCardtype is defined intypes/index.ts.buildTaskTimelineCardsconsumesBackendTask,ProcessRun,ProcessEvent, andProcessArtifact.- 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:
- Subagent-Driven (recommended) - Dispatch a fresh subagent per task, review between tasks, fast iteration.
- Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?