1import {
2 type ProviderApprovalDecision,
3 type ProviderApprovalOption,
4 type RuntimeRequestId,
5} from "@t3tools/contracts";
6import { memo } from "react";
7import { EllipsisIcon, TriangleAlertIcon } from "lucide-react";
8import { Button } from "../ui/button";
9import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
10import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
11import { composerFloatingLayerProps } from "./composerEventScope";
12
13interface ComposerPendingApprovalActionsProps {
14 requestId: RuntimeRequestId;
15 isResponding: boolean;
16 canRespond: boolean;
17 disabled?: boolean;
18 options?: ReadonlyArray<ProviderApprovalOption> | undefined;
19 onRespondToApproval: (
20 requestId: RuntimeRequestId,
21 decision: ProviderApprovalDecision,
22 ) => Promise<unknown>;
23}
24
25const DEFAULT_APPROVAL_OPTIONS = [
26 { decision: "cancel", label: "Cancel" },
27 { decision: "decline", label: "Decline" },
28 { decision: "acceptForSession", label: "Always allow this session" },
29 { decision: "accept", label: "Approve" },
30] satisfies ReadonlyArray<ProviderApprovalOption>;
31
32export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
33 requestId,
34 isResponding,
35 canRespond,
36 disabled = false,
37 options = DEFAULT_APPROVAL_OPTIONS,
38 onRespondToApproval,
39}: ComposerPendingApprovalActionsProps) {
40 const primaryOptions = options.filter(
41 (option) => option.decision === "decline" || option.decision === "accept",
42 );
43 const moreOptions = options.filter(
44 (option) => option.decision !== "decline" && option.decision !== "accept",
45 );
46
47 return (
48 <>
49 {primaryOptions.map((option) => {
50 const button = (
51 <Button
52 key={option.decision}
53 size="xs"
54 variant={option.decision === "accept" ? "default" : "outline"}
55 disabled={disabled || isResponding || !canRespond}
56 aria-description={option.warning}
57 onClick={() => {
58 if (!disabled && !isResponding) {
59 void onRespondToApproval(requestId, option.decision);
60 }
61 }}
62 >
63 {option.warning ? <TriangleAlertIcon className="size-3 shrink-0" /> : null}
64 <span className="max-w-40 truncate">{option.label}</span>
65 </Button>
66 );
67 return option.warning ? (
68 <Tooltip key={option.decision}>
69 <TooltipTrigger render={button} />
70 <TooltipPopup side="top">{option.warning}</TooltipPopup>
71 </Tooltip>
72 ) : (
73 button
74 );
75 })}
76 {moreOptions.length > 0 ? (
77 <Menu>
78 <MenuTrigger
79 disabled={disabled || isResponding}
80 render={<Button size="icon-xs" variant="outline" aria-label="More approval options" />}
81 >
82 <EllipsisIcon />
83 </MenuTrigger>
84 <MenuPopup {...composerFloatingLayerProps} side="top" align="end">
85 {moreOptions.map((option) => {
86 const item = (
87 <MenuItem
88 key={option.decision}
89 disabled={disabled || isResponding}
90 aria-description={option.warning}
91 onClick={() => {
92 if (!disabled && !isResponding)
93 void onRespondToApproval(requestId, option.decision);
94 }}
95 variant="ghost"
96 className="mb-1 last:mb-0"
97 >
98 {option.warning ? <TriangleAlertIcon className="size-3 text-warning" /> : null}
99 <span className="min-w-0 whitespace-normal wrap-break-word">{option.label}</span>
100 </MenuItem>
101 );
102 return option.warning ? (
103 <Tooltip key={option.decision}>
104 <TooltipTrigger render={item} />
105 <TooltipPopup side="top">{option.warning}</TooltipPopup>
106 </Tooltip>
107 ) : (
108 item
109 );
110 })}
111 </MenuPopup>
112 </Menu>
113 ) : null}
114 </>
115 );
116});