assistant-ui 0.0.120 → 0.0.121

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