OutputIframeTool.tsx

  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, "&amp;")
182      .replace(/</g, "&lt;")
183      .replace(/>/g, "&gt;")
184      .replace(/"/g, "&quot;")
185      .replace(/'/g, "&#39;");
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;