assistant-ui 0.0.120 → 0.0.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +60 -1
  2. package/dist/commands/add.d.ts.map +1 -1
  3. package/dist/commands/add.js +1 -2
  4. package/dist/commands/add.js.map +1 -1
  5. package/dist/commands/cloud.d.ts +25 -0
  6. package/dist/commands/cloud.d.ts.map +1 -0
  7. package/dist/commands/cloud.js +194 -0
  8. package/dist/commands/cloud.js.map +1 -0
  9. package/dist/commands/create.d.ts.map +1 -1
  10. package/dist/commands/create.js +10 -2
  11. package/dist/commands/create.js.map +1 -1
  12. package/dist/commands/info.js +2 -2
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +1 -2
  15. package/dist/commands/init.js.map +1 -1
  16. package/dist/commands/mcp.d.ts.map +1 -1
  17. package/dist/commands/mcp.js +95 -23
  18. package/dist/commands/mcp.js.map +1 -1
  19. package/dist/lib/agent-skill.d.ts +2 -2
  20. package/dist/lib/agent-skill.d.ts.map +1 -1
  21. package/dist/lib/agent-skill.js +10 -28
  22. package/dist/lib/agent-skill.js.map +1 -1
  23. package/dist/lib/cloud-abort.d.ts +2 -0
  24. package/dist/lib/cloud-abort.d.ts.map +1 -0
  25. package/dist/lib/cloud-abort.js +13 -0
  26. package/dist/lib/cloud-abort.js.map +1 -0
  27. package/dist/lib/cloud-auth.d.ts +28 -0
  28. package/dist/lib/cloud-auth.d.ts.map +1 -0
  29. package/dist/lib/cloud-auth.js +169 -0
  30. package/dist/lib/cloud-auth.js.map +1 -0
  31. package/dist/lib/cloud-config.d.ts +21 -0
  32. package/dist/lib/cloud-config.d.ts.map +1 -0
  33. package/dist/lib/cloud-config.js +76 -0
  34. package/dist/lib/cloud-config.js.map +1 -0
  35. package/dist/lib/cloud-provision.d.ts +20 -0
  36. package/dist/lib/cloud-provision.d.ts.map +1 -0
  37. package/dist/lib/cloud-provision.js +55 -0
  38. package/dist/lib/cloud-provision.js.map +1 -0
  39. package/dist/lib/cloud-setup-login.d.ts +9 -0
  40. package/dist/lib/cloud-setup-login.d.ts.map +1 -0
  41. package/dist/lib/cloud-setup-login.js +137 -0
  42. package/dist/lib/cloud-setup-login.js.map +1 -0
  43. package/dist/lib/cloud-url.d.ts +2 -0
  44. package/dist/lib/cloud-url.d.ts.map +1 -0
  45. package/dist/lib/cloud-url.js +14 -0
  46. package/dist/lib/cloud-url.js.map +1 -0
  47. package/dist/lib/create-project.d.ts +0 -9
  48. package/dist/lib/create-project.d.ts.map +1 -1
  49. package/dist/lib/create-project.js +15 -46
  50. package/dist/lib/create-project.js.map +1 -1
  51. package/dist/lib/transform.d.ts +0 -4
  52. package/dist/lib/transform.d.ts.map +1 -1
  53. package/dist/lib/transform.js +8 -17
  54. package/dist/lib/transform.js.map +1 -1
  55. package/dist/lib/utils/download.d.ts +5 -0
  56. package/dist/lib/utils/download.d.ts.map +1 -0
  57. package/dist/lib/utils/download.js +41 -0
  58. package/dist/lib/utils/download.js.map +1 -0
  59. package/dist/lib/utils/file-scanner.d.ts +0 -1
  60. package/dist/lib/utils/file-scanner.d.ts.map +1 -1
  61. package/dist/lib/utils/file-scanner.js +1 -16
  62. package/dist/lib/utils/file-scanner.js.map +1 -1
  63. package/dist/lib/utils/package-manager.d.ts +7 -0
  64. package/dist/lib/utils/package-manager.d.ts.map +1 -1
  65. package/dist/lib/utils/package-manager.js +15 -1
  66. package/dist/lib/utils/package-manager.js.map +1 -1
  67. package/dist/program.d.ts.map +1 -1
  68. package/dist/program.js +2 -1
  69. package/dist/program.js.map +1 -1
  70. package/package.json +12 -8
  71. package/src/commands/add.ts +2 -1
  72. package/src/commands/cloud.test.ts +238 -0
  73. package/src/commands/cloud.ts +455 -0
  74. package/src/commands/create.ts +13 -3
  75. package/src/commands/init.ts +5 -2
  76. package/src/commands/mcp.ts +223 -56
  77. package/src/lib/agent-skill.ts +18 -44
  78. package/src/lib/cloud-abort.ts +12 -0
  79. package/src/lib/cloud-auth.test.ts +500 -0
  80. package/src/lib/cloud-auth.ts +273 -0
  81. package/src/lib/cloud-config.test.ts +82 -0
  82. package/src/lib/cloud-config.ts +138 -0
  83. package/src/lib/cloud-provision.test.ts +132 -0
  84. package/src/lib/cloud-provision.ts +93 -0
  85. package/src/lib/cloud-setup-login.test.ts +361 -0
  86. package/src/lib/cloud-setup-login.ts +240 -0
  87. package/src/lib/cloud-url.ts +19 -0
  88. package/src/lib/create-project.test.ts +0 -17
  89. package/src/lib/create-project.ts +33 -98
  90. package/src/lib/transform-files.test.ts +1 -3
  91. package/src/lib/transform.ts +6 -20
  92. package/src/lib/utils/download.ts +57 -0
  93. package/src/lib/utils/file-scanner.ts +0 -26
  94. package/src/lib/utils/package-manager.ts +26 -0
  95. package/src/lib/utils/registry.test.ts +0 -6
  96. package/src/program.ts +2 -0
  97. package/templates/cloud-harness/.assistant-ui/template.json +3 -0
  98. package/templates/cloud-harness/.env.example +1 -0
  99. package/templates/cloud-harness/.gitignore +42 -0
  100. package/templates/cloud-harness/README.md +28 -0
  101. package/templates/cloud-harness/app/api/chat/route.ts +14 -0
  102. package/templates/cloud-harness/app/api/credential/route.ts +14 -0
  103. package/templates/cloud-harness/app/assistant.tsx +83 -0
  104. package/templates/cloud-harness/app/favicon.ico +0 -0
  105. package/templates/cloud-harness/app/globals.css +139 -0
  106. package/templates/cloud-harness/app/layout.tsx +37 -0
  107. package/templates/cloud-harness/app/page.tsx +5 -0
  108. package/templates/cloud-harness/components/assistant-ui/elements/attachment.aui.tsx +279 -0
  109. package/templates/cloud-harness/components/assistant-ui/elements/collapsible-root.tsx +19 -0
  110. package/templates/cloud-harness/components/assistant-ui/elements/markdown-text.tsx +238 -0
  111. package/templates/cloud-harness/components/assistant-ui/elements/thread.aui.tsx +521 -0
  112. package/templates/cloud-harness/components/assistant-ui/elements/tool-fallback.aui.tsx +1321 -0
  113. package/templates/cloud-harness/components/assistant-ui/elements/tooltip-icon-button.tsx +49 -0
  114. package/templates/cloud-harness/components/ui/avatar.tsx +109 -0
  115. package/templates/cloud-harness/components/ui/button.tsx +58 -0
  116. package/templates/cloud-harness/components/ui/collapsible.tsx +21 -0
  117. package/templates/cloud-harness/components/ui/dialog.tsx +159 -0
  118. package/templates/cloud-harness/components/ui/textarea.tsx +18 -0
  119. package/templates/cloud-harness/components/ui/tooltip.tsx +69 -0
  120. package/templates/cloud-harness/components.json +24 -0
  121. package/templates/cloud-harness/hooks/use-attachment-src.ts +46 -0
  122. package/templates/cloud-harness/hooks/use-copy-to-clipboard.ts +54 -0
  123. package/templates/cloud-harness/lib/utils.ts +1 -0
  124. package/templates/cloud-harness/next.config.ts +6 -0
  125. package/templates/cloud-harness/package.json +51 -0
  126. package/templates/cloud-harness/postcss.config.mjs +5 -0
  127. package/templates/cloud-harness/tsconfig.json +33 -0
  128. package/templates/cloud-harness/vendor/README.md +13 -0
  129. package/templates/cloud-harness/vendor/assistant-ui-react-harness-sdk-0.0.2.tgz +0 -0
  130. package/templates/cloud-harness/vendor/harness-sdk-0.3.1.tgz +0 -0
  131. package/templates/cloud-harness/vendor/provenance.json +38 -0
  132. package/templates/cloud-harness/vendor/statewire-0.19.3.tgz +0 -0
  133. /package/{LICENSE → templates/cloud-harness/vendor/LICENSE} +0 -0
