feat(awooop): add run detail action panel
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m7s
CD Pipeline / build-and-deploy (push) Successful in 3m27s
CD Pipeline / post-deploy-checks (push) Successful in 1m18s

This commit is contained in:
Your Name
2026-05-07 09:25:49 +08:00
parent c52ebfc042
commit beba668a4c
3 changed files with 183 additions and 0 deletions

View File

@@ -1587,6 +1587,40 @@
"count": "{count} items",
"empty": "No timeline records yet."
},
"action": {
"eyebrow": "Next Decision",
"approval": {
"title": "Waiting for human approval",
"detail": "AI is stopped at the human gate and has not resumed. Approve or reject from the approval page; every decision is written back to Run state and audit.",
"primary": "Open approval decision"
},
"manual": {
"title": "Manual handoff required",
"detail": "AI cannot safely close the loop, or execution has failed / timed out. Return to Run Monitor to compare same-project work and hand off to the SRE war room when needed.",
"primary": "Back to Run Monitor"
},
"completed": {
"title": "Completed, ready for audit review",
"detail": "The run has converged. Use the timeline to verify MCP calls, outbound messages, and cost records before writing back to KM / Playbook.",
"primary": "Back to Run Monitor"
},
"running": {
"title": "AI is processing",
"detail": "The run is still active and this page refreshes periodically. If it stays running for too long, check heartbeat, MCP latency, and worker state.",
"primary": "Back to Run Monitor"
},
"observe": {
"title": "Observing",
"detail": "The run has not reached a human gate or terminal state. Follow the timeline to verify inbound events, tool calls, and outbound messages.",
"primary": "Back to Run Monitor"
},
"evidence": {
"inbound": "Inbound",
"outbound": "Outbound",
"mcp": "MCP Calls",
"steps": "Steps"
}
},
"statuses": {
"blocked": "Blocked",
"cancelled": "Cancelled",

View File

@@ -1588,6 +1588,40 @@
"count": "{count} 筆",
"empty": "尚無時間線資料。"
},
"action": {
"eyebrow": "下一步判斷",
"approval": {
"title": "等待人工審批",
"detail": "AI 已停在人工閘門,尚未 resume。請從審批頁 approve 或 reject所有決策都會回寫 Run state 與 audit。",
"primary": "前往審批決策"
},
"manual": {
"title": "需人工接手",
"detail": "AI 無法安全閉環,或執行已失敗 / 超時。請回 Run 監控比對同專案任務,必要時交由 SRE 戰情室處置。",
"primary": "回 Run 監控"
},
"completed": {
"title": "已完成,等待稽核回看",
"detail": "Run 已收斂。請以時間線檢查 MCP、出站訊息與成本紀錄是否完整必要時再回寫 KM / Playbook。",
"primary": "回 Run 監控"
},
"running": {
"title": "AI 正在處理",
"detail": "Run 尚未結束,頁面會定期刷新。若長時間停留在 running請檢查 heartbeat、MCP latency 與 worker 狀態。",
"primary": "回 Run 監控"
},
"observe": {
"title": "觀察中",
"detail": "目前尚未進入人工閘門或終止狀態。請沿時間線確認入站事件、工具呼叫與出站訊息是否有缺口。",
"primary": "回 Run 監控"
},
"evidence": {
"inbound": "入站事件",
"outbound": "出站訊息",
"mcp": "MCP 呼叫",
"steps": "Steps"
}
},
"statuses": {
"blocked": "已阻擋",
"cancelled": "已取消",

View File

@@ -12,12 +12,17 @@ import {
Activity,
AlertCircle,
ArrowLeft,
ArrowRight,
CheckCircle2,
Clock,
MessageSquareText,
RefreshCw,
Route,
SearchCheck,
Send,
ShieldAlert,
ShieldCheck,
TriangleAlert,
Wrench,
} from "lucide-react";
@@ -102,6 +107,8 @@ const STATUS_TRANSLATION_KEYS: Record<string, string> = {
waiting_approval: "statuses.waitingApproval",
};
const MANUAL_STATES = new Set(["blocked", "cancelled", "error", "failed", "timeout"]);
function formatTime(value: string | null | undefined, locale: string, emptyLabel: string) {
if (!value) return emptyLabel;
return new Date(value).toLocaleString(locale === "zh-TW" ? "zh-TW" : "en-US", {
@@ -125,6 +132,112 @@ function itemIcon(kind: string) {
return Route;
}
function RunActionPanel({
run,
counts,
emptyLabel,
}: {
run?: RunDetail;
counts?: RunDetailResponse["counts"];
emptyLabel: string;
}) {
const t = useTranslations("awooop.runDetail.action");
if (!run) {
return (
<section className="border border-[#e0ddd4] bg-white p-4">
<div className="h-5 w-40 animate-pulse bg-[#f2efe6]" />
<div className="mt-3 h-4 w-full max-w-xl animate-pulse bg-[#f2efe6]" />
</section>
);
}
const isApproval = run.state === "waiting_approval";
const isManual = MANUAL_STATES.has(run.state);
const isCompleted = run.state === "completed" || run.state === "success";
const actionKey = isApproval
? "approval"
: isManual
? "manual"
: isCompleted
? "completed"
: run.state === "running"
? "running"
: "observe";
const Icon = isApproval
? ShieldCheck
: isManual
? TriangleAlert
: isCompleted
? CheckCircle2
: run.state === "running"
? Activity
: SearchCheck;
const toneClass = isApproval
? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
: isManual
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
: isCompleted
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]";
const projectParam = encodeURIComponent(run.project_id);
const primaryHref = isApproval
? (`/awooop/approvals/${run.run_id}?project_id=${projectParam}` as never)
: (`/awooop/runs?project_id=${projectParam}` as never);
const evidence = [
{ label: t("evidence.inbound"), value: counts?.inbound_events ?? 0 },
{ label: t("evidence.outbound"), value: counts?.outbound_messages ?? 0 },
{ label: t("evidence.mcp"), value: counts?.mcp_calls ?? 0 },
{ label: t("evidence.steps"), value: counts?.steps ?? 0 },
];
return (
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] lg:grid-cols-[1.1fr_0.9fr]">
<div className="bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn("flex h-8 w-8 items-center justify-center border", toneClass)}>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<div>
<p className="text-xs font-semibold text-[#77736a]">{t("eyebrow")}</p>
<h3 className="text-base font-semibold text-[#141413]">
{t(`${actionKey}.title` as never)}
</h3>
</div>
</div>
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#5f5b52]">
{t(`${actionKey}.detail` as never)}
</p>
</div>
<Link
href={primaryHref}
className={cn(
"inline-flex shrink-0 items-center gap-2 border px-3 py-2 text-sm font-semibold",
toneClass
)}
>
{t(`${actionKey}.primary` as never)}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
</div>
<div className="grid grid-cols-2 gap-px bg-[#e0ddd4] md:grid-cols-4 lg:grid-cols-2 xl:grid-cols-4">
{evidence.map((item) => (
<div key={item.label} className="bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{item.value ?? emptyLabel}
</p>
</div>
))}
</div>
</section>
);
}
function DetailField({
label,
value,
@@ -322,6 +435,8 @@ export default function RunDetailPage({
</div>
</section>
<RunActionPanel run={run} counts={detail?.counts} emptyLabel={t("empty")} />
<section className="grid gap-4 xl:grid-cols-[360px_1fr]">
<aside className="border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">