diff --git a/apps/apollo-vertex/app/_meta.ts b/apps/apollo-vertex/app/_meta.ts index 152665709..133fd3f89 100644 --- a/apps/apollo-vertex/app/_meta.ts +++ b/apps/apollo-vertex/app/_meta.ts @@ -6,6 +6,7 @@ export default { templates: "Templates", guidelines: "Guidelines", experiment: "Experiment", + "vertex-components": "Vertex Components", "data-querying": "Data Querying", localization: "Localization", mcp: "MCP Server", diff --git a/apps/apollo-vertex/app/patterns/ai-chat/page.mdx b/apps/apollo-vertex/app/patterns/ai-chat/page.mdx index b28514cef..9aad19945 100644 --- a/apps/apollo-vertex/app/patterns/ai-chat/page.mdx +++ b/apps/apollo-vertex/app/patterns/ai-chat/page.mdx @@ -1,12 +1,15 @@ import { AiChatTemplate } from '@/templates/AiChatTemplate'; +import { PreviewFullScreen } from '@/app/_components/preview-full-screen'; # AI Chat A composable AI chat UI component for Apollo Vertex. Built with React, TypeScript, and Tailwind CSS. Designed to work with [TanStack AI](https://tanstack.com/ai) — you bring `useChat` and a connection adapter, the component handles the chrome (scroll, input, loading, suggestions, errors) while you control how messages and tool calls render. -
+ {result} +
+ +{description}
++ {"All visual states and sub-components rendered with mock data."} +
+
+
+
+ + {description} +
+ )} +{children}
, + p: ({ children }: NodeProps) => ( +{children}
+ ), ul: ({ children }: NodeProps) => ( -++ ), + li: ({ children }: NodeProps) =>{children} -
{
+ const isBlock =
+ (className?.startsWith("language-") ?? false) ||
+ (typeof children === "string" && children.includes("\n"));
+
+ if (isBlock) {
+ const { language, code } = extractCodeProps({
+ className,
+ children,
+ ...props,
+ });
+ return {code} ;
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
+ a: ({ children, ...props }: AnchorProps) => (
+
{children}
-
- ),
- a: ({ children, ...props }: AnchorProps) => (
-
- {children}
),
+ img: ({ src, alt, title }: ImageProps) => (
+ +{children}), h1: ({ children }: NodeProps) => ( -{children}
+{children}
), h2: ({ children }: NodeProps) => ( -{children}
+{children}
), h3: ({ children }: NodeProps) => ( -{children}
+{children}
), - hr: () =>
, + hr: () =>
, table: ({ children }: NodeProps) => (-), thead: ({ children }: NodeProps) => ( - {children} + {children} ), tbody: ({ children }: NodeProps) => ( - {children} + {children} ), tr: ({ children }: NodeProps) =>{children}
++ {children} +
{children} , th: ({ children }: NodeProps) => ( @@ -86,7 +125,7 @@ interface AiChatMarkdownProps { export function AiChatMarkdown({ children }: AiChatMarkdownProps) { return ( -+{children} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx new file mode 100644 index 000000000..9cce3e036 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { + Check, + Copy, + Pencil, + RefreshCw, + ThumbsDown, + ThumbsUp, +} from "lucide-react"; +import { useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/registry/tooltip/tooltip"; +import type { MessageFeedbackType } from "../types"; + +const LABELS = { + copy: "Copy", + copied: "Copied!", + helpful: "Good response", + notHelpful: "Bad response", + regenerate: "Try again", + edit: "Edit", +} as const; + +interface AiChatMessageActionsProps { + content: string; + messageRole: "user" | "assistant"; + /** When true, actions are always visible (used for the latest assistant message). Defaults to false (hover/focus reveal). */ + isLatest?: boolean; + showCopy?: boolean; + onFeedback?: (type: MessageFeedbackType) => void; + onRegenerate?: () => void; + onEdit?: () => void; +} + +export function AiChatMessageActions({ + content, + messageRole, + isLatest = false, + showCopy = true, + onFeedback, + onRegenerate, + onEdit, +}: AiChatMessageActionsProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const copyLabel = copied ? LABELS.copied : LABELS.copy; + + // Latest assistant message keeps actions always visible. Everything else + // (older assistant messages, all user messages) reveals on hover/focus + // for keyboard a11y. + const visibilityClass = isLatest + ? "opacity-100" + : "opacity-0 group-hover/message:opacity-100 group-focus-within/message:opacity-100"; + + return ( ++ {showCopy && ( ++ ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message.tsx index 7c3b9aada..69de0e8ac 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message.tsx @@ -1,15 +1,49 @@ "use client"; import type { TextPart, UIMessage } from "@tanstack/ai-client"; -import { Sparkles } from "lucide-react"; -import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { ExternalLink, FileText } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { Badge } from "@/registry/badge/badge"; +import { useTypewriter } from "../hooks/use-typewriter"; +import type { MessageFeedbackType } from "../types"; +import { messageHasChoices } from "../utils/ai-chat-utils"; import { AiChatMarkdown } from "./ai-chat-markdown"; +import { AiChatMessageActions } from "./ai-chat-message-actions"; +import { AiChatSelectionMenu } from "./ai-chat-selection-menu"; +import { useAiChat } from "./ai-chat-provider"; + +// Quick, subtle entrance — fade + 8px slide up. Quartic ease-out for a soft settle. +const ENTRANCE_INITIAL = { opacity: 0, y: 8 }; +const ENTRANCE_ANIMATE = { opacity: 1, y: 0 }; +const ENTRANCE_TRANSITION = { + duration: 0.22, + ease: [0.22, 1, 0.36, 1] as const, +}; + +export interface MessageSource { + label: string; + url?: string; +} + +export interface MessageAttachment { + name: string; + type?: string; + size?: number; +} interface AiChatMessageProps { message: UIMessage; - assistantName?: string; children?: ReactNode; + /** Whether this message is currently being streamed */ + isStreaming?: boolean; + /** Callbacks for message actions */ + onFeedback?: (type: MessageFeedbackType) => void; + onRegenerate?: () => void; + /** Source citations shown below an assistant message */ + sources?: MessageSource[]; + /** File attachments shown in a user message bubble */ + attachments?: MessageAttachment[]; } function getDisplayText(message: UIMessage): string { @@ -19,48 +53,320 @@ function getDisplayText(message: UIMessage): string { .join(""); } +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export function AiChatMessage({ message, - assistantName, children, + isStreaming: isStreamingProp, + onFeedback, + onRegenerate, + sources, + attachments, }: AiChatMessageProps) { - const { t } = useTranslation(); + const config = useAiChat(); const isUser = message.role === "user"; - const displayName = assistantName ?? t("ai_assistant"); const displayContent = getDisplayText(message); - // Don't render assistant messages if they have no text content and no children (e.g. calling tools) + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(displayContent); + const editTextareaRef = useRef+ + )} + + {messageRole === "assistant" && ( + <> ++ + +{copyLabel} ++ + ++ + +{LABELS.helpful} ++ + > + )} + + {messageRole === "assistant" && onRegenerate && ( ++ + +{LABELS.notHelpful} ++ + )} + + {messageRole === "user" && onEdit && ( ++ + +{LABELS.regenerate} ++ + )} ++ + +{LABELS.edit} +(null); + + const [selectionMenu, setSelectionMenu] = useState<{ + x: number; + y: number; + text: string; + } | null>(null); + const contentRef = useRef (null); + + const handleMouseUp = () => { + if (isUser || !config.onQuoteSelect) return; + const selection = window.getSelection(); + const text = selection?.toString().trim(); + if (!text || !selection || selection.rangeCount === 0) { + setSelectionMenu(null); + return; + } + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + setSelectionMenu({ + x: rect.left + rect.width / 2, + y: rect.top, + text, + }); + }; + + // Keep editValue in sync if message content changes externally (e.g. regenerate) + useEffect(() => { + if (!isEditing) setEditValue(displayContent); + }, [displayContent, isEditing]); + + // Auto-focus, select all, and scroll into view when entering edit mode. + // rAF defers the scroll until after React has committed the new layout + // (bubble → textarea expansion), so scrollIntoView sees the final dimensions. + useEffect(() => { + if (isEditing && editTextareaRef.current) { + editTextareaRef.current.focus(); + editTextareaRef.current.select(); + const el = editTextareaRef.current; + requestAnimationFrame(() => { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + } + }, [isEditing]); + + const handleSave = () => { + if (editValue.trim() && editValue.trim() !== displayContent) { + config.onEditMessage?.(message.id, editValue.trim()); + } + setIsEditing(false); + }; + + // Streaming state — explicit prop wins, otherwise derive from chat-level isLoading + // and the latest assistant message ID. The latest assistant message is the one + // currently being generated when isLoading is true. + const isStreaming = + isStreamingProp ?? + (config.isLoading && + !isUser && + config.latestAssistantMessageId === message.id); + + // Throttle assistant text reveal to a comfortable reading pace. + // For user messages, cps=0 disables the typewriter so the full text appears instantly. + const { displayedText, isAnimating } = useTypewriter(displayContent, { + cps: isUser ? 0 : config.typewriterCps, + isStreaming, + }); + + // Latest assistant message keeps its actions always-visible; older messages + // hover-reveal. The chat parent computes the latest ID via context. + const isLatestAssistant = + !isUser && config.latestAssistantMessageId === message.id; + + // Push typewriter state up to the chat parent when this is the latest + // assistant message. The chat uses this to gate suggestion buttons until + // the response is fully revealed (typewriter has finished draining). + const setIsLatestResponseAnimating = config.setIsLatestResponseAnimating; + useEffect(() => { + if (!isLatestAssistant) return; + setIsLatestResponseAnimating(isAnimating); + return () => setIsLatestResponseAnimating(false); + }, [isLatestAssistant, isAnimating, setIsLatestResponseAnimating]); + + // Message actions (copy/thumbs/regenerate) only appear once the response is + // fully visible — both the LLM stream has finished AND the typewriter has + // drained any buffered characters. Prevents actions from popping in mid-reveal. + const isResponseFullyRevealed = !isStreaming && !isAnimating; + if (!isUser && !displayContent && !children) { return null; } if (isUser) { + // Edit mode — swap bubble for inline textarea + if (isEditing) { + return ( + + + ); + } + return ( -++-- {displayContent && ( -+ ); } + // Hide message-level actions when this message belongs to a turn currently + // presenting interactive choices — copy/feedback/regenerate aren't meaningful + // on a "pick an option" prompt. The chat parent computes which messages are + // part of the active choices turn (the prompt text and the tool call may live + // on separate sibling messages) and shares the set via context. + const isInActiveChoicesTurn = config.activeChoicesMessageIds.has(message.id); + return ( -{displayContent}
++ +-+ {attachments && attachments.length > 0 && ( ++ {config.showMessageActions && ( ++ {attachments.map((att) => ( ++ )} + {displayContent && ( ++ + {att.name} + {att.size != null && ( + + {formatFileSize(att.size)} + + )} ++ ))} +{displayContent}
+ )} +setIsEditing(true) } + : {})} + /> )} -+ )} +--- - {displayName} - - {displayContent &&-{displayContent} } - {children &&{children}} -+ + > ); } diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-provider.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-provider.tsx new file mode 100644 index 000000000..c82338ee7 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-provider.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { AiChatConfig } from "../types"; + +const defaultConfig: AiChatConfig = { + assistantName: "AI Assistant", + showTimestamps: false, + showMessageActions: true, + showCopyButton: true, + isLoading: false, + activeChoicesMessageIds: new Set(), + latestAssistantMessageId: null, + typewriterCps: 75, + isLatestResponseAnimating: false, + setIsLatestResponseAnimating: () => { + // no-op default — replaced by AiChat with the real setter via context override + }, +}; + +const AiChatContext = createContext+ {displayContent && + !messageHasChoices(message) && + (isResponseFullyRevealed ? ( ++{displayedText} + ) : ( ++ {displayedText} +
+ ))} + {children && ( +{children}+ )} + + {sources && sources.length > 0 && isResponseFullyRevealed && ( ++ {sources.map((source) => + source.url ? ( + + + )} + + {config.showMessageActions && + !isInActiveChoicesTurn && + isResponseFullyRevealed && ( ++ + {source.label} + + + ) : ( ++ + {source.label} + + ), + )} ++ + )} +config.onFeedback?.(message.id, type)), + } + : {})} + onRegenerate={onRegenerate ?? config.onRegenerate} + /> + (defaultConfig); + +interface AiChatProviderProps extends Partial { + children: React.ReactNode; +} + +export function AiChatProvider({ + children, + ...overrides +}: AiChatProviderProps) { + const config: AiChatConfig = { ...defaultConfig, ...overrides }; + + return ( + + {/* + display: contents — wrapper carries data attributes for styling/test + hooks but disappears from the box tree, so the AiChat outer div is a + direct child of whatever the consumer's parent is. Without this, h-full + on the AiChat outer would read this wrapper's auto height and collapse, + and the chat would grow to fit its content instead of scrolling within + a fixed-height parent like h-[500px]. + */} + + ); +} + +export function useAiChat(): AiChatConfig { + return useContext(AiChatContext); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-selection-menu.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-selection-menu.tsx new file mode 100644 index 000000000..ae581625c --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-selection-menu.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { AutopilotGradientIcon } from "./icons/autopilot-gradient"; + +interface AiChatSelectionMenuProps { + x: number; + y: number; + onAsk: () => void; + onDismiss: () => void; +} + +export function AiChatSelectionMenu({ + x, + y, + onAsk, + onDismiss, +}: AiChatSelectionMenuProps) { + const ref = useRef+ {children} ++(null); + + // Dismiss on outside mousedown + useEffect(() => { + const handler = (e: MouseEvent) => { + if ( + ref.current && + !(e.target instanceof Node && ref.current.contains(e.target)) + ) { + onDismiss(); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onDismiss]); + + return createPortal( + , + document.body, + ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-suggestions.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-suggestions.tsx index fa887c508..213f5e0b5 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-suggestions.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-suggestions.tsx @@ -1,37 +1,174 @@ "use client"; -import { cn } from "@/lib/utils"; +import { ChevronLeft, ChevronRight, Loader2, X } from "lucide-react"; +import { motion } from "framer-motion"; import type { ChoiceOption } from "../types"; +const ENTRANCE_EASE = [0.22, 1, 0.36, 1] as const; + +const containerVariants = { + hidden: { opacity: 0, y: 8 }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.28, + ease: ENTRANCE_EASE, + delayChildren: 0.18, + staggerChildren: 0.05, + }, + }, +}; + +const buttonVariants = { + hidden: { opacity: 0, y: 6 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.2, ease: ENTRANCE_EASE }, + }, +}; + interface AiChatSuggestionsProps { prompt?: string; options: ChoiceOption[]; onSelect: (option: ChoiceOption) => void; + step?: number; + totalSteps?: number; + canSkip?: boolean; + canGoBack?: boolean; + isLoading?: boolean; + onBack?: () => void; + onSkip?: () => void; + onDismiss?: () => void; } export function AiChatSuggestions({ prompt, options, onSelect, + step, + totalSteps, + canSkip, + canGoBack, + isLoading = false, + onBack, + onSkip, + onDismiss, }: AiChatSuggestionsProps) { + const isMultiStep = step != null; + + if (isMultiStep) { + return ( + + {/* Header */} + + ); + } + + // Single-step: original chip style return ( -++ + {/* Prompt */} + {prompt && ( ++ {isLoading ? ( + + ) : ( + <> + {canGoBack && onBack && ( + + )} + {totalSteps && ( + + {step} {"/"}{" "} + {totalSteps} + + )} + > + )} +++ {canSkip && onSkip && ( + + )} + {onDismiss && ( + + )} +++ {prompt} +
+ )} + + {/* Options */} ++ {options.map((option) => ( ++onSelect(option)} + > + {option.label} + + ))} +++ ); } diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-thinking.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-thinking.tsx new file mode 100644 index 000000000..7692d3f1f --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-thinking.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { motion } from "framer-motion"; +import { useId } from "react"; + +interface AiChatThinkingProps { + size?: number; + className?: string; + /** + * When true, plays the forward sequence (idle → thinking) and holds + * the steady-state pulse. When false, plays the reverse back to idle. + * Defaults to true so existing usages auto-play on mount. + */ + isThinking?: boolean; +} + +// Timing +const FORWARD_DURATION = 0.8; +const REVERSE_DURATION = 0.4; +const PULSE_DURATION = 1.8; + +// Single easing for both directions — quartic ease-in-out, smooth acceleration and deceleration +const EASE = [0.83, 0, 0.17, 1] as const; + +// Circle radius in viewBox units. The 24×24 viewBox at 25% target = 6 units diameter = 3 units radius. +const CIRCLE_RADIUS = 3; + +// Small sparkle geometric center within the 24×24 viewBox +const SMALL_SPARKLE_CENTER_X = 17.82; +const SMALL_SPARKLE_CENTER_Y = 6.35; +const VIEWBOX_CENTER = 12; + +export function AiChatThinking({ + size = 32, + className, + isThinking = true, +}: AiChatThinkingProps) { + const gradientId = useId(); + + // Framer Motion's x/y on SVG elements are applied as CSS translate in CSS pixels. + // Convert viewBox-unit deltas into pixel values at the current render size. + const unit = size / 24; + const smallSparkleTargetX = (VIEWBOX_CENTER - SMALL_SPARKLE_CENTER_X) * unit; + const smallSparkleTargetY = (VIEWBOX_CENTER - SMALL_SPARKLE_CENTER_Y) * unit; + + return ( + + ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx index c0049ef82..23d82f08b 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx @@ -1,30 +1,87 @@ +// oxlint-disable eslint/max-lines -- composite orchestration component; split would add indirection without clarity "use client"; -import type { UIMessage } from "@tanstack/ai-client"; -import { AlertCircle, ArrowDown, Sparkles } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import type { TextPart, UIMessage } from "@tanstack/ai-client"; +import { + AlertCircle, + ArrowDown, + MoreHorizontal, + RefreshCw, +} from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/registry/alert-dialog/alert-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/registry/dropdown-menu/dropdown-menu"; import { useStickyScroll } from "../hooks/use-sticky-scroll"; -import type { ChoiceOption } from "../types"; -import { findLatestChoices } from "../utils/ai-chat-utils"; -import { AiChatInput } from "./ai-chat-input"; +import type { ChoiceOption, MessageFeedbackType } from "../types"; +import { + findActiveChoicesMessageIds, + findLatestChoices, + findLatestFlow, +} from "../utils/ai-chat-utils"; +import { AiChatFlow } from "./ai-chat-flow"; +import { AiChatInput, type AiChatInputHandle } from "./ai-chat-input"; import { AiChatLoading } from "./ai-chat-loading"; +import { AiChatProvider } from "./ai-chat-provider"; import { AiChatSuggestions } from "./ai-chat-suggestions"; +import { AutopilotGradientIcon } from "./icons/autopilot-gradient"; + +const RETRY_LABEL = "Retry"; export interface AiChatProps { messages: UIMessage[]; isLoading: boolean; - onSendMessage: (content: string) => void; + onSendMessage: (content: string, attachments?: File[]) => void; onStop: () => void; onClearChat?: () => void; onChoiceSelect?: (option: ChoiceOption) => void; + onRetry?: () => void; + /** Callback when the user gives thumbs up/down feedback on an assistant message. */ + onFeedback?: (messageId: string, type: MessageFeedbackType) => void; + /** Callback to regenerate the last assistant response. When provided, the "Try again" button appears in assistant message actions. */ + onRegenerate?: () => void; + /** Callback when the user saves an edited user message. Receives the message ID and new content. */ + onEditMessage?: (messageId: string, content: string) => void; children?: ReactNode; assistantName?: string; + assistantAvatar?: ReactNode; + userAvatar?: ReactNode; title?: string; + renderHeader?: ReactNode; emptyState?: ReactNode; + /** Quick-start suggestions shown below the input in the empty state */ + suggestions?: string[]; + /** Called when the user clicks a suggestion in the empty state */ + onSuggestionClick?: (suggestion: string) => void; placeholder?: string; showClearButton?: boolean; + showTimestamps?: boolean; + showMessageActions?: boolean; + showCopyButton?: boolean; error?: Error | null; + /** Controlled input value */ + value?: string; + /** Controlled input onChange */ + onValueChange?: (value: string) => void; + /** Characters per second for the typewriter reveal on assistant messages. Set to 0 to disable (text appears instantly). Default: 40 */ + typewriterCps?: number; + /** When true, selecting text in an assistant message shows an "Ask Autopilot" button that quotes the selection into the input. */ + enableTextSelection?: boolean; } export function AiChat({ @@ -34,27 +91,137 @@ export function AiChat({ onStop, onClearChat, onChoiceSelect, + onRetry, + onFeedback, + onRegenerate, + onEditMessage, children, assistantName, + assistantAvatar, + userAvatar, title, + renderHeader, emptyState, + suggestions, + onSuggestionClick, placeholder, showClearButton = true, + showTimestamps = false, + showMessageActions = true, + showCopyButton = true, error, + value: controlledValue, + onValueChange, + typewriterCps = 75, + enableTextSelection = false, }: AiChatProps) { const { t } = useTranslation(); - const [input, setInput] = useState(""); + const [internalInput, setInternalInput] = useState(""); + const [isLatestResponseAnimating, setIsLatestResponseAnimating] = + useState(false); + const [quotedText, setQuotedText] = useState{prompt && {prompt}
}{options.map((option) => ( - + {option.label} + ))}-(null); + const [choiceHistory, setChoiceHistory] = useState ([]); const { scrollRef, contentRef, isStuck, scrollToBottom } = useStickyScroll(); + const inputRef = useRef (null); + + const isControlled = controlledValue != null; + const input = isControlled ? controlledValue : internalInput; + const setInput = + isControlled && onValueChange ? onValueChange : setInternalInput; + const displayName = assistantName ?? t("ai_assistant"); - const handleSubmit = () => { - if (!input.trim() || isLoading) return; - onSendMessage(input.trim()); + const queuedMessageRef = useRef<{ + content: string; + attachments?: File[]; + } | null>(null); + const [conversationCopied, setConversationCopied] = useState(false); + const flowFreeTextResolveRef = useRef<((text: string) => void) | null>(null); + + const handleCopyConversation = async () => { + const text = messages + .map((m) => { + const content = m.parts + .filter((p): p is TextPart => p.type === "text") + .map((p) => p.content) + .join(""); + if (!content) return null; + const label = m.role === "user" ? "You" : displayName; + return `${label}: ${content}`; + }) + .filter(Boolean) + .join("\n\n"); + await navigator.clipboard.writeText(text); + setConversationCopied(true); + setTimeout(() => setConversationCopied(false), 2000); + }; + + const handleSubmit = (attachments?: File[]) => { + if (!input.trim()) return; + // Flow is active: route free-text answer into the flow instead of sending a message + if (isFlowActive && flowFreeTextResolveRef.current) { + const resolve = flowFreeTextResolveRef.current; + flowFreeTextResolveRef.current = null; + resolve(input.trim()); + setInput(""); + return; + } + const content = quotedText + ? `> ${quotedText}\n\n${input.trim()}` + : input.trim(); + if (isLoading) { + queuedMessageRef.current = { content, attachments }; + setInput(""); + setQuotedText(null); + return; + } + onSendMessage(content, attachments); setInput(""); + setQuotedText(null); scrollToBottom(); }; + const wasLoadingRef = useRef(false); + useEffect(() => { + if (wasLoadingRef.current && !isLoading) { + if (queuedMessageRef.current) { + const queued = queuedMessageRef.current; + queuedMessageRef.current = null; + onSendMessage(queued.content, queued.attachments); + scrollToBottom(); + } else { + inputRef.current?.focus(); + } + } + wasLoadingRef.current = isLoading; + // oxlint-disable-next-line react-hooks/exhaustive-deps -- onSendMessage/scrollToBottom are stable enough; adding them would retrigger on every render + }, [isLoading]); + const latestChoices = findLatestChoices(messages); + const isMultiStep = latestChoices?.step != null; + const latestFlow = findLatestFlow(messages); + const [flowDismissed, setFlowDismissed] = useState(false); + const prevFlowIdRef = useRef (null); + useEffect(() => { + const id = latestFlow?.steps[0]?.id ?? null; + if (id !== null && id !== prevFlowIdRef.current) { + prevFlowIdRef.current = id; + setFlowDismissed(false); + } + }, [latestFlow]); + const isFlowActive = latestFlow !== null && !flowDismissed; + + // Clear the free-text resolver whenever the flow becomes inactive so stale + // refs never intercept normal messages or "Ask Autopilot" submissions. + useEffect(() => { + if (!isFlowActive) { + flowFreeTextResolveRef.current = null; + } + }, [isFlowActive]); + + const activeChoicesMessageIds = findActiveChoicesMessageIds(messages); + const latestAssistantMessageId = + messages.findLast((m) => m.role === "assistant")?.id ?? null; const lastMessage = messages.at(-1); const lastAssistantHasText = @@ -63,104 +230,327 @@ export function AiChat({ const showLoadingIndicator = isLoading && !lastAssistantHasText; const defaultEmptyState = ( - -- ++); return ( -+-+ {"What are we tackling today?"} +
++ {"I can help you review, fix, or complete your work."} +
{t("start_conversation_with", { name: displayName })}
setQuotedText(text) } + : {})} > - {title && ( -+ ); } diff --git a/apps/apollo-vertex/registry/ai-chat/components/icons/autopilot-gradient.tsx b/apps/apollo-vertex/registry/ai-chat/components/icons/autopilot-gradient.tsx new file mode 100644 index 000000000..d52c09077 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/icons/autopilot-gradient.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; + +interface AutopilotGradientIconProps + extends Omit-- )} - - {error && ( -- - {title} -
-- - {error.message} -- )} - --+ {messages.length > 0 && ( +- {messages.length === 0 ? ( - (emptyState ?? defaultEmptyState) - ) : ( --- {children} - - {latestChoices && !isLoading && ( ++ )} + + {error && ( ++ {renderHeader ?? + (title && ( +- - {!isStuck && ( - + )} +++ ))} + + {messages.length === 0 ? ( ++ + + {title} + ++ {messages.length > 0 && ( ++ + )} ++ ++ + ++ +{ + void handleCopyConversation(); + }} + > + {conversationCopied ? "Copied!" : "Copy conversation"} + + {onClearChat && showClearButton && ( ++ + )} ++ {"New conversation"} + ++ ++ ++ {"Start a new conversation?"} + ++ {"This will clear all messages and cannot be undone."} + ++ +{"Cancel"} +{ + onClearChat?.(); + setChoiceHistory([]); + }} + > + {"New conversation"} + +++ ) : ( ++++ {emptyState ?? defaultEmptyState} ++handleSubmit(files)} + onStop={onStop} + isLoading={isLoading} + placeholder={placeholder} + hasMessages={false} + /> + {suggestions && suggestions.length > 0 && ( + + {suggestions.map((suggestion) => ( + + ))} ++ )} ++ + {/* Chat messages — blurred when a multi-step flow is active */} +- )} -++ + {/* Multi-step HUD overlay */} + {isMultiStep && latestChoices && ( ++ {children} + + {/* Single-step choices render inline */} + {latestChoices && + !latestChoices.step && + !isLoading && + !isLatestResponseAnimating && ( ++{ + if (onChoiceSelect) { + onChoiceSelect(option); + } else { + onSendMessage(option.label); + } + }} + /> + )} + + {showLoadingIndicator && !isMultiStep && !isFlowActive && ( + + )} + + )} - {showLoadingIndicator && ( -{ + setChoiceHistory((h) => [...h, option.label]); if (onChoiceSelect) { onChoiceSelect(option); } else { onSendMessage(option.label); } }} + {...(latestChoices.canGoBack && choiceHistory.length > 0 + ? { + onBack: () => { + const prev = choiceHistory.at(-1); + setChoiceHistory((h) => h.slice(0, -1)); + onSendMessage( + `Actually, let me revise my previous answer: ${prev}`, + ); + }, + } + : {})} + {...(latestChoices.canSkip + ? { + onSkip: () => { + setChoiceHistory((h) => [...h, "(skipped)"]); + onSendMessage("Skip this step"); + }, + } + : {})} + onDismiss={() => { + setChoiceHistory([]); + onSendMessage("Never mind, let's stop here"); + }} /> - )} + - )} - -)} -- + + {error.message} + {onRetry && ( + + )} + 0} - /> - + {isFlowActive && latestFlow && ( ++ )} +++ )} +{ + setFlowDismissed(true); + flowFreeTextResolveRef.current = null; + const summary = answers + .map((a, i) => `Step ${i + 1} (${a.prompt}): ${a.answer}`) + .join(", "); + onSendMessage(summary); + }} + onDismiss={() => { + setFlowDismissed(true); + flowFreeTextResolveRef.current = null; + onSendMessage("Never mind, let's stop here"); + }} + onFreeTextReady={(resolve) => { + flowFreeTextResolveRef.current = resolve; + }} + /> + ++setQuotedText(null)} + /> + + {"AI-generated responses should be reviewed for accuracy."} ++, "width" | "height" | "fill"> { + size?: string | number; +} + +/** + * Autopilot brand mark filled with the purple → teal brand gradient. + * Uses the same gradient stops as `--ai-gradient-strong` so it matches + * the gradient used on the chat send button, suggestion buttons, and title. + * + * Each instance generates a unique gradient ID via `useId` to prevent + * collisions when multiple instances are rendered on the same page. + */ +export const AutopilotGradientIcon = React.forwardRef< + SVGSVGElement, + AutopilotGradientIconProps +>(({ size = 24, ...props }, ref) => { + const gradientId = React.useId(); + return ( + + ); +}); + +AutopilotGradientIcon.displayName = "AutopilotGradientIcon"; diff --git a/apps/apollo-vertex/registry/ai-chat/components/icons/autopilot.tsx b/apps/apollo-vertex/registry/ai-chat/components/icons/autopilot.tsx new file mode 100644 index 000000000..bf85cb7b7 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/icons/autopilot.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; + +interface AutopilotIconProps + extends Omit , "width" | "height"> { + size?: string | number; +} + +export const AutopilotIcon = React.forwardRef< + SVGSVGElement, + AutopilotIconProps +>(({ size = 24, ...props }, ref) => ( + +)); + +AutopilotIcon.displayName = "AutopilotIcon"; diff --git a/apps/apollo-vertex/registry/ai-chat/examples/choices-tool.ts b/apps/apollo-vertex/registry/ai-chat/examples/choices-tool.ts index a9d8ceb90..8efe20866 100644 --- a/apps/apollo-vertex/registry/ai-chat/examples/choices-tool.ts +++ b/apps/apollo-vertex/registry/ai-chat/examples/choices-tool.ts @@ -17,8 +17,25 @@ const presentChoicesInput = z.object({ options: z .array(choiceOptionSchema) .min(2) - .max(4) - .describe("2 to 4 options for the user to pick from"), + .max(6) + .describe("2 to 6 options for the user to pick from"), + step: z + .number() + .optional() + .describe( + "Current step number (1-based) — include when this is part of a multi-step flow", + ), + totalSteps: z + .number() + .optional() + .describe("Total number of steps in the flow — include alongside step"), + canSkip: z.boolean().optional().describe("Show a skip button for this step"), + canGoBack: z + .boolean() + .optional() + .describe( + "Show a back button to let the user revise their previous answer", + ), }); const presentChoicesOutput = presentChoicesInput.extend({ @@ -27,9 +44,7 @@ const presentChoicesOutput = presentChoicesInput.extend({ const presentChoicesDef = toolDefinition({ name: "presentChoices", - description: `Present the user with 2–4 clickable choices. - Call this tool whenever the user asks for choices, options, or wants to pick between alternatives. - Mark one option as recommended when there is a clear best pick.`, + description: `Present the user with clickable choices. Call this tool whenever the user needs to pick between alternatives, or when gathering information step by step. Mark one option as recommended when there is a clear best pick. For multi-step flows, include step/totalSteps and set canGoBack=true after the first step.`, inputSchema: presentChoicesInput, outputSchema: presentChoicesOutput, }); @@ -42,7 +57,15 @@ export const choicesTools = clientTools(presentChoices); export const CHOICES_TOOL_PROMPT = ` You have a "presentChoices" tool. -When the user asks for choices, options, or says things like "give me some choices", call this tool with 2–4 creative options. -Always mark exactly one option as recommended. +Use it when the user asks for choices, options, or when you need to gather information step by step before taking action. + +Single-step: call with 2–6 options, mark one as recommended when there is a clear best pick. + +Multi-step flows: when you need multiple pieces of information, call this tool once per step. +- Set step (1-based) and totalSteps on every call in the flow. +- Set canGoBack=true on step 2 and beyond so the user can revise previous answers. +- Set canSkip=true on optional steps. +- Always include a "Something else" option (id: "other") as the last choice so the user can type a custom answer. + After calling the tool keep your text reply short — the UI renders the options as buttons so do NOT repeat them in prose. `.trim(); diff --git a/apps/apollo-vertex/registry/ai-chat/examples/flow-tool.ts b/apps/apollo-vertex/registry/ai-chat/examples/flow-tool.ts new file mode 100644 index 000000000..08e016cb9 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/examples/flow-tool.ts @@ -0,0 +1,71 @@ +import { toolDefinition } from "@tanstack/ai"; +import { clientTools } from "@tanstack/ai-client"; +import { z } from "zod"; + +const flowOptionSchema = z.object({ + id: z.string().describe("Unique identifier for this option"), + label: z.string().describe("Button text shown to the user"), + value: z + .string() + .optional() + .describe("Optional payload value — defaults to label if omitted"), + recommended: z + .boolean() + .optional() + .describe("Highlight this as the recommended choice"), + freeText: z + .boolean() + .optional() + .describe( + "When true, selecting this option lets the user type a custom answer in the input instead of choosing a preset", + ), +}); + +const flowStepSchema = z.object({ + id: z.string().describe("Unique identifier for this step"), + prompt: z.string().describe("The question shown to the user for this step"), + options: z.array(flowOptionSchema).min(2).max(6).describe("2 to 6 options"), + canSkip: z.boolean().optional().describe("Allow the user to skip this step"), +}); + +const presentFlowInput = z.object({ + steps: z + .array(flowStepSchema) + .min(2) + .max(8) + .describe("Ordered list of steps — all defined upfront"), +}); + +const presentFlowOutput = presentFlowInput.extend({ + type: z.literal("flow"), +}); + +const presentFlowDef = toolDefinition({ + name: "presentFlow", + description: `Present the user with a multi-step guided flow. All steps are defined upfront and the user navigates them client-side — no round-trip between steps. Use this when you need 2–8 answers before you can take action. When the user completes the flow, a single message is sent with all their answers. Only include specific, concrete options per step — never add "Other", "Something else", or any catch-all fallback, as the text input is always available.`, + inputSchema: presentFlowInput, + outputSchema: presentFlowOutput, +}); + +const presentFlow = presentFlowDef.client((input) => + Object.assign({ type: "flow" as const }, input), +); + +export const flowTool = clientTools(presentFlow); + +export const FLOW_TOOL_PROMPT = ` +You have a "presentFlow" tool. +Use it when you need to gather 2–8 pieces of information before taking action and the questions are independent (each answer doesn't change the next question). + +- Define all steps upfront in the steps array. +- Each step has a prompt and 2–6 options. Only include concrete, specific choices. Do NOT add catch-all options like "Something else", "Other", "None of the above", or any similar fallback — the text input below the card is always available for the user to type a custom answer. +- Mark one option as recommended when there is a clear best pick. +- Set canSkip=true on optional steps. + +The user navigates all steps locally — no LLM round-trips between steps. When they finish, you receive a single message with all their answers formatted as: +"Step 1 (prompt): answer, Step 2 (prompt): answer, ..." + +The user can also type a custom free-text answer at any step instead of clicking an option. If an answer doesn't match the options you defined, treat it as a valid custom response and proceed — never question or re-ask it. + +After calling the tool keep your text reply very short — do NOT list the questions in prose. +`.trim(); diff --git a/apps/apollo-vertex/registry/ai-chat/hooks/use-sticky-scroll.ts b/apps/apollo-vertex/registry/ai-chat/hooks/use-sticky-scroll.ts index 10adb76e0..d4e34666f 100644 --- a/apps/apollo-vertex/registry/ai-chat/hooks/use-sticky-scroll.ts +++ b/apps/apollo-vertex/registry/ai-chat/hooks/use-sticky-scroll.ts @@ -49,7 +49,7 @@ export function useStickyScroll() { useEffect(() => { if (isStuckRef.current) { const el = scrollElRef.current; - if (el) el.scrollTop = el.scrollHeight; + if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); } }, [contentRect.height]); diff --git a/apps/apollo-vertex/registry/ai-chat/hooks/use-thinking-label.ts b/apps/apollo-vertex/registry/ai-chat/hooks/use-thinking-label.ts new file mode 100644 index 000000000..11f6216b6 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/hooks/use-thinking-label.ts @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState } from "react"; + +// English interleaved with each translation so "Thinking…" appears every other cycle +const THINKING_LABELS = [ + "Thinking…", + "Se gândește…", + "Thinking…", + "सोच रहा है…", + "Thinking…", + "Denkt nach…", + "Thinking…", + "Réflexion…", +]; + +// How long each label is visible before cycling to the next +const CYCLE_INTERVAL_MS = 3000; + +interface UseThinkingLabelOptions { + /** + * How long to show the initial "Thinking…" label before starting to cycle. + * During this phase the label is always THINKING_LABELS[0]. + * @default 2000 + */ + initialDelay?: number; +} + +interface UseThinkingLabelResult { + /** The current label text */ + label: string; + /** Unique key for AnimatePresence — use this instead of the label string since "Thinking…" appears multiple times in the cycle */ + key: number; +} + +/** + * Returns a thinking label that cycles through translations after an initial delay. + * + * - 0 → initialDelay: returns "Thinking…" (English, static) + * - initialDelay+: cycles through all labels every CYCLE_INTERVAL_MS + * + * English is interleaved so the sequence is: + * Thinking… → Se gândește… → Thinking… → सोच रहा है… → Thinking… → Denkt nach… → … + * + * The cycle wraps around indefinitely, so longer thinking phases just keep rotating. + */ +export function useThinkingLabel({ + initialDelay = 2000, +}: UseThinkingLabelOptions = {}): UseThinkingLabelResult { + const [index, setIndex] = useState(0); + const [generation, setGeneration] = useState(0); + const [isCycling, setIsCycling] = useState(false); + + // Start cycling after the initial delay + useEffect(() => { + const timer = setTimeout(() => { + setIsCycling(true); + // Jump to index 1 to skip re-showing "Thinking…" which was already visible + setIndex(1); + setGeneration((g) => g + 1); + }, initialDelay); + return () => clearTimeout(timer); + }, [initialDelay]); + + // Cycle through labels once cycling has started + useEffect(() => { + if (!isCycling) return; + const timer = setInterval(() => { + setIndex((prev) => (prev + 1) % THINKING_LABELS.length); + setGeneration((g) => g + 1); + }, CYCLE_INTERVAL_MS); + return () => clearInterval(timer); + }, [isCycling]); + + return { + label: THINKING_LABELS[index] ?? THINKING_LABELS[0], + key: generation, + }; +} diff --git a/apps/apollo-vertex/registry/ai-chat/hooks/use-typewriter.ts b/apps/apollo-vertex/registry/ai-chat/hooks/use-typewriter.ts new file mode 100644 index 000000000..a3a361e5d --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/hooks/use-typewriter.ts @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useReducer, useRef } from "react"; + +interface UseTypewriterOptions { + /** Characters per second to reveal. Set to 0 or negative to disable (returns full text instantly). */ + cps: number; + /** + * Whether the source text is currently being streamed (still growing). + * When false, the typewriter accelerates 2× to drain any remaining buffered characters. + */ + isStreaming: boolean; +} + +interface UseTypewriterResult { + /** The substring of `text` that should currently be visible. */ + displayedText: string; + /** True while the typewriter is still revealing characters (i.e., displayedText.length < text.length). */ + isAnimating: boolean; +} + +/** + * Throttles a string's reveal to a configurable characters-per-second rate. + * Designed for AI chat assistant messages whose text streams in faster than + * a human can comfortably read — ChatGPT, Claude, and other major products + * all throttle visible reveal to ~30–50 cps regardless of underlying stream speed. + * + * Behavior: + * - Mounts in "instant" mode (full text visible) when not streaming or when cps ≤ 0 + * - Mounts in "typewriter" mode (starts at length 0) when streaming and cps > 0 + * - When `isStreaming` flips from true → false, accelerates to 2× speed to drain + * - Stops the rAF chain when caught up; restarts naturally when text grows + * - Clamps `displayedLength` if text shrinks (e.g., regenerate) + */ +export function useTypewriter( + text: string, + { cps, isStreaming }: UseTypewriterOptions, +): UseTypewriterResult { + const displayedLengthRef = useRef ( + cps > 0 && isStreaming ? 0 : text.length, + ); + const [, forceRender] = useReducer((x: number) => x + 1, 0); + + // Clamp if text shrunk (e.g., regenerate replaced the message with shorter content) + if (displayedLengthRef.current > text.length) { + displayedLengthRef.current = text.length; + } + + useEffect(() => { + // Disabled — show full text instantly + if (cps <= 0) { + if (displayedLengthRef.current !== text.length) { + displayedLengthRef.current = text.length; + forceRender(); + } + return; + } + + // Already caught up — nothing to animate until text grows + if (displayedLengthRef.current >= text.length) { + return; + } + + let rafId: number | null = null; + let lastTime = performance.now(); + // Drain mode: when streaming has completed, catch up to the buffered text 2× faster + const speedMultiplier = isStreaming ? 1 : 2; + + const tick = (now: number) => { + const target = text.length; + if (displayedLengthRef.current >= target) { + // Caught up — let the rAF chain end + rafId = null; + return; + } + + const delta = (now - lastTime) / 1000; + // Floor to integer characters so we only re-render when at least one new char is ready; + // accumulate `lastTime` only when we actually advance, otherwise the next frame compounds + const advance = Math.floor(cps * speedMultiplier * delta); + if (advance > 0) { + lastTime = now; + displayedLengthRef.current = Math.min( + target, + displayedLengthRef.current + advance, + ); + forceRender(); + } + + rafId = requestAnimationFrame(tick); + }; + + rafId = requestAnimationFrame(tick); + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }, [text, isStreaming, cps]); + + return { + displayedText: text.slice(0, displayedLengthRef.current), + isAnimating: displayedLengthRef.current < text.length, + }; +} diff --git a/apps/apollo-vertex/registry/ai-chat/types.ts b/apps/apollo-vertex/registry/ai-chat/types.ts index eb8be9221..bde62fcf1 100644 --- a/apps/apollo-vertex/registry/ai-chat/types.ts +++ b/apps/apollo-vertex/registry/ai-chat/types.ts @@ -1 +1,55 @@ +export type { + MessageAttachment, + MessageSource, +} from "./components/ai-chat-message"; export type { ChoiceOption, ToolResultChoices } from "./utils/ai-chat-utils"; + +export type MessageFeedbackType = "up" | "down"; + +export interface MessageAction { + /** Unique key for the action */ + key: string; + /** Label shown in tooltip */ + label: string; + /** Lucide icon component */ + icon: React.ComponentType<{ className?: string }>; + /** Called when the action is triggered */ + onClick: () => void; + /** Only show for specific message roles */ + visibleFor?: "user" | "assistant"; +} + +export interface AiChatConfig { + /** Display name for the assistant */ + assistantName: string; + /** Custom avatar for the assistant (ReactNode) */ + assistantAvatar?: React.ReactNode; + /** Custom avatar for the user (ReactNode) */ + userAvatar?: React.ReactNode; + /** Show relative timestamps on messages */ + showTimestamps: boolean; + /** Show hover action toolbar on messages */ + showMessageActions: boolean; + /** Show copy button on code blocks and messages */ + showCopyButton: boolean; + /** Whether the chat is currently loading */ + isLoading: boolean; + /** IDs of assistant messages that belong to an active "pick a choice" turn — actions on these are suppressed */ + activeChoicesMessageIds: Set ; + /** ID of the latest assistant message — its actions stay always-visible while older messages reveal on hover/focus */ + latestAssistantMessageId: string | null; + /** Characters per second for the typewriter reveal effect on assistant messages. Set to 0 to disable. */ + typewriterCps: number; + /** True while the latest assistant message's typewriter is still revealing characters. Used to gate suggestion buttons until the response is fully visible. */ + isLatestResponseAnimating: boolean; + /** Setter for `isLatestResponseAnimating` — called by the latest assistant message component as its typewriter state changes. */ + setIsLatestResponseAnimating: (animating: boolean) => void; + /** Callback when the user gives thumbs up/down on an assistant message. */ + onFeedback?: (messageId: string, type: MessageFeedbackType) => void; + /** Callback to regenerate the last assistant response. When provided, the "Try again" button appears in assistant message actions. */ + onRegenerate?: () => void; + /** Callback when the user saves an edited user message. Receives the message ID and new content. */ + onEditMessage?: (messageId: string, content: string) => void; + /** Callback when the user selects text in an assistant message and clicks "Ask Autopilot". */ + onQuoteSelect?: (text: string) => void; +} diff --git a/apps/apollo-vertex/registry/ai-chat/utils/ai-chat-utils.ts b/apps/apollo-vertex/registry/ai-chat/utils/ai-chat-utils.ts index 4bcf725c6..b95a66cb1 100644 --- a/apps/apollo-vertex/registry/ai-chat/utils/ai-chat-utils.ts +++ b/apps/apollo-vertex/registry/ai-chat/utils/ai-chat-utils.ts @@ -1,6 +1,65 @@ import type { UIMessage } from "@tanstack/ai-client"; import { z } from "zod"; +// ─── Flow (client-side multi-step) ─────────────────────────────────────────── + +const flowOptionSchema = z.object({ + id: z.string(), + label: z.string(), + value: z.string().optional(), + recommended: z.boolean().optional(), + freeText: z.boolean().optional(), +}); + +const flowStepSchema = z.object({ + id: z.string(), + prompt: z.string(), + options: z.array(flowOptionSchema), + canSkip: z.boolean().optional(), +}); + +const toolResultFlowSchema = z.object({ + type: z.literal("flow"), + steps: z.array(flowStepSchema), +}); + +export type FlowOption = z.infer ; +export type FlowStep = z.infer ; +export type ToolResultFlow = z.infer ; + +function tryParseFlow(content: string): ToolResultFlow | null { + try { + const result = toolResultFlowSchema.safeParse(JSON.parse(content)); + return result.success ? result.data : null; + } catch { + // invalid JSON — not a flow result + return null; + } +} + +export function findLatestFlow(messages: UIMessage[]): ToolResultFlow | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!msg || msg.role !== "assistant") continue; + const hasUserAfter = messages.slice(i + 1).some((m) => m.role === "user"); + if (hasUserAfter) continue; + + for (const part of msg.parts) { + if (part.type === "tool-result" && "content" in part) { + const result = tryParseFlow(part.content); + if (result) return result; + } + if (part.type === "tool-call" && "output" in part) { + const result = toolResultFlowSchema.safeParse( + (part as { output?: unknown }).output, + ); + if (result.success) return result.data; + } + } + } + return null; +} + const choiceOptionSchema = z.object({ id: z.string(), label: z.string(), @@ -12,6 +71,10 @@ const toolResultChoicesSchema = z.object({ type: z.literal("choices"), prompt: z.string(), options: z.array(choiceOptionSchema), + step: z.number().optional(), + totalSteps: z.number().optional(), + canSkip: z.boolean().optional(), + canGoBack: z.boolean().optional(), }); export type ChoiceOption = z.infer ; @@ -41,3 +104,78 @@ export function findLatestChoices( } return null; } + +/** + * Returns true if the message contains a choices tool call/result. + * Handles both shapes: + * - `tool-result` with `content` (stringified JSON, server/wire format) + * - `tool-call` with `output` (parsed object, client tools) + */ +export function messageHasChoices(message: UIMessage): boolean { + return message.parts.some((part) => { + // tool-result with stringified JSON content + if (part.type === "tool-result" && "content" in part) { + try { + const parsed: unknown = JSON.parse( + (part as { content: string }).content, + ); + if ( + parsed !== null && + typeof parsed === "object" && + "type" in parsed && + (parsed as { type: unknown }).type === "choices" + ) + return true; + } catch { + // invalid JSON, skip + } + } + // tool-call with parsed object output (client tools) + if (part.type === "tool-call" && "output" in part) { + const output = (part as { output?: unknown }).output; + if ( + output != null && + typeof output === "object" && + "type" in output && + (output as { type: unknown }).type === "choices" + ) { + return true; + } + } + return false; + }); +} + +/** + * Returns the set of assistant message IDs that belong to a turn currently + * presenting interactive choices. This is all trailing assistant messages + * (after the latest user message) IF any of them contains a choices tool-call/result. + * + * Used to suppress message-level actions (copy/feedback/regenerate) on the entire + * choice-prompt turn — including any text-only assistant message that introduces + * the choices, since the choices tool-call may be on a separate sibling message. + */ +export function findActiveChoicesMessageIds( + messages: UIMessage[], +): Set { + // Find the index of the most recent user message + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg && msg.role === "user") { + lastUserIdx = i; + break; + } + } + + // Trailing assistant messages — everything after the latest user message + const trailingAssistants = messages + .slice(lastUserIdx + 1) + .filter((m) => m.role === "assistant"); + + // Only suppress actions if at least one trailing assistant has choices + const hasActiveChoices = trailingAssistants.some((m) => messageHasChoices(m)); + if (!hasActiveChoices) return new Set(); + + return new Set(trailingAssistants.map((m) => m.id)); +} diff --git a/apps/apollo-vertex/registry/alert-dialog/alert-dialog.tsx b/apps/apollo-vertex/registry/alert-dialog/alert-dialog.tsx index 6130fc945..72a53e899 100644 --- a/apps/apollo-vertex/registry/alert-dialog/alert-dialog.tsx +++ b/apps/apollo-vertex/registry/alert-dialog/alert-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; -import * as React from "react"; +import type * as React from "react"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/avatar/avatar.tsx b/apps/apollo-vertex/registry/avatar/avatar.tsx index 1393f5097..11c21d9bf 100644 --- a/apps/apollo-vertex/registry/avatar/avatar.tsx +++ b/apps/apollo-vertex/registry/avatar/avatar.tsx @@ -1,7 +1,7 @@ "use client"; import * as AvatarPrimitive from "@radix-ui/react-avatar"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/badge/badge.tsx b/apps/apollo-vertex/registry/badge/badge.tsx index 7f4e10fca..fdd4def1d 100644 --- a/apps/apollo-vertex/registry/badge/badge.tsx +++ b/apps/apollo-vertex/registry/badge/badge.tsx @@ -1,6 +1,6 @@ import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/breadcrumb/breadcrumb.tsx b/apps/apollo-vertex/registry/breadcrumb/breadcrumb.tsx index 4df4cc37d..72bf73550 100644 --- a/apps/apollo-vertex/registry/breadcrumb/breadcrumb.tsx +++ b/apps/apollo-vertex/registry/breadcrumb/breadcrumb.tsx @@ -2,7 +2,7 @@ import { Slot } from "@radix-ui/react-slot"; import { ChevronRight, MoreHorizontal } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/button/button.tsx b/apps/apollo-vertex/registry/button/button.tsx index 43ecee22f..0b2105369 100644 --- a/apps/apollo-vertex/registry/button/button.tsx +++ b/apps/apollo-vertex/registry/button/button.tsx @@ -1,6 +1,6 @@ import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/card/card.tsx b/apps/apollo-vertex/registry/card/card.tsx index 03434dbbb..faf3d7b23 100644 --- a/apps/apollo-vertex/registry/card/card.tsx +++ b/apps/apollo-vertex/registry/card/card.tsx @@ -1,5 +1,5 @@ import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/checkbox/checkbox.tsx b/apps/apollo-vertex/registry/checkbox/checkbox.tsx index 267c95b0d..e771797aa 100644 --- a/apps/apollo-vertex/registry/checkbox/checkbox.tsx +++ b/apps/apollo-vertex/registry/checkbox/checkbox.tsx @@ -2,7 +2,7 @@ import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import { CheckIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/command/command.tsx b/apps/apollo-vertex/registry/command/command.tsx index cab3c271b..397d948d8 100644 --- a/apps/apollo-vertex/registry/command/command.tsx +++ b/apps/apollo-vertex/registry/command/command.tsx @@ -2,7 +2,7 @@ import { Command as CommandPrimitive } from "cmdk"; import { SearchIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { Dialog, DialogContent, diff --git a/apps/apollo-vertex/registry/context-menu/context-menu.tsx b/apps/apollo-vertex/registry/context-menu/context-menu.tsx index 366aede80..5c4b9961c 100644 --- a/apps/apollo-vertex/registry/context-menu/context-menu.tsx +++ b/apps/apollo-vertex/registry/context-menu/context-menu.tsx @@ -2,7 +2,7 @@ import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/data-table/data-table-column-header.tsx b/apps/apollo-vertex/registry/data-table/data-table-column-header.tsx index a10ce5ee6..104bf3cf0 100644 --- a/apps/apollo-vertex/registry/data-table/data-table-column-header.tsx +++ b/apps/apollo-vertex/registry/data-table/data-table-column-header.tsx @@ -2,7 +2,7 @@ import type { Column, SortDirection } from "@tanstack/react-table"; import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/data-table/data-table-row.tsx b/apps/apollo-vertex/registry/data-table/data-table-row.tsx index 5db6a515e..fb0cdd809 100644 --- a/apps/apollo-vertex/registry/data-table/data-table-row.tsx +++ b/apps/apollo-vertex/registry/data-table/data-table-row.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { TableCell, TableRow } from "@/components/ui/table"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/date-picker/date-picker.tsx b/apps/apollo-vertex/registry/date-picker/date-picker.tsx index ff3526628..5c580fff7 100644 --- a/apps/apollo-vertex/registry/date-picker/date-picker.tsx +++ b/apps/apollo-vertex/registry/date-picker/date-picker.tsx @@ -1,7 +1,7 @@ "use client"; -import { DateTime } from "luxon"; import { ChevronDownIcon } from "lucide-react"; +import { DateTime } from "luxon"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; diff --git a/apps/apollo-vertex/registry/dialog/dialog.tsx b/apps/apollo-vertex/registry/dialog/dialog.tsx index 5fe451ff6..16ceece8a 100644 --- a/apps/apollo-vertex/registry/dialog/dialog.tsx +++ b/apps/apollo-vertex/registry/dialog/dialog.tsx @@ -2,7 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; import { XIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/drawer/drawer.tsx b/apps/apollo-vertex/registry/drawer/drawer.tsx index 88488665f..5b3d6eaff 100644 --- a/apps/apollo-vertex/registry/drawer/drawer.tsx +++ b/apps/apollo-vertex/registry/drawer/drawer.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { Drawer as DrawerPrimitive } from "vaul"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/dropdown-menu/dropdown-menu.tsx b/apps/apollo-vertex/registry/dropdown-menu/dropdown-menu.tsx index 9a5f645f0..184747adc 100644 --- a/apps/apollo-vertex/registry/dropdown-menu/dropdown-menu.tsx +++ b/apps/apollo-vertex/registry/dropdown-menu/dropdown-menu.tsx @@ -2,7 +2,7 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/hover-card/hover-card.tsx b/apps/apollo-vertex/registry/hover-card/hover-card.tsx index f64b24878..0ef6109c9 100644 --- a/apps/apollo-vertex/registry/hover-card/hover-card.tsx +++ b/apps/apollo-vertex/registry/hover-card/hover-card.tsx @@ -1,7 +1,7 @@ "use client"; import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/input/input.tsx b/apps/apollo-vertex/registry/input/input.tsx index b7c9d9163..041b2f6a1 100644 --- a/apps/apollo-vertex/registry/input/input.tsx +++ b/apps/apollo-vertex/registry/input/input.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/label/label.tsx b/apps/apollo-vertex/registry/label/label.tsx index 6166bcb63..a3661dfa9 100644 --- a/apps/apollo-vertex/registry/label/label.tsx +++ b/apps/apollo-vertex/registry/label/label.tsx @@ -1,7 +1,7 @@ "use client"; import * as LabelPrimitive from "@radix-ui/react-label"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/menubar/menubar.tsx b/apps/apollo-vertex/registry/menubar/menubar.tsx index cd7730f15..f83080362 100644 --- a/apps/apollo-vertex/registry/menubar/menubar.tsx +++ b/apps/apollo-vertex/registry/menubar/menubar.tsx @@ -2,7 +2,7 @@ import * as MenubarPrimitive from "@radix-ui/react-menubar"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/navigation-menu/navigation-menu.tsx b/apps/apollo-vertex/registry/navigation-menu/navigation-menu.tsx index f84f8e1c5..c18bb30d2 100644 --- a/apps/apollo-vertex/registry/navigation-menu/navigation-menu.tsx +++ b/apps/apollo-vertex/registry/navigation-menu/navigation-menu.tsx @@ -1,7 +1,7 @@ import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"; import { cva } from "class-variance-authority"; import { ChevronDownIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/pagination/pagination.tsx b/apps/apollo-vertex/registry/pagination/pagination.tsx index caa3b9e26..c7413ba79 100644 --- a/apps/apollo-vertex/registry/pagination/pagination.tsx +++ b/apps/apollo-vertex/registry/pagination/pagination.tsx @@ -5,7 +5,7 @@ import { ChevronRightIcon, MoreHorizontalIcon, } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { useTranslation } from "react-i18next"; import { type Button, buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/popover/popover.tsx b/apps/apollo-vertex/registry/popover/popover.tsx index 45d31d8cf..1555f0583 100644 --- a/apps/apollo-vertex/registry/popover/popover.tsx +++ b/apps/apollo-vertex/registry/popover/popover.tsx @@ -1,7 +1,7 @@ "use client"; import * as PopoverPrimitive from "@radix-ui/react-popover"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/progress/progress.tsx b/apps/apollo-vertex/registry/progress/progress.tsx index cedbe4281..d00d5504b 100644 --- a/apps/apollo-vertex/registry/progress/progress.tsx +++ b/apps/apollo-vertex/registry/progress/progress.tsx @@ -1,7 +1,7 @@ "use client"; import * as ProgressPrimitive from "@radix-ui/react-progress"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/radio-group/radio-group.tsx b/apps/apollo-vertex/registry/radio-group/radio-group.tsx index 29534ed08..6ac185e86 100644 --- a/apps/apollo-vertex/registry/radio-group/radio-group.tsx +++ b/apps/apollo-vertex/registry/radio-group/radio-group.tsx @@ -2,7 +2,7 @@ import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import { CircleIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/resizable/resizable.tsx b/apps/apollo-vertex/registry/resizable/resizable.tsx index f518282ac..732523652 100644 --- a/apps/apollo-vertex/registry/resizable/resizable.tsx +++ b/apps/apollo-vertex/registry/resizable/resizable.tsx @@ -1,7 +1,7 @@ "use client"; import { GripVerticalIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { Group, Panel, Separator } from "react-resizable-panels"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/scroll-area/scroll-area.tsx b/apps/apollo-vertex/registry/scroll-area/scroll-area.tsx index 49248f049..554e349a2 100644 --- a/apps/apollo-vertex/registry/scroll-area/scroll-area.tsx +++ b/apps/apollo-vertex/registry/scroll-area/scroll-area.tsx @@ -1,7 +1,7 @@ "use client"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/select/select.tsx b/apps/apollo-vertex/registry/select/select.tsx index 72ac7d29a..092125716 100644 --- a/apps/apollo-vertex/registry/select/select.tsx +++ b/apps/apollo-vertex/registry/select/select.tsx @@ -2,7 +2,7 @@ import * as SelectPrimitive from "@radix-ui/react-select"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/separator/separator.tsx b/apps/apollo-vertex/registry/separator/separator.tsx index ee7836062..50733e0aa 100644 --- a/apps/apollo-vertex/registry/separator/separator.tsx +++ b/apps/apollo-vertex/registry/separator/separator.tsx @@ -1,7 +1,7 @@ "use client"; import * as SeparatorPrimitive from "@radix-ui/react-separator"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/sheet/sheet.tsx b/apps/apollo-vertex/registry/sheet/sheet.tsx index 8a95dca09..4ddab8358 100644 --- a/apps/apollo-vertex/registry/sheet/sheet.tsx +++ b/apps/apollo-vertex/registry/sheet/sheet.tsx @@ -2,7 +2,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog"; import { XIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/shell/shell-nav-item.tsx b/apps/apollo-vertex/registry/shell/shell-nav-item.tsx index ad22ad73b..273ffee79 100644 --- a/apps/apollo-vertex/registry/shell/shell-nav-item.tsx +++ b/apps/apollo-vertex/registry/shell/shell-nav-item.tsx @@ -1,5 +1,5 @@ -import { Link, useLocation } from "@tanstack/react-router"; import { useLocalStorage } from "@mantine/hooks"; +import { Link, useLocation } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import type { LucideIcon } from "lucide-react"; import { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-content.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-content.tsx index 27a9b6332..76a130901 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-content.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-content.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-footer.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-footer.tsx index c5714bb41..84c952fec 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-footer.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-footer.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-group-action.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-group-action.tsx index 95817eea7..c67e32a4f 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-group-action.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-group-action.tsx @@ -1,7 +1,7 @@ "use client"; import { Slot } from "@radix-ui/react-slot"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarGroupAction({ diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-group-content.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-group-content.tsx index b76e575af..e7a36f561 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-group-content.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-group-content.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarGroupContent({ diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-group-label.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-group-label.tsx index 2b439ecfe..4459e8a9b 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-group-label.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-group-label.tsx @@ -1,7 +1,7 @@ "use client"; import { Slot } from "@radix-ui/react-slot"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarGroupLabel({ diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-group.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-group.tsx index eb0ad4d21..b5eb3cc5e 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-group.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-group.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-header.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-header.tsx index 2230fe5f8..160c1cafd 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-header.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-header.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-input.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-input.tsx index 84105a2ce..31657dcb9 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-input.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-input.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-inset.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-inset.tsx index 6fd8cc325..ef6f8a9df 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-inset.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-inset.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu-action.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu-action.tsx index 11c1a17b5..3cf746736 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu-action.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu-action.tsx @@ -1,7 +1,7 @@ "use client"; import { Slot } from "@radix-ui/react-slot"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarMenuAction({ diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu-badge.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu-badge.tsx index 97f236d2d..f501123e9 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu-badge.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu-badge.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarMenuBadge({ diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu-button.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu-button.tsx index 9ed1744c7..f711a5684 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu-button.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu-button.tsx @@ -2,7 +2,7 @@ import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; +import type * as React from "react"; import { Tooltip, TooltipContent, diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu-item.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu-item.tsx index 4d99b29bb..aefe93c62 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu-item.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu-item.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-button.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-button.tsx index b45de3c3d..67403bc7a 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-button.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-button.tsx @@ -1,7 +1,7 @@ "use client"; import { Slot } from "@radix-ui/react-slot"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarMenuSubButton({ diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-item.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-item.tsx index c4a686e91..1d4d0d277 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-item.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-item.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarMenuSubItem({ diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub.tsx index d15532802..c6790e9c3 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu.tsx index 991b03162..a34e76ec5 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-rail.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-rail.tsx index ff700abf6..0d6cf6afc 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-rail.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-rail.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; import { useSidebar } from "./sidebar-provider"; diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-separator.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-separator.tsx index a56327db4..275a15adf 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-separator.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-separator.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-trigger.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-trigger.tsx index 9d474ae84..67690d13c 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-trigger.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-trigger.tsx @@ -1,7 +1,7 @@ "use client"; import { PanelLeftIcon } from "lucide-react"; -import * as React from "react"; +import type * as React from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/sidebar/sidebar.tsx b/apps/apollo-vertex/registry/sidebar/sidebar.tsx index 8fb73c4c4..0b313ed3e 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { useTranslation } from "react-i18next"; import { Sheet, diff --git a/apps/apollo-vertex/registry/switch/switch.tsx b/apps/apollo-vertex/registry/switch/switch.tsx index 3bf9946f6..6423f00f5 100644 --- a/apps/apollo-vertex/registry/switch/switch.tsx +++ b/apps/apollo-vertex/registry/switch/switch.tsx @@ -1,7 +1,7 @@ "use client"; import * as SwitchPrimitive from "@radix-ui/react-switch"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/table/table.tsx b/apps/apollo-vertex/registry/table/table.tsx index 71936c551..503e81f56 100644 --- a/apps/apollo-vertex/registry/table/table.tsx +++ b/apps/apollo-vertex/registry/table/table.tsx @@ -1,6 +1,6 @@ "use client"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/tabs/tabs.tsx b/apps/apollo-vertex/registry/tabs/tabs.tsx index 10c310e83..2da77f22e 100644 --- a/apps/apollo-vertex/registry/tabs/tabs.tsx +++ b/apps/apollo-vertex/registry/tabs/tabs.tsx @@ -1,7 +1,7 @@ "use client"; import * as TabsPrimitive from "@radix-ui/react-tabs"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/textarea/textarea.tsx b/apps/apollo-vertex/registry/textarea/textarea.tsx index 7843cc2e5..73ebf4bc5 100644 --- a/apps/apollo-vertex/registry/textarea/textarea.tsx +++ b/apps/apollo-vertex/registry/textarea/textarea.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/toggle/toggle.tsx b/apps/apollo-vertex/registry/toggle/toggle.tsx index 91c5d7e36..87074579d 100644 --- a/apps/apollo-vertex/registry/toggle/toggle.tsx +++ b/apps/apollo-vertex/registry/toggle/toggle.tsx @@ -2,7 +2,7 @@ import * as TogglePrimitive from "@radix-ui/react-toggle"; import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/tooltip/tooltip.tsx b/apps/apollo-vertex/registry/tooltip/tooltip.tsx index 981f5b5e5..ed700c2fb 100644 --- a/apps/apollo-vertex/registry/tooltip/tooltip.tsx +++ b/apps/apollo-vertex/registry/tooltip/tooltip.tsx @@ -1,7 +1,7 @@ "use client"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -import * as React from "react"; +import type * as React from "react"; import { cn } from "@/lib/utils"; diff --git a/apps/apollo-vertex/registry/use-data-table/useEntityDataTable.tsx b/apps/apollo-vertex/registry/use-data-table/useEntityDataTable.tsx index d096a9c1b..dfde82546 100644 --- a/apps/apollo-vertex/registry/use-data-table/useEntityDataTable.tsx +++ b/apps/apollo-vertex/registry/use-data-table/useEntityDataTable.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLocalStorage } from "@mantine/hooks"; import { useLiveQuery } from "@tanstack/react-db"; import type { ColumnFiltersState, @@ -8,9 +9,7 @@ import type { } from "@tanstack/react-table"; import { useSolution } from "@uipath/vs-core"; import { useState } from "react"; - import { ENTITY_TABLE_STORAGE_PREFIX } from "@/lib/constants"; -import { useLocalStorage } from "@mantine/hooks"; import type { ColumnDefWithAccessorKey, diff --git a/apps/apollo-vertex/registry/use-data-table/usePersistedColumnOrder.ts b/apps/apollo-vertex/registry/use-data-table/usePersistedColumnOrder.ts index 1bef99736..ed8906409 100644 --- a/apps/apollo-vertex/registry/use-data-table/usePersistedColumnOrder.ts +++ b/apps/apollo-vertex/registry/use-data-table/usePersistedColumnOrder.ts @@ -1,9 +1,8 @@ "use client"; +import { useLocalStorage } from "@mantine/hooks"; import type { OnChangeFn } from "@tanstack/react-table"; - import { ENTITY_TABLE_STORAGE_PREFIX } from "@/lib/constants"; -import { useLocalStorage } from "@mantine/hooks"; export interface UsePersistedColumnOrderOptions { storageKey: string; diff --git a/apps/apollo-vertex/registry/use-data-table/usePersistedSorting.ts b/apps/apollo-vertex/registry/use-data-table/usePersistedSorting.ts index 6eb268f1a..0caada1b3 100644 --- a/apps/apollo-vertex/registry/use-data-table/usePersistedSorting.ts +++ b/apps/apollo-vertex/registry/use-data-table/usePersistedSorting.ts @@ -1,9 +1,8 @@ "use client"; +import { useLocalStorage } from "@mantine/hooks"; import type { OnChangeFn, SortingState } from "@tanstack/react-table"; - import { ENTITY_TABLE_STORAGE_PREFIX } from "@/lib/constants"; -import { useLocalStorage } from "@mantine/hooks"; export interface UsePersistedSortingOptions { storageKey: string; diff --git a/apps/apollo-vertex/templates/AiChatTemplate.tsx b/apps/apollo-vertex/templates/AiChatTemplate.tsx index 5c9f98270..fca0a6109 100644 --- a/apps/apollo-vertex/templates/AiChatTemplate.tsx +++ b/apps/apollo-vertex/templates/AiChatTemplate.tsx @@ -34,8 +34,8 @@ function AiChatWithConnection({ }); return ( - -++-{ @@ -64,7 +64,7 @@ function AiChatWithConnection({ +{mode === "agenthub" ? () : ( @@ -78,9 +78,9 @@ function AiChatWithConnection({ ); } -export function AiChatTemplate() { +export function AiChatTemplate({ className }: { className?: string }) { return ( - +diff --git a/apps/apollo-vertex/templates/ai-chat/AiChatLoginGate.tsx b/apps/apollo-vertex/templates/ai-chat/AiChatLoginGate.tsx index 713901c99..d3b73a33d 100644 --- a/apps/apollo-vertex/templates/ai-chat/AiChatLoginGate.tsx +++ b/apps/apollo-vertex/templates/ai-chat/AiChatLoginGate.tsx @@ -1,9 +1,9 @@ "use client"; +import { useLocalStorage } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; import { jwtDecode } from "jwt-decode"; import { ChevronRight, LogIn, LogOut } from "lucide-react"; -import { useLocalStorage } from "@mantine/hooks"; import type { ReactNode } from "react"; import { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -252,8 +252,8 @@ export function AiChatLoginGate({ children }: AiChatLoginGateProps) { } return ( -accessToken, systemPrompt, - tools: choicesTools, + tools: allTools, }); - const { messages, sendMessage, isLoading, stop, clear, error } = useChat({ - connection, - tools: choicesTools, - }); + const { messages, sendMessage, isLoading, stop, clear, reload, error } = + useChat({ + connection, + tools: allTools, + }); return ( { + void reload(); + }} + onEditMessage={(_id, content) => { + void sendMessage(content); + }} + title="Autopilot" assistantName={t("assistant")} + enableTextSelection error={error ?? null} + emptyState={ ); diff --git a/apps/apollo-vertex/templates/ai-chat/AiChatConversationalAgentMode.tsx b/apps/apollo-vertex/templates/ai-chat/AiChatConversationalAgentMode.tsx index 5ed2d6d06..1cfb33183 100644 --- a/apps/apollo-vertex/templates/ai-chat/AiChatConversationalAgentMode.tsx +++ b/apps/apollo-vertex/templates/ai-chat/AiChatConversationalAgentMode.tsx @@ -39,9 +39,10 @@ function ConversationalAgentChatInner({ }; }, [connection]); - const { messages, sendMessage, isLoading, stop, clear, error } = useChat({ - connection, - }); + const { messages, sendMessage, isLoading, stop, clear, reload, error } = + useChat({ + connection, + }); return (} + suggestions={[ + "Summarize a PDF", + "Create an executive brief", + "Draft a follow-up for my last meeting", + ]} > {messages.map((message) => ( - + ))} { + void reload(); + }} title={title} assistantName={assistantName} + enableTextSelection error={error ?? null} > {messages.map((message) => ( - ); @@ -151,7 +152,7 @@ export function ConversationalAgentChat({ sdk={sdk} agentId={selectedAgentConfig.agentId} folderId={selectedAgentConfig.folderId} - title={t("ai_assistant")} + title="Autopilot" assistantName={t("assistant")} />+ ))} -++{user && ( <> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65a3fccbe..7ba70344b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,9 @@ importers: framer-motion: specifier: ^12.26.2 version: 12.26.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 i18next: specifier: ^25.8.1 version: 25.8.1(typescript@5.9.3) @@ -8403,6 +8406,10 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} @@ -20860,6 +20867,8 @@ snapshots: highlight.js@10.7.3: {} + highlight.js@11.11.1: {} + highlightjs-vue@1.0.0: {} hoist-non-react-statics@3.3.2: