From 7fdbf24c4e4f95b689f72d723f6168ebeaac0a44 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:44:16 +0800 Subject: [PATCH] feat(ui): add exec approval card and refine tool/thinking expand style Add ExecApprovalItem for human-in-the-loop command approval with uniform outline buttons (Allow/Always/Deny), countdown timer, and command display. Refine ToolCallItem and ThinkingItem: transparent by default, unified bg-muted/30 wrapper on expand with seamless button+content integration. Co-Authored-By: Claude Opus 4.5 --- .../ui/src/components/exec-approval-item.tsx | 144 ++++++++++++++++++ packages/ui/src/components/tool-call-item.tsx | 9 +- 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/components/exec-approval-item.tsx diff --git a/packages/ui/src/components/exec-approval-item.tsx b/packages/ui/src/components/exec-approval-item.tsx new file mode 100644 index 00000000..4487e0f3 --- /dev/null +++ b/packages/ui/src/components/exec-approval-item.tsx @@ -0,0 +1,144 @@ +"use client" + +import { memo, useState, useEffect, useCallback } from "react" +import { HugeiconsIcon } from "@hugeicons/react" +import { + Tick01Icon, + TickDouble01Icon, + Cancel01Icon, + CommandLineIcon, +} from "@hugeicons/core-free-icons" +import { cn } from "@multica/ui/lib/utils" +import { Button } from "@multica/ui/components/ui/button" + +export interface ExecApprovalItemProps { + command: string + cwd?: string + riskLevel: "safe" | "needs-review" | "dangerous" + riskReasons: string[] + expiresAtMs: number + onDecision: (decision: "allow-once" | "allow-always" | "deny") => void +} + +function useCountdown(expiresAtMs: number): number { + const [remaining, setRemaining] = useState(() => + Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)), + ) + + useEffect(() => { + const id = setInterval(() => { + const next = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)) + setRemaining(next) + if (next <= 0) clearInterval(id) + }, 1000) + return () => clearInterval(id) + }, [expiresAtMs]) + + return remaining +} + +export const ExecApprovalItem = memo(function ExecApprovalItem({ + command, + cwd, + riskLevel, + riskReasons, + expiresAtMs, + onDecision, +}: ExecApprovalItemProps) { + const remaining = useCountdown(expiresAtMs) + const [decided, setDecided] = useState(false) + + const handleDecision = useCallback( + (decision: "allow-once" | "allow-always" | "deny") => { + if (decided) return + setDecided(true) + onDecision(decision) + }, + [decided, onDecision], + ) + + const riskLabel = + riskLevel === "dangerous" + ? "Dangerous command" + : riskLevel === "needs-review" + ? "Needs review" + : "Command approval" + + return ( +
+
+ {/* Header: icon + risk label + countdown */} +
+
+ + {riskLabel} +
+ {remaining > 0 && !decided && ( + + {remaining}s + + )} +
+ + {/* Command */} +
+ {command} + {cwd && ( + + in {cwd} + + )} +
+ + {/* Risk reasons */} + {riskReasons.length > 0 && ( +
+ {riskReasons.map((reason, i) => ( +

{reason}

+ ))} +
+ )} + + {/* Actions */} + {!decided && remaining > 0 ? ( +
+ + + +
+ ) : ( +

+ {decided ? "Decision sent" : "Expired"} +

+ )} +
+
+ ) +}) diff --git a/packages/ui/src/components/tool-call-item.tsx b/packages/ui/src/components/tool-call-item.tsx index e8374cc8..87e665f6 100644 --- a/packages/ui/src/components/tool-call-item.tsx +++ b/packages/ui/src/components/tool-call-item.tsx @@ -134,16 +134,18 @@ export const ToolCallItem = memo(function ToolCallItem({ message }: { message: M return (
+
)} +
) })