From 248d79e936ae09bbfb4c1fcd325d3c235abe3c40 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Sat, 17 Oct 2026 17:22:23 +0200 Subject: [PATCH 1/3] feat(agent): add approval cards, response feedback, and error recovery Co-Authored-By: Claude Opus 5.5 --- apps/ui/public/r/agent.json | 44 ++ .../default/agent/agent-approval-card.tsx | 413 ++++++++++++++++++ .../default/agent/agent-error-recovery.tsx | 86 ++++ .../default/agent/agent-response-feedback.tsx | 288 ++++++++++++ .../agent/hooks/use-agent-pending-choice.ts | 43 ++ .../agent/lib/get-agent-approval-state.ts | 80 ++++ .../default/agent/lib/get-agent-feedback.ts | 50 +++ apps/ui/registry/registry-agent.ts | 38 ++ .../get-agent-approval-group-shared.test.ts | 56 +++ .../agent/get-agent-approval-state.test.ts | 37 ++ .../ui/tests/agent/get-agent-feedback.test.ts | 79 ++++ packages/ui/src/agent/agent-approval-card.tsx | 413 ++++++++++++++++++ .../ui/src/agent/agent-error-recovery.tsx | 86 ++++ .../ui/src/agent/agent-response-feedback.tsx | 288 ++++++++++++ .../agent/hooks/use-agent-pending-choice.ts | 43 ++ .../src/agent/lib/get-agent-approval-state.ts | 80 ++++ .../ui/src/agent/lib/get-agent-feedback.ts | 50 +++ 17 files changed, 2174 insertions(+) create mode 100644 apps/ui/registry/default/agent/agent-approval-card.tsx create mode 100644 apps/ui/registry/default/agent/agent-error-recovery.tsx create mode 100644 apps/ui/registry/default/agent/agent-response-feedback.tsx create mode 100644 apps/ui/registry/default/agent/hooks/use-agent-pending-choice.ts create mode 100644 apps/ui/registry/default/agent/lib/get-agent-approval-state.ts create mode 100644 apps/ui/registry/default/agent/lib/get-agent-feedback.ts create mode 100644 apps/ui/tests/agent/get-agent-approval-group-shared.test.ts create mode 100644 apps/ui/tests/agent/get-agent-approval-state.test.ts create mode 100644 apps/ui/tests/agent/get-agent-feedback.test.ts create mode 100644 packages/ui/src/agent/agent-approval-card.tsx create mode 100644 packages/ui/src/agent/agent-error-recovery.tsx create mode 100644 packages/ui/src/agent/agent-response-feedback.tsx create mode 100644 packages/ui/src/agent/hooks/use-agent-pending-choice.ts create mode 100644 packages/ui/src/agent/lib/get-agent-approval-state.ts create mode 100644 packages/ui/src/agent/lib/get-agent-feedback.ts diff --git a/apps/ui/public/r/agent.json b/apps/ui/public/r/agent.json index e1778aaac..a78f03e4b 100644 --- a/apps/ui/public/r/agent.json +++ b/apps/ui/public/r/agent.json @@ -7,9 +7,17 @@ "@base-ui/react" ], "registryDependencies": [ + "@coss/badge", "@coss/button", + "@coss/card", "@coss/chat", "@coss/composer", + "@coss/dialog", + "@coss/field", + "@coss/fieldset", + "@coss/form", + "@coss/textarea", + "@coss/toggle", "@coss/toolbar", "@coss/tooltip", "@coss/use-copy-to-clipboard", @@ -23,12 +31,24 @@ "type": "registry:component", "target": "@components/agent/agent-announcer.tsx" }, + { + "path": "registry/default/agent/agent-approval-card.tsx", + "content": "\"use client\";\n\nimport {\n CheckIcon,\n CircleAlertIcon,\n InfoIcon,\n Loader2Icon,\n XIcon,\n} from \"lucide-react\";\nimport {\n type ComponentProps,\n type ReactElement,\n useEffect,\n useId,\n useRef,\n} from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Badge } from \"@/registry/default/ui/badge\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Card,\n CardFrame,\n CardFrameFooter,\n CardPanel,\n} from \"@/registry/default/ui/card\";\nimport { useAgentPendingChoice } from \"./hooks/use-agent-pending-choice\";\nimport {\n type AgentApprovalDecision,\n type AgentApprovalGroupItem,\n type AgentApprovalPrompt,\n type AgentApprovalState,\n getAgentApprovalGroupShared,\n getAgentApprovalState,\n} from \"./lib/get-agent-approval-state\";\n\ntype AgentApprovalBaseProps = Omit<\n ComponentProps,\n \"children\"\n> & {\n /** Called with the person’s answer. Return a promise to show the card responding until it settles; a rejection shows the error. */\n onRespond: (decision: AgentApprovalDecision) => void | Promise;\n /** The answer your agent runtime recorded. Set it to show the resolved state. */\n decision?: AgentApprovalDecision | null;\n /** An answer is on its way through your runtime. The card also responds while `onRespond`’s promise runs. */\n responding?: boolean;\n /** The last answer didn’t land. The card also shows an error when `onRespond`’s promise rejects. */\n error?: boolean;\n /** Blocks answering, for example while another turn is running. */\n disabled?: boolean;\n /** Announces the error label when `onRespond` rejects. Pass `announcer.announce`. */\n announce?: (message: string) => void;\n};\n\ntype AgentApprovalFooterLabels = {\n approve: string;\n deny: string;\n approved: string;\n denied: string;\n error: string;\n hint?: string;\n};\n\nfunction useAgentApproval({\n onRespond,\n decision,\n responding = false,\n error = false,\n errorLabel,\n announce,\n}: Pick<\n AgentApprovalBaseProps,\n \"onRespond\" | \"decision\" | \"responding\" | \"error\" | \"announce\"\n> & { errorLabel: string }) {\n const { failed, pending, run } =\n useAgentPendingChoice();\n const state = getAgentApprovalState({\n decision,\n error: error || failed,\n responding: responding || pending !== null,\n });\n const respond = async (choice: AgentApprovalDecision): Promise => {\n const outcome = await run(choice, () => onRespond(choice));\n if (outcome === \"failed\") announce?.(errorLabel);\n };\n return { pending, respond, state };\n}\n\nfunction AgentApprovalPromptBody({\n prompt,\n questionId,\n showQuestion = true,\n}: {\n prompt: AgentApprovalPrompt;\n questionId?: string;\n showQuestion?: boolean;\n}): ReactElement {\n return (\n
\n
\n {showQuestion && (\n

\n {prompt.question}\n

\n )}\n {prompt.context && (\n

\n \n \n \n {prompt.context}\n

\n )}\n {prompt.details?.length ? (\n
    \n {prompt.details.map((detail, index) => (\n
  • {detail}
  • \n ))}\n
\n ) : null}\n
\n {prompt.badge && (\n \n {prompt.badge}\n \n )}\n
\n );\n}\n\nfunction AgentApprovalFooter({\n state,\n pending,\n disabled,\n labels,\n onChoose,\n}: {\n state: AgentApprovalState;\n pending: AgentApprovalDecision | null;\n disabled: boolean;\n labels: AgentApprovalFooterLabels;\n onChoose: (decision: AgentApprovalDecision) => void;\n}): ReactElement {\n const statusRef = useRef(null);\n const approveRef = useRef(null);\n const denyRef = useRef(null);\n const hadFocusRef = useRef(false);\n const lastChoiceRef = useRef(\"approved\");\n const resolved = state === \"approved\" || state === \"denied\";\n const blocked = disabled || state === \"responding\";\n\n // Answering disables, then removes, the focused button. Once the card settles,\n // hand focus to the resolved line, or back to the button after an error.\n useEffect(() => {\n if (!hadFocusRef.current || state === \"responding\") return;\n const active = document.activeElement;\n if (active && active !== document.body) return;\n if (state === \"approved\" || state === \"denied\") statusRef.current?.focus();\n else if (lastChoiceRef.current === \"denied\") denyRef.current?.focus();\n else approveRef.current?.focus();\n }, [state]);\n\n const choose = (choice: AgentApprovalDecision): void => {\n lastChoiceRef.current = choice;\n onChoose(choice);\n };\n\n return (\n {\n // No related target means focus went nowhere, as when the button was disabled or removed.\n if (\n event.relatedTarget &&\n !event.currentTarget.contains(event.relatedTarget)\n )\n hadFocusRef.current = false;\n }}\n onFocus={() => {\n hadFocusRef.current = true;\n }}\n >\n {resolved ? (\n \n {state === \"approved\" ? (\n \n ) : (\n \n )}\n {state === \"approved\" ? labels.approved : labels.denied}\n

\n ) : (\n <>\n {state === \"error\" ? (\n \n \n {labels.error}\n

\n ) : (\n labels.hint && (\n

\n {labels.hint}\n

\n )\n )}\n
\n choose(\"denied\")}\n ref={denyRef}\n size=\"xs\"\n variant=\"ghost\"\n >\n {pending === \"denied\" && }\n {labels.deny}\n \n choose(\"approved\")}\n ref={approveRef}\n size=\"xs\"\n >\n {pending === \"approved\" && }\n {labels.approve}\n \n
\n \n )}\n \n );\n}\n\nfunction AgentApprovalPendingIcon(): ReactElement {\n return (\n \n );\n}\n\nexport type AgentApprovalCardLabels = {\n /** Default approve verb, such as “Confirm”. `prompt.approveLabel` overrides it. */\n approve: string;\n /** Default deny verb, such as “Cancel”. `prompt.denyLabel` overrides it. */\n deny: string;\n /** The resolved line after approving, such as “Approved”. */\n approved: string;\n /** The resolved line after denying, such as “Skipped”. */\n denied: string;\n /** Shown beside the actions when the answer didn’t land. */\n error: string;\n /** Optional line beside the actions, such as how to request changes. */\n hint?: string;\n};\n\nexport type AgentApprovalCardProps = AgentApprovalBaseProps & {\n prompt: AgentApprovalPrompt;\n labels: AgentApprovalCardLabels;\n};\n\n/** Asks the person to approve one agent action, then shows how it resolved. */\nexport function AgentApprovalCard({\n prompt,\n labels,\n onRespond,\n decision,\n responding,\n error,\n disabled = false,\n announce,\n className,\n ...props\n}: AgentApprovalCardProps): ReactElement {\n const questionId = useId();\n const approval = useAgentApproval({\n announce,\n decision,\n error,\n errorLabel: labels.error,\n onRespond,\n responding,\n });\n\n return (\n \n \n \n \n \n \n void approval.respond(choice)}\n pending={approval.pending}\n state={approval.state}\n />\n \n );\n}\n\nexport type AgentApprovalGroupCardLabels = {\n /** Approves every item, such as “Approve all”. A verb every item shares replaces it. */\n approveAll: string;\n /** Denies every item, such as “Skip all”. */\n skipAll: string;\n /** The resolved line after approving all. */\n approved: string;\n /** The resolved line after skipping all. */\n denied: string;\n /** Shown beside the actions when the answer didn’t land. */\n error: string;\n /** Optional line beside the actions, such as “Skip first to request changes”. */\n hint?: string;\n};\n\nexport type AgentApprovalGroupCardProps = AgentApprovalBaseProps & {\n items: readonly AgentApprovalGroupItem[];\n labels: AgentApprovalGroupCardLabels;\n};\n\n/**\n * Asks the person to approve several agent actions at once. Every item gets the same answer:\n * `onRespond` is called once, and your host answers each item.\n */\nexport function AgentApprovalGroupCard({\n items,\n labels,\n onRespond,\n decision,\n responding,\n error,\n disabled = false,\n announce,\n className,\n ...props\n}: AgentApprovalGroupCardProps): ReactElement | null {\n const questionId = useId();\n const approval = useAgentApproval({\n announce,\n decision,\n error,\n errorLabel: labels.error,\n onRespond,\n responding,\n });\n if (items.length === 0) return null;\n const shared = getAgentApprovalGroupShared(items);\n\n return (\n \n \n \n {shared.question && (\n

\n {shared.question}\n

\n )}\n
    \n {items.map((item) => (\n
  • \n \n
  • \n ))}\n
\n
\n
\n void approval.respond(choice)}\n pending={approval.pending}\n state={approval.state}\n />\n \n );\n}\n", + "type": "registry:component", + "target": "@components/agent/agent-approval-card.tsx" + }, { "path": "registry/default/agent/agent-conversation.tsx", "content": "\"use client\";\n\nimport type { ReactElement, UIEvent } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport {\n ChatConversation,\n type ChatConversationProps,\n} from \"../chat/chat-conversation\";\nimport {\n AGENT_SCROLL_OFFSET_VAR,\n getAgentScrollOffset,\n} from \"./lib/get-agent-scroll-offset\";\n\nexport type AgentConversationProps = ChatConversationProps;\n\nexport function AgentConversation({\n scroll,\n live = \"off\",\n contentClassName,\n ...props\n}: AgentConversationProps): ReactElement {\n const onScroll = (event: UIEvent): void => {\n event.currentTarget.style.setProperty(\n AGENT_SCROLL_OFFSET_VAR,\n getAgentScrollOffset(event.currentTarget.scrollTop),\n );\n scroll.onScroll(event);\n };\n\n return (\n \n );\n}\n", "type": "registry:component", "target": "@components/agent/agent-conversation.tsx" }, + { + "path": "registry/default/agent/agent-error-recovery.tsx", + "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { CircleAlertIcon } from \"lucide-react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\n\nexport type AgentErrorRecoveryProps = useRender.ComponentProps<\"div\"> & {\n /** What went wrong, in your words, such as “The connection dropped.” */\n children: ReactNode;\n /** Names the retry action, such as “Try again”. */\n retryLabel?: string;\n /** Runs the turn again. Without it, the retry action isn’t rendered. */\n onRetry?: () => void;\n /** Names the check-status action, such as “Check status”, for a turn that may have changed something. */\n checkStatusLabel?: string;\n /** Finds out whether the failed turn finished. Without it, the action isn’t rendered. */\n onCheckStatus?: () => void;\n};\n\n/**\n * Shows a failed turn and the recovery your host offers. Your host decides what failed\n * and which action fits; remove it once recovery starts.\n */\nexport function AgentErrorRecovery({\n children,\n retryLabel,\n onRetry,\n checkStatusLabel,\n onCheckStatus,\n className,\n render,\n ...props\n}: AgentErrorRecoveryProps): ReactElement {\n const actions = [\n { label: checkStatusLabel, onClick: onCheckStatus, slot: \"check-status\" },\n { label: retryLabel, onClick: onRetry, slot: \"retry\" },\n ].filter((action) => action.label && action.onClick);\n\n const defaultProps = {\n children: (\n <>\n \n \n \n \n {children}\n

\n {actions.length > 0 && (\n \n {actions.map((action) => (\n \n {action.label}\n \n ))}\n \n )}\n \n ),\n className: cn(\"flex flex-col items-start gap-2\", className),\n \"data-slot\": \"agent-error-recovery\",\n };\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(defaultProps, props),\n render,\n });\n}\n", + "type": "registry:component", + "target": "@components/agent/agent-error-recovery.tsx" + }, { "path": "registry/default/agent/agent-message-actions.tsx", "content": "\"use client\";\n\nimport type { Toolbar as ToolbarPrimitive } from \"@base-ui/react/toolbar\";\nimport { CheckIcon, CopyIcon, PencilIcon } from \"lucide-react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCopyToClipboard } from \"@/registry/default/hooks/use-copy-to-clipboard\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button, type ButtonProps } from \"@/registry/default/ui/button\";\nimport { Toolbar, ToolbarButton } from \"@/registry/default/ui/toolbar\";\nimport {\n Tooltip,\n TooltipPopup,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\nexport type AgentMessageActionsProps = Omit<\n ToolbarPrimitive.Root.Props,\n \"aria-label\"\n> & {\n label: string;\n};\n\nexport function AgentMessageActions({\n label,\n className,\n ...props\n}: AgentMessageActionsProps): ReactElement {\n return (\n \n );\n}\n\nexport type AgentMessageActionProps = Omit<\n ButtonProps,\n \"aria-label\" | \"children\" | \"size\" | \"variant\"\n> & {\n label: string;\n children: ReactNode;\n /** Tooltip content. Defaults to `label`. */\n tooltip?: ReactNode;\n};\n\nexport function AgentMessageAction({\n label,\n children,\n tooltip = label,\n onClick,\n render,\n disabled,\n className,\n ...props\n}: AgentMessageActionProps): ReactElement {\n // An action that can't do anything is a real disabled button, never aria-disabled alone.\n const inactive = disabled || (!onClick && !render);\n return (\n \n \n }\n />\n }\n >\n {children}\n \n {tooltip}\n \n );\n}\n\nexport type AgentMessageCopyActionProps = Omit<\n AgentMessageActionProps,\n \"children\" | \"onClick\" | \"render\" | \"tooltip\"\n> & {\n text: string;\n copiedLabel: string;\n /** Called after the text reaches the clipboard. Announce `copiedLabel` here; the button's name stays `label`. */\n onCopy?: () => void;\n};\n\nexport function AgentMessageCopyAction({\n text,\n label,\n copiedLabel,\n onCopy,\n disabled,\n ...props\n}: AgentMessageCopyActionProps): ReactElement {\n const { copyToClipboard, isCopied } = useCopyToClipboard({ onCopy });\n return (\n copyToClipboard(text)}\n {...props}\n >\n {isCopied ? (\n \n ) : (\n \n )}\n \n );\n}\n\nexport type AgentMessageEditActionProps = Omit<\n AgentMessageActionProps,\n \"children\" | \"onClick\" | \"render\"\n> & {\n onEdit: () => void;\n};\n\nexport function AgentMessageEditAction({\n onEdit,\n ...props\n}: AgentMessageEditActionProps): ReactElement {\n return (\n \n \n \n );\n}\n", @@ -41,12 +61,24 @@ "type": "registry:component", "target": "@components/agent/agent-message.tsx" }, + { + "path": "registry/default/agent/agent-response-feedback.tsx", + "content": "\"use client\";\n\nimport type { Toolbar as ToolbarPrimitive } from \"@base-ui/react/toolbar\";\nimport {\n CheckIcon,\n CircleAlertIcon,\n Loader2Icon,\n PlusIcon,\n ThumbsDownIcon,\n ThumbsUpIcon,\n} from \"lucide-react\";\nimport { type FormEvent, type ReactElement, useState } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogPanel,\n DialogPopup,\n DialogTitle,\n} from \"@/registry/default/ui/dialog\";\nimport { Field, FieldLabel } from \"@/registry/default/ui/field\";\nimport { Fieldset, FieldsetLegend } from \"@/registry/default/ui/fieldset\";\nimport { Form } from \"@/registry/default/ui/form\";\nimport { Textarea } from \"@/registry/default/ui/textarea\";\nimport { Toggle } from \"@/registry/default/ui/toggle\";\nimport { ToolbarGroup } from \"@/registry/default/ui/toolbar\";\nimport { AgentMessageAction } from \"./agent-message-actions\";\nimport { useAgentPendingChoice } from \"./hooks/use-agent-pending-choice\";\nimport {\n type AgentFeedbackRating,\n type AgentFeedbackValue,\n getAgentFeedbackAction,\n getAgentFeedbackSubmission,\n} from \"./lib/get-agent-feedback\";\n\nexport type AgentResponseFeedbackReason = {\n value: string;\n label: string;\n};\n\nexport type AgentResponseFeedbackLabels = {\n /** Names the thumbs-up button, such as “Good response”. */\n positive: string;\n /** Names the thumbs-down button, such as “Bad response”. */\n negative: string;\n dialogTitle: string;\n dialogDescription?: string;\n /** The legend over the reasons, such as “What went wrong?”. */\n reasons?: string;\n details: string;\n detailsPlaceholder?: string;\n cancel: string;\n submit: string;\n /** Announced when feedback is saved, such as “Thanks for your feedback”. */\n submitted: string;\n /** Shown and announced when saving fails. */\n error: string;\n};\n\nexport type AgentResponseFeedbackProps = Omit<\n ToolbarPrimitive.Group.Props,\n \"children\"\n> & {\n /** The rating to show. Controlled: update it once `onFeedback` resolves. */\n value: AgentFeedbackRating | null;\n /**\n * Saves feedback, or `null` when the person presses the shown rating again to clear it.\n * A rejection shows the error state; retrying is up to your host.\n */\n onFeedback: (feedback: AgentFeedbackValue | null) => void | Promise;\n labels: AgentResponseFeedbackLabels;\n /** Reasons offered after a thumbs down. When given, one is required to submit. */\n reasons?: readonly AgentResponseFeedbackReason[];\n /** Caps the details field, so text is never cut after the person typed it. */\n detailsMaxLength?: number;\n disabled?: boolean;\n /** Announces the saved and error labels. Pass `announcer.announce`. */\n announce?: (message: string) => void;\n};\n\n/**\n * Thumbs up and down for a reply. A thumbs down asks for reasons and details first.\n * Place it inside `AgentMessageActions`.\n */\nexport function AgentResponseFeedback({\n value,\n onFeedback,\n labels,\n reasons = [],\n detailsMaxLength,\n disabled = false,\n announce,\n className,\n ...props\n}: AgentResponseFeedbackProps): ReactElement {\n const { failed, pending, run } = useAgentPendingChoice();\n const [dialogOpen, setDialogOpen] = useState(false);\n const [selectedReasons, setSelectedReasons] = useState([]);\n const [details, setDetails] = useState(\"\");\n const submission = getAgentFeedbackSubmission({\n details,\n detailsMaxLength,\n reasons: selectedReasons,\n reasonsOffered: reasons.length > 0,\n });\n\n const save = async (\n rating: AgentFeedbackRating,\n feedback: AgentFeedbackValue | null,\n ): Promise => {\n const outcome = await run(rating, () => onFeedback(feedback));\n if (outcome === \"failed\") announce?.(labels.error);\n if (outcome === \"succeeded\" && feedback) announce?.(labels.submitted);\n return outcome === \"succeeded\";\n };\n\n const rate = (next: AgentFeedbackRating): void => {\n const action = getAgentFeedbackAction(value, next);\n if (action.type === \"describe\") setDialogOpen(true);\n else if (action.type === \"clear\") void save(next, null);\n else void save(next, action.feedback);\n };\n\n const submitDetails = async (\n event: FormEvent,\n ): Promise => {\n event.preventDefault();\n if (!submission) return;\n if (await save(\"negative\", submission)) setDialogOpen(false);\n };\n\n const ratings = [\n { icon: ThumbsUpIcon, label: labels.positive, rating: \"positive\" },\n { icon: ThumbsDownIcon, label: labels.negative, rating: \"negative\" },\n ] as const;\n\n return (\n \n {ratings.map(({ icon: Icon, label, rating }) => (\n rate(rating)}\n >\n {pending === rating && !dialogOpen ? (\n \n ) : (\n \n )}\n \n ))}\n {failed && !dialogOpen && (\n \n \n {labels.error}\n \n )}\n {\n if (!pending) setDialogOpen(open);\n }}\n onOpenChangeComplete={(open) => {\n if (open) return;\n setSelectedReasons([]);\n setDetails(\"\");\n }}\n open={dialogOpen}\n >\n \n \n {labels.dialogTitle}\n {labels.dialogDescription && (\n {labels.dialogDescription}\n )}\n \n ) =>\n void submitDetails(event)\n }\n >\n \n {reasons.length > 0 && (\n
\n {labels.reasons && (\n \n {labels.reasons}\n \n )}\n
\n {reasons.map((reason) => {\n const selected = selectedReasons.includes(reason.value);\n return (\n \n setSelectedReasons((current) =>\n pressed\n ? [...current, reason.value]\n : current.filter(\n (item) => item !== reason.value,\n ),\n )\n }\n pressed={selected}\n size=\"sm\"\n variant=\"outline\"\n >\n {selected ? (\n \n ) : (\n \n )}\n {reason.label}\n \n );\n })}\n
\n
\n )}\n \n {labels.details}\n setDetails(event.target.value)}\n placeholder={labels.detailsPlaceholder}\n value={details}\n />\n \n {failed && (\n \n \n {labels.error}\n

\n )}\n
\n \n }\n >\n {labels.cancel}\n \n {/* Stays enabled while saving so focus isn’t dropped; repeat submits are ignored. */}\n \n \n \n
\n \n \n );\n}\n", + "type": "registry:component", + "target": "@components/agent/agent-response-feedback.tsx" + }, { "path": "registry/default/agent/hooks/use-agent-announcer.ts", "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n type AgentAnnouncementState,\n type AgentPhase,\n getAgentAnnouncement,\n getAgentAnnouncerMessage,\n} from \"../lib/get-agent-announcement\";\n\nexport type AgentAnnouncerLabels = Partial<\n Record, string>\n>;\n\nexport type AgentAnnouncerOptions = {\n phase: AgentPhase;\n /** Localized text for each phase. A phase without a label is never announced. */\n labels: AgentAnnouncerLabels;\n /** Replaces the phase label, such as the error or the approval question. */\n detail?: string;\n /** Changing it starts a new baseline, so a switched conversation isn't replayed. */\n conversationId?: string;\n};\n\nexport function useAgentAnnouncer({\n phase,\n labels,\n detail,\n conversationId,\n}: AgentAnnouncerOptions) {\n const [message, setMessage] = useState(\"\");\n const previousRef = useRef(null);\n const text = phase === \"idle\" ? \"\" : (detail ?? labels[phase] ?? \"\");\n\n useEffect(() => {\n const next = { conversationId, phase, text };\n const announcement = getAgentAnnouncement(previousRef.current, next);\n previousRef.current = next;\n if (announcement)\n setMessage((current) => getAgentAnnouncerMessage(current, announcement));\n }, [conversationId, phase, text]);\n\n /** Announce a one-off result, such as a copied message. */\n const announce = useCallback((value: string): void => {\n if (value)\n setMessage((current) => getAgentAnnouncerMessage(current, value));\n }, []);\n\n return { announce, message };\n}\n\nexport type AgentAnnouncerController = ReturnType;\n", "type": "registry:component", "target": "@components/agent/hooks/use-agent-announcer.ts" }, + { + "path": "registry/default/agent/hooks/use-agent-pending-choice.ts", + "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\nexport type AgentPendingChoiceOutcome = \"succeeded\" | \"failed\" | \"ignored\";\n\n/**\n * Tracks the choice whose host callback is still running, and whether the last one failed.\n * A callback that returns nothing settles at once; a rejected promise sets `failed`.\n */\nexport function useAgentPendingChoice() {\n const [pending, setPending] = useState(null);\n const [failed, setFailed] = useState(false);\n const runningRef = useRef(false);\n\n /** Runs the callback for a choice. Another choice is ignored while one is running. */\n const run = useCallback(\n async (\n choice: T,\n callback: () => void | Promise,\n ): Promise => {\n if (runningRef.current) return \"ignored\";\n setFailed(false);\n const result = callback();\n if (!result) return \"succeeded\";\n runningRef.current = true;\n setPending(choice);\n try {\n await result;\n return \"succeeded\";\n } catch {\n setFailed(true);\n return \"failed\";\n } finally {\n runningRef.current = false;\n setPending(null);\n }\n },\n [],\n );\n\n return { failed, pending, run };\n}\n", + "type": "registry:component", + "target": "@components/agent/hooks/use-agent-pending-choice.ts" + }, { "path": "registry/default/agent/hooks/use-agent-reduced-motion.ts", "content": "\"use client\";\n\nimport { useMediaQuery } from \"@/registry/default/hooks/use-media-query\";\n\n/**\n * The agent family's reduced-motion check for motion timed in JavaScript, such as an\n * exit delay or an animation loop. CSS transitions use `motion-reduce:` variants instead.\n */\nexport function useAgentReducedMotion(): boolean {\n return useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n}\n", @@ -59,6 +91,18 @@ "type": "registry:component", "target": "@components/agent/lib/get-agent-announcement.ts" }, + { + "path": "registry/default/agent/lib/get-agent-approval-state.ts", + "content": "export type AgentApprovalDecision = \"approved\" | \"denied\";\n\nexport type AgentApprovalState =\n | \"idle\"\n | \"responding\"\n | AgentApprovalDecision\n | \"error\";\n\nexport type AgentApprovalStateInput = {\n /** The answer the agent runtime recorded. Once set, the question is resolved. */\n decision?: AgentApprovalDecision | null;\n /** An answer is on its way and hasn’t landed yet. */\n responding?: boolean;\n /** The last answer didn’t land. The question can be answered again. */\n error?: boolean;\n};\n\nexport function getAgentApprovalState({\n decision,\n responding = false,\n error = false,\n}: AgentApprovalStateInput): AgentApprovalState {\n // The runtime is the source of truth: a recorded decision outlasts any local attempt.\n if (decision) return decision;\n if (responding) return \"responding\";\n if (error) return \"error\";\n return \"idle\";\n}\n\n/** Everything one approval says. All wording comes from the host. */\nexport type AgentApprovalPrompt = {\n question: string;\n /** The question a group card asks once when every item asks `question`, such as its plural. */\n sharedQuestion?: string;\n /** One line on what happens, such as the affected booking. */\n context?: string;\n /** Lines listed under the question, such as the fields that change. */\n details?: readonly string[];\n /** A short tag, such as the tool or the resource. */\n badge?: string;\n /** The approve verb for this action, such as “Cancel booking”. Defaults to the card’s label. */\n approveLabel?: string;\n /** The deny verb for this action. Defaults to the card’s label. */\n denyLabel?: string;\n};\n\nexport type AgentApprovalGroupItem = {\n id: string;\n prompt: AgentApprovalPrompt;\n};\n\nexport type AgentApprovalGroupShared = {\n /** Set when every item asks the same question: the group asks it once. */\n question?: string;\n /** Set when every item uses the same approve verb: approve-all uses it. */\n approveLabel?: string;\n};\n\nfunction allSame(\n items: readonly AgentApprovalGroupItem[],\n pick: (prompt: AgentApprovalPrompt) => T,\n): boolean {\n const first = items[0];\n return (\n !!first && items.every((item) => pick(item.prompt) === pick(first.prompt))\n );\n}\n\nexport function getAgentApprovalGroupShared(\n items: readonly AgentApprovalGroupItem[],\n): AgentApprovalGroupShared {\n const first = items[0]?.prompt;\n if (!first) return {};\n const shared: AgentApprovalGroupShared = {};\n if (allSame(items, (prompt) => prompt.question))\n shared.question = first.sharedQuestion ?? first.question;\n if (allSame(items, (prompt) => prompt.approveLabel) && first.approveLabel)\n shared.approveLabel = first.approveLabel;\n return shared;\n}\n", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-approval-state.ts" + }, + { + "path": "registry/default/agent/lib/get-agent-feedback.ts", + "content": "export type AgentFeedbackRating = \"positive\" | \"negative\";\n\nexport type AgentFeedbackValue = {\n rating: AgentFeedbackRating;\n /** The values of the reasons the person picked. Empty for a thumbs up. */\n reasons: string[];\n /** Trimmed and capped at the details limit. */\n details: string;\n};\n\nexport type AgentFeedbackAction =\n | { type: \"submit\"; feedback: AgentFeedbackValue }\n | { type: \"clear\" }\n | { type: \"describe\" };\n\n/** What pressing a rating does, given the rating already shown. */\nexport function getAgentFeedbackAction(\n current: AgentFeedbackRating | null,\n next: AgentFeedbackRating,\n): AgentFeedbackAction {\n if (current === next) return { type: \"clear\" };\n if (next === \"negative\") return { type: \"describe\" };\n return {\n type: \"submit\",\n feedback: { details: \"\", rating: \"positive\", reasons: [] },\n };\n}\n\nexport type AgentFeedbackDraft = {\n reasons: readonly string[];\n details: string;\n /** Whether the form offered reasons to pick from. */\n reasonsOffered: boolean;\n detailsMaxLength?: number;\n};\n\n/** The thumbs-down feedback a draft submits, or null while it can’t be submitted yet. */\nexport function getAgentFeedbackSubmission({\n reasons,\n details,\n reasonsOffered,\n detailsMaxLength,\n}: AgentFeedbackDraft): AgentFeedbackValue | null {\n if (reasonsOffered && reasons.length === 0) return null;\n return {\n rating: \"negative\",\n reasons: [...reasons],\n details: details.trim().slice(0, detailsMaxLength),\n };\n}\n", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-feedback.ts" + }, { "path": "registry/default/agent/lib/get-agent-scroll-offset.ts", "content": "export const AGENT_SCROLL_OFFSET_VAR = \"--agent-conversation-scroll-top\";\n\n/**\n * Base UI popover viewports crossfade a cloned snapshot of the outgoing content,\n * and a clone keeps attributes but not scroll position. Written to the viewport's\n * inline style, this offset travels with the clone so the transcript can be\n * translated back to where the reader was.\n */\nexport function getAgentScrollOffset(scrollTop: number): string {\n return `${Math.max(0, scrollTop)}px`;\n}\n", diff --git a/apps/ui/registry/default/agent/agent-approval-card.tsx b/apps/ui/registry/default/agent/agent-approval-card.tsx new file mode 100644 index 000000000..5fa5986f1 --- /dev/null +++ b/apps/ui/registry/default/agent/agent-approval-card.tsx @@ -0,0 +1,413 @@ +"use client"; + +import { + CheckIcon, + CircleAlertIcon, + InfoIcon, + Loader2Icon, + XIcon, +} from "lucide-react"; +import { + type ComponentProps, + type ReactElement, + useEffect, + useId, + useRef, +} from "react"; +import { cn } from "@/registry/default/lib/utils"; +import { Badge } from "@/registry/default/ui/badge"; +import { Button } from "@/registry/default/ui/button"; +import { + Card, + CardFrame, + CardFrameFooter, + CardPanel, +} from "@/registry/default/ui/card"; +import { useAgentPendingChoice } from "./hooks/use-agent-pending-choice"; +import { + type AgentApprovalDecision, + type AgentApprovalGroupItem, + type AgentApprovalPrompt, + type AgentApprovalState, + getAgentApprovalGroupShared, + getAgentApprovalState, +} from "./lib/get-agent-approval-state"; + +type AgentApprovalBaseProps = Omit< + ComponentProps, + "children" +> & { + /** Called with the person’s answer. Return a promise to show the card responding until it settles; a rejection shows the error. */ + onRespond: (decision: AgentApprovalDecision) => void | Promise; + /** The answer your agent runtime recorded. Set it to show the resolved state. */ + decision?: AgentApprovalDecision | null; + /** An answer is on its way through your runtime. The card also responds while `onRespond`’s promise runs. */ + responding?: boolean; + /** The last answer didn’t land. The card also shows an error when `onRespond`’s promise rejects. */ + error?: boolean; + /** Blocks answering, for example while another turn is running. */ + disabled?: boolean; + /** Announces the error label when `onRespond` rejects. Pass `announcer.announce`. */ + announce?: (message: string) => void; +}; + +type AgentApprovalFooterLabels = { + approve: string; + deny: string; + approved: string; + denied: string; + error: string; + hint?: string; +}; + +function useAgentApproval({ + onRespond, + decision, + responding = false, + error = false, + errorLabel, + announce, +}: Pick< + AgentApprovalBaseProps, + "onRespond" | "decision" | "responding" | "error" | "announce" +> & { errorLabel: string }) { + const { failed, pending, run } = + useAgentPendingChoice(); + const state = getAgentApprovalState({ + decision, + error: error || failed, + responding: responding || pending !== null, + }); + const respond = async (choice: AgentApprovalDecision): Promise => { + const outcome = await run(choice, () => onRespond(choice)); + if (outcome === "failed") announce?.(errorLabel); + }; + return { pending, respond, state }; +} + +function AgentApprovalPromptBody({ + prompt, + questionId, + showQuestion = true, +}: { + prompt: AgentApprovalPrompt; + questionId?: string; + showQuestion?: boolean; +}): ReactElement { + return ( +
+
+ {showQuestion && ( +

+ {prompt.question} +

+ )} + {prompt.context && ( +

+ + + {prompt.context} +

+ )} + {prompt.details?.length ? ( +
    + {prompt.details.map((detail, index) => ( +
  • {detail}
  • + ))} +
+ ) : null} +
+ {prompt.badge && ( + + {prompt.badge} + + )} +
+ ); +} + +function AgentApprovalFooter({ + state, + pending, + disabled, + labels, + onChoose, +}: { + state: AgentApprovalState; + pending: AgentApprovalDecision | null; + disabled: boolean; + labels: AgentApprovalFooterLabels; + onChoose: (decision: AgentApprovalDecision) => void; +}): ReactElement { + const statusRef = useRef(null); + const approveRef = useRef(null); + const denyRef = useRef(null); + const hadFocusRef = useRef(false); + const lastChoiceRef = useRef("approved"); + const resolved = state === "approved" || state === "denied"; + const blocked = disabled || state === "responding"; + + // Answering disables, then removes, the focused button. Once the card settles, + // hand focus to the resolved line, or back to the button after an error. + useEffect(() => { + if (!hadFocusRef.current || state === "responding") return; + const active = document.activeElement; + if (active && active !== document.body) return; + if (state === "approved" || state === "denied") statusRef.current?.focus(); + else if (lastChoiceRef.current === "denied") denyRef.current?.focus(); + else approveRef.current?.focus(); + }, [state]); + + const choose = (choice: AgentApprovalDecision): void => { + lastChoiceRef.current = choice; + onChoose(choice); + }; + + return ( + { + // No related target means focus went nowhere, as when the button was disabled or removed. + if ( + event.relatedTarget && + !event.currentTarget.contains(event.relatedTarget) + ) + hadFocusRef.current = false; + }} + onFocus={() => { + hadFocusRef.current = true; + }} + > + {resolved ? ( +

+ {state === "approved" ? ( +

+ ) : ( + <> + {state === "error" ? ( +

+

+ ) : ( + labels.hint && ( +

+ {labels.hint} +

+ ) + )} +
+ + +
+ + )} +
+ ); +} + +function AgentApprovalPendingIcon(): ReactElement { + return ( +