Files
sundynix-agentix/sundynix-desktop/frontend/src/shell/ApprovalBar.tsx
T
Blizzard e8dc2e02c3 fix(desktop): 审批条提交后不再永卡「提交中」+ 按 taskId 重挂载
复现:登录恢复待审任务时若捞到一个无法续跑的任务(如早期 KV 冒号 bug 留下的无
resume 记录的孤儿),点批准 → 决定发出但消费者找不到记录 → 任务永远 waiting →
审批条 busy 状态成功后从不复位 → 永卡「提交中…」spinner,看似整个应用卡死。

- decide 成功后置 submitted 并在 finally 复位 busy(此前只在 catch 复位)。
- submitted 时显示「决定已发出,等待续跑…(若长时间无响应,该任务可能已失效)」
  而非停在 spinner —— 诚实反映状态,不误导成 hang。
- Bar 加 key={taskId}:换审批任务时重挂载,避免上一个的 submitted 残留。

(孤儿任务本身已在 PG 标记 failed 清理;冒号 bug 早已修复,不再产生新孤儿。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:40:07 +08:00

72 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { ShieldCheck, Check, X } from "lucide-react";
import { pendingApproval, type RunState } from "../lib/run";
import { approveTask } from "../lib/api";
import { Badge } from "../ui";
// ApprovalBarHITL 人工审批中断条 —— 全局常驻(任务停在审批节点时,不管在哪个页面都要能看见+操作)。
// 触发以「后端状态 waiting」为准(落 PG,可靠);exec 的 await 事件仅用于丰富摘要(可能抢跑丢失)。
// 决定经 approveTask 发回;dispatcher 续跑后状态轮询/SSE 更新,本条随 waiting 解除自动消失。
export function ApprovalBar({ run }: { run: RunState }) {
const approval =
run.taskId && run.lifecycle === "waiting"
? pendingApproval(run.exec) ?? { node: "", title: run.detail || "人工审批", summary: "" }
: null;
if (!approval || !run.taskId) return null;
// key=taskId:换任务时重新挂载,重置 submitted 等本地状态,避免上一个审批的「已提交」残留。
return <Bar key={run.taskId} taskId={run.taskId} node={approval.node} title={approval.title} summary={approval.summary} />;
}
function Bar({ taskId, node, title, summary }: { taskId: string; node: string; title: string; summary: string }) {
const [note, setNote] = useState("");
const [busy, setBusy] = useState<"approve" | "reject" | null>(null);
const [submitted, setSubmitted] = useState(false); // 决定已发出;正常情况下任务续跑后本条随 waiting 解除自动消失
const [err, setErr] = useState("");
const decide = async (approved: boolean) => {
setBusy(approved ? "approve" : "reject");
setErr("");
try {
await approveTask(taskId, approved, { node, note });
setSubmitted(true); // 关键:成功后置「已提交」而非停在 spinner —— 否则任务若恢复不了会永远卡「提交中…」
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(null);
}
};
return (
<div className="flex shrink-0 flex-col gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2">
<div className="flex items-center gap-2 text-[12px] text-amber-200">
<ShieldCheck className="h-4 w-4 text-amber-400" strokeWidth={2.2} />
<span className="font-semibold"></span>
<span className="text-amber-300/80">{title}</span>
<Badge tone="warn">{submitted ? "已提交决定" : "等待决定"}</Badge>
</div>
{summary && <pre className="max-h-20 overflow-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-amber-100/80">{summary}</pre>}
{submitted ? (
<p className="text-[11px] text-amber-200/80"></p>
) : (
<div className="flex items-center gap-2">
<input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选,拒绝原因等)"
className="min-w-0 flex-1 rounded border border-line bg-ink-950/60 px-2 py-1 text-[11px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500/50 focus:outline-none"
/>
<button onClick={() => decide(true)} disabled={busy !== null}
className="flex items-center gap-1 rounded bg-success/20 px-2.5 py-1 text-[11px] font-medium text-success hover:bg-success/30 disabled:opacity-50">
<Check className="h-3.5 w-3.5" /> {busy === "approve" ? "提交中…" : "批准"}
</button>
<button onClick={() => decide(false)} disabled={busy !== null}
className="flex items-center gap-1 rounded bg-danger/20 px-2.5 py-1 text-[11px] font-medium text-danger hover:bg-danger/30 disabled:opacity-50">
<X className="h-3.5 w-3.5" /> {busy === "reject" ? "提交中…" : "拒绝"}
</button>
</div>
)}
{err && <p className="text-[11px] text-danger">{err}</p>}
</div>
);
}