Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions multimodal/tarko/agent-ui/src/common/constants/iframeSandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Bytedance, Inc. and its affiliates.
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Sandbox policies for frames that carry content the UI does not author.
*
* `allow-same-origin` lets a framed document keep its own origin. Whether that is safe
* depends entirely on what "its own origin" resolves to:
*
* - `srcdoc` documents inherit the embedder's origin, so granting it there hands the UI's
* origin to the framed content. Combined with `allow-scripts` the sandbox is void and the
* content can reach `parent.document`, storage and same-origin APIs.
* - A document loaded from a cross-origin URL keeps that remote origin, which the embedder is
* already walled off from, so the flag buys the framed app its own storage without giving it
* any reach into the UI.
*/

/** Preview of agent-authored HTML, always handed over through `srcDoc`: opaque origin only. */
export const HTML_PREVIEW_SANDBOX = 'allow-scripts';

/** Embedded tools (code-server, VNC) drive their own forms, popups and dialogs. */
const EMBED_FRAME_SANDBOX = 'allow-scripts allow-forms allow-popups allow-modals';

/**
* Sandbox for an embedded tool, decided per URL.
*
* Cross-origin `http(s)` targets keep `allow-same-origin`, because losing their origin also
* loses their cookies, `localStorage` and same-origin requests. Anything that could end up
* sharing this page's origin — a relative or same-origin URL, an unparsable one, or a scheme
* such as `javascript:` or `data:` that inherits or opaques the origin — gets the strict policy.
*/
export function resolveEmbedFrameSandbox(src: string): string {
try {
const target = new URL(src, window.location.href);
const isRemoteHttpOrigin =
(target.protocol === 'https:' || target.protocol === 'http:') &&
target.origin !== window.location.origin;

if (isRemoteHttpOrigin) {
return `${EMBED_FRAME_SANDBOX} allow-same-origin`;
}
} catch {
// Unparsable URL: fall through to the strict policy
}

return EMBED_FRAME_SANDBOX;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MarkdownRenderer } from '@tarko/ui';
import { MessageContent } from './shared';
import { FullscreenFileData } from '../types/panelContent';
import { normalizeFilePath } from '@tarko/ui';
import { HTML_PREVIEW_SANDBOX } from '@/common/constants/iframeSandbox';