@@ -0,0 +1,1321 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ memo,
6
+ useCallback,
7
+ useImperativeHandle,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import {
13
+ AlertCircleIcon,
14
+ CheckIcon,
15
+ ChevronDownIcon,
16
+ CircleMinusIcon,
17
+ LoaderIcon,
18
+ XCircleIcon,
19
+ } from "lucide-react";
20
+ import {
21
+ toolApprovalAcceptsText,
22
+ useAuiState,
23
+ useScrollLock,
24
+ useToolCallElapsed,
25
+ type ToolApprovalAnswer,
26
+ type ToolApprovalOption,
27
+ type ToolApprovalQuestion,
28
+ type ToolCallMessagePart,
29
+ type ToolCallMessagePartProps,
30
+ type ToolCallMessagePartStatus,
31
+ type ToolCallMessagePartComponent,
32
+ } from "@assistant-ui/react";
33
+ import { cn } from "@/lib/utils";
34
+ import {
35
+ CollapsibleRoot as Collapsible,
36
+ CollapsibleContent,
37
+ CollapsibleTrigger,
38
+ } from "./collapsible-root";
39
+ import { Button } from "@/components/ui/button";
40
+ import { Textarea } from "@/components/ui/textarea";
41
+
42
+ const ANIMATION_DURATION = 200;
43
+
44
+ const pressable = "active:scale-[0.98]";
45
+
46
+ export type ToolFallbackRootProps = Omit<
47
+ React.ComponentProps<typeof Collapsible>,
48
+ "open" | "onOpenChange"
49
+ > & {
50
+ open?: boolean;
51
+ onOpenChange?: (open: boolean) => void;
52
+ defaultOpen?: boolean;
53
+ };
54
+
55
+ function ToolFallbackRoot({
56
+ className,
57
+ open: controlledOpen,
58
+ onOpenChange: controlledOnOpenChange,
59
+ defaultOpen = false,
60
+ children,
61
+ ...props
62
+ }: ToolFallbackRootProps) {
63
+ const collapsibleRef = useRef<HTMLDivElement>(null);
64
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
65
+ const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
66
+
67
+ const isControlled = controlledOpen !== undefined;
68
+ const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
69
+
70
+ const handleOpenChange = useCallback(
71
+ (open: boolean) => {
72
+ lockScroll();
73
+ if (!isControlled) {
74
+ setUncontrolledOpen(open);
75
+ }
76
+ controlledOnOpenChange?.(open);
77
+ },
78
+ [lockScroll, isControlled, controlledOnOpenChange],
79
+ );
80
+
81
+ return (
82
+ <Collapsible
83
+ ref={collapsibleRef}
84
+ data-slot="tool-fallback-root"
85
+ open={isOpen}
86
+ onOpenChange={handleOpenChange}
87
+ className={cn(
88
+ "aui-tool-fallback-root group/tool-fallback-root w-full",
89
+ className,
90
+ )}
91
+ style={
92
+ {
93
+ "--animation-duration": `${ANIMATION_DURATION}ms`,
94
+ } as React.CSSProperties
95
+ }
96
+ {...props}
97
+ >
98
+ {children}
99
+ </Collapsible>
100
+ );
101
+ }
102
+
103
+ type ToolStatus = ToolCallMessagePartStatus["type"];
104
+
105
+ const statusIconMap: Record<ToolStatus, React.ElementType> = {
106
+ running: LoaderIcon,
107
+ complete: CheckIcon,
108
+ incomplete: XCircleIcon,
109
+ "requires-action": AlertCircleIcon,
110
+ };
111
+
112
+ const formatToolDuration = (ms: number) => {
113
+ if (ms < 1000) return "<1s";
114
+ const seconds = ms / 1000;
115
+ if (seconds < 10) return `${(Math.floor(seconds * 10) / 10).toFixed(1)}s`;
116
+ if (seconds < 60) return `${Math.floor(seconds)}s`;
117
+ return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
118
+ };
119
+
120
+ function ToolFallbackDuration({
121
+ className,
122
+ ...props
123
+ }: React.ComponentProps<"span">) {
124
+ const elapsedMs = useToolCallElapsed();
125
+ if (elapsedMs === undefined) return null;
126
+
127
+ return (
128
+ <span
129
+ data-slot="tool-fallback-duration"
130
+ className={cn(
131
+ "aui-tool-fallback-duration text-muted-foreground text-xs tabular-nums",
132
+ className,
133
+ )}
134
+ {...props}
135
+ >
136
+ {formatToolDuration(elapsedMs)}
137
+ </span>
138
+ );
139
+ }
140
+
141
+ function ToolFallbackTrigger({
142
+ toolName,
143
+ status,
144
+ className,
145
+ ...props
146
+ }: React.ComponentProps<typeof CollapsibleTrigger> & {
147
+ toolName: string;
148
+ status?: ToolCallMessagePartStatus;
149
+ }) {
150
+ const statusType = status?.type ?? "complete";
151
+ const isRunning = statusType === "running";
152
+ const isCancelled =
153
+ status?.type === "incomplete" && status.reason === "cancelled";
154
+
155
+ const Icon = statusIconMap[statusType];
156
+ const label =
157
+ statusType === "running"
158
+ ? "Running tool"
159
+ : statusType === "requires-action"
160
+ ? "Waiting on tool"
161
+ : statusType === "incomplete"
162
+ ? `${isCancelled ? "Cancelled" : "Failed"} tool`
163
+ : "Used tool";
164
+
165
+ return (
166
+ <CollapsibleTrigger
167
+ data-slot="tool-fallback-trigger"
168
+ className={cn(
169
+ "aui-tool-fallback-trigger group/trigger text-muted-foreground hover:text-foreground flex w-fit max-w-full origin-left items-center gap-2 py-1.5 text-sm transition-[color,scale] active:scale-[0.98]",
170
+ className,
171
+ )}
172
+ {...props}
173
+ >
174
+ <Icon
175
+ data-slot="tool-fallback-trigger-icon"
176
+ className={cn(
177
+ "aui-tool-fallback-trigger-icon size-4 shrink-0",
178
+ isCancelled && "text-muted-foreground",
179
+ isRunning && "animate-spin [animation-duration:0.6s]",
180
+ )}
181
+ />
182
+ <span
183
+ data-slot="tool-fallback-trigger-label"
184
+ className={cn(
185
+ "aui-tool-fallback-trigger-label-wrapper inline-block min-w-0 text-start leading-none wrap-anywhere",
186
+ isCancelled && "text-muted-foreground line-through",
187
+ isRunning && "shimmer motion-reduce:animate-none",
188
+ )}
189
+ >
190
+ {label}: <b>{toolName}</b>
191
+ </span>
192
+ <ToolFallbackDuration />
193
+ <ChevronDownIcon
194
+ data-slot="tool-fallback-trigger-chevron"
195
+ className={cn(
196
+ "aui-tool-fallback-trigger-chevron size-4 shrink-0",
197
+ "transition-transform duration-(--animation-duration) ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
198
+ "-rotate-90",
199
+ "group-data-open/trigger:rotate-0",
200
+ "group-data-panel-open/trigger:rotate-0",
201
+ )}
202
+ />
203
+ </CollapsibleTrigger>
204
+ );
205
+ }
206
+
207
+ function ToolFallbackContent({
208
+ className,
209
+ children,
210
+ ...props
211
+ }: React.ComponentProps<typeof CollapsibleContent>) {
212
+ return (
213
+ <CollapsibleContent
214
+ data-slot="tool-fallback-content"
215
+ className={cn(
216
+ "aui-tool-fallback-content relative overflow-hidden text-sm outline-none",
217
+ "group/collapsible-content ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none",
218
+ "data-closed:animate-collapsible-up",
219
+ "data-open:animate-collapsible-down",
220
+ "data-closed:fill-mode-forwards",
221
+ "data-closed:pointer-events-none",
222
+ "[--tw-duration:var(--animation-duration)]",
223
+ className,
224
+ )}
225
+ {...props}
226
+ >
227
+ <div
228
+ className={cn(
229
+ "flex flex-col gap-2 ps-6 pt-1 pb-2 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none",
230
+ "group-data-open/collapsible-content:animate-in group-data-open/collapsible-content:fade-in-0 group-data-open/collapsible-content:blur-in-[2px] group-data-open/collapsible-content:slide-in-from-top-1",
231
+ "group-data-closed/collapsible-content:animate-out group-data-closed/collapsible-content:fade-out-0 group-data-closed/collapsible-content:blur-out-[2px] group-data-closed/collapsible-content:slide-out-to-top-1",
232
+ "group-data-closed/collapsible-content:animation-duration-(--animation-duration) group-data-open/collapsible-content:animation-duration-(--animation-duration)",
233
+ )}
234
+ >
235
+ {children}
236
+ </div>
237
+ </CollapsibleContent>
238
+ );
239
+ }
240
+
241
+ function ToolFallbackArgs({
242
+ argsText,
243
+ className,
244
+ ...props
245
+ }: React.ComponentProps<"div"> & {
246
+ argsText?: string;
247
+ }) {
248
+ if (!argsText) return null;
249
+
250
+ return (
251
+ <div
252
+ data-slot="tool-fallback-args"
253
+ className={cn("aui-tool-fallback-args", className)}
254
+ {...props}
255
+ >
256
+ <pre className="aui-tool-fallback-args-value bg-muted/50 text-foreground/90 rounded-md p-2.5 text-xs break-words whitespace-pre-wrap">
257
+ {argsText}
258
+ </pre>
259
+ </div>
260
+ );
261
+ }
262
+
263
+ const formatUnknownValue = (value: unknown, space?: number): string => {
264
+ if (typeof value === "string") return value;
265
+
266
+ try {
267
+ if (value instanceof Error) return String(value);
268
+
269
+ const json = JSON.stringify(value, null, space);
270
+ if (json !== undefined) return json;
271
+ } catch {}
272
+
273
+ try {
274
+ return String(value);
275
+ } catch {
276
+ return "[Unserializable value]";
277
+ }
278
+ };
279
+
280
+ function ToolFallbackResult({
281
+ result,
282
+ className,
283
+ ...props
284
+ }: React.ComponentProps<"div"> & {
285
+ result?: unknown;
286
+ }) {
287
+ if (result === undefined) return null;
288
+
289
+ return (
290
+ <div
291
+ data-slot="tool-fallback-result"
292
+ className={cn("aui-tool-fallback-result", className)}
293
+ {...props}
294
+ >
295
+ <p className="aui-tool-fallback-result-header text-muted-foreground text-xs font-medium">
296
+ Result:
297
+ </p>
298
+ <pre className="aui-tool-fallback-result-content bg-muted/50 text-foreground/90 mt-1 rounded-md p-2.5 text-xs break-words whitespace-pre-wrap">
299
+ {formatUnknownValue(result, 2)}
300
+ </pre>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ function ToolFallbackError({
306
+ status,
307
+ className,
308
+ ...props
309
+ }: React.ComponentProps<"div"> & {
310
+ status?: ToolCallMessagePartStatus;
311
+ }) {
312
+ if (status?.type !== "incomplete") return null;
313
+
314
+ const error = status.error;
315
+ const errorText =
316
+ error === undefined || error === null ? null : formatUnknownValue(error);
317
+
318
+ if (!errorText) return null;
319
+
320
+ const isCancelled = status.reason === "cancelled";
321
+ const headerText = isCancelled ? "Cancelled reason:" : "Error:";
322
+
323
+ return (
324
+ <div
325
+ data-slot="tool-fallback-error"
326
+ className={cn("aui-tool-fallback-error", className)}
327
+ {...props}
328
+ >
329
+ <p className="aui-tool-fallback-error-header text-muted-foreground font-semibold">
330
+ {headerText}
331
+ </p>
332
+ <p className="aui-tool-fallback-error-reason text-muted-foreground break-words whitespace-pre-line">
333
+ {errorText}
334
+ </p>
335
+ </div>
336
+ );
337
+ }
338
+
339
+ const APPROVED_RESULT = "Approved by user";
340
+ const DENIED_RESULT = "User denied tool execution";
341
+
342
+ const APPROVAL_OPTION_DEFAULT_LABELS: Record<string, string> = {
343
+ "allow-once": "Allow",
344
+ "allow-always": "Always allow",
345
+ "reject-once": "Deny",
346
+ "reject-always": "Always deny",
347
+ };
348
+
349
+ const isKnownKind = (kind: string) =>
350
+ Object.hasOwn(APPROVAL_OPTION_DEFAULT_LABELS, kind);
351
+
352
+ const isAllowKind = (kind: string) =>
353
+ kind === "allow-once" || kind === "allow-always";
354
+
355
+ const approvalOptionLabel = (option: ToolApprovalOption) =>
356
+ option.label ??
357
+ (isKnownKind(option.kind)
358
+ ? APPROVAL_OPTION_DEFAULT_LABELS[option.kind]
359
+ : undefined) ??
360
+ option.id;
361
+
362
+ /**
363
+ * A request that declares how it wants to be presented is asking a question,
364
+ * not gating an action, so a refusal is not one of the answers it accepts
365
+ * unless the request declares itself dismissible.
366
+ */
367
+ const isQuestion = (approval: ToolCallMessagePart["approval"]) =>
368
+ approval?.display === "select" ||
369
+ approval?.display === "text" ||
370
+ approval?.display === "questions";
371
+
372
+ const questionAcceptsText = (question: ToolApprovalQuestion) =>
373
+ !question.options?.length || question.allowFreeform === true;
374
+
375
+ const answerLabels = (
376
+ question: ToolApprovalQuestion,
377
+ answer: ToolApprovalAnswer | undefined,
378
+ ) => [
379
+ ...(answer?.optionIds ?? []).map(
380
+ (id) => question.options?.find((option) => option.id === id)?.label ?? id,
381
+ ),
382
+ ...(answer?.text?.trim() ? [answer.text] : []),
383
+ ];
384
+
385
+ const isSettled = (approval: ToolCallMessagePart["approval"]) =>
386
+ approval != null &&
387
+ (approval.approved !== undefined || approval.resolution !== undefined);
388
+
389
+ type ApprovalReceipt = {
390
+ outcome: "allowed" | "refused" | "closed";
391
+ label: string;
392
+ option?: string | undefined;
393
+ };
394
+
395
+ /**
396
+ * A settled request reads as a past-tense record of what happened to it, so
397
+ * scrolling back never shows a live control for a decision already made.
398
+ */
399
+ const approvalReceipt = (
400
+ approval: NonNullable<ToolCallMessagePart["approval"]>,
401
+ ): ApprovalReceipt => {
402
+ if (approval.resolution !== undefined)
403
+ return {
404
+ outcome: "closed",
405
+ label:
406
+ approval.resolution === "cancelled"
407
+ ? "Cancelled before a decision"
408
+ : "Expired before a decision",
409
+ };
410
+
411
+ const chosen =
412
+ approval.optionId === undefined
413
+ ? undefined
414
+ : approval.options?.find((option) => option.id === approval.optionId);
415
+ const option =
416
+ chosen !== undefined
417
+ ? approvalOptionLabel(chosen)
418
+ : approval.optionId === undefined
419
+ ? undefined
420
+ : approval.optionId;
421
+ const answered =
422
+ isQuestion(approval) || (chosen !== undefined && !isKnownKind(chosen.kind));
423
+ const automatic = approval.isAutomatic ? " automatically" : "";
424
+
425
+ if (approval.approved)
426
+ return {
427
+ outcome: "allowed",
428
+ label: `${answered ? "Answered" : "Allowed"}${automatic}`,
429
+ option,
430
+ };
431
+ return {
432
+ outcome: "refused",
433
+ label: `${answered ? "Dismissed" : "Denied"}${automatic}`,
434
+ option,
435
+ };
436
+ };
437
+
438
+ const receiptIcons = {
439
+ allowed: CheckIcon,
440
+ refused: XCircleIcon,
441
+ closed: CircleMinusIcon,
442
+ } satisfies Record<ApprovalReceipt["outcome"], React.ElementType>;
443
+
444
+ function ToolFallbackApprovalReceipt({
445
+ approval,
446
+ className,
447
+ focusReceiptRef,
448
+ receiptRef,
449
+ ...props
450
+ }: React.ComponentPropsWithoutRef<"div"> & {
451
+ approval: NonNullable<ToolCallMessagePart["approval"]>;
452
+ focusReceiptRef: React.MutableRefObject<string | null | undefined>;
453
+ receiptRef: React.MutableRefObject<HTMLDivElement | null>;
454
+ }) {
455
+ useLayoutEffect(() => {
456
+ const shouldFocus = focusReceiptRef.current === approval.id;
457
+ focusReceiptRef.current = null;
458
+ if (shouldFocus) receiptRef.current?.focus({ preventScroll: true });
459
+ }, [approval.id, focusReceiptRef, receiptRef]);
460
+
461
+ const receipt = approvalReceipt(approval);
462
+ const Icon = receiptIcons[receipt.outcome];
463
+ const notes = [
464
+ ...new Set(
465
+ [approval.text, approval.reason].filter(
466
+ (value): value is string => typeof value === "string" && value !== "",
467
+ ),
468
+ ),
469
+ ];
470
+
471
+ return (
472
+ <div
473
+ ref={receiptRef}
474
+ data-slot="tool-fallback-approval-receipt"
475
+ data-outcome={receipt.outcome}
476
+ tabIndex={-1}
477
+ className={cn(
478
+ "aui-tool-fallback-approval-receipt flex flex-col gap-1.5 pt-1",
479
+ className,
480
+ )}
481
+ {...props}
482
+ >
483
+ {approval.prompt ? (
484
+ <p className="aui-tool-fallback-approval-prompt text-muted-foreground whitespace-pre-line">
485
+ {approval.prompt}
486
+ </p>
487
+ ) : null}
488
+ <p className="aui-tool-fallback-approval-receipt-label flex items-center gap-1.5">
489
+ <Icon aria-hidden className="text-muted-foreground size-3.5 shrink-0" />
490
+ <span className="font-medium">{receipt.label}</span>
491
+ {receipt.option !== undefined ? (
492
+ <span className="text-muted-foreground">· {receipt.option}</span>
493
+ ) : null}
494
+ </p>
495
+ {approval.answers &&
496
+ approval.questions?.map((question) => {
497
+ const labels = answerLabels(
498
+ question,
499
+ approval.answers && Object.hasOwn(approval.answers, question.id)
500
+ ? approval.answers[question.id]
501
+ : undefined,
502
+ );
503
+ if (labels.length === 0) return null;
504
+ return (
505
+ <p
506
+ key={question.id}
507
+ className="aui-tool-fallback-approval-receipt-answer whitespace-pre-line"
508
+ >
509
+ <span className="text-muted-foreground">
510
+ {question.header ?? question.prompt}
511
+ </span>{" "}
512
+ · {labels.join(", ")}
513
+ </p>
514
+ );
515
+ })}
516
+ {notes.map((text) => (
517
+ <p
518
+ key={text}
519
+ className="aui-tool-fallback-approval-receipt-note text-muted-foreground whitespace-pre-line"
520
+ >
521
+ {text}
522
+ </p>
523
+ ))}
524
+ </div>
525
+ );
526
+ }
527
+
528
+ function ToolFallbackApprovalQuestions({
529
+ questions,
530
+ dismissible,
531
+ locked,
532
+ onSend,
533
+ onDismiss,
534
+ }: {
535
+ questions: readonly ToolApprovalQuestion[];
536
+ dismissible: boolean;
537
+ locked: boolean;
538
+ onSend: (answers: Record<string, ToolApprovalAnswer>) => void;
539
+ onDismiss: () => void;
540
+ }) {
541
+ const [selected, setSelected] = useState<
542
+ ReadonlyMap<string, readonly string[]>
543
+ >(() => new Map());
544
+ const [typed, setTyped] = useState<ReadonlyMap<string, string>>(
545
+ () => new Map(),
546
+ );
547
+
548
+ const toggle = (question: ToolApprovalQuestion, optionId: string) =>
549
+ setSelected((current) => {
550
+ const chosen = current.get(question.id) ?? [];
551
+ const next = chosen.includes(optionId)
552
+ ? chosen.filter((id) => id !== optionId)
553
+ : question.multiple
554
+ ? [...chosen, optionId]
555
+ : [optionId];
556
+ return new Map(current).set(question.id, next);
557
+ });
558
+
559
+ const answerOf = (question: ToolApprovalQuestion): ToolApprovalAnswer => {
560
+ const optionIds = selected.get(question.id) ?? [];
561
+ const draft = typed.get(question.id);
562
+ const text = draft?.trim() ? draft : undefined;
563
+ return {
564
+ ...(optionIds.length > 0 && { optionIds }),
565
+ ...(text !== undefined && { text }),
566
+ };
567
+ };
568
+
569
+ const complete =
570
+ questions.length > 0 &&
571
+ questions.every((question) => {
572
+ const answer = answerOf(question);
573
+ return answer.optionIds !== undefined || answer.text !== undefined;
574
+ });
575
+
576
+ const send = () => {
577
+ if (locked || !complete) return;
578
+ onSend(
579
+ Object.fromEntries(
580
+ questions.map((question) => [question.id, answerOf(question)]),
581
+ ),
582
+ );
583
+ };
584
+
585
+ return (
586
+ <>
587
+ {questions.map((question) => {
588
+ const chosen = selected.get(question.id) ?? [];
589
+ const described =
590
+ question.options?.some((option) => option.description) ?? false;
591
+ return (
592
+ <div
593
+ key={question.id}
594
+ role="group"
595
+ aria-label={question.prompt}
596
+ data-slot="tool-fallback-approval-question"
597
+ className="aui-tool-fallback-approval-question flex flex-col gap-2"
598
+ >
599
+ <p className="aui-tool-fallback-approval-question-prompt text-foreground whitespace-pre-line">
600
+ {question.header ? (
601
+ <span className="text-muted-foreground me-1.5 text-xs font-medium uppercase">
602
+ {question.header}
603
+ </span>
604
+ ) : null}
605
+ {question.prompt}
606
+ </p>
607
+ {question.options && question.options.length > 0 ? (
608
+ <div
609
+ className={cn(
610
+ "flex gap-2",
611
+ described
612
+ ? "flex-col items-stretch"
613
+ : "flex-wrap items-center",
614
+ )}
615
+ >
616
+ {question.options.map((option) => {
617
+ const pressed = chosen.includes(option.id);
618
+ return (
619
+ <Button
620
+ key={option.id}
621
+ size="sm"
622
+ variant={pressed ? "default" : "outline"}
623
+ className={cn(
624
+ pressable,
625
+ described &&
626
+ "h-auto flex-col items-start gap-0.5 py-2 text-start whitespace-normal",
627
+ )}
628
+ aria-pressed={pressed}
629
+ onClick={() => toggle(question, option.id)}
630
+ disabled={locked}
631
+ >
632
+ <span>{option.label}</span>
633
+ {option.description ? (
634
+ <span className="text-xs font-normal opacity-80">
635
+ {option.description}
636
+ </span>
637
+ ) : null}
638
+ </Button>
639
+ );
640
+ })}
641
+ </div>
642
+ ) : null}
643
+ {questionAcceptsText(question) ? (
644
+ <Textarea
645
+ value={typed.get(question.id) ?? ""}
646
+ onChange={(event) =>
647
+ setTyped((current) =>
648
+ new Map(current).set(question.id, event.target.value),
649
+ )
650
+ }
651
+ disabled={locked}
652
+ aria-label={question.prompt}
653
+ placeholder={
654
+ question.options?.length
655
+ ? "Or type an answer"
656
+ : "Type your answer"
657
+ }
658
+ />
659
+ ) : null}
660
+ </div>
661
+ );
662
+ })}
663
+ <div className="flex items-center gap-2">
664
+ <Button
665
+ size="sm"
666
+ className={pressable}
667
+ onClick={send}
668
+ disabled={locked || !complete}
669
+ >
670
+ Send
671
+ </Button>
672
+ {dismissible ? (
673
+ <Button
674
+ size="sm"
675
+ variant="outline"
676
+ className={pressable}
677
+ onClick={onDismiss}
678
+ disabled={locked}
679
+ >
680
+ Dismiss
681
+ </Button>
682
+ ) : null}
683
+ </div>
684
+ </>
685
+ );
686
+ }
687
+
688
+ const offersInterruptAction = (
689
+ status: ToolCallMessagePartStatus | undefined,
690
+ approval: ToolCallMessagePart["approval"],
691
+ interrupt: ToolCallMessagePart["interrupt"],
692
+ ) =>
693
+ status?.type !== "requires-action" ||
694
+ status.reason !== "interrupt" ||
695
+ approval != null ||
696
+ interrupt != null;
697
+
698
+ type ToolFallbackApprovalProps = React.ComponentPropsWithoutRef<"div"> &
699
+ Partial<
700
+ Pick<
701
+ ToolCallMessagePartProps,
702
+ "addResult" | "resume" | "respondToApproval" | "status"
703
+ >
704
+ > & {
705
+ interrupt?: ToolCallMessagePart["interrupt"];
706
+ approval?: ToolCallMessagePart["approval"];
707
+ };
708
+
709
+ const ToolFallbackApproval = forwardRef<
710
+ HTMLDivElement,
711
+ ToolFallbackApprovalProps
712
+ >(function ToolFallbackApproval(props, ref) {
713
+ const carryFocusRef = useRef(false);
714
+ return (
715
+ <ToolFallbackApprovalImpl
716
+ key={props.approval?.id}
717
+ carryFocusRef={carryFocusRef}
718
+ forwardedRef={ref}
719
+ {...props}
720
+ />
721
+ );
722
+ });
723
+
724
+ function ToolFallbackApprovalImpl({
725
+ className,
726
+ forwardedRef,
727
+ carryFocusRef,
728
+ addResult,
729
+ resume,
730
+ interrupt,
731
+ approval,
732
+ respondToApproval,
733
+ status,
734
+ ...props
735
+ }: Omit<ToolFallbackApprovalProps, "ref"> & {
736
+ forwardedRef: React.ForwardedRef<HTMLDivElement>;
737
+ carryFocusRef: React.MutableRefObject<boolean>;
738
+ }) {
739
+ const [submitted, setSubmitted] = useState(false);
740
+ const voiceActive = useAuiState((s) => s.thread.voice !== undefined);
741
+ const canAnswer = useAuiState((s) => s.thread.capabilities.answerToolCall);
742
+ const locked = submitted || voiceActive;
743
+ const [confirmingId, setConfirmingId] = useState<string | null>(null);
744
+ const [answer, setAnswer] = useState("");
745
+ const [error, setError] = useState<string | null>(null);
746
+ const isRequiresAction = status?.type === "requires-action";
747
+ const [prevRequiresAction, setPrevRequiresAction] =
748
+ useState(isRequiresAction);
749
+ const [attempt, setAttempt] = useState(0);
750
+ const attemptRef = useRef(attempt);
751
+ useLayoutEffect(() => {
752
+ attemptRef.current = attempt;
753
+ }, [attempt]);
754
+ if (isRequiresAction !== prevRequiresAction) {
755
+ setPrevRequiresAction(isRequiresAction);
756
+ if (isRequiresAction) {
757
+ setSubmitted(false);
758
+ setConfirmingId(null);
759
+ setAnswer("");
760
+ setError(null);
761
+ setAttempt(attempt + 1);
762
+ }
763
+ }
764
+ const pendingGroupRef = useRef<HTMLDivElement | null>(null);
765
+ const receiptRef = useRef<HTMLDivElement | null>(null);
766
+ const focusReceiptRef = useRef<string | null | undefined>(null);
767
+ useLayoutEffect(() => {
768
+ if (carryFocusRef.current) {
769
+ carryFocusRef.current = false;
770
+ pendingGroupRef.current?.focus({ preventScroll: true });
771
+ }
772
+ return () => {
773
+ const element = pendingGroupRef.current ?? receiptRef.current;
774
+ carryFocusRef.current =
775
+ element?.contains(element.ownerDocument.activeElement) ?? false;
776
+ };
777
+ }, [carryFocusRef]);
778
+ useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(
779
+ forwardedRef,
780
+ () => pendingGroupRef.current ?? receiptRef.current,
781
+ );
782
+ const setPendingGroup = useCallback(
783
+ (element: HTMLDivElement | null) => {
784
+ const group = pendingGroupRef.current;
785
+ focusReceiptRef.current =
786
+ !element && group?.contains(group.ownerDocument.activeElement)
787
+ ? approval?.id
788
+ : null;
789
+ pendingGroupRef.current = element;
790
+ },
791
+ [approval?.id],
792
+ );
793
+ const hidePendingApproval =
794
+ approval != null &&
795
+ !isSettled(approval) &&
796
+ status !== undefined &&
797
+ status.type !== "requires-action" &&
798
+ !(submitted && status.type === "running");
799
+ useLayoutEffect(() => {
800
+ if (hidePendingApproval) focusReceiptRef.current = null;
801
+ }, [hidePendingApproval]);
802
+ const focusPendingGroup = () => {
803
+ const group = pendingGroupRef.current;
804
+ if (group?.contains(group.ownerDocument.activeElement)) {
805
+ group.focus({ preventScroll: true });
806
+ }
807
+ };
808
+
809
+ if (approval != null && isSettled(approval))
810
+ return (
811
+ <ToolFallbackApprovalReceipt
812
+ approval={approval}
813
+ receiptRef={receiptRef}
814
+ focusReceiptRef={focusReceiptRef}
815
+ className={className}
816
+ {...props}
817
+ />
818
+ );
819
+
820
+ if (
821
+ hidePendingApproval ||
822
+ !offersInterruptAction(status, approval, interrupt)
823
+ )
824
+ return null;
825
+
826
+ const promptText = approval?.prompt ? (
827
+ <p className="aui-tool-fallback-approval-prompt text-foreground whitespace-pre-line">
828
+ {approval.prompt}
829
+ </p>
830
+ ) : null;
831
+
832
+ if (!canAnswer)
833
+ return (
834
+ promptText && (
835
+ <div
836
+ data-slot="tool-fallback-approval"
837
+ tabIndex={-1}
838
+ className={cn(
839
+ "aui-tool-fallback-approval flex flex-col gap-2 pt-1",
840
+ className,
841
+ )}
842
+ {...props}
843
+ ref={setPendingGroup}
844
+ >
845
+ {promptText}
846
+ </div>
847
+ )
848
+ );
849
+
850
+ // A declared option list is a host constraint: the kit never adds an
851
+ // approval path beyond it, and preserves a refusal path only where the
852
+ // request is an action the user may refuse.
853
+ const declaredOptions = respondToApproval ? approval?.options : undefined;
854
+ const acceptsText =
855
+ approval != null &&
856
+ respondToApproval != null &&
857
+ toolApprovalAcceptsText(approval);
858
+
859
+ // A refused response leaves the request open, so the controls come back
860
+ // rather than staying spent on a decision the runtime never recorded.
861
+ const submit = (send: () => Promise<void> | void) => {
862
+ focusPendingGroup();
863
+ setSubmitted(true);
864
+ setConfirmingId(null);
865
+ setError(null);
866
+ void (async () => {
867
+ try {
868
+ await send();
869
+ } catch (sendError) {
870
+ if (attemptRef.current !== attempt) return;
871
+ setSubmitted(false);
872
+ setError(
873
+ sendError instanceof Error ? sendError.message : String(sendError),
874
+ );
875
+ }
876
+ })();
877
+ };
878
+
879
+ const respond = (approved: boolean) => {
880
+ if (locked) return;
881
+ if (
882
+ approval != null &&
883
+ approval.approved === undefined &&
884
+ respondToApproval
885
+ ) {
886
+ submit(() => respondToApproval({ approved, ...typedNote() }));
887
+ } else if (interrupt) {
888
+ submit(() => resume?.({ approved }));
889
+ } else if (
890
+ status?.type === "requires-action" &&
891
+ status.reason === "interrupt"
892
+ ) {
893
+ return;
894
+ } else {
895
+ submit(() => addResult?.(approved ? APPROVED_RESULT : DENIED_RESULT));
896
+ }
897
+ };
898
+
899
+ const respondWithOption = (option: ToolApprovalOption) => {
900
+ if (locked) return;
901
+ // A custom kind has no decision class for the runtime to derive, and
902
+ // responding without one throws; picking a declared option is an answer,
903
+ // so it resolves as approved.
904
+ submit(() =>
905
+ respondToApproval?.(
906
+ isKnownKind(option.kind)
907
+ ? { optionId: option.id, ...typedNote() }
908
+ : { optionId: option.id, approved: true, ...typedNote() },
909
+ ),
910
+ );
911
+ };
912
+
913
+ const typedNote = () => (answer.trim() ? { text: answer } : {});
914
+
915
+ // The kit does not validate an answer the request never constrained: a host
916
+ // that cannot record an empty one rejects it, which reopens the controls.
917
+ const submitAnswer = () => {
918
+ if (locked) return;
919
+ submit(() => respondToApproval?.({ text: answer }));
920
+ };
921
+
922
+ // A dismissal is no answer at all, so a typed draft does not travel with it.
923
+ const dismiss = () => {
924
+ if (locked) return;
925
+ submit(() => respondToApproval?.({ approved: false }));
926
+ };
927
+
928
+ const handleOption = (option: ToolApprovalOption) => {
929
+ if (option.confirm) {
930
+ focusPendingGroup();
931
+ setConfirmingId(option.id);
932
+ } else {
933
+ respondWithOption(option);
934
+ }
935
+ };
936
+
937
+ const confirming =
938
+ confirmingId != null
939
+ ? declaredOptions?.find((o) => o.id === confirmingId)
940
+ : undefined;
941
+
942
+ const question = isQuestion(approval);
943
+ const dismissible =
944
+ question && respondToApproval != null && approval?.dismissible === true;
945
+
946
+ const dismissButton = dismissible ? (
947
+ <Button
948
+ size="sm"
949
+ variant="outline"
950
+ className={pressable}
951
+ onClick={dismiss}
952
+ disabled={locked}
953
+ >
954
+ Dismiss
955
+ </Button>
956
+ ) : null;
957
+
958
+ const errorText = error ? (
959
+ <p
960
+ role="alert"
961
+ className="aui-tool-fallback-approval-error text-destructive text-xs whitespace-pre-line"
962
+ >
963
+ {error}
964
+ </p>
965
+ ) : null;
966
+
967
+ const answerField = acceptsText ? (
968
+ <div className="aui-tool-fallback-approval-answer flex flex-col items-start gap-2">
969
+ <Textarea
970
+ value={answer}
971
+ onChange={(event) => setAnswer(event.target.value)}
972
+ disabled={locked}
973
+ aria-label={question ? (approval?.prompt ?? "Answer") : "Note"}
974
+ placeholder={
975
+ question ? "Type your answer" : "Add a note to your decision"
976
+ }
977
+ />
978
+ {question && (
979
+ <div className="flex items-center gap-2">
980
+ <Button
981
+ size="sm"
982
+ className={pressable}
983
+ onClick={submitAnswer}
984
+ disabled={locked}
985
+ >
986
+ Send
987
+ </Button>
988
+ {dismissButton}
989
+ </div>
990
+ )}
991
+ </div>
992
+ ) : null;
993
+
994
+ if (approval?.display === "questions" && respondToApproval) {
995
+ return (
996
+ <div
997
+ data-slot="tool-fallback-approval"
998
+ tabIndex={-1}
999
+ className={cn(
1000
+ "aui-tool-fallback-approval flex flex-col gap-3 pt-1",
1001
+ className,
1002
+ )}
1003
+ {...props}
1004
+ ref={setPendingGroup}
1005
+ >
1006
+ {promptText}
1007
+ <ToolFallbackApprovalQuestions
1008
+ questions={approval.questions ?? []}
1009
+ dismissible={approval.dismissible === true}
1010
+ locked={locked}
1011
+ onSend={(answers) => submit(() => respondToApproval({ answers }))}
1012
+ onDismiss={dismiss}
1013
+ />
1014
+ {errorText}
1015
+ </div>
1016
+ );
1017
+ }
1018
+
1019
+ if (confirming) {
1020
+ const confirmMeta =
1021
+ typeof confirming.confirm === "object" ? confirming.confirm : undefined;
1022
+ const confirmDescription =
1023
+ confirmMeta?.description ?? confirming.description;
1024
+ return (
1025
+ <div
1026
+ data-slot="tool-fallback-approval-confirm"
1027
+ tabIndex={-1}
1028
+ className={cn(
1029
+ "aui-tool-fallback-approval-confirm flex flex-col gap-2 pt-1",
1030
+ className,
1031
+ )}
1032
+ {...props}
1033
+ ref={setPendingGroup}
1034
+ >
1035
+ <p className="aui-tool-fallback-approval-confirm-title font-semibold">
1036
+ {confirmMeta?.title ?? `${approvalOptionLabel(confirming)}?`}
1037
+ </p>
1038
+ {confirmDescription && (
1039
+ <p className="aui-tool-fallback-approval-confirm-description text-muted-foreground whitespace-pre-line">
1040
+ {confirmDescription}
1041
+ </p>
1042
+ )}
1043
+ {confirming.grants && confirming.grants.length > 0 && (
1044
+ <ul className="aui-tool-fallback-approval-confirm-grants flex flex-col gap-1">
1045
+ {confirming.grants.map((grant) => (
1046
+ <li key={grant}>
1047
+ <code className="aui-tool-fallback-approval-confirm-grant bg-muted rounded px-1.5 py-0.5 text-xs">
1048
+ {grant}
1049
+ </code>
1050
+ </li>
1051
+ ))}
1052
+ </ul>
1053
+ )}
1054
+ <div className="flex items-center gap-2">
1055
+ <Button
1056
+ size="sm"
1057
+ className={pressable}
1058
+ onClick={() => respondWithOption(confirming)}
1059
+ disabled={locked}
1060
+ >
1061
+ Confirm
1062
+ </Button>
1063
+ <Button
1064
+ size="sm"
1065
+ variant="outline"
1066
+ className={pressable}
1067
+ onClick={() => {
1068
+ focusPendingGroup();
1069
+ setConfirmingId(null);
1070
+ }}
1071
+ disabled={locked}
1072
+ >
1073
+ Back
1074
+ </Button>
1075
+ </div>
1076
+ </div>
1077
+ );
1078
+ }
1079
+
1080
+ if (declaredOptions && declaredOptions.length > 0) {
1081
+ const allowOptions = declaredOptions.filter((o) => isAllowKind(o.kind));
1082
+ const customOptions = declaredOptions.filter((o) => !isKnownKind(o.kind));
1083
+ const rejectOptions = declaredOptions.filter(
1084
+ (o) => isKnownKind(o.kind) && !isAllowKind(o.kind),
1085
+ );
1086
+ return (
1087
+ <div
1088
+ data-slot="tool-fallback-approval"
1089
+ tabIndex={-1}
1090
+ className={cn(
1091
+ "aui-tool-fallback-approval flex flex-col gap-2 pt-1",
1092
+ className,
1093
+ )}
1094
+ {...props}
1095
+ ref={setPendingGroup}
1096
+ >
1097
+ {promptText}
1098
+ <div className="flex flex-wrap items-center gap-2">
1099
+ {[...allowOptions, ...customOptions, ...rejectOptions].map(
1100
+ (option) => (
1101
+ <Button
1102
+ key={option.id}
1103
+ size="sm"
1104
+ variant={option === allowOptions[0] ? "default" : "outline"}
1105
+ className={pressable}
1106
+ onClick={() => handleOption(option)}
1107
+ disabled={locked}
1108
+ >
1109
+ {approvalOptionLabel(option)}
1110
+ </Button>
1111
+ ),
1112
+ )}
1113
+ {rejectOptions.length === 0 && !question && (
1114
+ <Button
1115
+ size="sm"
1116
+ variant="outline"
1117
+ className={pressable}
1118
+ onClick={() => respond(false)}
1119
+ disabled={locked}
1120
+ >
1121
+ Deny
1122
+ </Button>
1123
+ )}
1124
+ {!acceptsText && dismissButton}
1125
+ </div>
1126
+ {answerField}
1127
+ {errorText}
1128
+ </div>
1129
+ );
1130
+ }
1131
+
1132
+ // A question carries no decision to fabricate, so it renders only what the
1133
+ // request declared, even when that leaves nothing to act on here.
1134
+ if (question) {
1135
+ return (
1136
+ <div
1137
+ data-slot="tool-fallback-approval"
1138
+ tabIndex={-1}
1139
+ className={cn(
1140
+ "aui-tool-fallback-approval flex flex-col gap-2 pt-1",
1141
+ className,
1142
+ )}
1143
+ {...props}
1144
+ ref={setPendingGroup}
1145
+ >
1146
+ {promptText}
1147
+ {answerField}
1148
+ {!acceptsText && dismissButton && (
1149
+ <div className="flex items-center gap-2">{dismissButton}</div>
1150
+ )}
1151
+ {errorText}
1152
+ </div>
1153
+ );
1154
+ }
1155
+
1156
+ return (
1157
+ <div
1158
+ data-slot="tool-fallback-approval"
1159
+ tabIndex={-1}
1160
+ className={cn(
1161
+ "aui-tool-fallback-approval flex flex-col gap-2 pt-1",
1162
+ className,
1163
+ )}
1164
+ {...props}
1165
+ ref={setPendingGroup}
1166
+ >
1167
+ {promptText}
1168
+ <div className="flex items-center gap-2">
1169
+ <Button
1170
+ size="sm"
1171
+ className={pressable}
1172
+ onClick={() => respond(true)}
1173
+ disabled={locked}
1174
+ >
1175
+ Allow
1176
+ </Button>
1177
+ <Button
1178
+ size="sm"
1179
+ variant="outline"
1180
+ className={pressable}
1181
+ onClick={() => respond(false)}
1182
+ disabled={locked}
1183
+ >
1184
+ Deny
1185
+ </Button>
1186
+ </div>
1187
+ {answerField}
1188
+ {errorText}
1189
+ </div>
1190
+ );
1191
+ }
1192
+
1193
+ const ToolFallbackImpl: ToolCallMessagePartComponent = ({
1194
+ toolName,
1195
+ argsText,
1196
+ result,
1197
+ status,
1198
+ addResult,
1199
+ resume,
1200
+ interrupt,
1201
+ approval,
1202
+ respondToApproval,
1203
+ }) => {
1204
+ const isCancelled =
1205
+ status?.type === "incomplete" && status.reason === "cancelled";
1206
+ const isRequiresAction = status?.type === "requires-action";
1207
+ const shouldRenderApproval =
1208
+ approval != null ||
1209
+ (isRequiresAction && offersInterruptAction(status, approval, interrupt));
1210
+
1211
+ const [open, setOpen] = useState(isRequiresAction);
1212
+ const [prevRequiresAction, setPrevRequiresAction] =
1213
+ useState(isRequiresAction);
1214
+ if (isRequiresAction !== prevRequiresAction) {
1215
+ setPrevRequiresAction(isRequiresAction);
1216
+ if (isRequiresAction) setOpen(true);
1217
+ }
1218
+ const approvalHadFocusRef = useRef(false);
1219
+ const approvalElementRef = useRef<HTMLDivElement | null>(null);
1220
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
1221
+
1222
+ useLayoutEffect(() => {
1223
+ const focusedElement = approvalElementRef.current;
1224
+ if (
1225
+ !focusedElement ||
1226
+ focusedElement.isConnected ||
1227
+ isRequiresAction ||
1228
+ !approvalHadFocusRef.current
1229
+ ) {
1230
+ return;
1231
+ }
1232
+
1233
+ const shouldFocusTrigger = approval == null || !isSettled(approval);
1234
+ approvalHadFocusRef.current = false;
1235
+ const { activeElement, body } = focusedElement.ownerDocument;
1236
+ if (
1237
+ shouldFocusTrigger &&
1238
+ (activeElement == null || activeElement === body)
1239
+ ) {
1240
+ triggerRef.current?.focus({ preventScroll: true });
1241
+ }
1242
+ }, [approval, isRequiresAction, status?.type]);
1243
+
1244
+ return (
1245
+ <ToolFallbackRoot open={open} onOpenChange={setOpen}>
1246
+ <ToolFallbackTrigger toolName={toolName} status={status} />
1247
+ <ToolFallbackContent>
1248
+ <ToolFallbackError status={status} />
1249
+ <ToolFallbackArgs
1250
+ argsText={argsText}
1251
+ className={cn(isCancelled && "opacity-60")}
1252
+ />
1253
+ {shouldRenderApproval && (
1254
+ <ToolFallbackApproval
1255
+ addResult={addResult}
1256
+ resume={resume}
1257
+ interrupt={interrupt}
1258
+ approval={approval}
1259
+ respondToApproval={respondToApproval}
1260
+ status={status}
1261
+ onFocusCapture={(event) => {
1262
+ const root = event.currentTarget.closest(
1263
+ '[data-slot="tool-fallback-root"]',
1264
+ );
1265
+ triggerRef.current =
1266
+ root?.querySelector<HTMLButtonElement>(
1267
+ '[data-slot="tool-fallback-trigger"]',
1268
+ ) ?? null;
1269
+ approvalElementRef.current = event.currentTarget;
1270
+ approvalHadFocusRef.current = true;
1271
+ }}
1272
+ onBlurCapture={(event) => {
1273
+ const relatedTarget = event.relatedTarget;
1274
+ const stillFocused = relatedTarget
1275
+ ? event.currentTarget.contains(relatedTarget as Node)
1276
+ : event.currentTarget.contains(
1277
+ event.currentTarget.ownerDocument.activeElement,
1278
+ );
1279
+ if (!stillFocused) approvalHadFocusRef.current = false;
1280
+ }}
1281
+ />
1282
+ )}
1283
+ <ToolFallbackResult result={result} />
1284
+ </ToolFallbackContent>
1285
+ </ToolFallbackRoot>
1286
+ );
1287
+ };
1288
+
1289
+ const ToolFallback = memo(
1290
+ ToolFallbackImpl,
1291
+ ) as unknown as ToolCallMessagePartComponent & {
1292
+ Root: typeof ToolFallbackRoot;
1293
+ Trigger: typeof ToolFallbackTrigger;
1294
+ Content: typeof ToolFallbackContent;
1295
+ Args: typeof ToolFallbackArgs;
1296
+ Result: typeof ToolFallbackResult;
1297
+ Error: typeof ToolFallbackError;
1298
+ Approval: typeof ToolFallbackApproval;
1299
+ };
1300
+
1301
+ ToolFallback.displayName = "ToolFallback";
1302
+ ToolFallback.Root = ToolFallbackRoot;
1303
+ ToolFallback.Trigger = ToolFallbackTrigger;
1304
+ ToolFallback.Content = ToolFallbackContent;
1305
+ ToolFallback.Args = ToolFallbackArgs;
1306
+ ToolFallback.Result = ToolFallbackResult;
1307
+ ToolFallback.Error = ToolFallbackError;
1308
+ ToolFallback.Approval = ToolFallbackApproval;
1309
+
1310
+ export {
1311
+ formatUnknownValue,
1312
+ offersInterruptAction,
1313
+ ToolFallback,
1314
+ ToolFallbackRoot,
1315
+ ToolFallbackTrigger,
1316
+ ToolFallbackContent,
1317
+ ToolFallbackArgs,
1318
+ ToolFallbackResult,
1319
+ ToolFallbackError,
1320
+ ToolFallbackApproval,
1321
+ };