diff --git a/ui/package.json b/ui/package.json index 1db1d1b..50c52b4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -35,6 +35,7 @@ "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", + "@zumer/snapdom": "^2.6.0", "clsx": "^2.1.1", "diff": "^8.0.3", "katex": "^0.16.38", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index b134e74..c9595c7 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.19 version: 3.13.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@zumer/snapdom': + specifier: ^2.6.0 + version: 2.6.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -2330,6 +2333,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@zumer/snapdom@2.6.0': + resolution: {integrity: sha512-JpPPkuMzozRVX6KArgCiMgLpgVW82kWgyoFk5DWGKE5msWGEshXEUdQHLLEyZRO7qioI1pI+yaBJz81tEP9gPg==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -7880,6 +7886,8 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@zumer/snapdom@2.6.0': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 diff --git a/ui/src/components/ChatHeader/ChatHeader.stories.tsx b/ui/src/components/ChatHeader/ChatHeader.stories.tsx index dd7f6b3..9248651 100644 --- a/ui/src/components/ChatHeader/ChatHeader.stories.tsx +++ b/ui/src/components/ChatHeader/ChatHeader.stories.tsx @@ -6,6 +6,7 @@ import type { Conversation, MessageUsage, ModelInstance } from "@/components/cha import type { ModelInfo } from "@/components/ModelPicker/ModelPicker"; import { PreferencesProvider } from "@/preferences/PreferencesProvider"; import type { TotalUsageResult } from "@/stores/conversationStore"; +import { ToastProvider } from "@/components/Toast/Toast"; import { TooltipProvider } from "@/components/Tooltip/Tooltip"; import { ChatHeader } from "./ChatHeader"; @@ -63,11 +64,13 @@ const meta: Meta = { (Story) => ( - -
- -
-
+ + +
+ +
+
+
), diff --git a/ui/src/components/ChatHeader/ChatHeader.tsx b/ui/src/components/ChatHeader/ChatHeader.tsx index 41c68b1..194b3e9 100644 --- a/ui/src/components/ChatHeader/ChatHeader.tsx +++ b/ui/src/components/ChatHeader/ChatHeader.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { Check, @@ -7,6 +8,7 @@ import { FileText, FolderOpen, GitFork, + Image, Maximize2, Minimize2, Trash2, @@ -35,6 +37,9 @@ import { useWidescreenMode, } from "@/stores/chatUIStore"; import { useUserProjects } from "@/hooks/useUserProjects"; +import { ScreenshotPreviewModal } from "@/components/ScreenshotRenderer/ScreenshotPreviewModal"; +import { ScreenshotRenderer } from "@/components/ScreenshotRenderer/ScreenshotRenderer"; +import { useScreenshotExport } from "@/hooks/useScreenshotExport"; import { downloadConversation } from "@/utils/exportConversation"; import { formatCost, formatTokens } from "@/utils/formatters"; @@ -106,6 +111,39 @@ export function ChatHeader({ const widescreenMode = useWidescreenMode(); const { setConversationMode, setModeConfig, toggleWidescreenMode } = useChatUIStore(); const canExport = conversation && conversation.messages.length > 0; + const { isCapturing, screenshot, startCapture, onCaptureComplete, dismissPreview } = + useScreenshotExport(); + + // Build instance labels map for screenshot + const instanceLabels = useMemo(() => { + const map = new Map(); + for (const inst of selectedInstances) { + if (inst.label) map.set(inst.id, inst.label); + } + return map; + }, [selectedInstances]); + + // Build message groups for screenshot (all messages, no hidden filtering) + const screenshotGroups = useMemo(() => { + if (!conversation) return []; + const groups: { + id: string; + userMessage: (typeof conversation.messages)[number]; + assistantResponses: (typeof conversation.messages)[number][]; + }[] = []; + const msgs = conversation.messages; + for (let i = 0; i < msgs.length; i++) { + const msg = msgs[i]; + if (msg.role === "user") { + const responses: typeof msgs = []; + for (let j = i + 1; j < msgs.length && msgs[j].role !== "user"; j++) { + if (msgs[j].role === "assistant") responses.push(msgs[j]); + } + groups.push({ id: msg.id, userMessage: msg, assistantResponses: responses }); + } + } + return groups; + }, [conversation]); // Fetch user projects for the project picker const { projects } = useUserProjects(); @@ -333,9 +371,36 @@ export function ChatHeader({ Markdown (readable) + + + Screenshot (PNG) + )} + {isCapturing && canExport && ( + + )} + {screenshot && canExport && ( + + )} {/* Fork button - hidden on mobile */} {canExport && onFork && ( diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx index 68cedeb..6508612 100644 --- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx +++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx @@ -209,6 +209,8 @@ interface MultiModelResponseProps { actionConfig?: ResponseActionConfig; /** History mode used when this message was sent (read-only display) */ historyMode?: HistoryMode; + /** Force stacked layout regardless of global viewMode (used for screenshot export) */ + forceStacked?: boolean; } /** @@ -1059,6 +1061,7 @@ function MultiModelResponseComponent({ selectedBest, actionConfig = DEFAULT_ACTION_CONFIG, historyMode, + forceStacked = false, }: MultiModelResponseProps) { // Use global UI state from store const viewMode = useViewMode(); @@ -1171,7 +1174,7 @@ function MultiModelResponseComponent({ const hasHiddenResponses = hiddenResponses.length > 0; // "grid" = horizontal layout with fixed-width cards, "stacked" = vertical full-width - const useHorizontalLayout = viewMode === "grid" && displayedResponses.length > 1; + const useHorizontalLayout = !forceStacked && viewMode === "grid" && displayedResponses.length > 1; // Horizontal scroll navigation state const scrollContainerRef = useRef(null); diff --git a/ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx b/ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx new file mode 100644 index 0000000..e438d9d --- /dev/null +++ b/ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Check, Copy, Download } from "lucide-react"; + +import { Button } from "@/components/Button/Button"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalHeader, + ModalTitle, +} from "@/components/Modal/Modal"; +import { useToast } from "@/components/Toast/Toast"; +import { downloadBlob, generateScreenshotFilename } from "@/utils/exportScreenshot"; + +interface ScreenshotPreviewModalProps { + open: boolean; + onClose: () => void; + imageUrl: string; + blob: Blob; + title: string; +} + +export function ScreenshotPreviewModal({ + open, + onClose, + imageUrl, + blob, + title, +}: ScreenshotPreviewModalProps) { + const [copied, setCopied] = useState(false); + const toast = useToast(); + const copyTimerRef = useRef>(undefined); + + useEffect(() => { + return () => clearTimeout(copyTimerRef.current); + }, []); + + const handleDownload = useCallback(() => { + downloadBlob(blob, generateScreenshotFilename(title)); + }, [blob, title]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + setCopied(true); + clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Copy failed", "Your browser may not support copying images"); + } + }, [blob, toast]); + + return ( + + + + Screenshot Preview + + + + {`Screenshot + + + + + + + + ); +} diff --git a/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx b/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx new file mode 100644 index 0000000..6599d3d --- /dev/null +++ b/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx @@ -0,0 +1,193 @@ +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +import { ChatMessage } from "@/components/ChatMessage/ChatMessage"; +import type { ChatMessage as ChatMessageType, MessageUsage } from "@/components/chat-types"; +import { HadrianIcon } from "@/components/HadrianIcon/HadrianIcon"; +import { MultiModelResponse } from "@/components/MultiModelResponse/MultiModelResponse"; +import { useConfig } from "@/config/ConfigProvider"; +import { usePreferences } from "@/preferences/PreferencesProvider"; +import type { TotalUsageResult } from "@/stores/conversationStore"; +import { captureElementAsBlob } from "@/utils/exportScreenshot"; +import { formatCost, formatTokens } from "@/utils/formatters"; + +interface MessageGroup { + id: string; + userMessage: ChatMessageType; + assistantResponses: ChatMessageType[]; +} + +interface ScreenshotRendererProps { + title: string; + messageGroups: MessageGroup[]; + instanceLabels: Map; + totalUsage?: TotalUsageResult | null; + titleGenerationUsage?: MessageUsage; + onComplete: (blob?: Blob, error?: Error) => void; +} + +export function ScreenshotRenderer({ + title, + messageGroups, + instanceLabels, + totalUsage, + titleGenerationUsage, + onComplete, +}: ScreenshotRendererProps) { + const containerRef = useRef(null); + const { config } = useConfig(); + const { resolvedTheme } = usePreferences(); + + const branding = config?.branding; + const logoUrl = + resolvedTheme === "dark" && branding?.logo_dark_url + ? branding.logo_dark_url + : branding?.logo_url; + const appName = branding?.title || "Hadrian Gateway"; + const isCustomBranded = !!( + (branding?.title && branding.title !== "Hadrian Gateway") || + branding?.logo_url || + branding?.logo_dark_url + ); + + // Compute combined usage for display + const grandTotalTokens = + (totalUsage?.grandTotal.totalTokens ?? 0) + (titleGenerationUsage?.totalTokens ?? 0); + const grandTotalCost = (totalUsage?.grandTotal.cost ?? 0) + (titleGenerationUsage?.cost ?? 0); + + const onCompleteRef = useRef(onComplete); + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); + + useEffect(() => { + let cancelled = false; + let timeoutId: ReturnType; + + const raf = requestAnimationFrame(() => { + timeoutId = setTimeout(async () => { + if (cancelled) return; + try { + const el = containerRef.current; + if (!el) throw new Error("Screenshot container not found"); + const blob = await captureElementAsBlob(el); + if (!cancelled) onCompleteRef.current(blob); + } catch (err) { + if (!cancelled) + onCompleteRef.current(undefined, err instanceof Error ? err : new Error(String(err))); + } + }, 500); + }); + + return () => { + cancelled = true; + cancelAnimationFrame(raf); + clearTimeout(timeoutId); + }; + }, [title]); + + const themeClass = document.documentElement.classList.contains("dark") ? "dark" : ""; + + return createPortal( + , + document.body + ); +} diff --git a/ui/src/hooks/useScreenshotExport.ts b/ui/src/hooks/useScreenshotExport.ts new file mode 100644 index 0000000..657d453 --- /dev/null +++ b/ui/src/hooks/useScreenshotExport.ts @@ -0,0 +1,52 @@ +import { useCallback, useRef, useState } from "react"; + +import { useToast } from "@/components/Toast/Toast"; + +export interface ScreenshotResult { + blob: Blob; + url: string; +} + +export function useScreenshotExport() { + const [isCapturing, setIsCapturing] = useState(false); + const [screenshot, setScreenshot] = useState(null); + const objectUrlRef = useRef(null); + const toast = useToast(); + + const startCapture = useCallback(() => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + setScreenshot(null); + setIsCapturing(true); + toast.info("Capturing screenshot…", "Rendering all messages"); + }, [toast]); + + const onCaptureComplete = useCallback( + (result?: Blob, error?: Error) => { + setIsCapturing(false); + if (error || !result) { + toast.error("Screenshot failed", error?.message ?? "Unknown error"); + return; + } + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + } + const url = URL.createObjectURL(result); + objectUrlRef.current = url; + setScreenshot({ blob: result, url }); + }, + [toast] + ); + + const dismissPreview = useCallback(() => { + setScreenshot(null); + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + }, []); + + return { isCapturing, screenshot, startCapture, onCaptureComplete, dismissPreview } as const; +} diff --git a/ui/src/utils/exportScreenshot.ts b/ui/src/utils/exportScreenshot.ts new file mode 100644 index 0000000..6683e34 --- /dev/null +++ b/ui/src/utils/exportScreenshot.ts @@ -0,0 +1,53 @@ +import { snapdom } from "@zumer/snapdom"; + +export interface CaptureOptions { + /** Device pixel ratio for the capture (default: 2 for retina) */ + scale?: number; + /** Background color override; auto-resolved from CSS --color-background if omitted */ + backgroundColor?: string; +} + +/** + * Capture a DOM element as a PNG blob. + */ +export async function captureElementAsBlob( + element: HTMLElement, + options: CaptureOptions = {} +): Promise { + const { scale = 2, backgroundColor } = options; + + const bg = + backgroundColor ?? + getComputedStyle(document.documentElement).getPropertyValue("--color-background").trim(); + + const snapshot = await snapdom(element, { + scale, + backgroundColor: bg || undefined, + }); + return snapshot.toBlob({ type: "png" }); +} + +/** + * Generate a filename for a screenshot export, matching the pattern used for other exports. + */ +export function generateScreenshotFilename(title: string): string { + const sanitized = title + .replace(/[^a-zA-Z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .toLowerCase() + .slice(0, 50); + + const timestamp = new Date().toISOString().slice(0, 10); + return `${sanitized}-${timestamp}.png`; +} + +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}