interface FullscreenModalProps {
data: FullscreenFileData | null;
Expand Down Expand Up @@ -85,7 +86,7 @@ export const FullscreenModal: React.FC<FullscreenModalProps> = ({ data, onClose
srcDoc={data.content}
className="w-full h-full border-0"
title="HTML Preview"
sandbox="allow-scripts allow-same-origin"
sandbox={HTML_PREVIEW_SANDBOX}
style={{ backgroundColor: 'white' }}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,119 +1,97 @@
import React, { useRef, useEffect, useState } from 'react';
import { useStableValue } from '@/common/hooks/useStableValue';
import { HTML_PREVIEW_SANDBOX } from '@/common/constants/iframeSandbox';

interface ThrottledHtmlRendererProps {
content: string;
isStreaming?: boolean;
className?: string;
}

/** Minimum gap between two document swaps while content is still streaming in. */
const STREAMING_UPDATE_INTERVAL = 200;

const FRAME_INDEXES = [0, 1] as const;

/**
* ThrottledHtmlRenderer - A component that renders HTML content with throttling to prevent flickering
* ThrottledHtmlRenderer - renders HTML content in a sandboxed iframe
*
* Features:
* - Throttled updates during streaming to reduce flickering
* - Smooth DOM replacement instead of full rebuild
* - Automatic iframe sizing and content injection
* The frame is sandboxed without `allow-same-origin`, so its document lives in an opaque
* origin and is unreachable from here: content can only be handed over through `srcDoc`.
* To keep streaming updates smooth without touching the frame's DOM, two frames alternate —
* the next document is parsed in the hidden one and swapped in once it has loaded, so the
* viewer never sees a blank frame mid-stream.
*/
export const ThrottledHtmlRenderer: React.FC<ThrottledHtmlRendererProps> = ({
content,
isStreaming = false,
className = '',
}) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [lastRenderedContent, setLastRenderedContent] = useState('');
const renderTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [frameContents, setFrameContents] = useState<[string, string]>(['', '']);
const [visibleIndex, setVisibleIndex] = useState(0);

const frameContentsRef = useRef<[string, string]>(['', '']);
const visibleIndexRef = useRef(0);
const pendingIndexRef = useRef<number | null>(null);
const renderedContentRef = useRef('');
const lastRenderAtRef = useRef(0);
const renderTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Use stable content to reduce unnecessary updates
const stableContent = useStableValue(content, (a, b) => a === b);

const showFrame = (index: number) => {
visibleIndexRef.current = index;
setVisibleIndex(index);
};

// Throttling logic for streaming updates
useEffect(() => {
if (!iframeRef.current) return;
if (stableContent === renderedContentRef.current) return;

// Clear any pending render
if (renderTimeoutRef.current) {
clearTimeout(renderTimeoutRef.current);
}

const shouldRender = () => {
if (stableContent === lastRenderedContent) return false;
const renderContent = () => {
renderTimeoutRef.current = null;
renderedContentRef.current = stableContent;
lastRenderAtRef.current = Date.now();

// If not streaming, render immediately
if (!isStreaming) return true;
const targetIndex = visibleIndexRef.current === 0 ? 1 : 0;

// During streaming, throttle updates to reduce flickering
const contentDelta = Math.abs(stableContent.length - lastRenderedContent.length);
const shouldThrottle = contentDelta < 100; // Only throttle small changes
// An unchanged srcDoc fires no load event, so nothing would trigger the swap
if (frameContentsRef.current[targetIndex] === stableContent) {
pendingIndexRef.current = null;
showFrame(targetIndex);
return;
}

return !shouldThrottle;
frameContentsRef.current =
targetIndex === 0
? [stableContent, frameContentsRef.current[1]]
: [frameContentsRef.current[0], stableContent];
pendingIndexRef.current = targetIndex;
setFrameContents(frameContentsRef.current);
};

const renderContent = () => {
if (!iframeRef.current || stableContent === lastRenderedContent) return;

try {
const iframe = iframeRef.current;
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;

if (!iframeDoc) return;

// For streaming, try to update content smoothly
if (isStreaming && lastRenderedContent) {
// Check if we can do partial update
if (stableContent.startsWith(lastRenderedContent)) {
// Content is appended, try to append to existing DOM
const additionalContent = stableContent.slice(lastRenderedContent.length);
if (additionalContent.trim()) {
// Create a temporary container to parse new content
const tempDiv = iframeDoc.createElement('div');
tempDiv.innerHTML = additionalContent;

// Append new nodes to body
while (tempDiv.firstChild) {
iframeDoc.body.appendChild(tempDiv.firstChild);
}

setLastRenderedContent(stableContent);
return;
}
}
}

// Full content replacement for non-streaming or significant changes
iframeDoc.open();
iframeDoc.write(stableContent);
iframeDoc.close();

// Ensure white background for HTML content
if (iframeDoc.body) {
iframeDoc.body.style.backgroundColor = 'white';
}

setLastRenderedContent(stableContent);
} catch (error) {
console.warn('Failed to update iframe content:', error);
// Fallback to srcDoc update
if (iframeRef.current) {
iframeRef.current.srcDoc = stableContent;
setLastRenderedContent(stableContent);
}
}
};
// Outside streaming every change is final, so render it right away
if (!isStreaming) {
renderContent();
return;
}

if (shouldRender()) {
const elapsed = Date.now() - lastRenderAtRef.current;
if (elapsed >= STREAMING_UPDATE_INTERVAL) {
renderContent();
} else if (isStreaming) {
// Schedule throttled update during streaming
renderTimeoutRef.current = setTimeout(renderContent, 200);
return;
}

renderTimeoutRef.current = setTimeout(renderContent, STREAMING_UPDATE_INTERVAL - elapsed);

return () => {
if (renderTimeoutRef.current) {
clearTimeout(renderTimeoutRef.current);
renderTimeoutRef.current = null;
}
};
}, [stableContent, isStreaming, lastRenderedContent]);
}, [stableContent, isStreaming]);

// Cleanup on unmount
useEffect(() => {
Expand All @@ -124,18 +102,28 @@ export const ThrottledHtmlRenderer: React.FC<ThrottledHtmlRendererProps> = ({
};
}, []);

const handleFrameLoad = (index: number) => {
if (pendingIndexRef.current !== index) return;
pendingIndexRef.current = null;
showFrame(index);
};

return (
<div
className={`border border-gray-200/50 dark:border-gray-700/30 rounded-lg overflow-hidden bg-white ${className}`}
className={`relative min-h-[100vh] border border-gray-200/50 dark:border-gray-700/30 rounded-lg overflow-hidden bg-white ${className}`}
>
<iframe
ref={iframeRef}
className="w-full border-0 min-h-[100vh]"
title="HTML Preview"
sandbox="allow-scripts allow-same-origin"
// Don't use srcDoc for streaming content to allow manual DOM updates
srcDoc={!isStreaming ? stableContent : undefined}
/>
{FRAME_INDEXES.map((index) => (
<iframe
key={index}
className={`absolute inset-0 w-full h-full border-0 ${
index === visibleIndex ? '' : 'invisible pointer-events-none'
}`}
title="HTML Preview"
sandbox={HTML_PREVIEW_SANDBOX}
srcDoc={frameContents[index]}
onLoad={() => handleFrameLoad(index)}
/>
))}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useRef, useEffect, useState } from 'react';
import type { StandardPanelContent } from '../types/panelContent';
import { FileDisplayMode } from '../types';
import { resolveEmbedFrameSandbox } from '@/common/constants/iframeSandbox';

interface EmbedFrameRendererProps {
panelContent: StandardPanelContent;
Expand All @@ -19,6 +20,8 @@ export const EmbedFrameRenderer: React.FC<EmbedFrameRendererProps> = ({
const src =
typeof panelContent.source === 'string' ? panelContent.source : panelContent.link || '';

const sandbox = resolveEmbedFrameSandbox(src);

const handleOpenInNewTab = () => {
if (src) {
window.open(src, '_blank');
Expand Down Expand Up @@ -136,7 +139,7 @@ export const EmbedFrameRenderer: React.FC<EmbedFrameRendererProps> = ({
className="border-0"
style={{ width: '1280px', height: '958px' }}
title={panelContent.title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
sandbox={sandbox}
loading="lazy"
/>
</div>
Expand All @@ -161,7 +164,7 @@ export const EmbedFrameRenderer: React.FC<EmbedFrameRendererProps> = ({
className="border-0"
style={{ width: '1280px', height: '958px' }}
title={panelContent.title}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
sandbox={sandbox}
loading="lazy"
/>
</div>
Expand Down