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 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 14:44:16 +08:00
parent 0a6e930c2b
commit 7fdbf24c4e
2 changed files with 150 additions and 3 deletions

View file

@ -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 (
<div className="py-0.5 px-2.5 text-sm text-muted-foreground">
<div className="rounded bg-muted/30 px-3 py-2.5 space-y-2.5">
{/* Header: icon + risk label + countdown */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<HugeiconsIcon icon={CommandLineIcon} strokeWidth={2} className="size-3.5 shrink-0" />
<span className="font-medium text-foreground">{riskLabel}</span>
</div>
{remaining > 0 && !decided && (
<span className="text-xs text-muted-foreground/60 font-[tabular-nums]">
{remaining}s
</span>
)}
</div>
{/* Command */}
<div className="rounded bg-background/80 border border-border/50 px-2.5 py-1.5 font-mono text-xs text-foreground break-words">
{command}
{cwd && (
<span className="block mt-1 text-muted-foreground/60 font-sans">
in {cwd}
</span>
)}
</div>
{/* Risk reasons */}
{riskReasons.length > 0 && (
<div className="text-xs text-muted-foreground/60 space-y-0.5">
{riskReasons.map((reason, i) => (
<p key={i}>{reason}</p>
))}
</div>
)}
{/* Actions */}
{!decided && remaining > 0 ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5 px-2.5"
onClick={() => handleDecision("allow-once")}
>
<HugeiconsIcon icon={Tick01Icon} strokeWidth={2} className="size-3.5" />
Allow
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5 px-2.5"
onClick={() => handleDecision("allow-always")}
>
<HugeiconsIcon icon={TickDouble01Icon} strokeWidth={2} className="size-3.5" />
Always
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5 px-2.5"
onClick={() => handleDecision("deny")}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-3.5" />
Deny
</Button>
</div>
) : (
<p className={cn(
"text-xs",
decided ? "text-muted-foreground" : "text-muted-foreground/60",
)}>
{decided ? "Decision sent" : "Expired"}
</p>
)}
</div>
</div>
)
})

View file

@ -134,16 +134,18 @@ export const ToolCallItem = memo(function ToolCallItem({ message }: { message: M
return (
<div className="py-0.5 px-2.5 text-sm text-muted-foreground">
<div className={cn("rounded transition-colors", expanded && "bg-muted/30")}>
<button
type="button"
aria-label={`${display.label}${subtitle ? ` ${subtitle}` : ""}${toolStatus}`}
aria-expanded={hasDetails ? expanded : undefined}
onClick={() => hasDetails && setExpanded(!expanded)}
className={cn(
"group flex w-full items-center gap-1.5 rounded px-1.5 py-0.5",
"group flex w-full items-center gap-1.5 rounded px-2.5 py-1",
"text-left transition-[color,background-color]",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 outline-none",
hasDetails && "hover:bg-muted/30 cursor-pointer",
hasDetails && !expanded && "hover:bg-muted/30 cursor-pointer",
hasDetails && expanded && "cursor-pointer",
!hasDetails && "cursor-default",
)}
>
@ -214,11 +216,12 @@ export const ToolCallItem = memo(function ToolCallItem({ message }: { message: M
role="region"
aria-label={`${display.label} result`}
tabIndex={0}
className="mt-1 ml-7 text-xs bg-muted rounded p-2 max-h-48 overflow-y-auto whitespace-pre-wrap break-all"
className="px-2.5 pt-1 pb-2 text-xs max-h-48 overflow-y-auto whitespace-pre-wrap break-all"
>
{resultText}
</div>
)}
</div>
</div>
)
})