# Task Detail Live Execution Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a timeline-first task detail page where ordinary users can watch Beaver select skills, call tools, coordinate agent teams, produce artifacts, and reach acceptance in real time. **Architecture:** Keep backend task detail as the durable source of truth, enrich process projection with clearer user-facing event metadata, then normalize backend and live WebSocket process events into a frontend `TaskTimelineCard` model. Refactor the task detail route into focused components: persistent header, chronological card feed, side rail, artifact list, and final acceptance surface. **Tech Stack:** FastAPI/Python backend, Next.js 13 app router, React, TypeScript, Zustand store, Vitest, pytest. --- ## File Structure - Create: `app-instance/frontend/lib/task-timeline.ts` - Converts `BackendTask`, `ProcessRun`, `ProcessEvent`, and `ProcessArtifact` records into stable user-facing timeline cards. - Create: `app-instance/frontend/lib/task-timeline.test.ts` - Covers skill cards, tool cards, agent team cards, artifact cards, result cards, acceptance cards, ordering, and fallback behavior. - Create: `app-instance/frontend/components/task-detail/TaskLiveHeader.tsx` - Sticky task status header. - Create: `app-instance/frontend/components/task-detail/TaskTimeline.tsx` - Main timeline renderer. - Create: `app-instance/frontend/components/task-detail/TaskTimelineCard.tsx` - Card renderer for each timeline card type. - Create: `app-instance/frontend/components/task-detail/TaskSideRail.tsx` - Agent team map, current activity, artifacts, warnings, and acceptance state. - Create: `app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx` - Accept, revise, abandon UI extracted from the current task detail page and adapted for final-result timeline cards. - Create: `app-instance/frontend/components/task-detail/index.ts` - Barrel export for task detail components. - Modify: `app-instance/frontend/types/index.ts` - Add task timeline card types and optional explicit process event kinds. - Modify: `app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx` - Replace phase-group-first layout with timeline-first layout. - Modify: `app-instance/backend/beaver/services/process_service.py` - Emit clearer event kinds and user-facing metadata from persisted task lifecycle records. - Modify: `app-instance/backend/tests/unit/test_process_projection.py` - Add coverage for skill selection, team, result-ready, and acceptance event projection. ## Existing Context - `GET /api/tasks/{task_id}` already returns task metadata plus `events`, `runs`, `process_runs`, `process_events`, and `process_artifacts`. - `AppRuntimeBridge` already ingests WebSocket `process_*` events into `useChatStore`. - `useChatStore.setSessionProcess()` can merge a persisted projection into the live process state. - The current `tasks/[taskId]/page.tsx` has useful acceptance and artifact UI, but the hierarchy is phase-group-first instead of timeline-first. - The current `SessionProcessProjector` maps task lifecycle events into generic process runs/events; this plan keeps that path but adds clearer `metadata.timeline_type` and more explicit user-facing `text`. ## Task 1: Timeline View Model **Files:** - Create: `app-instance/frontend/lib/task-timeline.ts` - Create: `app-instance/frontend/lib/task-timeline.test.ts` - Modify: `app-instance/frontend/types/index.ts` - [ ] **Step 1: Add the failing timeline tests** Create `app-instance/frontend/lib/task-timeline.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { buildTaskTimelineCards } from '@/lib/task-timeline'; import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types'; const baseTask: BackendTask = { task_id: 'task-1', session_id: 'web:default', description: '整理后端产品能力展示', short_title: '后端展示', goal: '整理后端产品能力展示', constraints: [], priority: 0, status: 'running', creator: 'main-agent', created_at: '2026-05-26T10:00:00.000Z', updated_at: '2026-05-26T10:03:00.000Z', run_ids: ['run-main'], skill_names: ['backend-review'], feedback: [], metadata: { short_title: '后端展示' }, }; describe('buildTaskTimelineCards', () => { it('builds ordered user-facing cards from task process data', () => { const processRuns: ProcessRun[] = [ { run_id: 'task:task-1:attempt:1', parent_run_id: null, session_id: 'web:default', actor_type: 'system', actor_id: 'task', actor_name: 'Task Planner', title: 'team plan: research_then_summarize', source: 'task_mode', status: 'running', started_at: '2026-05-26T10:00:01.000Z', summary: '分为调研、复核、汇总三个阶段', metadata: { task_id: 'task-1', plan_mode: 'team', strategy: 'research_then_summarize', selected_skill_names: ['backend-review'], }, }, { run_id: 'run-research', parent_run_id: 'task:task-1:attempt:1', session_id: 'web:default', actor_type: 'agent', actor_id: 'research', actor_name: 'Research Agent', title: '阅读后端文档', source: 'task_team', status: 'done', started_at: '2026-05-26T10:00:20.000Z', finished_at: '2026-05-26T10:02:00.000Z', summary: '已完成资料阅读', metadata: { task_id: 'task-1', node_id: 'research' }, }, ]; const processEvents: ProcessEvent[] = [ { event_id: 'evt-plan', run_id: 'task:task-1:attempt:1', parent_run_id: null, kind: 'task_planned', actor_type: 'system', actor_id: 'task', actor_name: 'Task Planner', text: 'Beaver planned a team execution.', status: 'running', created_at: '2026-05-26T10:00:01.000Z', metadata: { task_id: 'task-1', timeline_type: 'plan', plan_mode: 'team', strategy: 'research_then_summarize', selected_skill_names: ['backend-review'], }, }, { event_id: 'evt-skill', run_id: 'task:task-1:attempt:1', parent_run_id: null, kind: 'skill_selected', actor_type: 'system', actor_id: 'skill-selector', actor_name: 'Skill Selector', text: 'Selected backend-review to guide backend capability analysis.', status: 'done', created_at: '2026-05-26T10:00:02.000Z', metadata: { task_id: 'task-1', timeline_type: 'skill', skill_names: ['backend-review'], reason: 'Matches backend review and product roadshow documents.', }, }, { event_id: 'evt-tool-start', run_id: 'run-research', parent_run_id: 'task:task-1:attempt:1', kind: 'tool_call_started', actor_type: 'tool', actor_id: 'filesystem', actor_name: 'Filesystem', text: 'Reading backend product documents.', status: 'running', created_at: '2026-05-26T10:00:30.000Z', metadata: { task_id: 'task-1', timeline_type: 'tool_call', tool_name: 'filesystem', action: 'read_files', }, }, { event_id: 'evt-tool-finish', run_id: 'run-research', parent_run_id: 'task:task-1:attempt:1', kind: 'tool_call_finished', actor_type: 'tool', actor_id: 'filesystem', actor_name: 'Filesystem', text: 'Read 2 backend review documents.', status: 'done', created_at: '2026-05-26T10:00:40.000Z', metadata: { task_id: 'task-1', timeline_type: 'tool_result', tool_name: 'filesystem', result_summary: '2 documents read successfully.', }, }, ]; const processArtifacts: ProcessArtifact[] = [ { artifact_id: 'artifact-summary', run_id: 'run-research', actor_type: 'agent', actor_id: 'research', actor_name: 'Research Agent', title: '后端能力摘要', artifact_type: 'markdown', content: '# 后端能力摘要', created_at: '2026-05-26T10:02:10.000Z', metadata: { task_id: 'task-1' }, }, ]; const cards = buildTaskTimelineCards({ task: baseTask, processRuns, processEvents, processArtifacts, }); expect(cards.map((card) => card.type)).toEqual([ 'task_created', 'plan', 'skill', 'tool_call', 'tool_result', 'agent_progress', 'artifact', ]); expect(cards[1].title).toBe('执行计划'); expect(cards[2].title).toBe('选择 Skill'); expect(cards[4].summary).toBe('2 documents read successfully.'); expect(cards[6].relatedArtifactIds).toEqual(['artifact-summary']); }); it('adds result and acceptance cards from final task state and feedback', () => { const cards = buildTaskTimelineCards({ task: { ...baseTask, status: 'closed', updated_at: '2026-05-26T10:05:00.000Z', feedback: [ { acceptance_type: 'accept', comment: '可以', created_at: '2026-05-26T10:05:00.000Z', run_id: 'run-main', }, ], }, processRuns: [], processEvents: [], processArtifacts: [], }); expect(cards.at(-2)?.type).toBe('result'); expect(cards.at(-1)?.type).toBe('acceptance'); expect(cards.at(-1)?.summary).toContain('可以'); }); }); ``` - [ ] **Step 2: Run the failing test** Run: ```bash cd app-instance/frontend npm test -- task-timeline.test.ts ``` Expected: FAIL because `@/lib/task-timeline` does not exist. - [ ] **Step 3: Add timeline types** Modify `app-instance/frontend/types/index.ts` after the existing `ProcessArtifact` interface: ```ts export type TaskTimelineCardType = | 'task_created' | 'plan' | 'skill' | 'tool_call' | 'tool_result' | 'next_step' | 'agent_team' | 'agent_progress' | 'agent_handoff' | 'artifact' | 'error' | 'result' | 'acceptance'; export interface TaskTimelineCard { id: string; taskId: string; runId?: string | null; parentRunId?: string | null; type: TaskTimelineCardType; title: string; summary?: string; actorName?: string; status?: string; createdAt: string; relatedArtifactIds?: string[]; details?: Record; } ``` - [ ] **Step 4: Implement `buildTaskTimelineCards`** Create `app-instance/frontend/lib/task-timeline.ts`: ```ts import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun, TaskTimelineCard, TaskTimelineCardType, } from '@/types'; type BuildTaskTimelineCardsInput = { task: BackendTask; processRuns?: ProcessRun[]; processEvents?: ProcessEvent[]; processArtifacts?: ProcessArtifact[]; }; const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']); function asString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } function asStringArray(value: unknown): string[] { return Array.isArray(value) ? value.map((item) => String(item || '').trim()).filter(Boolean) : []; } function eventType(event: ProcessEvent): TaskTimelineCardType | null { const explicit = asString(event.metadata?.timeline_type); if (explicit) return explicit as TaskTimelineCardType; if (event.kind === 'task_planned' || event.kind === 'run_started') return 'plan'; if (event.kind === 'skill_selected') return 'skill'; if (event.kind === 'tool_call_started') return 'tool_call'; if (event.kind === 'tool_call_finished') return 'tool_result'; if (event.kind === 'agent_team_created') return 'agent_team'; if (event.kind === 'agent_handoff') return 'agent_handoff'; if (event.kind === 'run_progress' || event.kind === 'run_finished') return 'agent_progress'; if (event.kind === 'task_result_ready') return 'result'; if (event.kind === 'task_acceptance_recorded') return 'acceptance'; if (event.kind === 'task_error' || event.status === 'error') return 'error'; return null; } function titleForCard(type: TaskTimelineCardType, event?: ProcessEvent): string { if (type === 'task_created') return '任务已创建'; if (type === 'plan') return '执行计划'; if (type === 'skill') return '选择 Skill'; if (type === 'tool_call') return `调用工具${event?.actor_name ? `:${event.actor_name}` : ''}`; if (type === 'tool_result') return `工具结果${event?.actor_name ? `:${event.actor_name}` : ''}`; if (type === 'next_step') return '下一步'; if (type === 'agent_team') return '启动 Agent Team'; if (type === 'agent_progress') return event?.actor_name || 'Agent 进展'; if (type === 'agent_handoff') return 'Agent 交接'; if (type === 'artifact') return '生成产物'; if (type === 'error') return '执行遇到问题'; if (type === 'result') return '本轮结果'; if (type === 'acceptance') return '任务验收'; return event?.actor_name || '任务事件'; } function eventSummary(event: ProcessEvent): string | undefined { const metadataSummary = asString(event.metadata?.result_summary) || asString(event.metadata?.reason) || asString(event.metadata?.action_summary); return metadataSummary || event.text || undefined; } function buildTaskCreatedCard(task: BackendTask): TaskTimelineCard { return { id: `task-created:${task.task_id}`, taskId: task.task_id, type: 'task_created', title: '任务已创建', summary: task.goal || task.description || task.short_title || task.task_id, status: task.status, createdAt: task.created_at, details: { session_id: task.session_id, creator: task.creator, constraints: task.constraints, }, }; } function buildEventCard(task: BackendTask, event: ProcessEvent): TaskTimelineCard | null { const type = eventType(event); if (!type) return null; const skillNames = asStringArray(event.metadata?.skill_names).concat(asStringArray(event.metadata?.selected_skill_names)); return { id: event.event_id, taskId: task.task_id, runId: event.run_id, parentRunId: event.parent_run_id ?? null, type, title: titleForCard(type, event), summary: eventSummary(event), actorName: event.actor_name, status: event.status || undefined, createdAt: event.created_at, details: { ...(event.metadata ?? {}), skill_names: skillNames.length > 0 ? Array.from(new Set(skillNames)) : undefined, }, }; } function buildRunCards(task: BackendTask, processRuns: ProcessRun[], existingCardIds: Set): TaskTimelineCard[] { return processRuns .filter((run) => run.parent_run_id && !existingCardIds.has(`run:${run.run_id}`)) .map((run) => ({ id: `run:${run.run_id}`, taskId: task.task_id, runId: run.run_id, parentRunId: run.parent_run_id ?? null, type: 'agent_progress' as const, title: run.title || run.actor_name, summary: run.summary || undefined, actorName: run.actor_name, status: run.status, createdAt: run.finished_at || run.started_at, details: run.metadata, })); } function buildArtifactCards(task: BackendTask, artifacts: ProcessArtifact[]): TaskTimelineCard[] { return artifacts.map((artifact) => ({ id: `artifact:${artifact.artifact_id}`, taskId: task.task_id, runId: artifact.run_id, type: 'artifact' as const, title: artifact.title || '生成产物', summary: artifact.content ? artifact.content.slice(0, 180) : undefined, actorName: artifact.actor_name, status: 'done', createdAt: artifact.created_at, relatedArtifactIds: [artifact.artifact_id], details: { artifact_type: artifact.artifact_type, file_id: artifact.file_id, url: artifact.url, metadata: artifact.metadata, }, })); } function buildResultCard(task: BackendTask, artifacts: ProcessArtifact[]): TaskTimelineCard | null { if (!TERMINAL_TASK_STATUSES.has(task.status) && task.status !== 'awaiting_acceptance') return null; return { id: `result:${task.task_id}:${task.updated_at}`, taskId: task.task_id, runId: task.run_ids.at(-1) ?? null, type: 'result', title: '本轮结果', summary: task.status === 'awaiting_acceptance' ? '结果已准备好,请验收。' : '任务已结束。', status: task.status, createdAt: task.updated_at, relatedArtifactIds: artifacts.map((artifact) => artifact.artifact_id), details: { validation_result: task.validation_result, close_reason: task.close_reason, }, }; } function buildAcceptanceCards(task: BackendTask): TaskTimelineCard[] { return (task.feedback || []).map((item, index) => { const acceptanceType = asString(item.acceptance_type) || asString(item.feedback_type) || 'feedback'; const comment = asString(item.comment); return { id: `acceptance:${task.task_id}:${String(item.created_at || index)}`, taskId: task.task_id, runId: asString(item.run_id), type: 'acceptance' as const, title: '任务验收', summary: comment ? `${acceptanceType}: ${comment}` : acceptanceType, actorName: 'User Acceptance', status: 'done', createdAt: asString(item.created_at) || task.updated_at, details: { ...item }, }; }); } function compareCards(a: TaskTimelineCard, b: TaskTimelineCard): number { return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); } export function buildTaskTimelineCards({ task, processRuns = [], processEvents = [], processArtifacts = [], }: BuildTaskTimelineCardsInput): TaskTimelineCard[] { const eventCards = processEvents .map((event) => buildEventCard(task, event)) .filter((card): card is TaskTimelineCard => Boolean(card)); const eventCardIds = new Set(eventCards.map((card) => card.id)); const resultCard = buildResultCard(task, processArtifacts); return [ buildTaskCreatedCard(task), ...eventCards, ...buildRunCards(task, processRuns, eventCardIds), ...buildArtifactCards(task, processArtifacts), ...(resultCard ? [resultCard] : []), ...buildAcceptanceCards(task), ].sort(compareCards); } ``` - [ ] **Step 5: Run the timeline tests** Run: ```bash cd app-instance/frontend npm test -- task-timeline.test.ts ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add app-instance/frontend/types/index.ts app-instance/frontend/lib/task-timeline.ts app-instance/frontend/lib/task-timeline.test.ts git commit -m "feat: add task timeline model" ``` ## Task 2: Backend Projection Event Enrichment **Files:** - Modify: `app-instance/backend/beaver/services/process_service.py` - Modify: `app-instance/backend/tests/unit/test_process_projection.py` - [ ] **Step 1: Add failing projection tests** Append these tests to `app-instance/backend/tests/unit/test_process_projection.py`. If the file already has fixtures for session records and run records, reuse the local helper style but keep the assertions below: ```py def test_task_projection_emits_timeline_metadata_for_plan_and_skills(session_manager, run_memory_store): projector = SessionProcessProjector(session_manager, run_memory_store) projection = projector.project("web:default") plan_events = [event for event in projection["events"] if event["kind"] == "task_planned"] assert plan_events assert plan_events[0]["metadata"]["timeline_type"] == "plan" assert "selected_skill_names" in plan_events[0]["metadata"] skill_events = [event for event in projection["events"] if event["kind"] == "skill_selected"] assert skill_events assert skill_events[0]["metadata"]["timeline_type"] == "skill" assert skill_events[0]["metadata"]["skill_names"] def test_task_projection_emits_result_ready_and_acceptance_cards(session_manager, run_memory_store): projector = SessionProcessProjector(session_manager, run_memory_store) projection = projector.project("web:default") result_events = [event for event in projection["events"] if event["kind"] == "task_result_ready"] assert result_events assert result_events[0]["metadata"]["timeline_type"] == "result" assert result_events[0]["status"] == "done" acceptance_events = [event for event in projection["events"] if event["kind"] == "task_acceptance_recorded"] assert acceptance_events assert acceptance_events[0]["metadata"]["timeline_type"] == "acceptance" ``` If `test_process_projection.py` does not expose `session_manager` and `run_memory_store` fixtures, create local fake classes in that file: ```py from dataclasses import dataclass from typing import Any from beaver.services.process_service import SessionProcessProjector @dataclass class FakeEventRecord: message_id: int timestamp: float run_id: str | None event_type: str event_payload: dict[str, Any] @dataclass class FakeRunRecord: run_id: str session_id: str started_at: str ended_at: str | None success: bool task_text: str class FakeSessionManager: def __init__(self, records: list[FakeEventRecord]) -> None: self.records = records def get_event_records(self, session_id: str) -> list[FakeEventRecord]: return self.records class FakeRunMemoryStore: def __init__(self, records: list[FakeRunRecord]) -> None: self.records = records def list_runs(self) -> list[FakeRunRecord]: return self.records ``` - [ ] **Step 2: Run the failing backend tests** Run: ```bash cd app-instance/backend uv run pytest tests/unit/test_process_projection.py -q ``` Expected: FAIL because projection events are still generic `run_started` / `run_status` and do not include `timeline_type`. - [ ] **Step 3: Enrich `task_execution_planned` projection** Modify the `task_execution_planned` branch in `app-instance/backend/beaver/services/process_service.py` so the existing `add_event(...)` call uses: ```py kind="task_planned", text=f"Beaver planned {payload.get('plan_mode', 'single')} execution via {strategy}. {payload.get('reason') or ''}".strip(), metadata={ **root["metadata"], "timeline_type": "plan", "user_summary": f"Beaver will use {payload.get('plan_mode', 'single')} execution for this task.", }, ``` Immediately after that `add_event(...)`, add a second event when skills are present: ```py selected_skill_names = [str(item) for item in payload.get("selected_skill_names") or [] if str(item).strip()] if selected_skill_names: add_event( event_id=_event_id(record, "skills"), run_id=root_run_id, kind="skill_selected", actor_type="system", actor_id="skill-selector", actor_name="Skill Selector", text=f"Selected skill guidance: {', '.join(selected_skill_names)}.", created_at=created_at, status="done", metadata={ "task_id": task_id, "attempt_index": attempt_index, "timeline_type": "skill", "skill_names": selected_skill_names, "reason": payload.get("reason") or "Selected from task planning context.", }, ) ``` - [ ] **Step 4: Enrich team and node events** In the `task_team_run_completed` / `task_team_run_failed` branch, change the team-level event to: ```py kind="agent_team_created", text=payload.get("error") or ("Agent team completed" if team_success else "Agent team completed with failed nodes"), metadata={ **dict(payload), "timeline_type": "agent_team", "team_run_ids": payload.get("team_run_ids") or [], }, ``` Change each node result event to use: ```py kind="agent_finished", metadata={ **dict(item), "task_id": task_id, "timeline_type": "agent_progress", }, ``` - [ ] **Step 5: Enrich result and acceptance events** In the `task_evidence_recorded` branch, change the event to: ```py kind="task_result_ready", text="The task result is ready for user acceptance.", metadata={ **dict(payload), "timeline_type": "result", }, ``` In the `task_acceptance_recorded` branch, change the event to: ```py kind="task_acceptance_recorded", text=f"User acceptance recorded: {acceptance_type or 'unknown'}.", metadata={ **dict(payload), "timeline_type": "acceptance", }, ``` - [ ] **Step 6: Run backend tests** Run: ```bash cd app-instance/backend uv run pytest tests/unit/test_process_projection.py -q ``` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add app-instance/backend/beaver/services/process_service.py app-instance/backend/tests/unit/test_process_projection.py git commit -m "feat: enrich task process timeline events" ``` ## Task 3: Task Detail Components **Files:** - Create: `app-instance/frontend/components/task-detail/TaskLiveHeader.tsx` - Create: `app-instance/frontend/components/task-detail/TaskTimelineCard.tsx` - Create: `app-instance/frontend/components/task-detail/TaskTimeline.tsx` - Create: `app-instance/frontend/components/task-detail/TaskSideRail.tsx` - Create: `app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx` - Create: `app-instance/frontend/components/task-detail/index.ts` - [ ] **Step 1: Create the acceptance component** Create `app-instance/frontend/components/task-detail/TaskAcceptanceCard.tsx`: ```tsx 'use client'; import React from 'react'; import { CheckCircle2, Loader2, RefreshCw, ThumbsUp, XCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Textarea } from '@/components/ui/textarea'; import { formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; export type TaskFeedbackType = 'accept' | 'revise' | 'abandon'; export type TaskFeedbackItem = { acceptance_type?: unknown; feedback_type?: unknown; comment?: unknown; created_at?: unknown; run_id?: unknown; }; type Props = { sessionId: string; runId: string | null; taskStatus: string; feedbackItems: TaskFeedbackItem[]; actionBusy: string | null; revision?: string; onRevisionChange?: (value: string) => void; onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise; }; 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 ( ); } 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 ( {pickAppText(locale, '任务验收', 'Task acceptance')} {recordedFeedback ? (
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(String(recordedFeedback.acceptance_type || recordedFeedback.feedback_type || ''), locale)}
{recordedFeedback.comment ?

{String(recordedFeedback.comment)}

: null} {recordedFeedback.created_at ?

{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}

: null}
) : isFinalized ? (
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
) : !runId ? (
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
) : null}
} label={pickAppText(locale, '接受', 'Accept')} actionBusy={actionBusy} disabled={!canSubmit} onClick={() => submit('accept', comment.trim() || undefined)} /> } label={pickAppText(locale, '需要修改', 'Needs revision')} actionBusy={actionBusy} disabled={!canSubmit || !comment.trim()} onClick={() => submit('revise', comment.trim())} /> } label={pickAppText(locale, '放弃', 'Abandon')} actionBusy={actionBusy} disabled={!canSubmit} onClick={() => submit('abandon', comment.trim() || undefined)} />