diff --git a/apps/apollo-vertex/app/experiment/_meta.ts b/apps/apollo-vertex/app/experiment/_meta.ts
new file mode 100644
index 000000000..39bcde8e4
--- /dev/null
+++ b/apps/apollo-vertex/app/experiment/_meta.ts
@@ -0,0 +1,4 @@
+export default {
+ dashboard: "Dashboard",
+ "product-demo": "Product Demo",
+};
diff --git a/apps/apollo-vertex/app/experiment/dashboard/page.mdx b/apps/apollo-vertex/app/experiment/dashboard/page.mdx
new file mode 100644
index 000000000..73bf6b217
--- /dev/null
+++ b/apps/apollo-vertex/app/experiment/dashboard/page.mdx
@@ -0,0 +1,149 @@
+import { DashboardTemplate } from '@/templates/dashboard/DashboardTemplateDynamic';
+import { PreviewFullScreen } from '@/app/_components/preview-full-screen';
+
+# Dashboard
+
+A configurable dashboard template for building AI-assisted operational views. The dashboard is composed of named card regions with defined interaction patterns, designed to be data-driven and adaptable across verticals.
+
+## Previews
+
+### Sidebar Shell
+
+
+ {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..34eb7ab3e 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 { useAiChat } from "./ai-chat-provider"; +import { AiChatSelectionMenu } from "./ai-chat-selection-menu"; + +// 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,314 @@ 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) && ( ++{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..a45a8ca7f 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,88 @@ +// 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 { AnimatePresence } from "framer-motion"; +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 +92,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 +231,329 @@ 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..9226ca5b4 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"; @@ -10,7 +10,7 @@ export const GLASS_CLASSES = [ "dark:shadow-[0_2px_24px_2px_rgba(0,0,0,0.12),inset_0_1px_0_0_color-mix(in_srgb,var(--sidebar)_5%,transparent)]", ] as const; -const cardVariants = cva("flex flex-col text-card-foreground", { +const cardVariants = cva("flex flex-col gap-6 py-6 text-card-foreground", { variants: { variant: { default: GLASS_CLASSES, 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/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx b/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx new file mode 100644 index 000000000..88cbb5b12 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/AutopilotInsight.tsx @@ -0,0 +1,357 @@ +"use client"; + +import { useChat } from "@tanstack/ai-react"; +import { LogIn, MoreHorizontal, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { createAgentHubConnection } from "@/registry/ai-chat/adapters/agenthub/adapter"; +import { AiChat } from "@/registry/ai-chat/components/ai-chat"; +import { AiChatMessage } from "@/registry/ai-chat/components/ai-chat-message"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/registry/alert-dialog/alert-dialog"; +import { GLASS_CLASSES } from "@/registry/card/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/registry/dropdown-menu/dropdown-menu"; +import type { OrgTenantInfo } from "@/templates/ai-chat/AiChatLoginGate"; +import { useDashboardChat } from "./DashboardChatProvider"; +import { createDemoConnection } from "./demo-connection"; + +interface AutopilotInsightProps { + onClose: () => void; + onResponseReady?: () => void; + sourceCardTitle: string; + initialMessage?: string; +} + +interface AutopilotChatProps { + accessToken?: string; + orgTenant?: OrgTenantInfo; + sourceCardTitle: string; + initialMessage?: string; + demo?: boolean; + onClose: () => void; + onResponseReady?: () => void; +} + +function AutopilotChat({ + accessToken, + orgTenant, + sourceCardTitle, + initialMessage, + demo = false, + onClose, + onResponseReady, +}: AutopilotChatProps) { + // Keep access token ref in sync on every render so the connection closure + // always reads the latest value without recreating the connection. + const accessTokenRef = useRef(accessToken); + accessTokenRef.current = accessToken; + + // Stable connection — created once per mount via useState lazy init. + // Re-creating the connection object on every render causes useChat's + // updateOptions() to detect a changed connection and call + // cancelInFlightStream(), aborting any active stream mid-flight. + const [connection] = useState(() => + demo + ? createDemoConnection(sourceCardTitle) + : createAgentHubConnection({ + baseUrl: `/api/agenthub/${orgTenant!.orgName}/${orgTenant!.tenantName}/agenthub_/llm/api`, + model: { vendor: "openai", name: "gpt-4.1-mini-2025-04-14" }, + accessToken: () => accessTokenRef.current!, + systemPrompt: `You are an AI assistant analyzing the "${sourceCardTitle}" insight card in an operational dashboard. Provide concise, data-driven analysis and actionable recommendations. Always respond using markdown format.`, + }), + ); + + const { + messages, + sendMessage, + reload, + isLoading, + stop, + clear, + setMessages, + error, + } = useChat({ + connection, + }); + + const [conversationCopied, setConversationCopied] = useState(false); + + // True while the component is waiting for the initial auto-send to fire. + // Hides the generic "What are we tackling today?" empty state during this window + // so users never see irrelevant placeholder text in the autopilot panel. + const [preInitializing, setPreInitializing] = useState(!!initialMessage); + + // Auto-send the initial message once on mount (component remounts per unique prompt). + // setTimeout defers the send to a macrotask so it runs AFTER React 18 StrictMode's + // double-invoke cleanup cycle completes — preventing a null.signal race in useChat. + // oxlint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (!initialMessage) return; + const id = setTimeout(() => { + void sendMessage(initialMessage); + setPreInitializing(false); + }, 0); + return () => clearTimeout(id); + }, []); + + // Fire onResponseReady when the stream completes (isLoading flips false after messages arrive). + // Use a ref for the callback so changing function identity on parent re-renders + // (e.g. panel close) doesn't retrigger this effect spuriously. + const onResponseReadyRef = useRef(onResponseReady); + onResponseReadyRef.current = onResponseReady; + const prevLoadingRef = useRef(false); + useEffect(() => { + if (prevLoadingRef.current && !isLoading && messages.length > 0) { + onResponseReadyRef.current?.(); + } + prevLoadingRef.current = isLoading; + }, [isLoading, messages.length]); + + const handleCopyConversation = async () => { + const text = messages + .map((m) => { + const content = m.parts + .filter( + (p): p is { type: "text"; content: string } => p.type === "text", + ) + .map((p) => p.content) + .join(""); + if (!content) return null; + const label = m.role === "user" ? "You" : "AI assistant"; + return `${label}: ${content}`; + }) + .filter(Boolean) + .join("\n\n"); + await navigator.clipboard.writeText(text); + setConversationCopied(true); + setTimeout(() => setConversationCopied(false), 2000); + }; + + const handleEditMessage = (messageId: string, content: string) => { + const idx = messages.findIndex((m) => m.id === messageId); + if (idx === -1) return; + // Truncate to (and including) the edited user message, replacing its content. + // Subsequent assistant messages are discarded so reload() regenerates them. + const updated = messages + .slice(0, idx + 1) + .map((m, i) => + i === idx ? { ...m, parts: [{ type: "text" as const, content }] } : m, + ); + setMessages(updated); + void reload(); + }; + + const hasMessages = messages.length > 0; + + return ( +{ + void sendMessage(text); + }} + onStop={stop} + onClearChat={clear} + onRegenerate={() => void reload()} + onEditMessage={handleEditMessage} + assistantName="AI assistant" + enableTextSelection + showClearButton={hasMessages} + error={error ?? null} + // Hide the generic empty-state text while the auto-send is pending. + // The component mounts with messages=[] for ~1 frame before sendMessage fires. + emptyState={preInitializing ? <>> : undefined} + renderHeader={ + + ); +} + +export function AutopilotInsight({ + onClose, + onResponseReady, + sourceCardTitle, + initialMessage, +}: AutopilotInsightProps) { + const { accessToken, orgTenant, isLoading, login, demo } = useDashboardChat(); + + const isReady = demo || (!!accessToken && !!orgTenant); + + return ( +++ } + > + {messages.map((msg) => ( ++++
+
+++ AI assistant +
++ Analyzing {sourceCardTitle} +
++ {hasMessages && ( +++ + )} + ++ ++ + ++ +{ + void handleCopyConversation(); + }} + > + {conversationCopied ? "Copied!" : "Copy conversation"} + ++ +New conversation ++ ++ ++ Start a new conversation? + ++ This will clear all messages and cannot be undone. + ++ +Cancel ++ New conversation + ++ ))} + + {/* Header: shown only for loading/not-ready states. + AutopilotChat renders its own header via renderHeader when ready. */} + {!isReady && ( ++ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardCards.tsx b/apps/apollo-vertex/templates/dashboard/DashboardCards.tsx new file mode 100644 index 000000000..3a6ad30aa --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardCards.tsx @@ -0,0 +1,288 @@ +import { AlertTriangle, CheckCircle, Clock, XCircle } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +// --- Types --- + +export interface KpiItem { + label: string; + value: string; + icon: LucideIcon; + change: string; +} + +// --- Sample data --- + +const invoices = [ + { + id: "INV-4021", + vendor: "Acme Corp", + amount: "$12,450.00", + status: "Processed" as const, + date: "Mar 18, 2026", + }, + { + id: "INV-4020", + vendor: "Global Supplies Ltd", + amount: "$3,280.50", + status: "Pending" as const, + date: "Mar 18, 2026", + }, + { + id: "INV-4019", + vendor: "TechParts Inc", + amount: "$8,920.00", + status: "In Review" as const, + date: "Mar 17, 2026", + }, + { + id: "INV-4018", + vendor: "Office Depot", + amount: "$1,150.75", + status: "Processed" as const, + date: "Mar 17, 2026", + }, + { + id: "INV-4017", + vendor: "CloudServ Solutions", + amount: "$24,000.00", + status: "Failed" as const, + date: "Mar 16, 2026", + }, + { + id: "INV-4016", + vendor: "Metro Logistics", + amount: "$6,780.00", + status: "Processed" as const, + date: "Mar 16, 2026", + }, +]; + +const statusVariant: Record< + string, + "default" | "secondary" | "destructive" | "outline" +> = { + Processed: "default", + Pending: "secondary", + Failed: "destructive", + "In Review": "outline", +}; + +const statusIcon: Record++ )} + + {/* Body */} +++ ++
+
+++ AI assistant +
++ Analyzing {sourceCardTitle} +
++ {isLoading && ( +++ Signing in... ++ )} + + {!isLoading && !isReady && ( +++ )} + + {!isLoading && isReady && ( ++ Sign in to use AI assistant +
+ ++ )} + = { + Processed: CheckCircle, + Pending: Clock, + Failed: XCircle, + "In Review": AlertTriangle, +}; + +const activityBars = [ + { label: "Mon", height: 60 }, + { label: "Tue", height: 85 }, + { label: "Wed", height: 45 }, + { label: "Thu", height: 92 }, + { label: "Fri", height: 78 }, + { label: "Sat", height: 30 }, + { label: "Sun", height: 15 }, +]; + +const recentActivity = [ + { text: "INV-4021 processed successfully", time: "2 min ago" }, + { text: "INV-4020 submitted for review", time: "15 min ago" }, + { text: "Batch processing completed (42 invoices)", time: "1 hr ago" }, + { text: "INV-4017 failed — missing PO number", time: "3 hrs ago" }, +]; + +const pipelineStages = [ + { label: "OCR Extraction", value: 96 }, + { label: "Field Validation", value: 88 }, + { label: "Approval Routing", value: 72 }, + { label: "Final Review", value: 64 }, +]; + +const complianceChecks = [ + { label: "Income Verification", pass: 98 }, + { label: "Credit Score Threshold", pass: 96 }, + { label: "Debt-to-Income Ratio", pass: 91 }, + { label: "Collateral Appraisal", pass: 87 }, + { label: "Document Completeness", pass: 94 }, +]; + +// --- Card components --- + +export function KpiCards({ kpis }: { kpis: KpiItem[] }) { + return ( + <> + {kpis.map((kpi) => ( + + + ))} + > + ); +} + +export function InvoiceTable() { + return ( ++ ++++ {kpi.label} + ++ + +{kpi.value}++ {kpi.change} from last + week +
++ + ); +} + +export function ActivityBarChart() { + return ( ++ +Recent Invoices ++ ++
++ ++ +Invoice +Vendor +Amount +Status +Date ++ {invoices.map((inv) => { + const StatusIcon = statusIcon[inv.status]; + return ( + ++ + ); + })} +{inv.id} +{inv.vendor} +{inv.amount} ++ ++ ++ {inv.status} + + {inv.date} + ++ + ); +} + +export function ActivityFeed() { + return ( ++ +Processing Activity ++ ++ {activityBars.map((bar) => ( +++ + {bar.label} ++ ))} ++ + ); +} + +export function PipelineProgress() { + return ( ++ +Recent Activity ++ ++ {recentActivity.map((event) => ( +++ ++ ))} +++{event.text}
+{event.time}
++ + ); +} + +export function ComplianceProgress() { + return ( ++ +Processing Pipeline ++ ++ {pipelineStages.map((stage) => ( ++++ ))} ++ {stage.label} + {stage.value}% ++ ++ + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardChatProvider.tsx b/apps/apollo-vertex/templates/dashboard/DashboardChatProvider.tsx new file mode 100644 index 000000000..67115960d --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardChatProvider.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { jwtDecode } from "jwt-decode"; +import { createContext, type ReactNode, useContext } from "react"; +import { z } from "zod"; +import { useAuth } from "@/registry/shell/shell-auth-provider"; +import type { OrgTenantInfo } from "@/templates/ai-chat/AiChatLoginGate"; + +const PrtIdSchema = z.object({ prt_id: z.string() }); + +const TenantsAndOrgSchema = z.object({ + organization: z.object({ id: z.string(), name: z.string() }), + tenants: z.array(z.object({ id: z.string(), name: z.string() })), +}); + +function getOrgIdFromToken(token: string | null): string | null { + if (!token) return null; + try { + const parsed = PrtIdSchema.safeParse(jwtDecode(token)); + return parsed.success ? parsed.data.prt_id : null; + } catch { + return null; + } +} + +async function fetchOrgTenant( + orgId: string, + accessToken: string, +): Promise+ +Compliance Pass Rates ++ ++ {complianceChecks.map((check) => ( ++++ ))} ++ {check.label} + {check.pass}% ++ +{ + const res = await fetch( + `/_proxy/portal/${orgId}/filtering/leftnav/tenantsAndOrganizationInfo`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + if (!res.ok) throw new Error(`Failed to fetch org info (${res.status})`); + + const data = TenantsAndOrgSchema.safeParse(await res.json()); + if (!data.success || data.data.tenants.length === 0) + throw new Error("Invalid org response"); + + const { organization, tenants } = data.data; + return { + orgId: organization.id, + orgName: organization.name, + tenantId: tenants[0].id, + tenantName: tenants[0].name, + }; +} + +export interface DashboardChatContextValue { + accessToken: string | null; + orgTenant: OrgTenantInfo | null; + isLoading: boolean; + login: () => void; + demo: boolean; +} + +const DashboardChatContext = createContext ({ + accessToken: null, + orgTenant: null, + isLoading: false, + login: () => {}, + demo: false, +}); + +export function useDashboardChat() { + return useContext(DashboardChatContext); +} + +export function DashboardChatProvider({ + children, + demo = false, +}: { + children: ReactNode; + demo?: boolean; +}) { + // Demo mode: bypass auth entirely — autopilot uses a mock connection + if (demo) { + return ( + {}, + demo: true, + }} + > + {children} + + ); + } + + return{children} ; +} + +function DashboardChatProviderInner({ children }: { children: ReactNode }) { + const { + isAuthenticated, + isLoading: authLoading, + login, + accessToken, + } = useAuth(); + + const orgId = getOrgIdFromToken(accessToken ?? null); + + const { data: orgTenant, isLoading: orgLoading } = useQuery({ + queryKey: ["dashboard-org-tenant", orgId], + queryFn: () => fetchOrgTenant(orgId!, accessToken!), + enabled: !!accessToken && !!orgId, + staleTime: 5 * 60 * 1000, + }); + + return ( +{ + void login(); + }, + demo: false, + }} + > + {children} + + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx b/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx new file mode 100644 index 000000000..66e304c61 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardContent.tsx @@ -0,0 +1,1035 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { SlidersHorizontal } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Toaster } from "@/registry/sonner/sonner"; +import { AutopilotInsight } from "./AutopilotInsight"; +import type { InsightCardData } from "./dashboard-data"; +import { type AIScreen, detectLayoutIntent } from "./dashboard-layout-presets"; +import { + DashboardDataProvider, + useDashboardData, +} from "./DashboardDataProvider"; +import { DashboardGlow } from "./DashboardGlow"; +import { DashboardLoading } from "./DashboardLoading"; +import { GlowDevControls } from "./GlowDevControls"; +import { + type CardConfig, + cardBgStyle, + defaultDarkCards, + defaultDarkGlow, + defaultLayout, + type GlowConfig, + type LayoutConfig, +} from "./glow-config"; +import { InsightGrid } from "./InsightGrid"; +import { PromptBar } from "./PromptBar"; + +type LayoutType = "executive" | "operational" | "analytics"; + +type SkeletonCard = { title: string; size?: "sm" | "md" | "lg" }; + +function GridSkeleton({ + label, + cards, +}: { + label: string; + cards: SkeletonCard[]; +}) { + const [revealedCount, setRevealedCount] = useState(0); + + useEffect(() => { + setRevealedCount(0); + const timers = cards.map((_, i) => + setTimeout(() => setRevealedCount(i + 1), 200 + i * 260), + ); + return () => timers.forEach(clearTimeout); + }, [cards]); + + const rows: Array> = []; + for (let i = 0; i < cards.length; i += 2) { + rows.push( + cards.slice(i, i + 2).map((card, j) => ({ card, globalIdx: i + j })), + ); + } + + return ( + ++ ); +} + +function HeroSparkline({ points }: { points: number[] }) { + if (points.length < 2) return null; + const min = Math.min(...points); + const max = Math.max(...points); + const range = max - min || 1; + const step = 100 / (points.length - 1); + const coords = points.map((v, i): [number, number] => [ + i * step, + 8 + (1 - (v - min) / range) * 76, + ]); + const linePts = coords.map(([x, y]) => `${x},${y}`).join(" "); + const area = [ + "M0,100", + ...coords.map(([x, y]) => `L${x},${y}`), + "L100,100", + "Z", + ].join(" "); + const lastY = coords[coords.length - 1][1]; + + const fmt = (v: number) => + v >= 10000 + ? `${Math.round(v / 1000)}k` + : v >= 1000 + ? `${(v / 1000).toFixed(1)}k` + : v < 10 + ? v.toFixed(1) + : Number.isInteger(v) + ? v.toString() + : v.toFixed(1); + + return ( +++ ++ {[0, 1, 2].map((i) => ( + + ))} ++ + Building{" "} + {label} view + ++ {rows.map((row, rowIdx) => ( ++ + +(card.size === "sm" ? "1fr" : "2fr")) + .join(" "), + }} + > + {row.map(({ card, globalIdx }) => { + const isRevealed = globalIdx < revealedCount; + return ( ++ ))} +++ ); + })} +++ {!isRevealed && ( + + )} ++ {card.title} +
++ {Array.from({ length: 6 }, (_, j) => ( + + ))} +++ {/* Chart area */} ++ ); +} + +function ScreenNavigator({ + screens, + activeIdx, + onChange, + onRemove, +}: { + screens: string[]; + activeIdx: number; + onChange: (idx: number) => void; + onRemove?: (idx: number) => void; +}) { + return ( ++ + {/* 4px CSS circle dot at line endpoint */} + ++ {/* Labels row */} ++ + {fmt(points[0])} + + + {points.length} wks + + + {fmt(points[points.length - 1])} + +++ {screens.map((label, i) => ( + + ); +} + +function ExecutiveLayout({ + cards, + layout, + activeLayout, + viewMode, + buildingLabel, + buildingCards, + activeScreenIdx, + slideDir, + onAutopilotOpen, + autopilotActiveIdx, + autopilotUnreadIdx, + expandedCardIdx, + onExpandedChange, + onPromptSubmit, + pendingScreen, + onConfirmBuild, + onCancelBuild, + activeHeadline, + activeSubhead, + isEditMode, + editItems, + onReorderEditItems, + onRemoveEditItem, + onResizeEditItem, + aiScreenLabels, + onPinChart, + heroPoints, +}: { + cards: CardConfig; + layout: LayoutConfig; + activeLayout: LayoutConfig; + viewMode: ViewMode; + buildingLabel: string | null; + buildingCards: SkeletonCard[]; + activeScreenIdx: number; + slideDir: number; + onAutopilotOpen?: (sourceTitle: string, idx: number) => void; + autopilotActiveIdx?: number | null; + autopilotUnreadIdx?: number | null; + expandedCardIdx?: number | null; + onExpandedChange?: (idx: number | null) => void; + onPromptSubmit: (query: string) => void; + pendingScreen: AIScreen | null; + onConfirmBuild: (selectedIndices: number[]) => void; + onCancelBuild: () => void; + activeHeadline: string; + activeSubhead: string; + isEditMode: boolean; + editItems: Array<{ id: string; title: string; size: "sm" | "md" | "lg" }>; + onReorderEditItems: ( + items: Array<{ id: string; title: string; size: "sm" | "md" | "lg" }>, + ) => void; + onRemoveEditItem: (id: string) => void; + onResizeEditItem: (id: string, size: "sm" | "md" | "lg") => void; + aiScreenLabels: string[]; + onPinChart: (card: InsightCardData, screenIdx: number) => void; + heroPoints: number[]; +}) { + const { data } = useDashboardData(); + const [promptExpanded, setPromptExpanded] = useState(false); + + const borderClass = cards.borderVisible ? "" : "dark:!border-transparent"; + const blurClass = cards.backdropBlur ? "" : "dark:!backdrop-blur-none"; + const shared = `!shadow-none dark:![background:var(--card-bg-override)] ${borderClass} ${blurClass}`; + const gapStyle = { gap: `${layout.gap}px` }; + + return ( ++ + {i > 0 && onRemove && ( + + )} ++ ))} +++ ); +} + +function OperationalLayout() { + return ( +++{ + setPromptExpanded(true); + onPromptSubmit(q); + }} + onExpand={() => setPromptExpanded(true)} + onCollapse={() => setPromptExpanded(false)} + pendingScreen={pendingScreen} + onConfirmBuild={onConfirmBuild} + onCancelBuild={onCancelBuild} + aiScreenLabels={aiScreenLabels} + onPinChart={onPinChart} + /> + +++ ++ ++
+
+ {data.greeting} + ++ ++ + {/* Spacer: grows to fill space, minimum 80px gap from subtext */} + + {/* Hero sparkline — respects card padding, taller */} ++ ++ {activeHeadline} +
++ {activeSubhead} +
++ ++ ++ +++++ {buildingLabel ? ( + ++ + ) : ( ++ + + )} ++ + Operational layout — coming soon ++ ); +} + +function AnalyticsLayout() { + return ( ++ Analytics layout — coming soon ++ ); +} + +// --- Main component --- + +type ViewMode = "desktop" | "compact" | "stacked"; + +function useViewMode(ref: React.RefObject): ViewMode { + const [mode, setMode] = useState ("desktop"); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new ResizeObserver(([entry]) => { + const w = entry.contentRect.width; + if (w >= 1100) setMode("desktop"); + else if (w >= 800) setMode("compact"); + else setMode("stacked"); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [ref]); + + return mode; +} + +function DashboardContentInner() { + const { data, setDataset } = useDashboardData(); + const [layout] = useState ("executive"); + const [darkGlow, setDarkGlow] = useState (defaultDarkGlow); + const [darkCards, setDarkCards] = useState (defaultDarkCards); + const [layoutCfg, setLayoutCfg] = useState (defaultLayout); + const [replayCount] = useState(0); + const [expandedCardIdx, setExpandedCardIdx] = useState (null); + const [autopilotOpen, setAutopilotOpen] = useState(false); + const [autopilotDismissed, setAutopilotDismissed] = useState(false); + const [autopilotSource, setAutopilotSource] = useState(""); + const [autopilotInitialMessage, setAutopilotInitialMessage] = useState< + string | undefined + >(undefined); + const [autopilotActiveIdx, setAutopilotActiveIdx] = useState ( + null, + ); + const [autopilotUnreadIdx, setAutopilotUnreadIdx] = useState ( + null, + ); + + // AI screen state + const [aiScreens, setAiScreens] = useState ([]); + const [activeScreenIdx, setActiveScreenIdx] = useState(0); + const [pendingScreen, setPendingScreen] = useState (null); + const [buildingLabel, setBuildingLabel] = useState (null); + const [buildingCards, setBuildingCards] = useState ([]); + const prevScreenIdxRef = useRef(0); + const savedBaseCardsRef = useRef(data.insightCards); + + // Edit mode state + type EditItem = { id: string; title: string; size: "sm" | "md" | "lg" }; + const [isEditMode, setIsEditMode] = useState(false); + const [editItems, setEditItems] = useState ([]); + const editScreenRef = useRef (null); + + const closedAutopilotIdxRef = useRef (null); + const containerRef = useRef (null); + const viewMode = useViewMode(containerRef); + + const activeLayout = + activeScreenIdx === 0 + ? layoutCfg + : (aiScreens[activeScreenIdx - 1]?.layout ?? layoutCfg); + const activeScreen = + activeScreenIdx > 0 ? aiScreens[activeScreenIdx - 1] : null; + const activeHeadline = activeScreen?.headline ?? data.headline; + const activeSubhead = activeScreen?.subhead ?? data.subhead; + const activeHeroPoints = activeScreen?.heroPoints ?? data.heroPoints ?? []; + const slideDir = activeScreenIdx > prevScreenIdxRef.current ? 1 : -1; + const screenLabels = ["Overview", ...aiScreens.map((s) => s.label)]; + + const handleScreenChange = (idx: number) => { + if (isEditMode) setIsEditMode(false); + prevScreenIdxRef.current = activeScreenIdx; + setActiveScreenIdx(idx); + setExpandedCardIdx(null); + if (idx === 0) { + setDataset({ ...data, insightCards: savedBaseCardsRef.current }); + } else { + const screen = aiScreens[idx - 1]; + if (screen) setDataset({ ...data, insightCards: screen.cards }); + } + }; + + const MAX_CARDS = 5; + + const handlePromptSubmit = (query: string) => { + const screen = detectLayoutIntent(query); + if (!screen) return; + const existingIdx = aiScreens.findIndex((s) => s.label === screen.label); + if (existingIdx !== -1) { + handleScreenChange(existingIdx + 1); + return; + } + const limited: AIScreen = { + ...screen, + cards: screen.cards.slice(0, MAX_CARDS), + layout: { + ...screen.layout, + insightCards: screen.layout.insightCards.slice(0, MAX_CARDS), + }, + }; + setPendingScreen(limited); + }; + + const handleConfirmBuild = (selectedIndices: number[]) => { + if (!pendingScreen) return; + const base = pendingScreen; + const filtered = + selectedIndices.length > 0 && selectedIndices.length < base.cards.length + ? { + ...base, + cards: base.cards.filter((_, i) => selectedIndices.includes(i)), + layout: { + ...base.layout, + insightCards: base.layout.insightCards.filter((_, i) => + selectedIndices.includes(i), + ), + }, + } + : base; + setPendingScreen(null); + if (savedBaseCardsRef.current === data.insightCards) { + savedBaseCardsRef.current = [...data.insightCards]; + } + setBuildingLabel(filtered.label); + setBuildingCards( + filtered.cards.map((c) => ({ title: c.title, size: c.size })), + ); + const capturedAiScreens = aiScreens; + const capturedActiveIdx = activeScreenIdx; + const capturedData = data; + setTimeout(() => { + const newScreens = [...capturedAiScreens, filtered]; + const newIdx = newScreens.length; + prevScreenIdxRef.current = capturedActiveIdx; + setAiScreens(newScreens); + setActiveScreenIdx(newIdx); + setExpandedCardIdx(null); + setDataset({ ...capturedData, insightCards: filtered.cards }); + setBuildingLabel(null); + }, 1800); + }; + + const handleCancelBuild = () => setPendingScreen(null); + + const handleEnterEditMode = () => { + const screen = aiScreens[activeScreenIdx - 1]; + if (!screen) return; + const items: EditItem[] = screen.layout.insightCards.map((cfg, i) => ({ + id: screen.cards[i]?.title ?? String(i), + title: screen.cards[i]?.title ?? cfg.content.title, + size: cfg.size as "sm" | "md" | "lg", + })); + editScreenRef.current = screen; + setEditItems(items); + setExpandedCardIdx(null); + setIsEditMode(true); + }; + + const handleSaveEdit = () => { + if (!editScreenRef.current) { + setIsEditMode(false); + return; + } + const base = editScreenRef.current; + const cardByTitle = new Map(base.cards.map((c) => [c.title, c])); + const layoutByTitle = new Map( + base.layout.insightCards.map((c) => [c.content.title, c]), + ); + const newCards = editItems.map((item) => ({ + ...cardByTitle.get(item.id)!, + size: item.size, + })); + const newLayoutCards = editItems.map((item) => ({ + ...layoutByTitle.get(item.id)!, + size: item.size, + })); + const updatedScreen: AIScreen = { + ...base, + cards: newCards, + layout: { ...base.layout, insightCards: newLayoutCards }, + }; + const newScreens = aiScreens.map((s, i) => + i === activeScreenIdx - 1 ? updatedScreen : s, + ); + setAiScreens(newScreens); + setDataset({ ...data, insightCards: newCards }); + setIsEditMode(false); + }; + + const handleCancelEdit = () => setIsEditMode(false); + + const handleReorderEditItems = (items: EditItem[]) => setEditItems(items); + + const handleRemoveEditItem = (id: string) => { + setEditItems((prev) => + prev.length <= 1 ? prev : prev.filter((item) => item.id !== id), + ); + }; + + const handleResizeEditItem = (id: string, size: "sm" | "md" | "lg") => { + setEditItems((prev) => + prev.map((item) => (item.id === id ? { ...item, size } : item)), + ); + }; + + const handlePinChart = (card: InsightCardData, aiScreenIdx: number) => { + const screen = aiScreens[aiScreenIdx]; + if (!screen) return; + const updatedScreen: AIScreen = { + ...screen, + cards: [...screen.cards, card], + layout: { + ...screen.layout, + insightCards: [ + ...screen.layout.insightCards, + { + size: (card.size ?? "md") as "sm" | "md" | "lg", + visible: true, + interaction: (card.interaction ?? "expand") as + | "static" + | "expand" + | "navigate", + content: { + type: card.type, + chartType: card.chartType, + title: card.title, + }, + }, + ], + }, + }; + const newScreens = aiScreens.map((s, i) => + i === aiScreenIdx ? updatedScreen : s, + ); + setAiScreens(newScreens); + if (activeScreenIdx === aiScreenIdx + 1) { + setDataset({ ...data, insightCards: updatedScreen.cards }); + } + toast.success(`Pinned to ${screen.label}`); + }; + + const handleRemoveScreen = (navIdx: number) => { + if (isEditMode) setIsEditMode(false); + const screenIdx = navIdx - 1; + const newScreens = aiScreens.filter((_, i) => i !== screenIdx); + setAiScreens(newScreens); + if (activeScreenIdx === navIdx) { + prevScreenIdxRef.current = navIdx; + setActiveScreenIdx(0); + setDataset({ ...data, insightCards: savedBaseCardsRef.current }); + } else if (activeScreenIdx > navIdx) { + setActiveScreenIdx(activeScreenIdx - 1); + prevScreenIdxRef.current = Math.max(0, prevScreenIdxRef.current - 1); + } + }; + + const handleAutopilotOpen = ( + sourceTitle: string, + idx: number, + prompt?: string, + ) => { + if ( + autopilotOpen && + !autopilotDismissed && + autopilotActiveIdx === idx && + !prompt + ) { + setAutopilotDismissed(true); + setAutopilotOpen(false); + setAutopilotActiveIdx(null); + } else { + closedAutopilotIdxRef.current = null; + setAutopilotSource(sourceTitle); + setAutopilotActiveIdx(idx); + setAutopilotOpen(true); + setAutopilotDismissed(false); + setAutopilotInitialMessage(prompt); + setAutopilotUnreadIdx(null); + } + }; + + const handleAutopilotClose = () => { + closedAutopilotIdxRef.current = autopilotActiveIdx; + setAutopilotDismissed(true); + setAutopilotOpen(false); + setAutopilotActiveIdx(null); + }; + + const handleAutopilotResponseReady = () => { + if (autopilotOpen && !autopilotDismissed) return; + const idx = closedAutopilotIdxRef.current; + setAutopilotUnreadIdx(idx); + toast.info("AI assistant response ready", { + description: autopilotSource + ? `${autopilotSource} analysis complete` + : undefined, + action: { + label: "View", + onClick: () => { + if (idx === null) return; + setExpandedCardIdx(idx); + setAutopilotDismissed(false); + setAutopilotOpen(true); + setAutopilotActiveIdx(idx); + setAutopilotUnreadIdx(null); + }, + }, + }); + }; + + return ( + + + ); +} + +export function DashboardContent() { + return ( ++++ + + {/* Header */} ++++ + {/* Layout content */} +++ + {/* View navigator — centered between title and controls */} ++ {data.brandName}{" "} + {data.brandLine} +
++ {data.dashboardTitle} +
++ {data.badgeText} + +++ + {/* Right controls — normal controls always hold the layout width; + edit controls overlay them absolutely so the total width never changes */} ++++ {aiScreens.length > 0 && ( + + {aiScreens.length > 0 && ( + + )} ++ )} + + {/* Normal controls — always in flow to lock width */} +++ + ++ {/* Edit controls — absolutely overlaid on the right */} ++ + +++++ {layout === "executive" && ( ++ {/* Autopilot panel — slides in from right */} +s.label)} + onPinChart={handlePinChart} + heroPoints={activeHeroPoints} + /> + )} + {layout === "operational" && } + {layout === "analytics" && } + +++ {autopilotSource && ( +++ )} + + + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardDataProvider.tsx b/apps/apollo-vertex/templates/dashboard/DashboardDataProvider.tsx new file mode 100644 index 000000000..bdd763ff7 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardDataProvider.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; +import { + invoiceProcessingDataset, + type DashboardDataset, +} from "./dashboard-data"; + +interface DashboardDataContextValue { + data: DashboardDataset; + setDataset: (data: DashboardDataset) => void; +} + +const DashboardDataContext = createContext+ + ({ + data: invoiceProcessingDataset, + setDataset: () => {}, +}); + +const DashboardDataSeedContext = createContext ( + invoiceProcessingDataset, +); + +export function useDashboardData() { + return useContext(DashboardDataContext); +} + +export function DashboardDataSeedProvider({ + children, + dataset, +}: { + children: ReactNode; + dataset: DashboardDataset; +}) { + return ( + + {children} + + ); +} + +export function DashboardDataProvider({ children }: { children: ReactNode }) { + const seed = useContext(DashboardDataSeedContext); + const [data, setData] = useState(seed); + + return ( + + {children} + + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardGlow.tsx b/apps/apollo-vertex/templates/dashboard/DashboardGlow.tsx new file mode 100644 index 000000000..953d42119 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardGlow.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { + defaultDarkGlow, + defaultLightGlow, + type GlowConfig, +} from "./glow-config"; + +interface DashboardGlowProps { + className?: string; + darkConfig?: GlowConfig; +} + +function GlowSvg({ id, config }: { id: string; config: GlowConfig }) { + return ( + + ); +} + +export function DashboardGlow({ className, darkConfig }: DashboardGlowProps) { + const light = defaultLightGlow; + const dark = darkConfig ?? defaultDarkGlow; + + return ( +++ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardLoading.tsx b/apps/apollo-vertex/templates/dashboard/DashboardLoading.tsx new file mode 100644 index 000000000..9c378dc38 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardLoading.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +type Phase = "logo" | "skeleton" | "done"; + +interface DashboardLoadingProps { + children: React.ReactNode; + triggerReplay?: number; +} + +function LogoPhase({ exiting }: { exiting: boolean }) { + return ( ++++ +++ + {/* Morphing glow */} ++ ); +} + +function SkeletonPhase({ exiting }: { exiting: boolean }) { + return ( ++ +++ ++ + {/* App icon */} +++ + {/* Loading text */} ++
+ Creating your overview... +
+ + +++ ); +} + +export function DashboardLoading({ + children, + triggerReplay, +}: DashboardLoadingProps) { + const [phase, setPhase] = useState+ + +++++ + +++ + + + ++("done"); + const [exiting, setExiting] = useState(false); + + const startSequence = useCallback(() => { + setExiting(false); + setPhase("logo"); + }, []); + + useEffect(() => { + if (triggerReplay === 0) return; + if (triggerReplay) startSequence(); + }, [triggerReplay, startSequence]); + + useEffect(() => { + if (phase === "done") return; + + if (phase === "logo") { + const timer = setTimeout(() => { + setExiting(true); + setTimeout(() => { + setExiting(false); + setPhase("skeleton"); + }, 500); + }, 2000); + return () => clearTimeout(timer); + } + + if (phase === "skeleton") { + const timer = setTimeout(() => { + setExiting(true); + setTimeout(() => { + setPhase("done"); + }, 500); + }, 1000); + return () => clearTimeout(timer); + } + }, [phase]); + + if (phase === "done") { + return ( + {children}+ ); + } + + return ( ++ {phase === "logo" &&+ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardProductDemoPreview.tsx b/apps/apollo-vertex/templates/dashboard/DashboardProductDemoPreview.tsx new file mode 100644 index 000000000..0a3492a98 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardProductDemoPreview.tsx @@ -0,0 +1,45 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { productDemoDataset } from "./product-demo-data"; +import type { DashboardTemplateProps } from "./DashboardTemplate"; + +function DashboardSkeleton() { + return ( +} + {phase === "skeleton" && } + + {/* Sidebar skeleton */} + + {/* Content skeleton */} ++ ); +} + +const DashboardTemplate = dynamic+++ + +++++ + +++ + + + ++( + () => + import("./DashboardTemplate").then((mod) => ({ + default: mod.DashboardTemplate, + })), + { ssr: false, loading: DashboardSkeleton }, +); + +export function DashboardProductDemoPreview() { + return ; +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardRoutes.tsx b/apps/apollo-vertex/templates/dashboard/DashboardRoutes.tsx new file mode 100644 index 000000000..30965402e --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardRoutes.tsx @@ -0,0 +1,59 @@ +import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"; +import { DashboardContent } from "./DashboardContent"; +import { DashboardShellWrapper } from "./DashboardShellWrapper"; + +export const dashboardRootRoute = createRootRoute(); + +// --- Sidebar variant routes --- + +export const dashboardShellRoute = createRoute({ + getParentRoute: () => dashboardRootRoute, + path: "/preview/dashboard", + component: () => ( + + + ), +}); + +export const dashboardIndexRoute = createRoute({ + getParentRoute: () => dashboardShellRoute, + path: "/", + component: DashboardContent, +}); + +export const dashboardHomeRoute = createRoute({ + getParentRoute: () => dashboardShellRoute, + path: "/home", + component: DashboardContent, +}); + +export const dashboardCatchAllRoute = createRoute({ + getParentRoute: () => dashboardShellRoute, + path: "$", + component: DashboardContent, +}); + +// --- Minimal variant routes --- + +export const dashboardMinimalShellRoute = createRoute({ + getParentRoute: () => dashboardRootRoute, + path: "/preview/dashboard-minimal", + component: () => ( ++ + + ), +}); + +export const dashboardMinimalIndexRoute = createRoute({ + getParentRoute: () => dashboardMinimalShellRoute, + path: "/", + component: DashboardContent, +}); + +export const dashboardMinimalCatchAllRoute = createRoute({ + getParentRoute: () => dashboardMinimalShellRoute, + path: "$", + component: DashboardContent, +}); diff --git a/apps/apollo-vertex/templates/dashboard/DashboardShellWrapper.tsx b/apps/apollo-vertex/templates/dashboard/DashboardShellWrapper.tsx new file mode 100644 index 000000000..fe3ffc163 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardShellWrapper.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import type { ShellNavItem } from "@/registry/shell/shell"; +import { ApolloShell } from "@/registry/shell/shell"; +import { BarChart3, FolderOpen, Home, Settings, Users } from "lucide-react"; + +const sidebarNavItems: ShellNavItem[] = [ + { path: "/preview/dashboard/home", label: "dashboard", icon: Home }, + { path: "/preview/dashboard/projects", label: "projects", icon: FolderOpen }, + { path: "/preview/dashboard/analytics", label: "analytics", icon: BarChart3 }, + { path: "/preview/dashboard/team", label: "team", icon: Users }, + { path: "/preview/dashboard/settings", label: "settings", icon: Settings }, +]; + +const minimalNavItems: ShellNavItem[] = [ + { path: "/preview/dashboard-minimal", label: "dashboard", icon: Home }, + { + path: "/preview/dashboard-minimal/projects", + label: "projects", + icon: FolderOpen, + }, + { + path: "/preview/dashboard-minimal/analytics", + label: "analytics", + icon: BarChart3, + }, +]; + +export function DashboardShellWrapper({ + variant, + children, +}: { + variant?: "minimal"; + children: ReactNode; +}) { + return ( ++ + {children} + + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx b/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx new file mode 100644 index 000000000..a3c5d3081 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardTemplate.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { + Component, + type ErrorInfo, + type ReactNode, + useEffect, + useState, +} from "react"; +import { ShellAuthProvider } from "@/registry/shell/shell-auth-provider"; +import { + AICHAT_CLIENT_ID, + AICHAT_SCOPE, +} from "@/templates/ai-chat/ai-chat-example-utils"; +import { DashboardChatProvider } from "./DashboardChatProvider"; +import { DashboardDataSeedProvider } from "./DashboardDataProvider"; +import { + dashboardCatchAllRoute, + dashboardHomeRoute, + dashboardIndexRoute, + dashboardMinimalCatchAllRoute, + dashboardMinimalIndexRoute, + dashboardMinimalShellRoute, + dashboardRootRoute, + dashboardShellRoute, +} from "./DashboardRoutes"; +import type { DashboardDataset } from "./dashboard-data"; + +class DashboardErrorBoundary extends Component< + { children: ReactNode }, + { error: Error | null; componentStack: string } +> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { error: null, componentStack: "" }; + } + static getDerivedStateFromError(error: Error) { + return { error, componentStack: "" }; + } + componentDidCatch(error: Error, info: ErrorInfo) { + console.error( + "[DashboardErrorBoundary] caught:", + error.message, + info.componentStack, + ); + this.setState({ error, componentStack: info.componentStack ?? "" }); + } + render() { + if (this.state.error) { + return ( ++ DashboardTemplate Error ++ ); + } + return this.props.children; + } +} + +export interface DashboardTemplateProps { + shellVariant?: "minimal"; + dataset?: DashboardDataset; + /** Skip auth requirements — for product demos and standalone previews */ + demoMode?: boolean; +} + +const DASHBOARD_PREVIEW_PATH_KEY = "dashboard-preview-path"; +const DASHBOARD_MINIMAL_PREVIEW_PATH_KEY = "dashboard-minimal-preview-path"; + +type DashboardPreviewPathKey = + | typeof DASHBOARD_PREVIEW_PATH_KEY + | typeof DASHBOARD_MINIMAL_PREVIEW_PATH_KEY; + +const queryClient = new QueryClient(); + +const routeTree = dashboardRootRoute.addChildren([ + dashboardShellRoute.addChildren([ + dashboardIndexRoute, + dashboardHomeRoute, + dashboardCatchAllRoute, + ]), + dashboardMinimalShellRoute.addChildren([ + dashboardMinimalIndexRoute, + dashboardMinimalCatchAllRoute, + ]), +]); + +function getInitialEntry( + storageKey: DashboardPreviewPathKey, + variant?: "minimal", +) { + const stored = localStorage.getItem(storageKey); + if (stored) return stored; + return variant === "minimal" + ? "/preview/dashboard-minimal" + : "/preview/dashboard"; +} + +function createDashboardRouter( + storageKey: DashboardPreviewPathKey, + variant?: "minimal", +) { + const history = createMemoryHistory({ + initialEntries: [getInitialEntry(storageKey, variant)], + }); + return createRouter({ routeTree, history }); +} + +export function DashboardTemplate({ + shellVariant, + dataset, + demoMode = false, +}: DashboardTemplateProps) { + // Diagnostic: check for undefined components + if (process.env.NODE_ENV === "development") { + const checks = { + QueryClientProvider, + ShellAuthProvider, + DashboardChatProvider, + RouterProvider, + DashboardDataSeedProvider, + }; + for (const [name, comp] of Object.entries(checks)) { + if (!comp) console.error(`[DashboardTemplate] ${name} is undefined!`); + } + } + + const storageKey = + shellVariant === "minimal" + ? DASHBOARD_MINIMAL_PREVIEW_PATH_KEY + : DASHBOARD_PREVIEW_PATH_KEY; + const [router] = useState(() => + createDashboardRouter(storageKey, shellVariant), + ); + + useEffect(() => { + const unsubscribe = router.subscribe("onResolved", ({ toLocation }) => { + localStorage.setItem(storageKey, toLocation.pathname); + }); + return unsubscribe; + }, [router, storageKey]); + + // In demo mode: skip ShellAuthProvider so authContext is null in ApolloShellContent, + // bypassing the login gate. DashboardChatProvider uses its own demo mock for autopilot. + const content = demoMode ? ( +{this.state.error.message}
++ {this.state.componentStack} +++ + ) : ( ++ ++ + + ); + + if (dataset) { + return ( ++ ++ ++ + + ); + } + + return+ {content} + +{content} ; +} diff --git a/apps/apollo-vertex/templates/dashboard/DashboardTemplateDynamic.tsx b/apps/apollo-vertex/templates/dashboard/DashboardTemplateDynamic.tsx new file mode 100644 index 000000000..966f2c9e4 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/DashboardTemplateDynamic.tsx @@ -0,0 +1,35 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type React from "react"; + +type DashboardTemplateProps = React.ComponentProps< + typeof import("./DashboardTemplate").DashboardTemplate +>; + +function DashboardLoadingFallback() { + return ( +++ ); +} + +export const DashboardTemplate = dynamic+++++
+ Loading dashboard… +
+( + () => + import("./DashboardTemplate").then((mod) => ({ + default: mod.DashboardTemplate, + })), + { ssr: false, loading: DashboardLoadingFallback }, +); diff --git a/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx b/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx new file mode 100644 index 000000000..bb6162b80 --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/ExpandedInsightContent.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; + +// --- Sample data --- + +const trendData = { + weeks: ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"], + series: [ + { + label: "Wrong size/fit", + color: "bg-chart-1", + stroke: "stroke-chart-1", + values: [31, 33, 34, 35, 36, 37, 39, 41], + }, + { + label: "Damaged in transit", + color: "bg-chart-2", + stroke: "stroke-chart-2", + values: [25, 24, 26, 23, 22, 24, 23, 21], + }, + { + label: "Not as described", + color: "bg-chart-3", + stroke: "stroke-chart-3", + values: [20, 19, 18, 19, 18, 17, 18, 17], + }, + ], + takeaway: + "Fit-related returns have grown steadily over 8 weeks (+32%), while damage and description issues remain flat.", +}; + +const categoryBreakdown = [ + { category: "Women's Apparel", pct: 48, highlight: true }, + { category: "Footwear", pct: 27 }, + { category: "Men's Apparel", pct: 16 }, + { category: "Accessories", pct: 9 }, +]; +const categoryInsight = + "Women's apparel and footwear account for 75% of all fit-related returns. Sizing inconsistency across brands is the primary driver."; + +const topProducts = [ + { + name: "Slim Fit Chinos — Navy", + returnRate: 18.4, + issue: "Wrong size", + impact: "$12,400", + }, + { + name: "Running Shoe Pro V2", + returnRate: 15.2, + issue: "Wrong fit", + impact: "$9,800", + }, + { + name: "Wrap Dress — Floral", + returnRate: 14.7, + issue: "Wrong size", + impact: "$8,200", + }, + { + name: "Oversized Hoodie — Black", + returnRate: 12.1, + issue: "Too large", + impact: "$6,900", + }, + { + name: "Ankle Boot — Tan", + returnRate: 11.8, + issue: "Wrong fit", + impact: "$5,400", + }, +]; + +const recommendations = [ + { + action: "Deploy dynamic size recommendation for top 3 SKUs", + impact: "Est. 22% reduction in fit returns", + priority: "High", + }, + { + action: "Add fit-specific review prompts to product pages", + impact: "Improve size confidence pre-purchase", + priority: "Medium", + }, + { + action: "Flag brands with >15% size variance for supplier review", + impact: "Address root cause across catalog", + priority: "Medium", + }, +]; + +const suggestedPrompts = [ + "Why are fit-related returns increasing?", + "Which products are driving return volume?", + "What orders are at risk of return?", +]; + +// --- Components --- + +function TrendChart({ data }: { data: typeof trendData }) { + const allValues = data.series.flatMap((s) => s.values); + const max = Math.max(...allValues); + const h = 60; + const w = 180; + const step = w / (data.weeks.length - 1); + + return ( + ++ ); +} + +function CategoryBreakdown() { + return ( +++ ++ Trend over time +++ 8-week view of the top 3 return reasons +
++ {data.series.map((s) => ( +++ + {s.label} ++ ))} +++{data.takeaway}
+++ ); +} + +function TopProducts() { + return ( ++++ Category breakdown +++ Where "Wrong size/fit" returns are concentrated +
++ {categoryBreakdown.map((cat) => ( ++++ ))} ++ {cat.category} + {cat.pct}% +++++ ++++{categoryInsight}
+++ ); +} + +function Recommendations() { + return ( ++++ Top products driving issues +++ Ranked by return rate with revenue impact +
++++ Product + Return % + Issue + Impact ++ {topProducts.map((p) => ( ++ {p.name} + + {p.returnRate}% + ++ ))} ++ {p.issue} + + + {p.impact} + +++ ); +} + +// --- Exports --- + +export type DrilldownTab = + | "overview" + | "trend" + | "categories" + | "products" + | "actions"; + +export const drilldownTabs: { key: DrilldownTab; label: string }[] = [ + { key: "overview", label: "Overview" }, + { key: "categories", label: "Categories" }, + { key: "products", label: "Products" }, + { key: "actions", label: "Actions" }, +]; + +export function DrilldownTabContent({ tab }: { tab: DrilldownTab }) { + if (tab === "trend") return+++ Recommended actions +++ AI-assisted next steps based on current data +
++ {recommendations.map((rec, i) => ( ++++ ))} ++ + {i + 1} + +++ {rec.priority} + +{rec.action}
++ {rec.impact} +
+; + if (tab === "categories") return ; + if (tab === "products") return ; + if (tab === "actions") return ; + return null; // "overview" is handled by the original card content +} + +export function AutopilotPrompts({ + onPromptSelect, +}: { + onPromptSelect?: (prompt: string) => void; +}) { + const [pressedPrompt, setPressedPrompt] = useState (null); + + return ( + ++ ); +} diff --git a/apps/apollo-vertex/templates/dashboard/GlowDevControls.tsx b/apps/apollo-vertex/templates/dashboard/GlowDevControls.tsx new file mode 100644 index 000000000..036aeb1bb --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/GlowDevControls.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useRef, useState } from "react"; +import type { CardConfig, GlowConfig, LayoutConfig } from "./glow-config"; +import { CardsTab, GlowTab, LayoutTab } from "./dev-controls-tabs"; +import { useDashboardData } from "./DashboardDataProvider"; +import { datasetPresets, type DashboardDataset } from "./dashboard-data"; + +interface DevControlsProps { + glowConfig: GlowConfig; + onGlowChange: (config: GlowConfig) => void; + cardConfig: CardConfig; + onCardChange: (config: CardConfig) => void; + layoutConfig: LayoutConfig; + onLayoutChange: (config: LayoutConfig) => void; +} + +type Tab = "glow" | "cards" | "layout" | "data"; + +export function GlowDevControls({ + glowConfig, + onGlowChange, + cardConfig, + onCardChange, + layoutConfig, + onLayoutChange, +}: DevControlsProps) { + const [open, setOpen] = useState(false); + const [tab, setTab] = useState+++
+ Ask AI assistant +
+ {suggestedPrompts.map((prompt) => ( + + ))} ++("glow"); + const { data, setDataset } = useDashboardData(); + const fileInputRef = useRef (null); + const [uploadError, setUploadError] = useState(""); + const [uploadedDatasets, setUploadedDatasets] = useState ( + [], + ); + + const configMap: Record = { + glow: glowConfig, + cards: cardConfig, + layout: layoutConfig, + data: data, + }; + const currentConfig = configMap[tab]; + + return ( + <> + { + const file = e.target.files?.[0]; + if (!file) return; + setUploadError(""); + const reader = new FileReader(); + reader.onload = () => { + try { + const parsed = JSON.parse( + reader.result as string, + ) as DashboardDataset; + if (!parsed.brandName || !parsed.insightCards) { + setUploadError("Invalid format"); + return; + } + setUploadedDatasets((prev) => { + const exists = prev.some((d) => d.name === parsed.name); + return exists + ? prev.map((d) => (d.name === parsed.name ? parsed : d)) + : [...prev, parsed]; + }); + setDataset(parsed); + } catch { + setUploadError("Invalid JSON"); + } + }; + reader.readAsText(file); + e.target.value = ""; + }} + /> + + {open && ( ++ > + ); +} diff --git a/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx b/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx new file mode 100644 index 000000000..0152c221c --- /dev/null +++ b/apps/apollo-vertex/templates/dashboard/InsightGrid.tsx @@ -0,0 +1,851 @@ +"use client"; + +import { + ArrowUpRight, + GripVertical, + Maximize2, + Minimize2, + X, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useDashboardData } from "./DashboardDataProvider"; +import { + AutopilotPrompts, + type DrilldownTab, + DrilldownTabContent, + drilldownTabs, +} from "./ExpandedInsightContent"; +import { + type CardConfig, + cardBgStyle, + getInsightCardClasses, + type InsightCardConfig, + type LayoutConfig, +} from "./glow-config"; +import { InsightCardBody } from "./insight-card-renderers"; + +const sizeToFr: Record++ )} + ++ + + + +++ {tab === "glow" && ( +++ )} + {tab === "cards" && ( + + )} + {tab === "layout" && ( + + )} + {tab === "data" && ( + ++ )} ++ Dataset: {data.name} ++++Preset+ ++ + ++ {uploadError && ( ++ {uploadError} ++ )} +++Config:++ {JSON.stringify(currentConfig, null, 2)} ++= { sm: "1fr", md: "2fr", lg: "1fr" }; + +// --- Edit mode types and components --- + +interface EditCardItem { + id: string; + title: string; + size: "sm" | "md" | "lg"; +} + +function SkeletonEditCard({ + item, + cardRef, + isDragging, + onGripPointerDown, + onRemove, + onResize, +}: { + item: EditCardItem; + cardRef: (el: HTMLDivElement | null) => void; + isDragging: boolean; + onGripPointerDown: (e: React.PointerEvent ) => void; + onRemove: () => void; + onResize: (size: "sm" | "md" | "lg") => void; +}) { + return ( + + {/* Content — hidden while dragging so the ghost is clearly a placeholder */} ++ ); +} + +function EditSkeletonGrid({ + editItems, + onReorderEditItems, + onRemoveEditItem, + onResizeEditItem, + gap, +}: { + editItems: EditCardItem[]; + onReorderEditItems?: (items: EditCardItem[]) => void; + onRemoveEditItem?: (id: string) => void; + onResizeEditItem?: (id: string, size: "sm" | "md" | "lg") => void; + gap: number; +}) { + // Ref map keyed by card id — never desyncs from DOM regardless of reorder + const cardRefMap = useRef+ {/* Title */} ++ + {/* Drag grip — always visible, top left */} ++ + {item.title} + ++ + {/* Skeleton bars */} ++ {[38, 62, 48, 72, 55, 80, 44].map((h, i) => ( + + ))} ++ + {/* Remove — top right */} + + + {/* Size toggles — bottom right */} ++ {(["sm", "md", "lg"] as const).map((s) => ( + + ))} +++++