From e87090c7e599ce6a05fa596f4dbf1b053d7e3bfb Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Mon, 20 Apr 2026 11:08:54 -0400 Subject: [PATCH] feat(apollo-vertex): ai-chat display components [2/5] Adds passive/read-only rendering components: loading, markdown, code-block, thinking, empty-state, and suggestions. Co-Authored-By: Claude Sonnet 4.6 --- apps/apollo-vertex/registry.json | 12 + .../ai-chat/components/ai-chat-code-block.tsx | 164 +++++++++++++ .../components/ai-chat-empty-state.tsx | 31 +++ .../ai-chat/components/ai-chat-loading.tsx | 88 +++++-- .../ai-chat/components/ai-chat-markdown.tsx | 95 +++++--- .../components/ai-chat-suggestions.tsx | 159 ++++++++++++- .../ai-chat/components/ai-chat-thinking.tsx | 220 ++++++++++++++++++ 7 files changed, 706 insertions(+), 63 deletions(-) create mode 100644 apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx create mode 100644 apps/apollo-vertex/registry/ai-chat/components/ai-chat-empty-state.tsx create mode 100644 apps/apollo-vertex/registry/ai-chat/components/ai-chat-thinking.tsx diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 6fe3838e3..35ade3451 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -418,6 +418,18 @@ "type": "registry:ui", "target": "components/ui/ai-chat/components/ai-chat-loading.tsx" }, + { + "path": "registry/ai-chat/components/ai-chat-thinking.tsx", + "type": "registry:component" + }, + { + "path": "registry/ai-chat/components/ai-chat-code-block.tsx", + "type": "registry:component" + }, + { + "path": "registry/ai-chat/components/ai-chat-empty-state.tsx", + "type": "registry:component" + }, { "path": "registry/ai-chat/components/ai-chat-input.tsx", "type": "registry:ui", diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx new file mode 100644 index 000000000..58bd052fa --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx @@ -0,0 +1,164 @@ +"use client"; + +import "highlight.js/styles/github.min.css"; + +// Dark-mode override: github-dark-dimmed palette scoped to `.dark` +const DARK_HLJS_STYLE = ` +.dark .hljs { + color: #adbac7; + background: transparent; +} +.dark .hljs-doctag,.dark .hljs-keyword,.dark .hljs-meta .hljs-keyword,.dark .hljs-template-tag,.dark .hljs-template-variable,.dark .hljs-type,.dark .hljs-variable.language_ { + color: #f47067; +} +.dark .hljs-title,.dark .hljs-title.class_,.dark .hljs-title.class_.inherited__,.dark .hljs-title.function_ { + color: #dcbdfb; +} +.dark .hljs-attr,.dark .hljs-attribute,.dark .hljs-literal,.dark .hljs-meta,.dark .hljs-number,.dark .hljs-operator,.dark .hljs-variable,.dark .hljs-selector-attr,.dark .hljs-selector-class,.dark .hljs-selector-id { + color: #6cb6ff; +} +.dark .hljs-regexp,.dark .hljs-string,.dark .hljs-meta .hljs-string { + color: #96d0ff; +} +.dark .hljs-built_in,.dark .hljs-symbol { + color: #f69d50; +} +.dark .hljs-comment,.dark .hljs-code,.dark .hljs-formula { + color: #768390; +} +.dark .hljs-name,.dark .hljs-quote,.dark .hljs-selector-tag,.dark .hljs-selector-pseudo { + color: #8ddb8c; +} +.dark .hljs-subst { + color: #adbac7; +} +.dark .hljs-section { + color: #316dca; + font-weight: bold; +} +.dark .hljs-bullet { + color: #eac55f; +} +.dark .hljs-emphasis { + color: #adbac7; + font-style: italic; +} +.dark .hljs-strong { + color: #adbac7; + font-weight: bold; +} +.dark .hljs-addition { + color: #b4f1b4; + background-color: #1b4721; +} +.dark .hljs-deletion { + color: #ffd8d3; + background-color: #78191b; +} +`; + +import hljs from "highlight.js/lib/core"; +import bash from "highlight.js/lib/languages/bash"; +import css from "highlight.js/lib/languages/css"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import python from "highlight.js/lib/languages/python"; +import sql from "highlight.js/lib/languages/sql"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import { Check, Copy } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/registry/tooltip/tooltip"; + +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("js", javascript); +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("ts", typescript); +hljs.registerLanguage("tsx", typescript); +hljs.registerLanguage("jsx", javascript); +hljs.registerLanguage("python", python); +hljs.registerLanguage("py", python); +hljs.registerLanguage("bash", bash); +hljs.registerLanguage("sh", bash); +hljs.registerLanguage("shell", bash); +hljs.registerLanguage("json", json); +hljs.registerLanguage("css", css); +hljs.registerLanguage("html", xml); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("sql", sql); + +const COPY_LABEL = "Copy code"; +const COPIED_LABEL = "Copied!"; + +interface AiChatCodeBlockProps { + children: string; + language?: string; +} + +export function AiChatCodeBlock({ children, language }: AiChatCodeBlockProps) { + const [copied, setCopied] = useState(false); + const codeRef = useRef(null); + + const highlightedHtml = + language && hljs.getLanguage(language) + ? hljs.highlight(children, { language }).value + : hljs.highlightAuto(children).value; + + useEffect(() => { + if (codeRef.current) { + codeRef.current.innerHTML = highlightedHtml; + } + }, [highlightedHtml]); + + const handleCopy = async () => { + await navigator.clipboard.writeText(children); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const copyLabel = copied ? COPIED_LABEL : COPY_LABEL; + + return ( + <> + +
+
+ {language && ( + + {language} + + )} + + + + + {copyLabel} + +
+
+          
+        
+
+ + ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-empty-state.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-empty-state.tsx new file mode 100644 index 000000000..397a8acc4 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-empty-state.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { ReactNode } from "react"; + +interface AiChatEmptyStateProps { + title?: string; + description?: string; + icon?: ReactNode; +} + +export function AiChatEmptyState({ + title = "How can I help you?", + description, + icon, +}: AiChatEmptyStateProps) { + return ( +
+ {icon} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-loading.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-loading.tsx index f4df84fc7..702ae158d 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-loading.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-loading.tsx @@ -1,33 +1,73 @@ "use client"; -import { Sparkles } from "lucide-react"; -import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { AiChatThinking } from "./ai-chat-thinking"; -interface AiChatLoadingProps { - assistantName?: string; -} +// Quartic ease-out — same curve used inside AiChatThinking for consistency +const ENTRANCE_EASE = [0.22, 1, 0.36, 1] as const; +// Container slide-up + fade-in duration +const ENTRANCE_DURATION = 0.5; +// Text appears after the icon's morph completes (FORWARD_DURATION in AiChatThinking is 0.8s) plus a small gap +const TEXT_DELAY = 0.9; +const TEXT_DURATION = 0.3; -export function AiChatLoading({ assistantName }: AiChatLoadingProps) { - const { t } = useTranslation(); - const displayName = assistantName ?? t("ai_assistant"); +const shimmerStyle = { + display: "inline-block", + whiteSpace: "nowrap", + lineHeight: 1.3, + fontSize: "14px", + fontWeight: 500, + backgroundImage: + "linear-gradient(90deg, var(--muted-foreground) 0%, var(--muted-foreground) 30%, #6C5AEF 42%, var(--foreground) 50%, #69C7DD 58%, var(--muted-foreground) 70%, var(--muted-foreground) 100%)", + backgroundSize: "200% 100%", + backgroundClip: "text", + WebkitBackgroundClip: "text", + color: "transparent", + animation: "ap-chat-loading-shimmer 2.4s linear infinite", +} as const; +// TODO: Progressive thinking states +// The indicator should become more informative as latency grows, and should +// reflect what the agent is actually doing (not just that it is busy): +// +// < 500ms No indicator — avoids flicker on fast responses +// 500ms–2s Static "Thinking…" + shimmer (current) +// 2s+ Contextual label — text reflects the current operation +// (e.g. "Reading document…", "Running automation…") +// 4s+ Step-level UI — surfaces discrete agent actions as they occur, +// so the user understands what the agent is doing on their behalf +// +// Labels will be caller-supplied once the state model is defined, so the +// component stays agnostic to the specific agent and its toolset. +export function AiChatLoading() { return ( -
-
- -
-
- - {displayName} - -
-
- - - -
-
+ + +
+ + + {"Thinking\u2026"} +
-
+ ); } diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-markdown.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-markdown.tsx index f2cd1d929..3629a66d9 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-markdown.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-markdown.tsx @@ -3,73 +3,112 @@ import type { ComponentProps, ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { AiChatCodeBlock } from "./ai-chat-code-block"; type NodeProps = { children?: ReactNode }; type AnchorProps = { children?: ReactNode; href?: string }; +type ImageProps = { src?: string | Blob; alt?: string; title?: string }; + +function extractCodeProps(props: NodeProps & { className?: string }) { + const { className, children } = props; + const match = /language-(\w+)/.exec(className ?? ""); + const language = match ? match[1] : ""; + const code = (typeof children === "string" ? children : "").replace( + /\n$/, + "", + ); + return { language, code }; +} const components: ComponentProps["components"] = { - p: ({ children }: NodeProps) =>

{children}

, + p: ({ children }: NodeProps) => ( +

{children}

+ ), ul: ({ children }: NodeProps) => ( -
    {children}
+
    + {children} +
), ol: ({ children }: NodeProps) => ( -
    {children}
- ), - li: ({ children }: NodeProps) =>
  • {children}
  • , - pre: ({ children }: NodeProps) => ( -
    +    
      {children} -
    + ), + li: ({ children }: NodeProps) =>
  • {children}
  • , + pre: ({ children }: NodeProps) =>
    {children}
    , code: ({ children, className, ...props - }: NodeProps & { className?: string }) => ( - { + 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) => ( + {alt + ), strong: ({ children }: NodeProps) => ( {children} ), em: ({ children }: NodeProps) => {children}, blockquote: ({ children }: NodeProps) => ( -
    +
    {children}
    ), h1: ({ children }: NodeProps) => ( -

    {children}

    +

    {children}

    ), h2: ({ children }: NodeProps) => ( -

    {children}

    +

    {children}

    ), h3: ({ children }: NodeProps) => ( -

    {children}

    +

    {children}

    ), - hr: () =>
    , + hr: () =>
    , table: ({ children }: NodeProps) => (
    - {children}
    + + {children} +
    ), thead: ({ children }: NodeProps) => ( - {children} + {children} ), tbody: ({ children }: NodeProps) => ( - {children} + {children} ), tr: ({ children }: NodeProps) => {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-suggestions.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-suggestions.tsx index 20498125f..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 type { ChoiceOption } from "../tools/choices"; +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 */} +
    +
    + {isLoading ? ( +
    +
    + {canSkip && onSkip && ( + + )} + {onDismiss && ( + + )} +
    +
    + + {/* Prompt */} + {prompt && ( +

    + {prompt} +

    + )} + + {/* Options */} +
    + {options.map((option) => ( + onSelect(option)} + > + {option.label} + + ))} +
    +
    + ); + } + + // Single-step: original chip style return ( -
    + {prompt &&

    {prompt}

    }
    {options.map((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 ( + + ); +}