feat: add task detail timeline components
This commit is contained in:
@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { CheckCircle2, Loader2, RefreshCw, ThumbsUp, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||||
|
|
||||||
|
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>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||||
|
|
||||||
|
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||||
|
return RUNTIME_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
||||||
|
if (!runId) return null;
|
||||||
|
return [...items].reverse().find((item) => String(item.run_id || '') === runId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
||||||
|
return [...items].reverse()[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedbackKind(item: TaskFeedbackItem): string {
|
||||||
|
return String(item.acceptance_type || item.feedback_type || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 FeedbackButton({
|
||||||
|
type,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
actionBusy,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
type: TaskFeedbackType;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
actionBusy: string | null;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const isBusy = actionBusy === type || 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 trimmedComment = comment.trim();
|
||||||
|
|
||||||
|
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||||||
|
if (!runId || !canSubmit) return;
|
||||||
|
void onSubmit(feedbackType, nextComment);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
|
||||||
|
{isRuntimeStatus(taskStatus) ? (
|
||||||
|
<TaskRuntimeStatusBadge status={taskStatus} />
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[11px]">
|
||||||
|
{taskStatus}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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(feedbackKind(recordedFeedback), locale)}
|
||||||
|
</div>
|
||||||
|
{recordedFeedback.comment ? <p className="mt-2 whitespace-pre-wrap 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', trimmedComment || undefined)}
|
||||||
|
/>
|
||||||
|
<FeedbackButton
|
||||||
|
type="revise"
|
||||||
|
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
||||||
|
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
||||||
|
actionBusy={actionBusy}
|
||||||
|
disabled={!canSubmit || !trimmedComment}
|
||||||
|
onClick={() => submit('revise', trimmedComment)}
|
||||||
|
/>
|
||||||
|
<FeedbackButton
|
||||||
|
type="abandon"
|
||||||
|
icon={<XCircle className="mr-2 h-4 w-4" />}
|
||||||
|
label={pickAppText(locale, '放弃', 'Abandon')}
|
||||||
|
actionBusy={actionBusy}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={() => submit('abandon', trimmedComment || 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||||
|
import type { BackendTask } from '@/types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
task: BackendTask;
|
||||||
|
activeLabel: string;
|
||||||
|
durationMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||||
|
|
||||||
|
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||||
|
return RUNTIME_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||||
|
const map: Record<string, [string, string]> = {
|
||||||
|
open: ['已创建', 'Open'],
|
||||||
|
running: ['执行中', 'Running'],
|
||||||
|
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||||
|
needs_revision: ['需要修改', 'Needs revision'],
|
||||||
|
closed: ['已完成', 'Closed'],
|
||||||
|
abandoned: ['已放弃', 'Abandoned'],
|
||||||
|
};
|
||||||
|
const item = map[status];
|
||||||
|
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="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 flex-wrap items-center gap-2">
|
||||||
|
{isRuntimeStatus(task.status) ? (
|
||||||
|
<TaskRuntimeStatusBadge status={task.status} />
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[11px]">
|
||||||
|
{humanTaskStatus(task.status, locale)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-xl font-semibold leading-tight">{title}</h1>
|
||||||
|
{task.description && task.description !== title ? (
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{task.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(durationMs, locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
app-instance/frontend/components/task-detail/TaskSideRail.tsx
Normal file
193
app-instance/frontend/components/task-detail/TaskSideRail.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AlertTriangle, Bot, Download, ExternalLink, FileText, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
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 { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||||
|
import type { BackendTask, ProcessArtifact, ProcessRun, TaskTimelineCard } from '@/types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
task: BackendTask;
|
||||||
|
runs: ProcessRun[];
|
||||||
|
artifacts: ProcessArtifact[];
|
||||||
|
cards: TaskTimelineCard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_RUN_STATUSES = new Set<ProcessRun['status']>(['queued', 'running', 'waiting']);
|
||||||
|
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||||
|
|
||||||
|
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||||
|
return RUNTIME_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||||
|
const map: Record<string, [string, string]> = {
|
||||||
|
open: ['已创建', 'Open'],
|
||||||
|
running: ['执行中', 'Running'],
|
||||||
|
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||||
|
needs_revision: ['需要修改', 'Needs revision'],
|
||||||
|
closed: ['已完成', 'Closed'],
|
||||||
|
abandoned: ['已放弃', 'Abandoned'],
|
||||||
|
};
|
||||||
|
const item = map[status];
|
||||||
|
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTime(value: string): number {
|
||||||
|
const parsed = new Date(value).getTime();
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWarningOrError(card: TaskTimelineCard): boolean {
|
||||||
|
const severity = String(card.details?.severity || card.details?.level || '').toLowerCase();
|
||||||
|
return card.type === 'error' || card.status === 'error' || severity === 'warning' || severity === 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactHref(artifact: ProcessArtifact): string | null {
|
||||||
|
if (artifact.url) return artifact.url;
|
||||||
|
if (artifact.file_id) return getFileUrl(artifact.file_id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunRow({ run }: { run: ProcessRun }) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{run.title || run.actor_name}</div>
|
||||||
|
<div className="mt-1 truncate text-xs text-muted-foreground">{run.actor_name}</div>
|
||||||
|
</div>
|
||||||
|
<TaskRuntimeStatusBadge status={run.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(run.started_at, locale)}</div>
|
||||||
|
{run.summary ? <p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{run.summary}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
const activeRuns = runs.filter((run) => ACTIVE_RUN_STATUSES.has(run.status));
|
||||||
|
const childRuns = runs.filter((run) => Boolean(run.parent_run_id));
|
||||||
|
const latestAlert = cards.filter(isWarningOrError).sort((a, b) => toTime(b.createdAt) - toTime(a.createdAt))[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<Card className="rounded-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{pickAppText(locale, '任务状态', 'Task status')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
{isRuntimeStatus(task.status) ? (
|
||||||
|
<TaskRuntimeStatusBadge status={task.status} />
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[11px]">
|
||||||
|
{humanTaskStatus(task.status, locale)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{pickAppText(locale, '活跃运行', 'Active runs')}: <span className="font-medium text-foreground">{activeRuns.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{pickAppText(locale, '运行中', 'Active runs')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{activeRuns.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无活跃运行', 'No active runs')}</p>
|
||||||
|
) : (
|
||||||
|
activeRuns.map((run) => <RunRow key={run.run_id} run={run} />)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{latestAlert ? (
|
||||||
|
<Card className="rounded-md border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||||
|
{pickAppText(locale, '最新提醒', 'Latest alert')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">{latestAlert.title}</div>
|
||||||
|
{latestAlert.summary ? <p className="text-sm text-muted-foreground">{latestAlert.summary}</p> : null}
|
||||||
|
<div className="text-xs text-muted-foreground">{formatTaskRuntimeTime(latestAlert.createdAt, locale)}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card className="rounded-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{pickAppText(locale, 'Agent Team', 'Agent team')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{childRuns.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无子运行', 'No child runs')}</p>
|
||||||
|
) : (
|
||||||
|
childRuns.map((run) => <RunRow key={run.run_id} run={run} />)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{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) => {
|
||||||
|
const href = artifactHref(artifact);
|
||||||
|
return (
|
||||||
|
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{artifact.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{artifact.artifact_type}</div>
|
||||||
|
</div>
|
||||||
|
{href ? (
|
||||||
|
<Button asChild size="sm" variant="outline" className="shrink-0">
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
|
{artifact.url ? <ExternalLink className="mr-2 h-3.5 w-3.5" /> : <Download className="mr-2 h-3.5 w-3.5" />}
|
||||||
|
{artifact.url ? pickAppText(locale, '打开', 'Open') : pickAppText(locale, '下载', 'Download')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Activity } 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';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cards: TaskTimelineCardView[];
|
||||||
|
isLive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TaskTimeline({ cards, isLive }: Props) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-base font-semibold">{pickAppText(locale, '时间线', 'Timeline')}</h2>
|
||||||
|
{isLive ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
|
||||||
|
</span>
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
{pickAppText(locale, '实时更新', 'Live')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cards.length === 0 ? (
|
||||||
|
<Card className="rounded-md border-dashed">
|
||||||
|
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||||
|
{pickAppText(locale, 'Beaver 正在准备第一步。', 'Beaver is preparing the first step.')}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<TaskTimelineCard key={card.id} card={card} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRightCircle,
|
||||||
|
Bot,
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
|
FileText,
|
||||||
|
GitBranch,
|
||||||
|
ListChecks,
|
||||||
|
Sparkles,
|
||||||
|
TerminalSquare,
|
||||||
|
ThumbsUp,
|
||||||
|
Users,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||||
|
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
card: TaskTimelineCardView;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||||
|
|
||||||
|
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
|
||||||
|
return RUNTIME_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconForType(type: TaskTimelineCardType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'task_created':
|
||||||
|
return ClipboardList;
|
||||||
|
case 'plan':
|
||||||
|
return ListChecks;
|
||||||
|
case 'skill':
|
||||||
|
return Sparkles;
|
||||||
|
case 'tool_call':
|
||||||
|
return Wrench;
|
||||||
|
case 'tool_result':
|
||||||
|
return TerminalSquare;
|
||||||
|
case 'next_step':
|
||||||
|
return ArrowRightCircle;
|
||||||
|
case 'agent_team':
|
||||||
|
return Users;
|
||||||
|
case 'agent_progress':
|
||||||
|
return Bot;
|
||||||
|
case 'agent_handoff':
|
||||||
|
return GitBranch;
|
||||||
|
case 'artifact':
|
||||||
|
return FileText;
|
||||||
|
case 'error':
|
||||||
|
return AlertTriangle;
|
||||||
|
case 'result':
|
||||||
|
return CheckCircle2;
|
||||||
|
case 'acceptance':
|
||||||
|
return ThumbsUp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailsJson(details: Record<string, unknown>): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(details, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardTypeLabel(type: TaskTimelineCardType, locale: 'zh-CN' | 'en-US') {
|
||||||
|
const labels: Record<TaskTimelineCardType, [string, string]> = {
|
||||||
|
task_created: ['任务', 'Task'],
|
||||||
|
plan: ['计划', 'Plan'],
|
||||||
|
skill: ['Skill', 'Skill'],
|
||||||
|
tool_call: ['工具调用', 'Tool call'],
|
||||||
|
tool_result: ['工具结果', 'Tool result'],
|
||||||
|
next_step: ['下一步', 'Next step'],
|
||||||
|
agent_team: ['Agent Team', 'Agent team'],
|
||||||
|
agent_progress: ['Agent', 'Agent'],
|
||||||
|
agent_handoff: ['交接', 'Handoff'],
|
||||||
|
artifact: ['产物', 'Artifact'],
|
||||||
|
error: ['异常', 'Error'],
|
||||||
|
result: ['结果', 'Result'],
|
||||||
|
acceptance: ['验收', 'Acceptance'],
|
||||||
|
};
|
||||||
|
const label = labels[type];
|
||||||
|
return pickAppText(locale, label[0], label[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskTimelineCard({ card }: Props) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
const Icon = iconForType(card.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-md">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="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-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="truncate text-sm font-semibold">{card.title}</h3>
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-[11px]">
|
||||||
|
{cardTypeLabel(card.type, locale)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{card.actorName ? <span>{card.actorName}</span> : null}
|
||||||
|
<span>{formatTaskRuntimeTime(card.createdAt, locale)}</span>
|
||||||
|
{card.runId ? <span className="font-mono">{card.runId.slice(0, 8)}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{card.status ? (
|
||||||
|
isRuntimeStatus(card.status) ? (
|
||||||
|
<TaskRuntimeStatusBadge status={card.status} />
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[11px]">
|
||||||
|
{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}
|
||||||
|
|
||||||
|
{card.details ? (
|
||||||
|
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||||
|
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
||||||
|
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5 text-muted-foreground">
|
||||||
|
{detailsJson(card.details)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app-instance/frontend/components/task-detail/index.ts
Normal file
5
app-instance/frontend/components/task-detail/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { TaskAcceptanceCard, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||||
|
export { TaskLiveHeader } from './TaskLiveHeader';
|
||||||
|
export { TaskSideRail } from './TaskSideRail';
|
||||||
|
export { TaskTimeline } from './TaskTimeline';
|
||||||
|
export { TaskTimelineCard } from './TaskTimelineCard';
|
||||||
Reference in New Issue
Block a user