import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, ShieldQuestion, X } from "lucide-react"; import type { ToolActionRequestListItem } from "@paperclipai/shared"; import { humanizeConnectionDisplayName } from "@paperclipai/shared"; import { useCompany } from "@/context/CompanyContext"; import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; import { timeAgo } from "@/lib/timeAgo"; import { issuesApi } from "@/api/issues"; import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard"; import { toolsApi } from "@/api/tools"; import { Button } from "@/components/ui/button"; import { MarkdownBody } from "@/components/MarkdownBody"; /** * "Ask first" review queue (M1b float / M9 card, PAP-10859). * * Renders pending `tool_action_requests` as prosumer cards with three choices: * • Allow once → approve this single request * • Always allow → approve + create a trust rule (won't ask again) * • Decline → reject this request * * Pass `connectionId` to scope the queue to a single app (App detail); omit it * to show every pending request (Needs attention page). */ export function ReviewQueueCard({ connectionId, emptyState = "hidden", heading = "Waiting for your OK", plain = false, }: { connectionId?: string; emptyState?: "hidden" | "reassure"; heading?: string; plain?: boolean; }) { const { selectedCompanyId } = useCompany(); const query = useQuery({ queryKey: queryKeys.tools.actionRequests(selectedCompanyId ?? "__none__", "pending"), queryFn: () => toolsApi.listActionRequests(selectedCompanyId!, "pending"), enabled: !!selectedCompanyId, refetchInterval: 20_000, }); const items = useMemo(() => { const all = query.data?.actionRequests ?? []; return connectionId ? all.filter((item) => item.connectionId === connectionId) : all; }, [query.data, connectionId]); if (!selectedCompanyId) return null; if (query.isLoading) return null; if (query.isError) return

Could not load connection reviews. Please refresh to try again.

; if (items.length === 0) { if (emptyState === "hidden") return null; return (
Nothing is waiting for your OK right now.
); } return (

{heading}

{items.length}
{items.map((item) => ( ))}
); } function ReviewRow({ companyId, item, plain, }: { companyId: string; item: ToolActionRequestListItem; plain: boolean; }) { const queryClient = useQueryClient(); const { pushToast } = useToast(); const [resolving, setResolving] = useState(null); const interactionQuery = useQuery({ queryKey: queryKeys.issues.interactions(item.request.issueId ?? "__none__"), queryFn: () => issuesApi.listInteractions(item.request.issueId!), enabled: Boolean(item.request.issueId && item.request.interactionId), }); const linkedInteraction = interactionQuery.data?.find(row => row.id === item.request.interactionId); const invalidate = () => { queryClient.invalidateQueries({ queryKey: queryKeys.tools.actionRequests(companyId, "pending") }); queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.tools.trustRules(companyId) }); if (item.request.issueId) queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(item.request.issueId) }); }; const allowOnce = useMutation({ mutationFn: () => toolsApi.approveActionRequest(companyId, item.request.id), onMutate: () => setResolving("allow"), onSuccess: () => { pushToast({ title: "Allowed once", body: `${actionLabel(item)} can run this time.`, tone: "success" }); invalidate(); }, onError: (error) => { invalidate(); failToast(pushToast, error); }, onSettled: () => setResolving(null), }); const alwaysAllow = useMutation({ mutationFn: async () => { return toolsApi.approveActionRequest(companyId, item.request.id, true); }, onMutate: () => setResolving("always"), onSuccess: () => { pushToast({ title: "Always allowed", body: `${actionLabel(item)} won’t ask again.`, tone: "success", }); invalidate(); queryClient.invalidateQueries({ queryKey: queryKeys.tools.trustRules(companyId) }); }, onError: (error) => { invalidate(); failToast(pushToast, error); }, onSettled: () => setResolving(null), }); const decline = useMutation({ mutationFn: () => toolsApi.declineActionRequest(companyId, item.request.id), onMutate: () => setResolving("decline"), onSuccess: () => { pushToast({ title: "Declined", body: `${actionLabel(item)} won’t run.`, tone: "info" }); invalidate(); }, onError: (error) => { invalidate(); failToast(pushToast, error); }, onSettled: () => setResolving(null), }); if (linkedInteraction) return { try { await toolsApi.approveActionRequest(companyId, item.request.id, rememberAction); } finally { invalidate(); } }} onRejectInteraction={async (_interaction, reason) => { try { await toolsApi.declineActionRequest(companyId, item.request.id, reason); } finally { invalidate(); } }} />; const busy = resolving !== null; const preview = item.request.previewMarkdown?.trim(); return (
{actionLabel(item)} {item.applicationName && ( in {humanizeConnectionDisplayName(item.applicationName)} )} · asked {timeAgo(item.request.createdAt)}
{preview ? (
{preview}
) : (

An agent wants to run this action. Your connection policy requires approval first.

)} {item.requestedByAgentId && item.connectionId && !item.request.approvalId ?

Always allow lets this agent use this action with different arguments on this connection, within the current project when present.

: null}
{item.requestedByAgentId && item.connectionId && !item.request.approvalId ? : null}
); } function actionLabel(item: ToolActionRequestListItem): string { if (!item.toolTitle && !item.toolName) return "This action"; return humanizeConnectionDisplayName(item.toolName ?? "", { title: item.toolTitle }); } function failToast( pushToast: ReturnType["pushToast"], error: unknown, ) { pushToast({ title: "Couldn’t save that", body: error instanceof Error ? error.message : "Please try again.", tone: "error", }); }