1import React, { useState, useRef, useEffect, useCallback } from "react";
2import JSZip from "jszip";
3import { LLMContent } from "../types";
4
5interface EmbeddedFile {
6 name: string;
7 path: string;
8 content: string;
9 type: string;
10}
11
12interface OutputIframeToolProps {
13 // For tool_use (pending state)
14 toolInput?: unknown; // { path: string, title?: string, files?: object }
15 isRunning?: boolean;
16
17 // For tool_result (completed state)
18 toolResult?: LLMContent[];
19 hasError?: boolean;
20 executionTime?: string;
21 display?: unknown; // OutputIframeDisplay from the Go tool
22}
23
24// Script injected into iframe to report its content height
25const HEIGHT_REPORTER_SCRIPT = `
26<script>
27(function() {
28 function reportHeight() {
29 var height = Math.max(
30 document.body.scrollHeight,
31 document.body.offsetHeight,
32 document.documentElement.scrollHeight,
33 document.documentElement.offsetHeight
34 );
35 window.parent.postMessage({ type: 'iframe-height', height: height }, '*');
36 }
37 // Report on load
38 if (document.readyState === 'complete') {
39 reportHeight();
40 } else {
41 window.addEventListener('load', reportHeight);
42 }
43 // Report after a short delay to catch async content
44 setTimeout(reportHeight, 100);
45 setTimeout(reportHeight, 500);
46 // Report on resize
47 window.addEventListener('resize', reportHeight);
48 // Observe DOM changes
49 if (typeof MutationObserver !== 'undefined') {
50 var observer = new MutationObserver(reportHeight);
51 observer.observe(document.body, { childList: true, subtree: true, attributes: true });
52 }
53})();
54</script>
55`;
56
57const MIN_HEIGHT = 100;
58const MAX_HEIGHT = 600;
59
60// Remove injected scripts/styles from HTML to get the original version for download
61function getOriginalHtml(html: string): string {
62 // Remove the window.__FILES__ script block
63 let result = html.replace(
64 /<script>\s*window\.__FILES__\s*=\s*window\.__FILES__\s*\|\|\s*\{\};[\s\S]*?<\/script>\s*/g,
65 "",
66 );
67 // Remove injected style tags
68 result = result.replace(/<style data-file="[^"]*">[\s\S]*?<\/style>\s*/g, "");
69 // Remove injected script tags
70 result = result.replace(/<script data-file="[^"]*">[\s\S]*?<\/script>\s*/g, "");
71 // Remove empty head tags that might have been added
72 result = result.replace(/<head>\s*<\/head>\s*/g, "");
73 return result;
74}
75
76function OutputIframeTool({
77 toolInput,
78 isRunning,
79 toolResult,
80 hasError,
81 executionTime,
82 display,
83}: OutputIframeToolProps) {
84 // Default to expanded for visual content
85 const [isExpanded, setIsExpanded] = useState(true);
86 const [iframeHeight, setIframeHeight] = useState(300);
87 const iframeRef = useRef<HTMLIFrameElement>(null);
88
89 // Extract input data
90 const getTitle = (input: unknown): string | undefined => {
91 if (
92 typeof input === "object" &&
93 input !== null &&
94 "title" in input &&
95 typeof input.title === "string"
96 ) {
97 return input.title;
98 }
99 return undefined;
100 };
101
102 const getHtmlFromInput = (input: unknown): string | undefined => {
103 if (
104 typeof input === "object" &&
105 input !== null &&
106 "html" in input &&
107 typeof input.html === "string"
108 ) {
109 return input.html;
110 }
111 return undefined;
112 };
113
114 // Get display data - prefer from display prop, fall back to toolInput
115 const getDisplayData = (): {
116 html?: string;
117 title?: string;
118 filename?: string;
119 files?: EmbeddedFile[];
120 } => {
121 // First try display prop (from tool result)
122 if (display && typeof display === "object" && display !== null) {
123 const d = display as {
124 html?: string;
125 title?: string;
126 filename?: string;
127 files?: EmbeddedFile[];
128 };
129 return {
130 html: typeof d.html === "string" ? d.html : undefined,
131 title: typeof d.title === "string" ? d.title : undefined,
132 filename: typeof d.filename === "string" ? d.filename : undefined,
133 files: Array.isArray(d.files) ? d.files : undefined,
134 };
135 }
136 // Fall back to toolInput
137 return {
138 html: getHtmlFromInput(toolInput),
139 title: getTitle(toolInput),
140 };
141 };
142
143 const displayData = getDisplayData();
144 const title = displayData.title || "HTML Output";
145 const html = displayData.html;
146 const filename = displayData.filename || "output.html";
147 const files = displayData.files || [];
148 const hasMultipleFiles = files.length > 0;
149
150 // Inject height reporter script into HTML
151 const htmlWithHeightReporter = html
152 ? html.includes("</body>")
153 ? html.replace("</body>", HEIGHT_REPORTER_SCRIPT + "</body>")
154 : html + HEIGHT_REPORTER_SCRIPT
155 : undefined;
156
157 // Listen for height messages from iframe
158 const handleMessage = useCallback((event: MessageEvent) => {
159 if (
160 event.data &&
161 typeof event.data === "object" &&
162 event.data.type === "iframe-height" &&
163 typeof event.data.height === "number"
164 ) {
165 // Verify the message is from our iframe
166 if (iframeRef.current && event.source === iframeRef.current.contentWindow) {
167 const newHeight = Math.min(Math.max(event.data.height, MIN_HEIGHT), MAX_HEIGHT);
168 setIframeHeight(newHeight);
169 }
170 }
171 }, []);
172
173 useEffect(() => {
174 window.addEventListener("message", handleMessage);
175 return () => window.removeEventListener("message", handleMessage);
176 }, [handleMessage]);
177
178 // Escape HTML special characters for safe embedding
179 const escapeHtml = (str: string): string => {
180 return str
181 .replace(/&/g, "&")
182 .replace(/</g, "<")
183 .replace(/>/g, ">")
184 .replace(/"/g, """)
185 .replace(/'/g, "'");
186 };
187
188 // Open HTML in new tab with sandbox protection
189 const handleOpenInNewTab = (e: React.MouseEvent) => {
190 e.stopPropagation();
191 if (!html) return;
192
193 // Create a wrapper HTML page that embeds the content in a sandboxed iframe
194 // This preserves security even when opened in a new tab
195 const escapedHtml = escapeHtml(html);
196 const escapedTitle = escapeHtml(title);
197
198 const wrapperHtml = `<!DOCTYPE html>
199<html>
200<head>
201 <meta charset="UTF-8">
202 <title>${escapedTitle}</title>
203 <style>
204 * { margin: 0; padding: 0; box-sizing: border-box; }
205 html, body { height: 100%; background: #f5f5f5; }
206 iframe {
207 width: 100%;
208 height: 100%;
209 border: none;
210 background: white;
211 }
212 </style>
213</head>
214<body>
215 <iframe sandbox="allow-scripts" srcdoc="${escapedHtml}"></iframe>
216</body>
217</html>`;
218
219 const blob = new Blob([wrapperHtml], { type: "text/html" });
220 const url = URL.createObjectURL(blob);
221 window.open(url, "_blank");
222 // Clean up the URL after a delay
223 setTimeout(() => URL.revokeObjectURL(url), 1000);
224 };
225
226 // Download files - single HTML or zip with all files
227 const handleDownload = async (e: React.MouseEvent) => {
228 e.stopPropagation();
229 if (!html) return;
230
231 if (hasMultipleFiles) {
232 // Create a zip file with all files
233 const zip = new JSZip();
234
235 // Add the original HTML (without injected content)
236 const originalHtml = getOriginalHtml(html);
237 zip.file(filename, originalHtml);
238
239 // Add all embedded files
240 for (const file of files) {
241 zip.file(file.path || file.name, file.content);
242 }
243
244 // Generate and download the zip
245 const zipBlob = await zip.generateAsync({ type: "blob" });
246 const url = URL.createObjectURL(zipBlob);
247 const a = document.createElement("a");
248 a.href = url;
249 // Use the HTML filename without extension for the zip name
250 const zipName = filename.replace(/\.[^.]+$/, "") + ".zip";
251 a.download = zipName;
252 document.body.appendChild(a);
253 a.click();
254 document.body.removeChild(a);
255 setTimeout(() => URL.revokeObjectURL(url), 1000);
256 } else {
257 // Single file download
258 const blob = new Blob([html], { type: "text/html" });
259 const url = URL.createObjectURL(blob);
260 const a = document.createElement("a");
261 a.href = url;
262 a.download = filename;
263 document.body.appendChild(a);
264 a.click();
265 document.body.removeChild(a);
266 setTimeout(() => URL.revokeObjectURL(url), 1000);
267 }
268 };
269
270 const isComplete = !isRunning && toolResult !== undefined;
271 const downloadLabel = hasMultipleFiles ? "Download ZIP" : "Download HTML";
272
273 return (
274 <div
275 className="output-iframe-tool"
276 data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
277 >
278 <div className="output-iframe-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
279 <div className="output-iframe-tool-summary">
280 <span className={`output-iframe-tool-emoji ${isRunning ? "running" : ""}`}>✨</span>
281 <span className="output-iframe-tool-title">{title}</span>
282 {isComplete && hasError && <span className="output-iframe-tool-error">✗</span>}
283 {isComplete && !hasError && <span className="output-iframe-tool-success">✓</span>}
284 </div>
285 <div className="output-iframe-tool-actions">
286 {isComplete && !hasError && html && (
287 <>
288 <button
289 className="output-iframe-tool-download-btn"
290 onClick={handleDownload}
291 aria-label={downloadLabel}
292 title={downloadLabel}
293 >
294 <svg
295 width="14"
296 height="14"
297 viewBox="0 0 24 24"
298 fill="none"
299 stroke="currentColor"
300 strokeWidth="2"
301 strokeLinecap="round"
302 strokeLinejoin="round"
303 >
304 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
305 <polyline points="7 10 12 15 17 10" />
306 <line x1="12" y1="15" x2="12" y2="3" />
307 </svg>
308 </button>
309 <button
310 className="output-iframe-tool-open-btn"
311 onClick={handleOpenInNewTab}
312 aria-label="Open in new tab"
313 title="Open in new tab"
314 >
315 <svg
316 width="14"
317 height="14"
318 viewBox="0 0 24 24"
319 fill="none"
320 stroke="currentColor"
321 strokeWidth="2"
322 strokeLinecap="round"
323 strokeLinejoin="round"
324 >
325 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
326 <polyline points="15 3 21 3 21 9" />
327 <line x1="10" y1="14" x2="21" y2="3" />
328 </svg>
329 </button>
330 </>
331 )}
332 <button
333 className="output-iframe-tool-toggle"
334 onClick={(e) => {
335 e.stopPropagation();
336 setIsExpanded(!isExpanded);
337 }}
338 aria-label={isExpanded ? "Collapse" : "Expand"}
339 aria-expanded={isExpanded}
340 >
341 <svg
342 width="12"
343 height="12"
344 viewBox="0 0 12 12"
345 fill="none"
346 xmlns="http://www.w3.org/2000/svg"
347 style={{
348 transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
349 transition: "transform 0.2s",
350 }}
351 >
352 <path
353 d="M4.5 3L7.5 6L4.5 9"
354 stroke="currentColor"
355 strokeWidth="1.5"
356 strokeLinecap="round"
357 strokeLinejoin="round"
358 />
359 </svg>
360 </button>
361 </div>
362 </div>
363
364 {isExpanded && (
365 <div className="output-iframe-tool-details">
366 {isComplete && !hasError && htmlWithHeightReporter && (
367 <div className="output-iframe-tool-section">
368 {executionTime && (
369 <div className="output-iframe-tool-label">
370 <span>Output:</span>
371 <span className="output-iframe-tool-time">{executionTime}</span>
372 </div>
373 )}
374 <div className="output-iframe-container">
375 <iframe
376 ref={iframeRef}
377 srcDoc={htmlWithHeightReporter}
378 sandbox="allow-scripts"
379 title={title}
380 style={{
381 width: "100%",
382 height: `${iframeHeight}px`,
383 border: "1px solid var(--border-color, #e5e7eb)",
384 borderRadius: "4px",
385 backgroundColor: "white",
386 }}
387 />
388 </div>
389 </div>
390 )}
391
392 {isComplete && hasError && (
393 <div className="output-iframe-tool-section">
394 <div className="output-iframe-tool-label">
395 <span>Error:</span>
396 {executionTime && <span className="output-iframe-tool-time">{executionTime}</span>}
397 </div>
398 <pre className="output-iframe-tool-error-message">
399 {toolResult && toolResult[0]?.Text
400 ? toolResult[0].Text
401 : "Failed to display HTML content"}
402 </pre>
403 </div>
404 )}
405
406 {isRunning && (
407 <div className="output-iframe-tool-section">
408 <div className="output-iframe-tool-label">Preparing HTML output...</div>
409 </div>
410 )}
411 </div>
412 )}
413 </div>
414 );
415}
416
417export default OutputIframeTool;