"use client"; import { ArrowUpRight, RotateCcw, Share2, ThumbsDown, ThumbsUp } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type ReactNode, useState } from "react"; import { CopyButton } from "@/components/motion/copy-button"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type StreamingResponseStatus = "streaming" | "complete" | "error"; export type StreamingResponseFeedback = "up" | "down" | null; export interface StreamingResponseProps { /** The response itself, such as a StreamingText or rendered markdown. Left unstyled: no card, no border. */ children: ReactNode; /** Default "streaming". The action row fades in once this leaves "streaming". */ status?: StreamingResponseStatus; /** Text the copy action writes to the clipboard. Omit to hide that action. */ copyText?: string; /** Shows a replay action. Omit to hide it. */ onRetry?: () => void; /** Shows a share action. Omit to hide it. */ onShare?: () => void; /** Shows a thumbs up / down toggle. Default false. */ showFeedback?: boolean; /** Controlled feedback value. */ feedback?: StreamingResponseFeedback; /** Starting feedback when uncontrolled. Default null. */ defaultFeedback?: StreamingResponseFeedback; onFeedbackChange?: (feedback: StreamingResponseFeedback) => void; /** Suggested next prompts, listed below the actions once settled. Omit to hide the list. */ followUps?: string[]; onFollowUp?: (text: string, index: number) => void; className?: string; } const ICON_BUTTON = cn( "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", "transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", ); /** * Wraps a response with the actions people expect once it settles: copy, * replay, share, a thumbs up or down. No card or border, so the answer * reads as part of the page. Each action is opt-in: pass a handler to show it. */ export function StreamingResponse({ children, status = "streaming", copyText, onRetry, onShare, showFeedback = false, feedback, defaultFeedback = null, onFeedbackChange, followUps, onFollowUp, className, }: StreamingResponseProps) { const reduce = useReducedMotion(); const [uncontrolled, setUncontrolled] = useState(defaultFeedback); const isControlled = feedback !== undefined; const current = isControlled ? feedback : uncontrolled; const setFeedback = (next: StreamingResponseFeedback) => { if (!isControlled) setUncontrolled(next); onFeedbackChange?.(next); }; const settled = status !== "streaming"; const hasActions = settled && (copyText !== undefined || Boolean(onRetry) || Boolean(onShare) || showFeedback); return (
{children}
{hasActions ? ( {copyText !== undefined ? ( ) : null} {onRetry ? ( ) : null} {onShare ? ( ) : null} {showFeedback ? ( <> ) : null} ) : null} {settled && followUps?.length ? (
{followUps.map((text, index) => ( onFollowUp?.(text, index)} initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: EASE_OUT, delay: reduce ? 0 : index * 0.05 }} 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" > {text} ))}
) : null}
); }