From 60d941c6d64d53186476c7262c456ef91f2ce887 Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Thu, 30 Apr 2026 07:56:28 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(apollo-vertex):=20ai-chat=20code=20blo?= =?UTF-8?q?cks=20=E2=80=94=20syntax-highlighted=20fenced=20code=20with=20c?= =?UTF-8?q?opy=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiChatCodeBlock: highlight.js syntax highlighting (JS/TS/Python/Bash/JSON/CSS/SQL), github light + github-dark-dimmed themes, hover-reveal copy button with tooltip - AiChatMarkdown: routes fenced code blocks to AiChatCodeBlock; updates inline code, links, blockquote, table, and image rendering to use ai-chat-* colour tokens; removes prose bubble wrapper for a cleaner reading width - registry.json: registers AiChatCodeBlock, adds highlight.js dependency Co-Authored-By: Claude Sonnet 4.6 --- apps/apollo-vertex/package.json | 1 + apps/apollo-vertex/registry.json | 6 + .../ai-chat/components/ai-chat-code-block.tsx | 164 ++++++++++++++++++ .../ai-chat/components/ai-chat-markdown.tsx | 95 +++++++--- pnpm-lock.yaml | 3 + 5 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx diff --git a/apps/apollo-vertex/package.json b/apps/apollo-vertex/package.json index 99023cd29..48bd7a379 100644 --- a/apps/apollo-vertex/package.json +++ b/apps/apollo-vertex/package.json @@ -70,6 +70,7 @@ "embla-carousel-react": "^8.6.0", "eventsource-parser": "^3.0.6", "framer-motion": "^12.26.2", + "highlight.js": "^11.11.1", "i18next": "^25.8.1", "input-otp": "^1.4.2", "jwt-decode": "^4.0.0", diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index cc9862754..4815be871 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -321,6 +321,7 @@ "lucide-react", "luxon", "react-i18next", + "highlight.js", "react-markdown", "remark-gfm", "framer-motion" @@ -397,6 +398,11 @@ "type": "registry:ui", "target": "components/ui/ai-chat/components/ai-chat-markdown.tsx" }, + { + "path": "registry/ai-chat/components/ai-chat-code-block.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-code-block.tsx" + }, { "path": "registry/ai-chat/components/ai-chat-message.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..197d5b786 --- /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 "@/components/ui/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-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/pnpm-lock.yaml b/pnpm-lock.yaml index 48552d3b5..1b3b38925 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,9 @@ importers: framer-motion: specifier: ^12.26.2 version: 12.26.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 i18next: specifier: ^25.8.1 version: 25.8.1(typescript@5.9.3) From fffc1892a4e880dd3e31509d205e6b69b6e45e92 Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Thu, 30 Apr 2026 08:00:17 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(apollo-vertex):=20ai-chat=20message=20?= =?UTF-8?q?actions=20=E2=80=94=20provider=20context,=20copy/feedback/edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: AiChatConfig interface + MessageFeedbackType shared across components - AiChatProvider: React context that distributes config (assistantName, action callbacks, isLoading, latestAssistantMessageId) to message components without prop drilling; uses display:contents wrapper to preserve h-full layout - AiChatMessageActions: copy / thumbs up+down / regenerate / edit toolbar; latest assistant message keeps actions always-visible, older messages reveal on hover/focus for clean reading - AiChatMessage: rewritten to use provider context; adds entrance animation (fade + 8px slide-up), styled user/assistant bubbles, inline edit mode (textarea swap, Save & re-run / Cancel, auto-scroll to centre) - AiChat: wraps children with AiChatProvider; adds onFeedback, onRegenerate, onEditMessage, showMessageActions, showCopyButton props; computes latestAssistantMessageId for the provider - AiChatAgentHubMode: removes assistantName from AiChatMessage (now in provider) Co-Authored-By: Claude Sonnet 4.6 --- apps/apollo-vertex/registry.json | 15 + .../components/ai-chat-message-actions.tsx | 170 +++++++++ .../ai-chat/components/ai-chat-message.tsx | 219 +++++++++-- .../ai-chat/components/ai-chat-provider.tsx | 46 +++ .../registry/ai-chat/components/ai-chat.tsx | 361 ++++++++++-------- apps/apollo-vertex/registry/ai-chat/types.ts | 28 ++ .../templates/ai-chat/AiChatAgentHubMode.tsx | 6 +- .../ai-chat/AiChatConversationalAgentMode.tsx | 6 +- 8 files changed, 638 insertions(+), 213 deletions(-) create mode 100644 apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx create mode 100644 apps/apollo-vertex/registry/ai-chat/components/ai-chat-provider.tsx create mode 100644 apps/apollo-vertex/registry/ai-chat/types.ts diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 4815be871..b5d85c435 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -428,6 +428,21 @@ "type": "registry:ui", "target": "components/ui/ai-chat/components/ai-chat-suggestions.tsx" }, + { + "path": "registry/ai-chat/types.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/types.ts" + }, + { + "path": "registry/ai-chat/components/ai-chat-provider.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-provider.tsx" + }, + { + "path": "registry/ai-chat/components/ai-chat-message-actions.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-message-actions.tsx" + }, { "path": "registry/ai-chat/components/ai-chat-thinking.tsx", "type": "registry:ui", 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..2de702b1e --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { + Check, + Copy, + Pencil, + RefreshCw, + ThumbsDown, + ThumbsUp, +} from "lucide-react"; +import { useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/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). */ + 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 + // reveals on hover/focus for keyboard accessibility. + const visibilityClass = isLatest + ? "opacity-100" + : "opacity-0 group-hover/message:opacity-100 group-focus-within/message:opacity-100"; + + return ( +
    + {showCopy && ( + + + + + {copyLabel} + + )} + + {messageRole === "assistant" && ( + <> + + + + + {LABELS.helpful} + + + + + + + {LABELS.notHelpful} + + + )} + + {messageRole === "assistant" && onRegenerate && ( + + + + + {LABELS.regenerate} + + )} + + {messageRole === "user" && onEdit && ( + + + + + {LABELS.edit} + + )} +
    + ); +} 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 a87018cdf..8a91d2637 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,25 +1,90 @@ "use client"; -import type { UIMessage } from "@tanstack/ai-client"; -import { Sparkles } from "lucide-react"; -import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; +import type { TextPart, UIMessage } from "@tanstack/ai-client"; +import { motion } from "framer-motion"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import type { MessageFeedbackType } from "../types"; import { AiChatMarkdown } from "./ai-chat-markdown"; +import { AiChatMessageActions } from "./ai-chat-message-actions"; +import { useAiChat } from "./ai-chat-provider"; + +// Quick, subtle entrance — fade + 8px slide up. Quartic ease-out for a soft settle. +const ENTRANCE_INITIAL = { opacity: 0, y: 8 }; +const ENTRANCE_ANIMATE = { opacity: 1, y: 0 }; +const ENTRANCE_TRANSITION = { + duration: 0.22, + ease: [0.22, 1, 0.36, 1] as const, +}; interface AiChatMessageProps { message: UIMessage; - assistantName?: string; children?: ReactNode; + /** Whether this message is currently being streamed */ + isStreaming?: boolean; + /** Per-message feedback callback — falls back to the provider-level onFeedback */ + onFeedback?: (type: MessageFeedbackType) => void; + /** Per-message regenerate callback — falls back to the provider-level onRegenerate */ + onRegenerate?: () => void; +} + +function getDisplayText(message: UIMessage): string { + return message.parts + .filter((p): p is TextPart => p.type === "text") + .map((p) => p.content) + .join(""); } export function AiChatMessage({ message, - assistantName, children, + isStreaming: isStreamingProp, + onFeedback, + onRegenerate, }: AiChatMessageProps) { - const { t } = useTranslation(); + const config = useAiChat(); const isUser = message.role === "user"; - const displayName = assistantName ?? t("ai_assistant"); + const displayContent = getDisplayText(message); + + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(displayContent); + const editTextareaRef = useRef(null); + + // 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. + 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 whether this is the latest assistant message currently being generated. + const isStreaming = + isStreamingProp ?? + (config.isLoading && + !isUser && + config.latestAssistantMessageId === message.id); + + const isLatestAssistant = + !isUser && config.latestAssistantMessageId === message.id; + const isResponseFullyRevealed = !isStreaming; const hasToolOutputs = message.parts.some( (p) => p.type === "tool-call" && p.output != null, @@ -27,44 +92,124 @@ export function AiChatMessage({ const hasContent = hasToolOutputs || message.parts.some((p) => p.type === "text" && p.content); - if (!isUser && !hasContent) { - return null; - } - - const text = message.parts - .filter((p) => p.type === "text") - .map((p) => p.content) - .join(""); + if (!isUser && !hasContent) return null; if (isUser) { + // Edit mode — swap bubble for inline textarea + if (isEditing) { + return ( + +
    +