Merge pull request #545 from kubet/fix/redundancy-fixes

Fix/redundancy fixes
This commit is contained in:
kubet 2025-05-27 22:58:20 +02:00 committed by GitHub
commit 53300e2ee6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 587 additions and 176 deletions

View File

@ -25,6 +25,7 @@ import { SubscriptionStatus } from '@/components/thread/chat-input/_use-model-se
import { UnifiedMessage, ApiMessageType, ToolCallInput, Project } from '../_types';
import { useThreadData, useToolCalls, useBilling, useKeyboardShortcuts } from '../_hooks';
import { ThreadError, UpgradeDialog, ThreadLayout } from '../_components';
import { useVncPreloader } from '@/hooks/useVncPreloader';
export default function ThreadPage({
params,
@ -44,6 +45,7 @@ export default function ThreadPage({
const [isSending, setIsSending] = useState(false);
const [fileViewerOpen, setFileViewerOpen] = useState(false);
const [fileToView, setFileToView] = useState<string | null>(null);
const [filePathList, setFilePathList] = useState<string[] | undefined>(undefined);
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
const [debugMode, setDebugMode] = useState(false);
const [initialPanelOpenAttempted, setInitialPanelOpenAttempted] = useState(false);
@ -125,6 +127,9 @@ export default function ThreadPage({
? 'active'
: 'no_subscription';
useVncPreloader(project);
const handleProjectRenamed = useCallback((newName: string) => {
}, []);
@ -329,12 +334,13 @@ export default function ThreadPage({
}
}, [stopStreaming, agentRunId, stopAgentMutation, agentRunsQuery, setAgentStatus]);
const handleOpenFileViewer = useCallback((filePath?: string) => {
const handleOpenFileViewer = useCallback((filePath?: string, filePathList?: string[]) => {
if (filePath) {
setFileToView(filePath);
} else {
setFileToView(null);
}
setFilePathList(filePathList);
setFileViewerOpen(true);
}, []);
@ -523,6 +529,7 @@ export default function ThreadPage({
fileViewerOpen={fileViewerOpen}
setFileViewerOpen={setFileViewerOpen}
fileToView={fileToView}
filePathList={filePathList}
toolCalls={toolCalls}
messages={messages as ApiMessageType[]}
externalNavIndex={externalNavIndex}
@ -564,6 +571,7 @@ export default function ThreadPage({
fileViewerOpen={fileViewerOpen}
setFileViewerOpen={setFileViewerOpen}
fileToView={fileToView}
filePathList={filePathList}
toolCalls={toolCalls}
messages={messages as ApiMessageType[]}
externalNavIndex={externalNavIndex}

View File

@ -17,10 +17,11 @@ interface ThreadLayoutProps {
isSidePanelOpen: boolean;
onToggleSidePanel: () => void;
onProjectRenamed?: (newName: string) => void;
onViewFiles: (filePath?: string) => void;
onViewFiles: (filePath?: string, filePathList?: string[]) => void;
fileViewerOpen: boolean;
setFileViewerOpen: (open: boolean) => void;
fileToView: string | null;
filePathList?: string[];
toolCalls: ToolCallInput[];
messages: ApiMessageType[];
externalNavIndex?: number;
@ -53,6 +54,7 @@ export function ThreadLayout({
fileViewerOpen,
setFileViewerOpen,
fileToView,
filePathList,
toolCalls,
messages,
externalNavIndex,
@ -79,8 +81,7 @@ export function ThreadLayout({
)}
<div
className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${
(!initialLoadCompleted || isSidePanelOpen)
className={`flex flex-col flex-1 overflow-hidden transition-all duration-200 ease-in-out ${(!initialLoadCompleted || isSidePanelOpen)
? 'mr-[90%] sm:mr-[450px] md:mr-[500px] lg:mr-[550px] xl:mr-[650px]'
: ''
}`}
@ -122,6 +123,7 @@ export function ThreadLayout({
sandboxId={sandboxId}
initialFilePath={fileToView}
project={project || undefined}
filePathList={filePathList}
/>
)}

View File

@ -30,6 +30,7 @@ import { safeJsonParse } from '@/components/thread/utils';
import { useAgentStream } from '@/hooks/useAgentStream';
import { threadErrorCodeMessages } from '@/lib/constants/errorCodeMessages';
import { ThreadSkeleton } from '@/components/thread/content/ThreadSkeleton';
import { useVncPreloader } from '@/hooks/useVncPreloader';
// Extend the base Message type with the expected database fields
interface ApiMessageType extends BaseApiMessageType {
@ -101,6 +102,9 @@ export default function ThreadPage({
const userClosedPanelRef = useRef(false);
// Preload VNC iframe as soon as project data is available
useVncPreloader(project);
useEffect(() => {
userClosedPanelRef.current = true;
setIsSidePanelOpen(false);
@ -612,7 +616,7 @@ export default function ThreadPage({
[messages, toolCalls],
);
const handleOpenFileViewer = useCallback((filePath?: string) => {
const handleOpenFileViewer = useCallback((filePath?: string, filePathList?: string[]) => {
if (filePath) {
setFileToView(filePath);
} else {

View File

@ -29,7 +29,7 @@ interface AttachmentGroupProps {
onRemove?: (index: number) => void;
layout?: LayoutStyle;
className?: string;
onFileClick?: (path: string) => void;
onFileClick?: (path: string, filePathList?: string[]) => void;
showPreviews?: boolean;
maxHeight?: string;
gridImageHeight?: number; // New prop for grid image height
@ -101,8 +101,10 @@ export function AttachmentGroup({
// Ensure path has proper format when clicking
const handleFileClick = (path: string) => {
if (onFileClick) {
// Just pass the path to the parent handler which will call getFileUrl
onFileClick(path);
// Create the file path list from all files in the group
const filePathList = uniqueFiles.map(file => getFilePath(file));
// Pass both the clicked path and the complete list
onFileClick(path, filePathList);
}
};

View File

@ -52,7 +52,7 @@ const HIDE_STREAMING_XML_TAGS = new Set([
]);
// Helper function to render attachments (keeping original implementation for now)
export function renderAttachments(attachments: string[], fileViewerHandler?: (filePath?: string) => void, sandboxId?: string, project?: Project) {
export function renderAttachments(attachments: string[], fileViewerHandler?: (filePath?: string, filePathList?: string[]) => void, sandboxId?: string, project?: Project) {
if (!attachments || attachments.length === 0) return null;
// Note: Preloading is now handled by React Query in the main ThreadContent component
@ -72,7 +72,7 @@ export function renderMarkdownContent(
content: string,
handleToolClick: (assistantMessageId: string | null, toolName: string) => void,
messageId: string | null,
fileViewerHandler?: (filePath?: string) => void,
fileViewerHandler?: (filePath?: string, filePathList?: string[]) => void,
sandboxId?: string,
project?: Project,
debugMode?: boolean
@ -165,7 +165,7 @@ export interface ThreadContentProps {
streamingToolCall?: any;
agentStatus: 'idle' | 'running' | 'connecting' | 'error';
handleToolClick: (assistantMessageId: string | null, toolName: string) => void;
handleOpenFileViewer: (filePath?: string) => void;
handleOpenFileViewer: (filePath?: string, filePathList?: string[]) => void;
readOnly?: boolean;
visibleMessages?: UnifiedMessage[]; // For playback mode
streamingText?: string; // For playback mode
@ -327,9 +327,9 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
}
// Handle streaming content
if(streamingTextContent) {
if (streamingTextContent) {
const lastGroup = groupedMessages.at(-1);
if(!lastGroup || lastGroup.type === 'user'){
if (!lastGroup || lastGroup.type === 'user') {
// Create new assistant group for streaming content
assistantGroupCounter++;
groupedMessages.push({

View File

@ -521,7 +521,7 @@ export function FileAttachment({
interface FileAttachmentGridProps {
attachments: string[];
onFileClick?: (path: string) => void;
onFileClick?: (path: string, filePathList?: string[]) => void;
className?: string;
sandboxId?: string;
showPreviews?: boolean;

View File

@ -59,6 +59,7 @@ interface FileViewerModalProps {
sandboxId: string;
initialFilePath?: string | null;
project?: Project;
filePathList?: string[];
}
export function FileViewerModal({
@ -67,6 +68,7 @@ export function FileViewerModal({
sandboxId,
initialFilePath,
project,
filePathList,
}: FileViewerModalProps) {
// Safely handle initialFilePath to ensure it's a string or null
const safeInitialFilePath = typeof initialFilePath === 'string' ? initialFilePath : null;
@ -78,6 +80,20 @@ export function FileViewerModal({
const [currentPath, setCurrentPath] = useState('/workspace');
const [isInitialLoad, setIsInitialLoad] = useState(true);
// Add navigation state for file list mode
const [currentFileIndex, setCurrentFileIndex] = useState<number>(-1);
const isFileListMode = Boolean(filePathList && filePathList.length > 0);
// Debug filePathList changes
useEffect(() => {
console.log('[FILE VIEWER DEBUG] filePathList changed:', {
filePathList,
length: filePathList?.length,
isFileListMode,
currentFileIndex
});
}, [filePathList, isFileListMode, currentFileIndex]);
// Use React Query for directory listing
const {
data: files = [],
@ -176,12 +192,20 @@ export function FileViewerModal({
// Helper function to clear the selected file
const clearSelectedFile = useCallback(() => {
console.log(`[FILE VIEWER DEBUG] clearSelectedFile called, isFileListMode: ${isFileListMode}`);
setSelectedFilePath(null);
setRawContent(null);
setTextContentForRenderer(null); // Clear derived text content
setBlobUrlForRenderer(null); // Clear derived blob URL
setContentError(null);
}, []);
// Only reset file list mode index when not in file list mode
if (!isFileListMode) {
console.log(`[FILE VIEWER DEBUG] Resetting currentFileIndex in clearSelectedFile`);
setCurrentFileIndex(-1);
} else {
console.log(`[FILE VIEWER DEBUG] Keeping currentFileIndex in clearSelectedFile because in file list mode`);
}
}, [isFileListMode]);
// Core file opening function
const openFile = useCallback(
@ -227,6 +251,14 @@ export function FileViewerModal({
clearSelectedFile();
setSelectedFilePath(file.path);
// Only reset file index if we're NOT in file list mode or the file is not in the list
if (!isFileListMode || !filePathList?.includes(file.path)) {
console.log(`[FILE VIEWER DEBUG] Resetting currentFileIndex because not in file list mode or file not in list`);
setCurrentFileIndex(-1);
} else {
console.log(`[FILE VIEWER DEBUG] Keeping currentFileIndex because file is in file list mode`);
}
// The useFileContentQuery hook will automatically handle loading the content
// No need to manually fetch here - React Query will handle it
},
@ -234,6 +266,8 @@ export function FileViewerModal({
selectedFilePath,
clearSelectedFile,
normalizePath,
isFileListMode,
filePathList,
],
);
@ -376,6 +410,51 @@ export function FileViewerModal({
[sandboxId],
);
// Navigation functions for file list mode
const navigateToFileByIndex = useCallback((index: number) => {
console.log('[FILE VIEWER DEBUG] navigateToFileByIndex called:', {
index,
isFileListMode,
filePathList,
filePathListLength: filePathList?.length
});
if (!isFileListMode || !filePathList || index < 0 || index >= filePathList.length) {
console.log('[FILE VIEWER DEBUG] navigateToFileByIndex early return - invalid conditions');
return;
}
const filePath = filePathList[index];
console.log('[FILE VIEWER DEBUG] Setting currentFileIndex to:', index, 'for file:', filePath);
setCurrentFileIndex(index);
// Create a temporary FileInfo object for the file
const fileName = filePath.split('/').pop() || '';
const normalizedPath = normalizePath(filePath);
const fileInfo: FileInfo = {
name: fileName,
path: normalizedPath,
is_dir: false,
size: 0,
mod_time: new Date().toISOString(),
};
openFile(fileInfo);
}, [isFileListMode, filePathList, normalizePath, openFile]);
const navigatePrevious = useCallback(() => {
if (currentFileIndex > 0) {
navigateToFileByIndex(currentFileIndex - 1);
}
}, [currentFileIndex, navigateToFileByIndex]);
const navigateNext = useCallback(() => {
if (isFileListMode && filePathList && currentFileIndex < filePathList.length - 1) {
navigateToFileByIndex(currentFileIndex + 1);
}
}, [currentFileIndex, isFileListMode, filePathList, navigateToFileByIndex]);
// Handle initial file path - Runs ONLY ONCE on open if initialFilePath is provided
useEffect(() => {
// Only run if modal is open, initial path is provided, AND it hasn't been processed yet
@ -384,6 +463,32 @@ export function FileViewerModal({
`[FILE VIEWER] useEffect[initialFilePath]: Processing initial path: ${safeInitialFilePath}`,
);
// If we're in file list mode, find the index and navigate to it
if (isFileListMode && filePathList) {
console.log('[FILE VIEWER DEBUG] Initial file path - file list mode detected:', {
isFileListMode,
filePathList,
safeInitialFilePath,
filePathListLength: filePathList.length
});
const normalizedInitialPath = normalizePath(safeInitialFilePath);
const index = filePathList.findIndex(path => normalizePath(path) === normalizedInitialPath);
console.log('[FILE VIEWER DEBUG] Found index for initial file:', {
normalizedInitialPath,
index,
foundPath: index !== -1 ? filePathList[index] : 'not found'
});
if (index !== -1) {
console.log(`[FILE VIEWER] File list mode: navigating to index ${index} for ${normalizedInitialPath}`);
navigateToFileByIndex(index);
setInitialPathProcessed(true);
return;
}
}
// Normalize the initial path
const fullPath = normalizePath(safeInitialFilePath);
const lastSlashIndex = fullPath.lastIndexOf('/');
@ -434,7 +539,7 @@ export function FileViewerModal({
);
setInitialPathProcessed(false);
}
}, [open, safeInitialFilePath, initialPathProcessed, normalizePath, currentPath, openFile]);
}, [open, safeInitialFilePath, initialPathProcessed, normalizePath, currentPath, openFile, isFileListMode, filePathList, navigateToFileByIndex]);
// Effect to handle cached file content updates
useEffect(() => {
@ -505,6 +610,24 @@ export function FileViewerModal({
};
}, [blobUrlForRenderer, isDownloading]);
// Keyboard navigation
useEffect(() => {
if (!open || !isFileListMode) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
navigatePrevious();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
navigateNext();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, isFileListMode, navigatePrevious, navigateNext]);
// Handle modal close
const handleOpenChange = useCallback(
(open: boolean) => {
@ -522,6 +645,7 @@ export function FileViewerModal({
// React Query will handle clearing the files data
setInitialPathProcessed(false);
setIsInitialLoad(true);
setCurrentFileIndex(-1); // Reset file index
}
onOpenChange(open);
},
@ -856,14 +980,63 @@ export function FileViewerModal({
[currentPath, sandboxId, refetchFiles],
);
// Reset file list mode when modal opens without filePathList
useEffect(() => {
if (open && !filePathList) {
setCurrentFileIndex(-1);
}
}, [open, filePathList]);
// --- Render --- //
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[90vw] md:max-w-[1200px] w-[95vw] h-[90vh] max-h-[900px] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="px-4 py-2 border-b flex-shrink-0">
<DialogHeader className="px-4 py-2 border-b flex-shrink-0 flex flex-row gap-4 items-center">
<DialogTitle className="text-lg font-semibold">
Workspace Files
</DialogTitle>
<div className="flex items-center gap-2">
{/* Navigation arrows for file list mode */}
{(() => {
// Debug logging
console.log('[FILE VIEWER DEBUG] Navigation visibility check:', {
isFileListMode,
selectedFilePath,
filePathList,
filePathListLength: filePathList?.length,
currentFileIndex,
shouldShow: isFileListMode && selectedFilePath && filePathList && filePathList.length > 1 && currentFileIndex >= 0
});
return isFileListMode && selectedFilePath && filePathList && filePathList.length > 1 && currentFileIndex >= 0;
})() && (
<>
<Button
variant="outline"
size="sm"
onClick={navigatePrevious}
disabled={currentFileIndex <= 0}
className="h-8 w-8 p-0"
title="Previous file (←)"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-xs text-muted-foreground px-1">
{currentFileIndex + 1} / {filePathList.length}
</div>
<Button
variant="outline"
size="sm"
onClick={navigateNext}
disabled={currentFileIndex >= filePathList.length - 1}
className="h-8 w-8 p-0"
title="Next file (→)"
>
<ChevronRight className="h-4 w-4" />
</Button>
</>
)}
</div>
</DialogHeader>
{/* Navigation Bar */}

View File

@ -46,6 +46,7 @@ export function WebSearchToolView({
const [expandedResults, setExpandedResults] = useState<Record<number, boolean>>({});
const query = extractSearchQuery(assistantContent);
console.log('toolContent', toolContent);
const searchResults = extractSearchResults(toolContent);
const toolTitle = getToolTitle(name);
@ -226,6 +227,7 @@ export function WebSearchToolView({
<div className="space-y-4">
{searchResults.map((result, idx) => {
const { icon: ResultTypeIcon, label: resultTypeLabel } = getResultType(result);
console.log('result', result);
const isExpanded = expandedResults[idx] || false;
const favicon = getFavicon(result.url);
@ -290,7 +292,18 @@ export function WebSearchToolView({
"text-sm text-zinc-600 dark:text-zinc-400",
isExpanded ? "" : "line-clamp-2"
)}>
{result.snippet}
{result?.snippet
?.replace(/\\\\\n/g, ' ')
?.replace(/\\\\n/g, ' ')
?.replace(/\\n/g, ' ')
?.replace(/\\\\\t/g, ' ')
?.replace(/\\\\t/g, ' ')
?.replace(/\\t/g, ' ')
?.replace(/\\\\\r/g, ' ')
?.replace(/\\\\r/g, ' ')
?.replace(/\\r/g, ' ')
?.replace(/\s+/g, ' ')
?.trim()}
</p>
)}
</div>

View File

@ -517,6 +517,25 @@ export function extractSearchQuery(content: string | object | undefined | null):
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
// First, look for ToolResult pattern in the content string
const toolResultMatch = contentStr.match(
/ToolResult\(.*?output='([\s\S]*?)'.*?\)/,
);
if (toolResultMatch) {
try {
// Parse the output JSON from ToolResult
const outputJson = JSON.parse(toolResultMatch[1]);
// Check if this is the new Tavily response format with query field
if (outputJson.query && typeof outputJson.query === 'string') {
return outputJson.query;
}
} catch (e) {
// Continue with other extraction methods
}
}
let contentToSearch = contentStr; // Start with the normalized content
// Try parsing as JSON first
@ -597,31 +616,38 @@ export function extractUrlsAndTitles(
): Array<{ title: string; url: string; snippet?: string }> {
const results: Array<{ title: string; url: string; snippet?: string }> = [];
// First try to handle the case where content contains fragments of JSON objects
// This pattern matches both complete and partial result objects
const jsonFragmentPattern =
/"?title"?\s*:\s*"([^"]+)"[^}]*"?url"?\s*:\s*"?(https?:\/\/[^",\s]+)"?|https?:\/\/[^",\s]+[",\s]*"?title"?\s*:\s*"([^"]+)"/g;
let fragmentMatch;
while ((fragmentMatch = jsonFragmentPattern.exec(content)) !== null) {
// Extract title and URL from the matched groups
// Groups can match in different orders depending on the fragment format
const title = fragmentMatch[1] || fragmentMatch[3] || '';
let url = fragmentMatch[2] || '';
// If no URL was found in the JSON fragment pattern but we have a title,
// try to find a URL on its own line above the title
if (!url && title) {
// Look backwards from the match position
const beforeText = content.substring(0, fragmentMatch.index);
const urlMatch = beforeText.match(/https?:\/\/[^\s",]+\s*$/);
if (urlMatch && urlMatch[0]) {
url = urlMatch[0].trim();
// Try to parse as JSON first to extract proper results
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
return parsed.map(result => ({
title: result.title || '',
url: result.url || '',
snippet: result.content || result.snippet || '',
}));
}
if (parsed.results && Array.isArray(parsed.results)) {
return parsed.results.map(result => ({
title: result.title || '',
url: result.url || '',
snippet: result.content || '',
}));
}
} catch (e) {
// Not valid JSON, continue with regex extraction
}
// Look for properly formatted JSON objects with title and url
const jsonObjectPattern = /\{\s*"title"\s*:\s*"([^"]+)"\s*,\s*"url"\s*:\s*"(https?:\/\/[^"]+)"\s*(?:,\s*"content"\s*:\s*"([^"]*)")?\s*\}/g;
let objectMatch;
while ((objectMatch = jsonObjectPattern.exec(content)) !== null) {
const title = objectMatch[1];
const url = objectMatch[2];
const snippet = objectMatch[3] || '';
if (url && title && !results.some((r) => r.url === url)) {
results.push({ title, url });
results.push({ title, url, snippet });
}
}
@ -1015,8 +1041,50 @@ export function extractSearchResults(
const contentStr = normalizeContentToString(content);
if (!contentStr) return [];
// First check if it's the new Tavily response format
try {
// Instead of trying to parse the complex ToolResult JSON,
// let's look for the results array pattern directly in the content
// Look for the results array pattern within the content
const resultsPattern = /"results":\s*\[([^\]]*(?:\[[^\]]*\][^\]]*)*)\]/;
const resultsMatch = contentStr.match(resultsPattern);
if (resultsMatch) {
try {
// Extract just the results array and parse it
const resultsArrayStr = '[' + resultsMatch[1] + ']';
const results = JSON.parse(resultsArrayStr);
if (Array.isArray(results)) {
return results.map(result => ({
title: result.title || '',
url: result.url || '',
snippet: result.content || '',
}));
}
} catch (e) {
console.warn('Failed to parse results array:', e);
}
}
// Fallback: Look for individual result objects
const resultObjectPattern = /\{\s*"url":\s*"([^"]+)"\s*,\s*"title":\s*"([^"]+)"\s*,\s*"content":\s*"([^"]*)"[^}]*\}/g;
const results = [];
let match;
while ((match = resultObjectPattern.exec(contentStr)) !== null) {
results.push({
url: match[1],
title: match[2],
snippet: match[3],
});
}
if (results.length > 0) {
return results;
}
// Try parsing the entire content as JSON (for direct Tavily responses)
const parsedContent = JSON.parse(contentStr);
// Check if this is the new Tavily response format
@ -1031,28 +1099,16 @@ export function extractSearchResults(
// Continue with existing logic for backward compatibility
if (parsedContent.content && typeof parsedContent.content === 'string') {
// Look for a tool_result tag (with attributes)
const toolResultMatch = parsedContent.content.match(
const toolResultTagMatch = parsedContent.content.match(
/<tool_result[^>]*>\s*<web-search[^>]*>([\s\S]*?)<\/web-search>\s*<\/tool_result>/,
);
if (toolResultMatch) {
if (toolResultTagMatch) {
// Try to parse the results array
try {
return JSON.parse(toolResultMatch[1]);
return JSON.parse(toolResultTagMatch[1]);
} catch (e) {
// Fallback to regex extraction of URLs and titles
return extractUrlsAndTitles(toolResultMatch[1]);
}
}
// Look for ToolResult pattern
const outputMatch = parsedContent.content.match(
/ToolResult\(.*?output='([\s\S]*?)'.*?\)/,
);
if (outputMatch) {
try {
return JSON.parse(outputMatch[1]);
} catch (e) {
return extractUrlsAndTitles(outputMatch[1]);
return extractUrlsAndTitles(toolResultTagMatch[1]);
}
}

View File

@ -41,13 +41,26 @@ function CodeBlockCode({
useEffect(() => {
async function highlight() {
const html = await codeToHtml(code, { lang: language, theme });
const html = await codeToHtml(code, {
lang: language,
theme,
transformers: [
{
pre(node) {
if (node.properties.style) {
node.properties.style = (node.properties.style as string)
.replace(/background-color:[^;]+;?/g, '');
}
}
}
]
});
setHighlightedHtml(html);
}
highlight();
}, [code, language, theme]);
const classNames = cn('', className);
const classNames = cn('[&_pre]:!bg-background/95 [&_pre]:rounded-lg [&_pre]:p-4', className);
// SSR fallback: render plain code if not hydrated yet
return highlightedHtml ? (

View File

@ -0,0 +1,140 @@
import { useEffect, useRef, useCallback } from 'react';
import { Project } from '@/lib/api';
export function useVncPreloader(project: Project | null) {
const preloadedIframeRef = useRef<HTMLIFrameElement | null>(null);
const isPreloadedRef = useRef(false);
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const maxRetriesRef = useRef(0);
const isRetryingRef = useRef(false);
const startPreloading = useCallback((vncUrl: string) => {
// Prevent multiple simultaneous preload attempts
if (isRetryingRef.current || isPreloadedRef.current) {
return;
}
isRetryingRef.current = true;
console.log(`[VNC PRELOADER] Attempt ${maxRetriesRef.current + 1}/10 - Starting VNC preload:`, vncUrl);
// Create hidden iframe for preloading
const iframe = document.createElement('iframe');
iframe.src = vncUrl;
iframe.style.position = 'absolute';
iframe.style.left = '-9999px';
iframe.style.top = '-9999px';
iframe.style.width = '1024px';
iframe.style.height = '768px';
iframe.style.border = '0';
iframe.title = 'VNC Preloader';
// Set a timeout to detect if iframe fails to load (for 502 errors)
const loadTimeout = setTimeout(() => {
console.log('[VNC PRELOADER] Load timeout - VNC service likely not ready');
// Clean up current iframe
if (iframe.parentNode) {
iframe.parentNode.removeChild(iframe);
}
// Retry if we haven't exceeded max retries
if (maxRetriesRef.current < 10) {
maxRetriesRef.current++;
isRetryingRef.current = false;
// Exponential backoff: 2s, 3s, 4.5s, 6.75s, etc. (max 15s)
const delay = Math.min(2000 * Math.pow(1.5, maxRetriesRef.current - 1), 15000);
console.log(`[VNC PRELOADER] Retrying in ${delay}ms (attempt ${maxRetriesRef.current + 1}/10)`);
retryTimeoutRef.current = setTimeout(() => {
startPreloading(vncUrl);
}, delay);
} else {
console.log('[VNC PRELOADER] Max retries reached, giving up on preloading');
isRetryingRef.current = false;
}
}, 5000); // 5 second timeout
// Handle successful iframe load
iframe.onload = () => {
clearTimeout(loadTimeout);
console.log('[VNC PRELOADER] ✅ VNC iframe preloaded successfully!');
isPreloadedRef.current = true;
isRetryingRef.current = false;
preloadedIframeRef.current = iframe;
};
// Handle iframe load errors
iframe.onerror = () => {
clearTimeout(loadTimeout);
console.log('[VNC PRELOADER] VNC iframe failed to load (onerror)');
// Clean up current iframe
if (iframe.parentNode) {
iframe.parentNode.removeChild(iframe);
}
// Retry if we haven't exceeded max retries
if (maxRetriesRef.current < 10) {
maxRetriesRef.current++;
isRetryingRef.current = false;
const delay = Math.min(2000 * Math.pow(1.5, maxRetriesRef.current - 1), 15000);
console.log(`[VNC PRELOADER] Retrying in ${delay}ms (attempt ${maxRetriesRef.current + 1}/10)`);
retryTimeoutRef.current = setTimeout(() => {
startPreloading(vncUrl);
}, delay);
} else {
console.log('[VNC PRELOADER] Max retries reached, giving up on preloading');
isRetryingRef.current = false;
}
};
// Add to DOM to start loading
document.body.appendChild(iframe);
console.log('[VNC PRELOADER] VNC iframe added to DOM, waiting for load...');
}, []);
useEffect(() => {
// Only preload if we have project data with VNC info and haven't started preloading yet
if (!project?.sandbox?.vnc_preview || !project?.sandbox?.pass || isPreloadedRef.current || isRetryingRef.current) {
return;
}
const vncUrl = `${project.sandbox.vnc_preview}/vnc_lite.html?password=${project.sandbox.pass}&autoconnect=true&scale=local&width=1024&height=768`;
// Reset retry counter for new project
maxRetriesRef.current = 0;
isRetryingRef.current = false;
// Start the preloading process with a small delay to let the sandbox initialize
const initialDelay = setTimeout(() => {
startPreloading(vncUrl);
}, 1000); // 1 second initial delay
// Cleanup function
return () => {
clearTimeout(initialDelay);
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
if (preloadedIframeRef.current && preloadedIframeRef.current.parentNode) {
preloadedIframeRef.current.parentNode.removeChild(preloadedIframeRef.current);
preloadedIframeRef.current = null;
}
isPreloadedRef.current = false;
isRetryingRef.current = false;
maxRetriesRef.current = 0;
};
}, [project?.sandbox?.vnc_preview, project?.sandbox?.pass, startPreloading]);
return {
isPreloaded: isPreloadedRef.current,
preloadedIframe: preloadedIframeRef.current
};
}