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..b5d85c435 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", @@ -422,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-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-input.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx index 2ad1743f8..d8df47b38 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx @@ -1,17 +1,26 @@ "use client"; -import { ArrowUp, CircleStop } from "lucide-react"; +import { ArrowUp, CircleStop, FileText, Paperclip, X } from "lucide-react"; import { + type ClipboardEvent, type CSSProperties, type FocusEvent, type FormEvent, type KeyboardEvent, type Ref, + createPortal, + useEffect, useImperativeHandle, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, @@ -19,15 +28,43 @@ import { } from "@/components/ui/tooltip"; import { AiChatInputGlow } from "./ai-chat-input-glow"; +export interface PendingFile { + uid: string; + name: string; + size: number; + type: string; + file: File; + thumbnailUrl?: string; +} + +function makePendingFile(file: File): PendingFile { + const isImage = file.type.startsWith("image/"); + return { + uid: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + name: file.name, + size: file.size, + type: file.type, + file, + thumbnailUrl: isImage ? URL.createObjectURL(file) : undefined, + }; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + interface AiChatInputProps { value: string; onChange: (value: string) => void; - onSubmit: () => void; + onSubmit: (files?: File[]) => void; onStop: () => void; isLoading: boolean; disabled?: boolean; placeholder?: string; hasMessages?: boolean; + maxLength?: number; ref?: Ref; } @@ -44,6 +81,7 @@ export function AiChatInput({ disabled = false, placeholder, hasMessages = false, + maxLength, ref, }: AiChatInputProps) { const { t } = useTranslation(); @@ -51,10 +89,22 @@ export function AiChatInput({ const [focused, setFocused] = useState(false); const displayPlaceholder = placeholder ?? t("shell_input_placeholder"); + const [pendingFiles, setPendingFiles] = useState([]); + const [lightboxUrl, setLightboxUrl] = useState(null); + useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus(), })); + useEffect(() => { + if (!lightboxUrl) return; + const handler = (e: globalThis.KeyboardEvent) => { + if (e.key === "Escape") setLightboxUrl(null); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [lightboxUrl]); + const adjustHeight = () => { if (!hasMessages) return; const el = textareaRef.current; @@ -68,9 +118,50 @@ export function AiChatInput({ requestAnimationFrame(adjustHeight); }; + const addFiles = (files: FileList | File[]) => { + const newPending = Array.from(files).map(makePendingFile); + setPendingFiles((prev) => [...prev, ...newPending]); + }; + + const removeFile = (uid: string) => { + setPendingFiles((prev) => { + const target = prev.find((f) => f.uid === uid); + if (target?.thumbnailUrl) URL.revokeObjectURL(target.thumbnailUrl); + return prev.filter((f) => f.uid !== uid); + }); + }; + + const handleFileClick = () => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = () => { + if (input.files?.length) addFiles(input.files); + }; + input.click(); + }; + + const handlePaste = (e: ClipboardEvent) => { + const imageItems = Array.from(e.clipboardData.items).filter((item) => + item.type.startsWith("image/"), + ); + if (imageItems.length === 0) return; + e.preventDefault(); + const files = imageItems + .map((item) => item.getAsFile()) + .filter((f): f is File => f !== null); + addFiles(files); + }; + const submitMessage = () => { - if (!value.trim()) return; - onSubmit(); + if (!value.trim() && pendingFiles.length === 0) return; + const files = + pendingFiles.length > 0 ? pendingFiles.map((p) => p.file) : undefined; + for (const f of pendingFiles) { + if (f.thumbnailUrl) URL.revokeObjectURL(f.thumbnailUrl); + } + onSubmit(files); + setPendingFiles([]); requestAnimationFrame(() => { const el = textareaRef.current; if (el) el.style.height = "auto"; @@ -110,6 +201,73 @@ export function AiChatInput({ ...(focused && { style: focusedStyle }), }; + const fileChips = + pendingFiles.length > 0 ? ( +
+ {pendingFiles.map((pf) => ( +
+ {pf.thumbnailUrl ? ( + + ) : ( +
+ ))} +
+ ) : null; + + const plusMenu = ( + + + + + + + {"Upload files"} + + + + ); + const sendStopButton = isLoading ? ( @@ -128,7 +286,7 @@ export function AiChatInput({ + {/* blob: URL — next/image requires static/known hosts */} + Preview e.stopPropagation()} + /> + , + document.body, + ); + return (
+ {lightbox}
{hasMessages ? (
+ {fileChips}
+ {plusMenu}