Skip to content

Commit 4758698

Browse files
authored
Add chat screenshot export option (#22)
1 parent 2b05465 commit 4758698

File tree

9 files changed

+461
-6
lines changed

9 files changed

+461
-6
lines changed

ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@tanstack/react-query": "^5.90.21",
3636
"@tanstack/react-table": "^8.21.3",
3737
"@tanstack/react-virtual": "^3.13.19",
38+
"@zumer/snapdom": "^2.6.0",
3839
"clsx": "^2.1.1",
3940
"diff": "^8.0.3",
4041
"katex": "^0.16.38",

ui/pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/components/ChatHeader/ChatHeader.stories.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Conversation, MessageUsage, ModelInstance } from "@/components/cha
66
import type { ModelInfo } from "@/components/ModelPicker/ModelPicker";
77
import { PreferencesProvider } from "@/preferences/PreferencesProvider";
88
import type { TotalUsageResult } from "@/stores/conversationStore";
9+
import { ToastProvider } from "@/components/Toast/Toast";
910
import { TooltipProvider } from "@/components/Tooltip/Tooltip";
1011

1112
import { ChatHeader } from "./ChatHeader";
@@ -63,11 +64,13 @@ const meta: Meta<typeof ChatHeader> = {
6364
(Story) => (
6465
<QueryClientProvider client={queryClient}>
6566
<PreferencesProvider>
66-
<TooltipProvider>
67-
<div className="w-full max-w-4xl mx-auto">
68-
<Story />
69-
</div>
70-
</TooltipProvider>
67+
<ToastProvider>
68+
<TooltipProvider>
69+
<div className="w-full max-w-4xl mx-auto">
70+
<Story />
71+
</div>
72+
</TooltipProvider>
73+
</ToastProvider>
7174
</PreferencesProvider>
7275
</QueryClientProvider>
7376
),

ui/src/components/ChatHeader/ChatHeader.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useMemo } from "react";
12
import { useQuery } from "@tanstack/react-query";
23
import {
34
Check,
@@ -7,6 +8,7 @@ import {
78
FileText,
89
FolderOpen,
910
GitFork,
11+
Image,
1012
Maximize2,
1113
Minimize2,
1214
Trash2,
@@ -35,6 +37,9 @@ import {
3537
useWidescreenMode,
3638
} from "@/stores/chatUIStore";
3739
import { useUserProjects } from "@/hooks/useUserProjects";
40+
import { ScreenshotPreviewModal } from "@/components/ScreenshotRenderer/ScreenshotPreviewModal";
41+
import { ScreenshotRenderer } from "@/components/ScreenshotRenderer/ScreenshotRenderer";
42+
import { useScreenshotExport } from "@/hooks/useScreenshotExport";
3843
import { downloadConversation } from "@/utils/exportConversation";
3944
import { formatCost, formatTokens } from "@/utils/formatters";
4045

@@ -106,6 +111,39 @@ export function ChatHeader({
106111
const widescreenMode = useWidescreenMode();
107112
const { setConversationMode, setModeConfig, toggleWidescreenMode } = useChatUIStore();
108113
const canExport = conversation && conversation.messages.length > 0;
114+
const { isCapturing, screenshot, startCapture, onCaptureComplete, dismissPreview } =
115+
useScreenshotExport();
116+
117+
// Build instance labels map for screenshot
118+
const instanceLabels = useMemo(() => {
119+
const map = new Map<string, string>();
120+
for (const inst of selectedInstances) {
121+
if (inst.label) map.set(inst.id, inst.label);
122+
}
123+
return map;
124+
}, [selectedInstances]);
125+
126+
// Build message groups for screenshot (all messages, no hidden filtering)
127+
const screenshotGroups = useMemo(() => {
128+
if (!conversation) return [];
129+
const groups: {
130+
id: string;
131+
userMessage: (typeof conversation.messages)[number];
132+
assistantResponses: (typeof conversation.messages)[number][];
133+
}[] = [];
134+
const msgs = conversation.messages;
135+
for (let i = 0; i < msgs.length; i++) {
136+
const msg = msgs[i];
137+
if (msg.role === "user") {
138+
const responses: typeof msgs = [];
139+
for (let j = i + 1; j < msgs.length && msgs[j].role !== "user"; j++) {
140+
if (msgs[j].role === "assistant") responses.push(msgs[j]);
141+
}
142+
groups.push({ id: msg.id, userMessage: msg, assistantResponses: responses });
143+
}
144+
}
145+
return groups;
146+
}, [conversation]);
109147

110148
// Fetch user projects for the project picker
111149
const { projects } = useUserProjects();
@@ -333,9 +371,36 @@ export function ChatHeader({
333371
<FileText className="h-4 w-4" />
334372
Markdown (readable)
335373
</DropdownItem>
374+
<DropdownItem
375+
onClick={startCapture}
376+
disabled={isStreaming || isCapturing}
377+
className="gap-2"
378+
>
379+
<Image className="h-4 w-4" />
380+
Screenshot (PNG)
381+
</DropdownItem>
336382
</DropdownContent>
337383
</Dropdown>
338384
)}
385+
{isCapturing && canExport && (
386+
<ScreenshotRenderer
387+
title={conversation.title}
388+
messageGroups={screenshotGroups}
389+
instanceLabels={instanceLabels}
390+
totalUsage={totalUsage}
391+
titleGenerationUsage={conversation.titleGenerationUsage}
392+
onComplete={onCaptureComplete}
393+
/>
394+
)}
395+
{screenshot && canExport && (
396+
<ScreenshotPreviewModal
397+
open
398+
onClose={dismissPreview}
399+
imageUrl={screenshot.url}
400+
blob={screenshot.blob}
401+
title={conversation.title}
402+
/>
403+
)}
339404
{/* Fork button - hidden on mobile */}
340405
{canExport && onFork && (
341406
<Tooltip>

ui/src/components/MultiModelResponse/MultiModelResponse.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ interface MultiModelResponseProps {
209209
actionConfig?: ResponseActionConfig;
210210
/** History mode used when this message was sent (read-only display) */
211211
historyMode?: HistoryMode;
212+
/** Force stacked layout regardless of global viewMode (used for screenshot export) */
213+
forceStacked?: boolean;
212214
}
213215

214216
/**
@@ -1059,6 +1061,7 @@ function MultiModelResponseComponent({
10591061
selectedBest,
10601062
actionConfig = DEFAULT_ACTION_CONFIG,
10611063
historyMode,
1064+
forceStacked = false,
10621065
}: MultiModelResponseProps) {
10631066
// Use global UI state from store
10641067
const viewMode = useViewMode();
@@ -1171,7 +1174,7 @@ function MultiModelResponseComponent({
11711174
const hasHiddenResponses = hiddenResponses.length > 0;
11721175

11731176
// "grid" = horizontal layout with fixed-width cards, "stacked" = vertical full-width
1174-
const useHorizontalLayout = viewMode === "grid" && displayedResponses.length > 1;
1177+
const useHorizontalLayout = !forceStacked && viewMode === "grid" && displayedResponses.length > 1;
11751178

11761179
// Horizontal scroll navigation state
11771180
const scrollContainerRef = useRef<HTMLDivElement>(null);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
import { Check, Copy, Download } from "lucide-react";
3+
4+
import { Button } from "@/components/Button/Button";
5+
import {
6+
Modal,
7+
ModalClose,
8+
ModalContent,
9+
ModalFooter,
10+
ModalHeader,
11+
ModalTitle,
12+
} from "@/components/Modal/Modal";
13+
import { useToast } from "@/components/Toast/Toast";
14+
import { downloadBlob, generateScreenshotFilename } from "@/utils/exportScreenshot";
15+
16+
interface ScreenshotPreviewModalProps {
17+
open: boolean;
18+
onClose: () => void;
19+
imageUrl: string;
20+
blob: Blob;
21+
title: string;
22+
}
23+
24+
export function ScreenshotPreviewModal({
25+
open,
26+
onClose,
27+
imageUrl,
28+
blob,
29+
title,
30+
}: ScreenshotPreviewModalProps) {
31+
const [copied, setCopied] = useState(false);
32+
const toast = useToast();
33+
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
34+
35+
useEffect(() => {
36+
return () => clearTimeout(copyTimerRef.current);
37+
}, []);
38+
39+
const handleDownload = useCallback(() => {
40+
downloadBlob(blob, generateScreenshotFilename(title));
41+
}, [blob, title]);
42+
43+
const handleCopy = useCallback(async () => {
44+
try {
45+
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
46+
setCopied(true);
47+
clearTimeout(copyTimerRef.current);
48+
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
49+
} catch {
50+
toast.error("Copy failed", "Your browser may not support copying images");
51+
}
52+
}, [blob, toast]);
53+
54+
return (
55+
<Modal open={open} onClose={onClose} className="max-w-5xl w-[92vw] max-h-[90vh] flex flex-col">
56+
<ModalClose onClose={onClose} />
57+
<ModalHeader>
58+
<ModalTitle>Screenshot Preview</ModalTitle>
59+
</ModalHeader>
60+
61+
<ModalContent className="flex-1 overflow-auto -mx-6 px-0">
62+
<img src={imageUrl} alt={`Screenshot of ${title}`} className="w-full" />
63+
</ModalContent>
64+
65+
<ModalFooter>
66+
<Button variant="ghost" onClick={handleCopy} className="gap-2">
67+
{copied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
68+
{copied ? "Copied" : "Copy to clipboard"}
69+
</Button>
70+
<Button onClick={handleDownload} className="gap-2">
71+
<Download className="h-4 w-4" />
72+
Download
73+
</Button>
74+
</ModalFooter>
75+
</Modal>
76+
);
77+
}

0 commit comments

Comments
 (0)