refactor(desktop): 观测收敛进「运行」页(tab 切换),删全局底部抽屉

底部抽屉与新版运行页重复,且默认展开常驻占 ~176px、引用/评测还是空壳。按高内聚收敛:

- 运行·观测 详情区改 tab 切换:执行轨迹 / 模型输出 / 工具调用 / 评测(去掉没接的「引用」空标签);
  OutputView(含 chart 渲染) + ToolCalls 从抽屉并入运行页;评测标签接 taskEval 全量展示。
- 删除全局 BottomDrawer,释放底部空间。
- HITL 审批条抽成独立 shell/ApprovalBar,全局常驻于 TopBar 下(审批中断必须随处可见可操作)。
- 发起运行自动跳「运行」页 + 新运行自动切回「当前运行」,实时观测不丢。

tsc+vite 构建通过;wails HMR 热加载生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-26 16:56:52 +08:00
parent 3cf3c0b070
commit 7cc7f5fd26
4 changed files with 176 additions and 211 deletions
@@ -0,0 +1,63 @@
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;
return <Bar 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 [err, setErr] = useState("");
const decide = async (approved: boolean) => {
setBusy(approved ? "approve" : "reject");
setErr("");
try {
await approveTask(taskId, approved, { node, note });
} catch (e) {
setErr((e as Error).message);
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"></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>}
<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>
);
}