"use client" import { forwardRef, useState } from "react" import { cn } from "../../utils/cn" import { Button } from "../ui/button" import { Tag } from "../ui/tag" import { ToolType } from "../platform" import { ToolIcon } from "../tool-icon" import { CheckCircleIcon, DotsLoaderIcon, XmarkCircleIcon, XmarkIcon } from "../icons-v2-generated" import { ExpandChevron } from "./expand-chevron" import { useCollapsible } from "./hooks/use-collapsible" import { ArgRow, ResultBlock } from "./tool-call-blocks" import type { ApprovalBlockVariant, AssistantType, ApprovalBatchExecutionState, ApprovalBatchSegment, PendingToolCallData, } from "./types" import { COMMAND_BODY_ARG_KEYS, getCommandText, } from "./utils/tool-call-helpers" export interface ApprovalBatchMessageProps extends React.HTMLAttributes { data: ApprovalBatchSegment["data"] status?: ApprovalBatchSegment["status"] onApprove?: (requestId?: string) => void | boolean | Promise onReject?: (requestId?: string) => void | boolean | Promise /** * Cap the tool-call list height so it scrolls internally while the footer * (explanations + Approve/Reject) stays pinned below. Omit for the default * chat behaviour where the whole batch grows with its content. */ maxBodyHeight?: number | string /** Display name of the user who resolved the request; shown as "by {name}" beside the status tag. */ resolvedByName?: string | null /** * Per-tool execution status icon (queued dots → check/cross). On in dialog messages where tool * execution is tracked live; turn off for notifications, which have no execution state and would * otherwise show the dots spinner forever after approval. Defaults to on. */ showExecutionStatus?: boolean /** * Chat identity. Kept for prop parity with the message renderers; does NOT * drive the styling anymore — use `variant` instead. An admin can be * looking at a Fae dialog (tickets dialog client tab) and must still see * the full admin block. */ assistantType?: AssistantType /** * Viewer variant. `'admin'` (default) = full block with command preview, * expandable args/result and tool icon. `'client'` = end-client (Fae * desktop app) card that shows ONLY the BE-generated title(s) plus the * Approve/Reject buttons or the full-text resolved pill ("Approved by * {name}") — commands and scripts are never rendered. */ variant?: ApprovalBlockVariant /** * Render the footer Approve/Reject buttons (or the resolved-status tag). * Turn off when the host owns the actions row — e.g. the approval * notification tile. Explanation bullets still render. */ showFooterActions?: boolean } const COMMAND_BODY_KEYS = new Set(COMMAND_BODY_ARG_KEYS) export interface ApprovalStatusTagProps { status: ApprovalBatchSegment["status"] resolvedByName?: string | null inlineResolver?: boolean } /** * Terminal-status badge for a resolved approval batch (approved / rejected / * cancelled); renders nothing while pending. With `inlineResolver` the * resolver's name is baked into the tag as a single full-text pill * ("Approved by {name}"); otherwise callers render "by {name}" as a separate * muted span. Also used by the approval notification tile, which hosts the * actions row itself (`showFooterActions={false}`). */ export function ApprovalStatusTag({ status, resolvedByName, inlineResolver = false }: ApprovalStatusTagProps) { const suffix = inlineResolver && resolvedByName ? ` by ${resolvedByName}` : "" if (status === "approved") { return } /> } if (status === "cancelled") { return } /> } if (status === "rejected") { return } /> } return null } function getArgEntries(call: PendingToolCallData): Array<[string, unknown]> { const args = call.toolCallArguments if (!args || typeof args !== "object") return [] return Object.entries(args).filter(([k, v]) => !COMMAND_BODY_KEYS.has(k) && v !== null && v !== undefined && v !== "") } /** * Status icon for one tool call inside an approved/done batch. * - pending batch → null (chevron-only row, no status icon) * - approved, no exec → DotsLoaderIcon (queued / waiting for backend) * - executing → DotsLoaderIcon * - done + success → green check * - done + failure → red cross */ function ExecutionStatusIcon({ batchStatus, execution, }: { batchStatus: ApprovalBatchSegment["status"] execution: ApprovalBatchExecutionState | undefined }) { if (batchStatus !== "approved") return null if (!execution || execution.status === "executing") return if (execution.success === false) return return } interface ToolCallRowProps { call: PendingToolCallData expanded: boolean onToggle: () => void batchStatus: ApprovalBatchSegment["status"] execution: ApprovalBatchExecutionState | undefined showExecutionStatus: boolean } // ADMIN-only row: command preview header, expandable args/result. The client // variant never renders tool calls — see the `variant === 'client'` branch of // ``. function ToolCallRow({ call, expanded, onToggle, batchStatus, execution, showExecutionStatus }: ToolCallRowProps) { const command = getCommandText(call) const args = getArgEntries(call) const toolType = (call.toolType as ToolType) || ("OPENFRAME" as ToolType) const { innerRef, containerStyle } = useCollapsible({ expanded }) const result = execution?.status === "done" ? execution.result : undefined const hasExpandableBody = args.length > 0 || (typeof result === "string" && result.length > 0) return (
{hasExpandableBody && (
{args.map(([key, value]) => ( ))} {result && ( 0 ? "mt-[var(--spacing-system-xsf)]" : undefined} /> )}
)}
) } const ApprovalBatchMessage = forwardRef( ({ className, data, onApprove, onReject, status = "pending", maxBodyHeight, resolvedByName, showExecutionStatus = true, assistantType: _assistantType, variant = "admin", showFooterActions = true, ...props }, ref) => { const [expandedId, setExpandedId] = useState(null) const [isProcessing, setIsProcessing] = useState(false) const isClient = variant === "client" const explanations = data.toolCalls .map((c) => c.toolExplanation?.trim()) .filter((s): s is string => !!s) const handleApprove = async () => { setIsProcessing(true) try { await onApprove?.(data.approvalRequestId) } finally { setIsProcessing(false) } } const handleReject = async () => { setIsProcessing(true) try { await onReject?.(data.approvalRequestId) } finally { setIsProcessing(false) } } const actionButtons = ( <> ) // CLIENT (Fae end-user) card — Figma 203-11947 "fae-approval-block". // One bordered card: BE-generated title(s) + Approve/Reject buttons or the // full-text resolved pill ("Approved by {name}"). No commands, scripts, // expansion or execution icons — the end client must not see them. if (isClient) { const titles = data.toolCalls .map((c) => c.toolExplanation?.trim() || c.toolTitle?.trim()) .filter((s): s is string => !!s) return (
{titles.length > 0 ? ( titles.map((title, i) => (

{title}

)) ) : (

Approval required

)} {showFooterActions && (status === "pending" ? (
{actionButtons}
) : (
))}
) } const showFooterBlock = explanations.length > 0 || showFooterActions return (
{data.toolCalls.map((call) => ( setExpandedId((prev) => prev === call.toolExecutionRequestId ? null : call.toolExecutionRequestId, ) } batchStatus={status} execution={data.executions?.[call.toolExecutionRequestId]} showExecutionStatus={showExecutionStatus} /> ))}
{showFooterBlock && (
{explanations.length > 0 && (
    {explanations.map((expl, i) => (
  • {expl}
  • ))}
)} {showFooterActions && (status === "pending" ? (
{actionButtons}
) : (
{resolvedByName && ( by {resolvedByName} )}
))}
)}
) }, ) ApprovalBatchMessage.displayName = "ApprovalBatchMessage" export { ApprovalBatchMessage }