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

54 KiB
Raw Blame History

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:

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_planned projection

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.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:

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:

  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:

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?