From 75402d46e3a9af2b3b22c4ed35fd72cbe86016d4 Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Mon, 20 Apr 2026 11:09:36 -0400 Subject: [PATCH] feat(apollo-vertex): ai-chat main component and examples [5/5] Adds the top-level AiChat orchestration component and example tool definitions (choices-tool, flow-tool). Co-Authored-By: Claude Sonnet 4.6 --- .../registry/ai-chat/components/ai-chat.tsx | 426 ++++++++++++++---- .../registry/ai-chat/examples/choices-tool.ts | 71 +++ .../registry/ai-chat/examples/flow-tool.ts | 71 +++ 3 files changed, 485 insertions(+), 83 deletions(-) create mode 100644 apps/apollo-vertex/registry/ai-chat/examples/choices-tool.ts create mode 100644 apps/apollo-vertex/registry/ai-chat/examples/flow-tool.ts 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 2f1a6dff9..08f27d417 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat.tsx @@ -1,26 +1,76 @@ "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 { AiChatInput } from "./ai-chat-input"; +import type { MessageFeedbackType } from "../types"; +import { AiChatInput, type AiChatInputHandle } from "./ai-chat-input"; import { AiChatLoading } from "./ai-chat-loading"; +import { AiChatProvider } from "./ai-chat-provider"; +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; + 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; + /** When true, selecting text in an assistant message shows an "Ask AI assistant" button that quotes the selection into the input. */ + enableTextSelection?: boolean; } export function AiChat({ @@ -29,26 +79,102 @@ export function AiChat({ onSendMessage, onStop, onClearChat, + 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, + enableTextSelection = false, }: AiChatProps) { const { t } = useTranslation(); - const [input, setInput] = useState(""); + const [internalInput, setInternalInput] = useState(""); + const [quotedText, setQuotedText] = useState(null); 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 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; + 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 latestAssistantMessageId = + messages.findLast((m) => m.role === "assistant")?.id ?? null; + const lastMessage = messages.at(-1); const lastAssistantHasText = lastMessage?.role === "assistant" && @@ -56,90 +182,224 @@ export function AiChat({ const showLoadingIndicator = isLoading && !lastAssistantHasText; const defaultEmptyState = ( -
-
-