diff --git a/apps/ui/content/docs/(root)/agent.mdx b/apps/ui/content/docs/(root)/agent.mdx index 43f727b1e..a2121545e 100644 --- a/apps/ui/content/docs/(root)/agent.mdx +++ b/apps/ui/content/docs/(root)/agent.mdx @@ -163,6 +163,70 @@ A phase without a label is never announced. Pass the error message or the approv Every transition the agent parts add has a reduced-motion path: CSS transitions use `motion-reduce:transition-none`, and loops such as the streaming caret run only under `motion-safe`. Parts the family composes, such as chat’s jump to latest and coss tooltips, follow their own primitives. For motion timed in JavaScript, such as an exit delay before a crossfade, read `useAgentReducedMotion()` and skip the delay when it returns `true`. Follow the same policy in your compositions. +## Approvals, feedback and recovery + +These parts sit inside a turn: an approval card when the agent needs a yes before acting, feedback under a finished reply, and recovery when a turn fails. Every word comes from your application through props, and every result that should be heard goes through the [announcer](#announcer): pass `announcer.announce` to `announce`. None of them adds its own live region. + +| Component | File | Responsibility | +| --- | --- | --- | +| `AgentApprovalCard` | `agent/agent-approval-card.tsx` | One approval with idle, responding, approved, denied and error states | +| `AgentApprovalGroupCard` | `agent/agent-approval-card.tsx` | Several approvals answered together, with approve all and skip all | +| `getAgentApprovalState`, `getAgentApprovalGroupShared` | `agent/lib/get-agent-approval-state.ts` | The pure rules for a card’s state and a group’s shared wording | +| `AgentResponseFeedback` | `agent/agent-response-feedback.tsx` | Thumbs up and down, reasons and details, saving and failure | +| `getAgentFeedbackAction`, `getAgentFeedbackSubmission` | `agent/lib/get-agent-feedback.ts` | What a rating press does, and what a thumbs down submits | +| `AgentErrorRecovery` | `agent/agent-error-recovery.tsx` | A failed turn’s message with retry and check-status actions | + +### Approval card + + + +`AgentApprovalCard` asks the question in `prompt` and calls `onRespond` with `"approved"` or `"denied"`. Its labels are the default verbs, `approve` and `deny`, the resolved lines, `approved` and `denied`, and the `error` line. A prompt’s `approveLabel` and `denyLabel` replace the verbs for that action, such as “Cancel booking”. The prompt can also carry a `context` line, `details` lines and a `badge`. + +The card renders its resolved states itself, so your transcript doesn’t need its own “Approved” row. It derives one state from three inputs, by this rule: + +| Input | State | +| --- | --- | +| `decision` is set, from your agent runtime | `approved` or `denied`, whatever else is true | +| `responding`, or `onRespond`’s promise is running | `responding`: both actions are disabled | +| `error`, or `onRespond` threw or its promise rejected | `error`: the error line shows, and the person can answer again | +| None of these | `idle` | + +Return a promise from `onRespond` when your runtime confirms answers, or pass `responding` and `error` yourself. A throw or a rejection also announces the `error` label. Set `decision` once the runtime records the answer; the card keeps it even if a late response or a stale error arrives. Announce the question itself through the announcer’s `approval` phase, with the question as `detail`. + +When answering removes the focused button, the card moves focus to the resolved line, or back to the button after an error, so keyboard users keep their place. Set `disabled` while the agent can’t take an answer, such as during another turn. `getAgentApprovalState` is the rule behind the card; use it to test your mapping from runtime states. + +### Group approval card + + + +`AgentApprovalGroupCard` takes `items`, each an `id` and a `prompt`, and answers them together: approve all or skip all. It calls `onRespond` once with the decision, and your application answers each item. It renders nothing when `items` is empty. + +When every item asks the same `question`, the card asks it once, using the first item’s `sharedQuestion` when it has one, such as a plural. When the questions differ, each item shows its own. When every item has the same `approveLabel`, approve all uses that verb. `getAgentApprovalGroupShared(items)` returns both decisions. The group takes the same `decision`, `responding`, `error`, `disabled` and `announce` props as the single card. + +### Composing a crossfade + +Neither card animates into what comes next. To crossfade a card into a status line once it’s answered, wrap it in your own element and select on its stable attributes: `data-slot="agent-approval-card"` or `"agent-approval-group-card"` on the root, and `data-state` with the card’s state. Inside, the question is `agent-approval-question`, each prompt is `agent-approval-prompt`, the footer is `agent-approval-footer`, the resolved or error line is `agent-approval-status`, and the actions are `agent-approval-approve` and `agent-approval-deny`. Time any exit in JavaScript with `useAgentReducedMotion()`, and give CSS transitions a `motion-reduce:` variant. + +### Response feedback + + + +`AgentResponseFeedback` is a pair of thumbs for a finished reply. Place it inside `AgentMessageActions`, beside the copy action. It’s controlled: pass the shown rating as `value` and update it when `onFeedback` resolves. + +- **A thumbs up** calls `onFeedback` with `{ rating: "positive", reasons: [], details: "" }`. +- **A thumbs down** opens a dialog first. With `reasons`, the person picks at least one; without them, the dialog asks only for details. `detailsMaxLength` caps the details field, and the submitted details are trimmed and capped to it. +- **Pressing the shown rating again** calls `onFeedback(null)`. Keep the rating shown if your application can’t delete a stored one. + +While `onFeedback` runs, the pressed button shows a spinner and repeat presses are ignored. When it throws or rejects, the `error` label shows beside the thumbs, or in the dialog, and is announced; a saved rating announces `submitted`. The error clears on the next attempt, or when the dialog closes. Retrying is up to your application, for example one delayed retry inside `onFeedback` before it rejects. Show feedback with the message’s `actionsAlwaysVisible` when the error must stay visible without hover. + +### Error recovery + + + +`AgentErrorRecovery` shows a failed turn: your message as children, and up to two actions. Pass `retryLabel` and `onRetry` to run the turn again, and `checkStatusLabel` and `onCheckStatus` when the turn may have changed something and running it again could repeat that. An action renders only when it has both a label and a handler. Your application decides what failed, from its own failure codes, and which action fits. Remove the slot once recovery starts, and announce the failure through the announcer’s `failed` phase with your message as `detail`. + +Place it where the reply would have been, inside the failed `AgentMessage` or its `footer`. The root is a `div` with `data-slot="agent-error-recovery"`; use `render` to change its element. + ## Before shipping Check keyboard-only operation of message actions, a screen reader with streamed replies, a touch device and reduced motion. Test long replies, stopped and failed turns, and switching conversations, with your own agent runtime. diff --git a/apps/ui/public/r/agent.json b/apps/ui/public/r/agent.json index e1778aaac..571eb4fd5 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 {labels.approved}\n \n ) : (\n <>\n \n {labels.denied}\n \n )}\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 { clearFailure, failed, pending, run } =\n 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 if (pending) return;\n const action = getAgentFeedbackAction(value, next);\n if (action.type === \"ask-details\") {\n clearFailure();\n 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 // Closing the form, saved or not, moves on from its failure.\n clearFailure();\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 throw or 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 let result: void | Promise;\n try {\n result = callback();\n } catch {\n setFailed(true);\n return \"failed\";\n }\n // Only a promise shows as pending, so a synchronous answer never flashes a spinner.\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 /** Clears a failure the person has moved on from, such as by closing a form. */\n const clearFailure = useCallback(() => setFailed(false), []);\n\n return { clearFailure, 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: \"ask-details\" };\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: \"ask-details\" };\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/public/r/p-agent-20.json b/apps/ui/public/r/p-agent-20.json new file mode 100644 index 000000000..1ec25f98b --- /dev/null +++ b/apps/ui/public/r/p-agent-20.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-agent-20", + "description": "Approval card that responds, resolves, or shows an error when the answer fails", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/label", + "@coss/switch" + ], + "files": [ + { + "path": "registry/default/particles/p-agent-20.tsx", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { AgentAnnouncer } from \"@/registry/default/agent/agent-announcer\";\nimport { AgentApprovalCard } from \"@/registry/default/agent/agent-approval-card\";\nimport { useAgentAnnouncer } from \"@/registry/default/agent/hooks/use-agent-announcer\";\nimport type { AgentApprovalDecision } from \"@/registry/default/agent/lib/get-agent-approval-state\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Label } from \"@/registry/default/ui/label\";\nimport { Switch } from \"@/registry/default/ui/switch\";\n\nconst prompt = {\n approveLabel: \"Cancel booking\",\n badge: \"Bookings\",\n context: \"Dana gets a cancellation email with your note.\",\n denyLabel: \"Keep it\",\n question: \"Cancel your 3 pm call with Dana?\",\n};\n\nconst labels = {\n approve: \"Confirm\",\n approved: \"Booking cancelled\",\n denied: \"Booking kept\",\n deny: \"Cancel\",\n error: \"Your answer didn’t go through. Try again.\",\n};\n\nexport default function Particle() {\n const [decision, setDecision] = useState(null);\n const [failAnswers, setFailAnswers] = useState(false);\n const announcer = useAgentAnnouncer({\n detail: prompt.question,\n labels: {},\n phase: decision ? \"idle\" : \"approval\",\n });\n\n const respond = (choice: AgentApprovalDecision) =>\n new Promise((resolve, reject) => {\n window.setTimeout(() => {\n if (failAnswers) {\n reject(new Error(\"Not delivered\"));\n return;\n }\n setDecision(choice);\n resolve();\n }, 1000);\n });\n\n return (\n
\n \n
\n \n setDecision(null)}\n size=\"sm\"\n variant=\"outline\"\n >\n Ask again\n \n
\n \n
\n );\n}\n", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "categories": [ + "agent", + "card", + "error" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-agent-21.json b/apps/ui/public/r/p-agent-21.json new file mode 100644 index 000000000..07f02c02b --- /dev/null +++ b/apps/ui/public/r/p-agent-21.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-agent-21", + "description": "Group approval card with approve all, skip all, and one shared question", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/label", + "@coss/switch" + ], + "files": [ + { + "path": "registry/default/particles/p-agent-21.tsx", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { AgentApprovalGroupCard } from \"@/registry/default/agent/agent-approval-card\";\nimport type { AgentApprovalDecision } from \"@/registry/default/agent/lib/get-agent-approval-state\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Label } from \"@/registry/default/ui/label\";\nimport { Switch } from \"@/registry/default/ui/switch\";\n\nconst cancelDana = {\n id: \"dana\",\n prompt: {\n context: \"Tue, 3:00–3:30 pm with Dana Lee\",\n question: \"Cancel this booking?\",\n sharedQuestion: \"Cancel these 2 bookings?\",\n },\n};\n\nconst cancellations = [\n cancelDana,\n {\n id: \"sam\",\n prompt: {\n context: \"Wed, 10:00–10:45 am with Sam Ortiz\",\n question: \"Cancel this booking?\",\n sharedQuestion: \"Cancel these 2 bookings?\",\n },\n },\n];\n\nconst reschedule = {\n id: \"sam\",\n prompt: {\n context: \"Moves Wed, 10:00 am with Sam Ortiz to Thu, 2:00 pm\",\n details: [\"Sam gets an updated invite\", \"The video link stays the same\"],\n question: \"Reschedule this booking?\",\n },\n};\n\nconst labels = {\n approveAll: \"Approve all\",\n approved: \"Approved\",\n denied: \"Skipped\",\n error: \"Your answer didn’t go through. Try again.\",\n hint: \"Skip first to request changes\",\n skipAll: \"Skip all\",\n};\n\nexport default function Particle() {\n const [sameQuestion, setSameQuestion] = useState(true);\n const [decision, setDecision] = useState(null);\n const items = sameQuestion ? cancellations : [cancelDana, reschedule];\n\n const respond = (choice: AgentApprovalDecision) =>\n new Promise((resolve) => {\n window.setTimeout(() => {\n setDecision(choice);\n resolve();\n }, 800);\n });\n\n return (\n
\n \n
\n \n setDecision(null)}\n size=\"sm\"\n variant=\"outline\"\n >\n Ask again\n \n
\n
\n );\n}\n", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "categories": [ + "agent", + "card" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-agent-22.json b/apps/ui/public/r/p-agent-22.json new file mode 100644 index 000000000..c3ef7bc8e --- /dev/null +++ b/apps/ui/public/r/p-agent-22.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-agent-22", + "description": "Thumbs up and down feedback with reasons, details, and a failure state", + "registryDependencies": [ + "@coss/agent", + "@coss/label", + "@coss/switch" + ], + "files": [ + { + "path": "registry/default/particles/p-agent-22.tsx", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { AgentAnnouncer } from \"@/registry/default/agent/agent-announcer\";\nimport { AgentMessage } from \"@/registry/default/agent/agent-message\";\nimport {\n AgentMessageActions,\n AgentMessageCopyAction,\n} from \"@/registry/default/agent/agent-message-actions\";\nimport { AgentResponseFeedback } from \"@/registry/default/agent/agent-response-feedback\";\nimport { useAgentAnnouncer } from \"@/registry/default/agent/hooks/use-agent-announcer\";\nimport type {\n AgentFeedbackRating,\n AgentFeedbackValue,\n} from \"@/registry/default/agent/lib/get-agent-feedback\";\nimport { Label } from \"@/registry/default/ui/label\";\nimport { Switch } from \"@/registry/default/ui/switch\";\n\nconst answer =\n \"Your Thursday is clear after 2 pm. I held 2:00–2:30 pm for the design review and sent the invite to the team.\";\n\nconst labels = {\n cancel: \"Cancel\",\n details: \"Details\",\n detailsPlaceholder: \"What should the agent have done?\",\n dialogDescription: \"Your feedback helps improve the agent.\",\n dialogTitle: \"What went wrong?\",\n error: \"Feedback wasn’t saved\",\n negative: \"Bad response\",\n positive: \"Good response\",\n reasons: \"Pick at least one reason\",\n submit: \"Send feedback\",\n submitted: \"Thanks for your feedback\",\n};\n\nconst reasons = [\n { label: \"Wrong time\", value: \"wrong_time\" },\n { label: \"Wrong person\", value: \"wrong_person\" },\n { label: \"Didn’t do what I asked\", value: \"ignored_request\" },\n { label: \"Other\", value: \"other\" },\n];\n\nexport default function Particle() {\n const [rating, setRating] = useState(null);\n const [failSaves, setFailSaves] = useState(false);\n const announcer = useAgentAnnouncer({ labels: {}, phase: \"idle\" });\n\n const saveFeedback = (feedback: AgentFeedbackValue | null) =>\n new Promise((resolve, reject) => {\n window.setTimeout(() => {\n if (failSaves) {\n reject(new Error(\"Not saved\"));\n return;\n }\n setRating(feedback?.rating ?? null);\n resolve();\n }, 800);\n });\n\n return (\n
\n \n announcer.announce(\"Copied\")}\n text={answer}\n />\n \n \n }\n actionsAlwaysVisible\n from=\"assistant\"\n >\n {answer}\n \n
\n \n

\n Screen readers hear:{\" \"}\n \n {announcer.message || \"nothing yet\"}\n \n

\n
\n \n
\n );\n}\n", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "categories": [ + "agent", + "dialog", + "error" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-agent-23.json b/apps/ui/public/r/p-agent-23.json new file mode 100644 index 000000000..93df20fef --- /dev/null +++ b/apps/ui/public/r/p-agent-23.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-agent-23", + "description": "Failed agent turn with retry and check status recovery actions", + "registryDependencies": [ + "@coss/agent", + "@coss/label", + "@coss/switch" + ], + "files": [ + { + "path": "registry/default/particles/p-agent-23.tsx", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { AgentErrorRecovery } from \"@/registry/default/agent/agent-error-recovery\";\nimport { AgentMessage } from \"@/registry/default/agent/agent-message\";\nimport { Label } from \"@/registry/default/ui/label\";\nimport { Switch } from \"@/registry/default/ui/switch\";\n\nexport default function Particle() {\n const [mayHaveWritten, setMayHaveWritten] = useState(false);\n const [attempts, setAttempts] = useState(0);\n const recover = () => setAttempts((count) => count + 1);\n\n return (\n
\n Move my 1:1 with Priya to Friday.\n 0 ? `Recovery attempts: ${attempts}` : undefined}\n from=\"assistant\"\n >\n {mayHaveWritten ? (\n \n The connection dropped after the agent started changing your\n calendar. It may have finished.\n \n ) : (\n \n The connection dropped before the agent replied.\n \n )}\n \n \n
\n );\n}\n", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "categories": [ + "agent", + "error" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index 728292420..f3315641c 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -1223,9 +1223,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", @@ -1238,11 +1246,21 @@ "type": "registry:component", "target": "@components/agent/agent-announcer.tsx" }, + { + "path": "registry/default/agent/agent-approval-card.tsx", + "type": "registry:component", + "target": "@components/agent/agent-approval-card.tsx" + }, { "path": "registry/default/agent/agent-conversation.tsx", "type": "registry:component", "target": "@components/agent/agent-conversation.tsx" }, + { + "path": "registry/default/agent/agent-error-recovery.tsx", + "type": "registry:component", + "target": "@components/agent/agent-error-recovery.tsx" + }, { "path": "registry/default/agent/agent-message-actions.tsx", "type": "registry:component", @@ -1253,11 +1271,21 @@ "type": "registry:component", "target": "@components/agent/agent-message.tsx" }, + { + "path": "registry/default/agent/agent-response-feedback.tsx", + "type": "registry:component", + "target": "@components/agent/agent-response-feedback.tsx" + }, { "path": "registry/default/agent/hooks/use-agent-announcer.ts", "type": "registry:component", "target": "@components/agent/hooks/use-agent-announcer.ts" }, + { + "path": "registry/default/agent/hooks/use-agent-pending-choice.ts", + "type": "registry:component", + "target": "@components/agent/hooks/use-agent-pending-choice.ts" + }, { "path": "registry/default/agent/hooks/use-agent-reduced-motion.ts", "type": "registry:component", @@ -1268,6 +1296,16 @@ "type": "registry:component", "target": "@components/agent/lib/get-agent-announcement.ts" }, + { + "path": "registry/default/agent/lib/get-agent-approval-state.ts", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-approval-state.ts" + }, + { + "path": "registry/default/agent/lib/get-agent-feedback.ts", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-feedback.ts" + }, { "path": "registry/default/agent/lib/get-agent-scroll-offset.ts", "type": "registry:component", @@ -1525,6 +1563,102 @@ ], "type": "registry:block" }, + { + "categories": [ + "agent", + "card", + "error" + ], + "description": "Approval card that responds, resolves, or shows an error when the answer fails", + "files": [ + { + "path": "registry/default/particles/p-agent-20.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-20", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "card" + ], + "description": "Group approval card with approve all, skip all, and one shared question", + "files": [ + { + "path": "registry/default/particles/p-agent-21.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-21", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "dialog", + "error" + ], + "description": "Thumbs up and down feedback with reasons, details, and a failure state", + "files": [ + { + "path": "registry/default/particles/p-agent-22.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-22", + "registryDependencies": [ + "@coss/agent", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "error" + ], + "description": "Failed agent turn with retry and check status recovery actions", + "files": [ + { + "path": "registry/default/particles/p-agent-23.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-23", + "registryDependencies": [ + "@coss/agent", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, { "categories": [ "alert", diff --git a/apps/ui/registry.json b/apps/ui/registry.json index 728292420..f3315641c 100644 --- a/apps/ui/registry.json +++ b/apps/ui/registry.json @@ -1223,9 +1223,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", @@ -1238,11 +1246,21 @@ "type": "registry:component", "target": "@components/agent/agent-announcer.tsx" }, + { + "path": "registry/default/agent/agent-approval-card.tsx", + "type": "registry:component", + "target": "@components/agent/agent-approval-card.tsx" + }, { "path": "registry/default/agent/agent-conversation.tsx", "type": "registry:component", "target": "@components/agent/agent-conversation.tsx" }, + { + "path": "registry/default/agent/agent-error-recovery.tsx", + "type": "registry:component", + "target": "@components/agent/agent-error-recovery.tsx" + }, { "path": "registry/default/agent/agent-message-actions.tsx", "type": "registry:component", @@ -1253,11 +1271,21 @@ "type": "registry:component", "target": "@components/agent/agent-message.tsx" }, + { + "path": "registry/default/agent/agent-response-feedback.tsx", + "type": "registry:component", + "target": "@components/agent/agent-response-feedback.tsx" + }, { "path": "registry/default/agent/hooks/use-agent-announcer.ts", "type": "registry:component", "target": "@components/agent/hooks/use-agent-announcer.ts" }, + { + "path": "registry/default/agent/hooks/use-agent-pending-choice.ts", + "type": "registry:component", + "target": "@components/agent/hooks/use-agent-pending-choice.ts" + }, { "path": "registry/default/agent/hooks/use-agent-reduced-motion.ts", "type": "registry:component", @@ -1268,6 +1296,16 @@ "type": "registry:component", "target": "@components/agent/lib/get-agent-announcement.ts" }, + { + "path": "registry/default/agent/lib/get-agent-approval-state.ts", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-approval-state.ts" + }, + { + "path": "registry/default/agent/lib/get-agent-feedback.ts", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-feedback.ts" + }, { "path": "registry/default/agent/lib/get-agent-scroll-offset.ts", "type": "registry:component", @@ -1525,6 +1563,102 @@ ], "type": "registry:block" }, + { + "categories": [ + "agent", + "card", + "error" + ], + "description": "Approval card that responds, resolves, or shows an error when the answer fails", + "files": [ + { + "path": "registry/default/particles/p-agent-20.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-20", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "card" + ], + "description": "Group approval card with approve all, skip all, and one shared question", + "files": [ + { + "path": "registry/default/particles/p-agent-21.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-21", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "dialog", + "error" + ], + "description": "Thumbs up and down feedback with reasons, details, and a failure state", + "files": [ + { + "path": "registry/default/particles/p-agent-22.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-22", + "registryDependencies": [ + "@coss/agent", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "error" + ], + "description": "Failed agent turn with retry and check status recovery actions", + "files": [ + { + "path": "registry/default/particles/p-agent-23.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-23", + "registryDependencies": [ + "@coss/agent", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, { "categories": [ "alert", diff --git a/apps/ui/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index 30afed97a..8dccbe417 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -1169,15 +1169,23 @@ export const Index: Record = { name: "agent", description: "Agent conversation, messages, message actions, and a screen reader announcer built on chat.", type: "registry:ui", - registryDependencies: ["@coss/button","@coss/chat","@coss/composer","@coss/toolbar","@coss/tooltip","@coss/use-copy-to-clipboard","@coss/use-media-query","@coss/utils"], + 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","@coss/use-media-query","@coss/utils"], files: [{ path: "registry/default/agent/agent-announcer.tsx", type: "registry:component", target: "@components/agent/agent-announcer.tsx" + },{ + path: "registry/default/agent/agent-approval-card.tsx", + type: "registry:component", + target: "@components/agent/agent-approval-card.tsx" },{ path: "registry/default/agent/agent-conversation.tsx", type: "registry:component", target: "@components/agent/agent-conversation.tsx" + },{ + path: "registry/default/agent/agent-error-recovery.tsx", + type: "registry:component", + target: "@components/agent/agent-error-recovery.tsx" },{ path: "registry/default/agent/agent-message-actions.tsx", type: "registry:component", @@ -1186,10 +1194,18 @@ export const Index: Record = { path: "registry/default/agent/agent-message.tsx", type: "registry:component", target: "@components/agent/agent-message.tsx" + },{ + path: "registry/default/agent/agent-response-feedback.tsx", + type: "registry:component", + target: "@components/agent/agent-response-feedback.tsx" },{ path: "registry/default/agent/hooks/use-agent-announcer.ts", type: "registry:component", target: "@components/agent/hooks/use-agent-announcer.ts" + },{ + path: "registry/default/agent/hooks/use-agent-pending-choice.ts", + type: "registry:component", + target: "@components/agent/hooks/use-agent-pending-choice.ts" },{ path: "registry/default/agent/hooks/use-agent-reduced-motion.ts", type: "registry:component", @@ -1198,6 +1214,14 @@ export const Index: Record = { path: "registry/default/agent/lib/get-agent-announcement.ts", type: "registry:component", target: "@components/agent/lib/get-agent-announcement.ts" + },{ + path: "registry/default/agent/lib/get-agent-approval-state.ts", + type: "registry:component", + target: "@components/agent/lib/get-agent-approval-state.ts" + },{ + path: "registry/default/agent/lib/get-agent-feedback.ts", + type: "registry:component", + target: "@components/agent/lib/get-agent-feedback.ts" },{ path: "registry/default/agent/lib/get-agent-scroll-offset.ts", type: "registry:component", @@ -1409,6 +1433,78 @@ export const Index: Record = { categories: ["agent","info"], meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"}, }, + "p-agent-20": { + name: "p-agent-20", + description: "Approval card that responds, resolves, or shows an error when the answer fails", + type: "registry:block", + registryDependencies: ["@coss/agent","@coss/button","@coss/label","@coss/switch"], + files: [{ + path: "registry/default/particles/p-agent-20.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-agent-20.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["agent","card","error"], + meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"}, + }, + "p-agent-21": { + name: "p-agent-21", + description: "Group approval card with approve all, skip all, and one shared question", + type: "registry:block", + registryDependencies: ["@coss/agent","@coss/button","@coss/label","@coss/switch"], + files: [{ + path: "registry/default/particles/p-agent-21.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-agent-21.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["agent","card"], + meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"}, + }, + "p-agent-22": { + name: "p-agent-22", + description: "Thumbs up and down feedback with reasons, details, and a failure state", + type: "registry:block", + registryDependencies: ["@coss/agent","@coss/label","@coss/switch"], + files: [{ + path: "registry/default/particles/p-agent-22.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-agent-22.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["agent","dialog","error"], + meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"}, + }, + "p-agent-23": { + name: "p-agent-23", + description: "Failed agent turn with retry and check status recovery actions", + type: "registry:block", + registryDependencies: ["@coss/agent","@coss/label","@coss/switch"], + files: [{ + path: "registry/default/particles/p-agent-23.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-agent-23.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["agent","error"], + meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"}, + }, "p-alert-4": { name: "p-alert-4", description: "Info alert", 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..0a6750974 --- /dev/null +++ b/apps/ui/registry/default/agent/agent-approval-card.tsx @@ -0,0 +1,430 @@ +"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 ( +