From c6f4e0736ebf6184f5ecba032a2eb58a49d91a15 Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 26 Mar 2026 23:12:36 +1000 Subject: [PATCH 1/5] Add chat screenshot export option --- ui/package.json | 1 + ui/pnpm-lock.yaml | 8 + ui/src/components/ChatHeader/ChatHeader.tsx | 65 ++++++ .../MultiModelResponse/MultiModelResponse.tsx | 5 +- .../ScreenshotPreviewModal.tsx | 71 +++++++ .../ScreenshotRenderer/ScreenshotRenderer.tsx | 187 ++++++++++++++++++ ui/src/hooks/useScreenshotExport.ts | 44 +++++ ui/src/utils/exportConversation.ts | 2 +- ui/src/utils/exportScreenshot.ts | 53 +++++ 9 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx create mode 100644 ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx create mode 100644 ui/src/hooks/useScreenshotExport.ts create mode 100644 ui/src/utils/exportScreenshot.ts 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.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..cdf71cd --- /dev/null +++ b/ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx @@ -0,0 +1,71 @@ +import { useCallback, 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 handleDownload = useCallback(() => { + downloadBlob(blob, generateScreenshotFilename(title)); + }, [blob, title]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + setCopied(true); + 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..fadaf63 --- /dev/null +++ b/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx @@ -0,0 +1,187 @@ +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); + + 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) onComplete(blob); + } catch (err) { + if (!cancelled) + onComplete(undefined, err instanceof Error ? err : new Error(String(err))); + } + }, 500); + }); + + return () => { + cancelled = true; + cancelAnimationFrame(raf); + clearTimeout(timeoutId); + }; + }, [title, onComplete]); + + const themeClass = document.documentElement.classList.contains("dark") ? "dark" : ""; + + return createPortal( +
+ {/* Branding header */} +
+
+ {logoUrl ? ( + {appName} + ) : ( +
+ +
+ )} + {appName} +
+ {!isCustomBranded && ( + hadriangateway.com + )} +
+ + {/* Title + usage breakdown */} +
+

{title}

+ {totalUsage && grandTotalTokens > 0 && ( +
+
Usage
+
Input: {formatTokens(totalUsage.total.inputTokens)} tokens
+
Output: {formatTokens(totalUsage.total.outputTokens)} tokens
+ {(totalUsage.total.cachedTokens ?? 0) > 0 && ( +
Cached: {formatTokens(totalUsage.total.cachedTokens!)} tokens
+ )} + {(totalUsage.total.reasoningTokens ?? 0) > 0 && ( +
Reasoning: {formatTokens(totalUsage.total.reasoningTokens!)} tokens
+ )} + {totalUsage.modeOverhead.totalTokens > 0 && ( +
+ Mode overhead: {formatTokens(totalUsage.modeOverhead.totalTokens)} tokens + {(totalUsage.modeOverhead.cost ?? 0) > 0 && ( + <> · {formatCost(totalUsage.modeOverhead.cost!)} + )} +
+ )} + {titleGenerationUsage && ( +
+ Title generation: {formatTokens(titleGenerationUsage.totalTokens)} tokens + {(titleGenerationUsage.cost ?? 0) > 0 && ( + <> · {formatCost(titleGenerationUsage.cost!)} + )} +
+ )} +
+ Total: {formatTokens(grandTotalTokens)} tokens + {grandTotalCost > 0 && <> · {formatCost(grandTotalCost)}} +
+
+ )} +
+ + {messageGroups.map((group) => ( +
+ + {group.assistantResponses.length > 0 && ( + { + const instanceId = m.instanceId ?? m.model ?? "unknown"; + return { + model: m.model || "unknown", + instanceId, + messageId: m.id, + label: instanceLabels.get(instanceId), + content: m.content, + isStreaming: false, + error: m.error, + usage: m.usage, + feedback: m.feedback, + modeMetadata: m.modeMetadata, + citations: m.citations, + artifacts: m.artifacts, + toolExecutionRounds: m.toolExecutionRounds, + completedRounds: m.completedRounds, + debugMessageId: m.debugMessageId, + }; + })} + timestamp={group.assistantResponses[0].timestamp} + /> + )} +
+ ))} +
, + document.body + ); +} diff --git a/ui/src/hooks/useScreenshotExport.ts b/ui/src/hooks/useScreenshotExport.ts new file mode 100644 index 0000000..94fbfe9 --- /dev/null +++ b/ui/src/hooks/useScreenshotExport.ts @@ -0,0 +1,44 @@ +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(() => { + 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; + } + 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/exportConversation.ts b/ui/src/utils/exportConversation.ts index 834aba8..db52425 100644 --- a/ui/src/utils/exportConversation.ts +++ b/ui/src/utils/exportConversation.ts @@ -3,7 +3,7 @@ import type { ChatMessage, Conversation, MessageUsage } from "@/components/chat- /** * Export format options for conversations */ -export type ExportFormat = "json" | "markdown"; +export type ExportFormat = "json" | "markdown" | "png"; /** * JSON export structure - includes all metadata 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); +} From 7965823506ab9f25e2637d482c0c7dc3b7cc7f6c Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 26 Mar 2026 23:32:28 +1000 Subject: [PATCH 2/5] Review fixes --- .../ScreenshotRenderer/ScreenshotRenderer.tsx | 11 ++++++++--- ui/src/hooks/useScreenshotExport.ts | 3 +++ ui/src/utils/exportConversation.ts | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx b/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx index fadaf63..92920ee 100644 --- a/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx +++ b/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx @@ -55,6 +55,11 @@ export function ScreenshotRenderer({ (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; @@ -66,10 +71,10 @@ export function ScreenshotRenderer({ const el = containerRef.current; if (!el) throw new Error("Screenshot container not found"); const blob = await captureElementAsBlob(el); - if (!cancelled) onComplete(blob); + if (!cancelled) onCompleteRef.current(blob); } catch (err) { if (!cancelled) - onComplete(undefined, err instanceof Error ? err : new Error(String(err))); + onCompleteRef.current(undefined, err instanceof Error ? err : new Error(String(err))); } }, 500); }); @@ -79,7 +84,7 @@ export function ScreenshotRenderer({ cancelAnimationFrame(raf); clearTimeout(timeoutId); }; - }, [title, onComplete]); + }, [title]); const themeClass = document.documentElement.classList.contains("dark") ? "dark" : ""; diff --git a/ui/src/hooks/useScreenshotExport.ts b/ui/src/hooks/useScreenshotExport.ts index 94fbfe9..d97e4db 100644 --- a/ui/src/hooks/useScreenshotExport.ts +++ b/ui/src/hooks/useScreenshotExport.ts @@ -25,6 +25,9 @@ export function useScreenshotExport() { 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 }); diff --git a/ui/src/utils/exportConversation.ts b/ui/src/utils/exportConversation.ts index db52425..834aba8 100644 --- a/ui/src/utils/exportConversation.ts +++ b/ui/src/utils/exportConversation.ts @@ -3,7 +3,7 @@ import type { ChatMessage, Conversation, MessageUsage } from "@/components/chat- /** * Export format options for conversations */ -export type ExportFormat = "json" | "markdown" | "png"; +export type ExportFormat = "json" | "markdown"; /** * JSON export structure - includes all metadata From c6b3896dde44bf6e3b30738227ce2892f7dd1490 Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 26 Mar 2026 23:46:35 +1000 Subject: [PATCH 3/5] Fix storybook issue --- ui/src/components/ChatHeader/ChatHeader.stories.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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) => ( - -
- -
-
+ + +
+ +
+
+
), From 1c070b4a928abc95683acf1e28e4ad79f9b8dfac Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 26 Mar 2026 23:48:31 +1000 Subject: [PATCH 4/5] Review fixes --- .../ScreenshotRenderer/ScreenshotPreviewModal.tsx | 10 ++++++++-- .../ScreenshotRenderer/ScreenshotRenderer.tsx | 1 + ui/src/hooks/useScreenshotExport.ts | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx b/ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx index cdf71cd..fcf85bc 100644 --- a/ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx +++ b/ui/src/components/ScreenshotRenderer/ScreenshotPreviewModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy, Download } from "lucide-react"; import { Button } from "@/components/Button/Button"; @@ -30,6 +30,11 @@ export function ScreenshotPreviewModal({ }: ScreenshotPreviewModalProps) { const [copied, setCopied] = useState(false); const toast = useToast(); + const copyTimerRef = useRef>(); + + useEffect(() => { + return () => clearTimeout(copyTimerRef.current); + }, []); const handleDownload = useCallback(() => { downloadBlob(blob, generateScreenshotFilename(title)); @@ -39,7 +44,8 @@ export function ScreenshotPreviewModal({ try { await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); setCopied(true); - setTimeout(() => setCopied(false), 2000); + clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopied(false), 2000); } catch { toast.error("Copy failed", "Your browser may not support copying images"); } diff --git a/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx b/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx index 92920ee..6599d3d 100644 --- a/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx +++ b/ui/src/components/ScreenshotRenderer/ScreenshotRenderer.tsx @@ -91,6 +91,7 @@ export function ScreenshotRenderer({ return createPortal(