{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"streaming-response","type":"registry:component","title":"Streaming Response","description":"Wraps a response with the actions people expect once it settles: copy, replay, share, a thumbs up or down, and a list of suggested follow-up prompts. No card or border, so the answer reads as part of the page.","author":"Ryan","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/streaming-response.tsx","type":"registry:component","target":"@components/motion/streaming-response.tsx","content":"\"use client\";\n// easeui.dev/components/agents/streaming-response\n\nimport { ArrowUpRight, RotateCcw, Share2, ThumbsDown, ThumbsUp } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useState } from \"react\";\nimport { CopyButton } from \"@/components/motion/copy-button\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type StreamingResponseStatus = \"streaming\" | \"complete\" | \"error\";\nexport type StreamingResponseFeedback = \"up\" | \"down\" | null;\n\nexport interface StreamingResponseProps {\n  /** The response itself, such as a StreamingText or rendered markdown. Left unstyled: no card, no border. */\n  children: ReactNode;\n  /** Default \"streaming\". The action row fades in once this leaves \"streaming\". */\n  status?: StreamingResponseStatus;\n  /** Text the copy action writes to the clipboard. Omit to hide that action. */\n  copyText?: string;\n  /** Shows a replay action. Omit to hide it. */\n  onRetry?: () => void;\n  /** Shows a share action. Omit to hide it. */\n  onShare?: () => void;\n  /** Shows a thumbs up / down toggle. Default false. */\n  showFeedback?: boolean;\n  /** Controlled feedback value. */\n  feedback?: StreamingResponseFeedback;\n  /** Starting feedback when uncontrolled. Default null. */\n  defaultFeedback?: StreamingResponseFeedback;\n  onFeedbackChange?: (feedback: StreamingResponseFeedback) => void;\n  /** Suggested next prompts, listed below the actions once settled. Omit to hide the list. */\n  followUps?: string[];\n  onFollowUp?: (text: string, index: number) => void;\n  className?: string;\n}\n\nconst ICON_BUTTON = cn(\n  \"relative inline-flex h-8 w-8 shrink-0 touch-manipulation items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1.5\",\n  \"transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground\",\n  \"focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n);\n\n/**\n * Wraps a response with the actions people expect once it settles: copy,\n * replay, share, a thumbs up or down. No card or border, so the answer\n * reads as part of the page. Each action is opt-in: pass a handler to show it.\n */\nexport function StreamingResponse({\n  children,\n  status = \"streaming\",\n  copyText,\n  onRetry,\n  onShare,\n  showFeedback = false,\n  feedback,\n  defaultFeedback = null,\n  onFeedbackChange,\n  followUps,\n  onFollowUp,\n  className,\n}: StreamingResponseProps) {\n  const reduce = useReducedMotion();\n  const [uncontrolled, setUncontrolled] = useState(defaultFeedback);\n  const isControlled = feedback !== undefined;\n  const current = isControlled ? feedback : uncontrolled;\n\n  const setFeedback = (next: StreamingResponseFeedback) => {\n    if (!isControlled) setUncontrolled(next);\n    onFeedbackChange?.(next);\n  };\n\n  const settled = status !== \"streaming\";\n  const hasActions =\n    settled && (copyText !== undefined || Boolean(onRetry) || Boolean(onShare) || showFeedback);\n\n  return (\n    <div className={cn(\"flex flex-col gap-2\", className)}>\n      <div aria-busy={!settled}>{children}</div>\n      {hasActions ? (\n        <motion.div\n          initial={reduce ? false : { opacity: 0, y: 4 }}\n          animate={{ opacity: 1, y: 0 }}\n          transition={{ duration: 0.2, ease: EASE_OUT }}\n          className=\"-ml-2 flex items-center gap-0.5\"\n        >\n          {copyText !== undefined ? (\n            <CopyButton\n              value={copyText}\n              className={cn(ICON_BUTTON, \"bg-transparent shadow-none hover:bg-muted\")}\n            />\n          ) : null}\n          {onRetry ? (\n            <button type=\"button\" aria-label=\"Replay\" onClick={onRetry} className={ICON_BUTTON}>\n              <RotateCcw aria-hidden=\"true\" className=\"h-4 w-4\" />\n            </button>\n          ) : null}\n          {onShare ? (\n            <button type=\"button\" aria-label=\"Share\" onClick={onShare} className={ICON_BUTTON}>\n              <Share2 aria-hidden=\"true\" className=\"h-4 w-4\" />\n            </button>\n          ) : null}\n          {showFeedback ? (\n            <>\n              <button\n                type=\"button\"\n                aria-label=\"Good response\"\n                aria-pressed={current === \"up\"}\n                onClick={() => setFeedback(current === \"up\" ? null : \"up\")}\n                className={cn(ICON_BUTTON, current === \"up\" && \"bg-muted text-success\")}\n              >\n                <ThumbsUp aria-hidden=\"true\" className=\"h-4 w-4\" />\n              </button>\n              <button\n                type=\"button\"\n                aria-label=\"Bad response\"\n                aria-pressed={current === \"down\"}\n                onClick={() => setFeedback(current === \"down\" ? null : \"down\")}\n                className={cn(ICON_BUTTON, current === \"down\" && \"bg-muted text-destructive\")}\n              >\n                <ThumbsDown aria-hidden=\"true\" className=\"h-4 w-4\" />\n              </button>\n            </>\n          ) : null}\n        </motion.div>\n      ) : null}\n      {settled && followUps?.length ? (\n        <div className=\"flex flex-col\">\n          {followUps.map((text, index) => (\n            <motion.button\n              // biome-ignore lint/suspicious/noArrayIndexKey: the list is static once passed in and never reorders.\n              key={index}\n              type=\"button\"\n              onClick={() => onFollowUp?.(text, index)}\n              initial={reduce ? false : { opacity: 0, y: 6 }}\n              animate={{ opacity: 1, y: 0 }}\n              transition={{ duration: 0.2, ease: EASE_OUT, delay: reduce ? 0 : index * 0.05 }}\n              className=\"group flex items-center gap-2 border-b border-border py-2 text-left text-sm text-foreground transition-colors duration-150 last:border-b-0 hover:text-accent\"\n            >\n              <span className=\"flex-1\">{text}</span>\n              <ArrowUpRight\n                aria-hidden=\"true\"\n                className=\"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-150 group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent motion-reduce:transition-none\"\n              />\n            </motion.button>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"components/motion/copy-button.tsx","type":"registry:component","target":"@components/motion/copy-button.tsx","content":"\"use client\";\n\nimport { Check, Copy } from \"lucide-react\";\nimport { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype CopyState = \"idle\" | \"copied\" | \"failed\";\n\nexport interface CopyButtonProps\n  extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\" | \"value\" | \"onCopy\"> {\n  /** Text written to the clipboard. */\n  value: string;\n  /** Visible text. Leave it out for an icon only button. */\n  label?: string;\n  /** Visible text after copying. Default \"Copied\". */\n  copiedLabel?: string;\n  /** How long the copied state stays, in ms. Default 1500. */\n  timeout?: number;\n  /** Called after the text reaches the clipboard. */\n  onCopied?: (value: string) => void;\n}\n\n// Both icons, and both labels, share a grid cell and trade places with a fade and a small\n// scale. Sharing the cell keeps the button the same width in either state.\nconst SWAP =\n  \"col-start-1 row-start-1 transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none\";\nconst SHOWN = \"scale-100 opacity-100\";\nconst ICON_HIDDEN = \"scale-50 opacity-0\";\nconst TEXT_HIDDEN = \"scale-[0.97] opacity-0\";\n\nexport const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton(\n  { value, label, copiedLabel = \"Copied\", timeout = 1500, onCopied, className, onClick, ...props },\n  ref,\n) {\n  const [state, setState] = useState<CopyState>(\"idle\");\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(value);\n      setState(\"copied\");\n      onCopied?.(value);\n    } catch {\n      // Clipboard access can be blocked, for example in an insecure context.\n      setState(\"failed\");\n    }\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = setTimeout(() => setState(\"idle\"), timeout);\n  };\n\n  const copied = state === \"copied\";\n  const status = copied ? \"Copied to clipboard\" : state === \"failed\" ? \"Could not copy\" : \"\";\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      aria-label={label ?? \"Copy\"}\n      data-state={state}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) void copy();\n      }}\n      className={cn(\n        \"relative inline-flex h-9 shrink-0 touch-manipulation select-none items-center justify-center gap-2 rounded-full bg-card text-sm font-medium text-foreground outline-none\",\n        \"shadow-[0_0_0_1px_var(--border)] transition-[background-color,scale] duration-150 ease-out hover:bg-muted active:scale-[0.97] motion-reduce:active:scale-100\",\n        \"focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        label ? \"px-3.5\" : \"w-9 after:absolute after:-inset-1\",\n        className,\n      )}\n      {...props}\n    >\n      <span aria-hidden=\"true\" className=\"grid place-items-center\">\n        <Copy className={cn(SWAP, \"h-4 w-4\", copied ? ICON_HIDDEN : SHOWN)} />\n        <Check className={cn(SWAP, \"h-4 w-4\", copied ? SHOWN : ICON_HIDDEN)} />\n      </span>\n      {label ? (\n        <span aria-hidden=\"true\" className=\"grid whitespace-nowrap\">\n          <span className={cn(SWAP, copied ? TEXT_HIDDEN : SHOWN)}>{label}</span>\n          <span className={cn(SWAP, copied ? SHOWN : TEXT_HIDDEN)}>{copiedLabel}</span>\n        </span>\n      ) : null}\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {status}\n      </span>\n    </button>\n  );\n});\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI\n// 150 to 250ms, and panels up to 300ms.\n// Easing curves mirror the CSS custom properties in globals.css.\n\n/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */\nexport const EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** ease-in-out-cubic. Elements already on screen moving to a new spot. */\nexport const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\n/** Sheet and drawer glide. */\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\n// Springs are described by duration and bounce, which is easier to reason about\n// than stiffness and damping. Bounce stays at zero for product UI.\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  duration: 0.15,\n  bounce: 0,\n} as const;\n\n/** Content swaps, label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  duration: 0.2,\n  bounce: 0,\n} as const;\n\n/** Overlay panel entrances, modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  duration: 0.25,\n  bounce: 0,\n} as const;\n\n/** Shared-layout glides, pills and indicators moving between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */\nexport const SPRING_MOUSE = {\n  stiffness: 320,\n  damping: 26,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders). Critically damped `useSpring` config,\n * so the value follows the pointer closely and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}