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,11 +81,10 @@ export function ThreadLayout({
)}
<div
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]'
: ''
}`}
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]'
: ''
}`}
>
<SiteHeader
threadId={threadId}
@ -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);
@ -361,9 +365,9 @@ export default function ThreadPage({
const projectData = threadData?.project_id
? await getProject(threadData.project_id).catch((err) => {
console.warn('[SHARE] Could not load project data:', err);
return null;
})
console.warn('[SHARE] Could not load project data:', err);
return null;
})
: null;
if (isMounted) {
@ -423,7 +427,7 @@ export default function ThreadPage({
return assistantMsg.content;
}
})();
// Try to extract tool name from content
const xmlMatch = assistantContent.match(
/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/,
@ -460,7 +464,7 @@ export default function ThreadPage({
return resultMessage.content;
}
})();
// Check for ToolResult pattern first
if (toolResultContent && typeof toolResultContent === 'string') {
// Look for ToolResult(success=True/False) pattern
@ -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 {
@ -721,7 +725,7 @@ export default function ThreadPage({
(m) => m.message_id === assistantId && m.type === 'assistant'
);
if (!assistantMessage) return false;
// Check if this tool call matches
return tc.assistantCall?.content === assistantMessage.content;
});

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
@ -282,7 +282,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
const groupedMessages: MessageGroup[] = [];
let currentGroup: MessageGroup | null = null;
let assistantGroupCounter = 0; // Counter for assistant groups
displayMessages.forEach((message, index) => {
const messageType = message.type;
const key = message.message_id || `msg-${index}`;
@ -306,10 +306,10 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
}
// Create a new assistant group with a group-level key
assistantGroupCounter++;
currentGroup = {
type: 'assistant_group',
messages: [message],
key: `assistant-group-${assistantGroupCounter}`
currentGroup = {
type: 'assistant_group',
messages: [message],
key: `assistant-group-${assistantGroupCounter}`
};
}
} else if (messageType !== 'status') {
@ -320,20 +320,20 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
}
}
});
// Finalize any remaining group
if (currentGroup) {
groupedMessages.push(currentGroup);
}
// 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({
type: 'assistant_group',
type: 'assistant_group',
messages: [{
content: streamingTextContent,
type: 'assistant',
@ -344,7 +344,7 @@ export const ThreadContent: React.FC<ThreadContentProps> = ({
is_llm_message: true,
thread_id: 'streamingTextContent',
sequence: Infinity,
}],
}],
key: `assistant-group-${assistantGroupCounter}-streaming`
});
} else if (lastGroup.type === 'assistant_group') {

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

@ -57,7 +57,7 @@ export function AskToolView({
if (assistantContent) {
try {
const contentStr = normalizeContentToString(assistantContent);
// Extract attachments if present
const attachmentsMatch = contentStr.match(/attachments=["']([^"']*)["']/i);
if (attachmentsMatch) {
@ -92,13 +92,13 @@ export function AskToolView({
</CardTitle>
</div>
</div>
{!isStreaming && (
<Badge
variant="secondary"
<Badge
variant="secondary"
className={
isSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
isSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
}
>
@ -129,12 +129,12 @@ export function AskToolView({
<Paperclip className="h-4 w-4" />
Files ({askData.attachments.length})
</div>
<div className={cn(
"grid gap-3",
askData.attachments.length === 1 ? "grid-cols-1" :
askData.attachments.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
"grid-cols-1 sm:grid-cols-2"
askData.attachments.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
"grid-cols-1 sm:grid-cols-2"
)}>
{askData.attachments
.sort((a, b) => {
@ -142,7 +142,7 @@ export function AskToolView({
const bIsImage = isImageFile(b);
const aIsPreviewable = isPreviewableFile(a);
const bIsPreviewable = isPreviewableFile(b);
if (aIsImage && !bIsImage) return -1;
if (!aIsImage && bIsImage) return 1;
if (aIsPreviewable && !bIsPreviewable) return -1;
@ -152,10 +152,10 @@ export function AskToolView({
.map((attachment, index) => {
const isImage = isImageFile(attachment);
const isPreviewable = isPreviewableFile(attachment);
const shouldSpanFull = (askData.attachments!.length % 2 === 1 &&
askData.attachments!.length > 1 &&
index === askData.attachments!.length - 1);
const shouldSpanFull = (askData.attachments!.length % 2 === 1 &&
askData.attachments!.length > 1 &&
index === askData.attachments!.length - 1);
return (
<div
key={index}
@ -174,7 +174,7 @@ export function AskToolView({
className={cn(
"w-full",
isImage ? "h-auto min-h-[54px]" :
isPreviewable ? "min-h-[240px] max-h-[400px] overflow-auto" : "h-[54px]"
isPreviewable ? "min-h-[240px] max-h-[400px] overflow-auto" : "h-[54px]"
)}
customStyle={
isImage ? {
@ -182,14 +182,14 @@ export function AskToolView({
height: 'auto',
'--attachment-height': shouldSpanFull ? '240px' : '180px'
} as React.CSSProperties :
isPreviewable ? {
gridColumn: '1 / -1'
} :
shouldSpanFull ? {
gridColumn: '1 / -1'
} : {
width: '100%'
}
isPreviewable ? {
gridColumn: '1 / -1'
} :
shouldSpanFull ? {
gridColumn: '1 / -1'
} : {
width: '100%'
}
}
collapsed={false}
project={project}
@ -198,7 +198,7 @@ export function AskToolView({
);
})}
</div>
{assistantTimestamp && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
@ -230,7 +230,7 @@ export function AskToolView({
User Interaction
</Badge>
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{assistantTimestamp ? formatTimestamp(assistantTimestamp) : ''}
</div>

View File

@ -54,7 +54,7 @@ export function CompleteToolView({
if (assistantContent) {
try {
const contentStr = normalizeContentToString(assistantContent);
// Try to extract content from <complete> tag
const completeMatch = contentStr.match(/<complete[^>]*>([^<]*)<\/complete>/);
if (completeMatch) {
@ -88,7 +88,7 @@ export function CompleteToolView({
if (toolContent && !isStreaming) {
try {
const contentStr = normalizeContentToString(toolContent);
// Try to extract from ToolResult pattern
const toolResultMatch = contentStr.match(/ToolResult\([^)]*output=['"]([^'"]+)['"]/);
if (toolResultMatch) {
@ -143,13 +143,13 @@ export function CompleteToolView({
</CardTitle>
</div>
</div>
{!isStreaming && (
<Badge
variant="secondary"
<Badge
variant="secondary"
className={
isSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
isSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
}
>
@ -211,7 +211,7 @@ export function CompleteToolView({
const { icon: FileIcon, color, bgColor } = getFileIconAndColor(attachment);
const fileName = attachment.split('/').pop() || attachment;
const filePath = attachment.includes('/') ? attachment.substring(0, attachment.lastIndexOf('/')) : '';
return (
<button
key={index}
@ -255,7 +255,7 @@ export function CompleteToolView({
</div>
<div className="space-y-2">
{completeData.tasksCompleted.map((task, index) => (
<div
<div
key={index}
className="flex items-start gap-3 p-3 bg-muted/30 rounded-lg border border-border/50"
>
@ -314,7 +314,7 @@ export function CompleteToolView({
Task Completion
</Badge>
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{toolTimestamp && !isStreaming
? formatTimestamp(toolTimestamp)

View File

@ -35,9 +35,9 @@ import { cn } from '@/lib/utils';
import { useTheme } from 'next-themes';
import { CodeBlockCode } from '@/components/ui/code-block';
import { constructHtmlPreviewUrl } from '@/lib/utils/url';
import {
Card,
CardContent,
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
@ -168,16 +168,16 @@ export function FileOperationToolView({
};
const operation = getOperationType();
let filePath: string | null = null;
let fileContent: string | null = null;
// console.log('[FileOperationToolView] Debug:', {
// isStreaming,
// assistantContent,
// assistantContentType: typeof assistantContent,
// });
if (assistantContent) {
try {
const parsed = typeof assistantContent === 'string' ? JSON.parse(assistantContent) : assistantContent;
@ -190,7 +190,7 @@ export function FileOperationToolView({
/file_path=["']([^"']*?)["']/i,
/<(?:create-file|delete-file|full-file-rewrite)\s+file_path=["']([^"']*)/i,
];
for (const pattern of filePathPatterns) {
const match = messageContent.match(pattern);
if (match && match[1]) {
@ -199,7 +199,7 @@ export function FileOperationToolView({
break;
}
}
if (operation !== 'delete' && !fileContent) {
const tagName = operation === 'create' ? 'create-file' : 'full-file-rewrite';
const openTagMatch = messageContent.match(new RegExp(`<${tagName}[^>]*>`, 'i'));
@ -207,7 +207,7 @@ export function FileOperationToolView({
const tagEndIndex = messageContent.indexOf(openTagMatch[0]) + openTagMatch[0].length;
const afterTag = messageContent.substring(tagEndIndex);
const closeTagMatch = afterTag.match(new RegExp(`<\\/${tagName}>`, 'i'));
fileContent = closeTagMatch
fileContent = closeTagMatch
? afterTag.substring(0, closeTagMatch.index)
: afterTag;
console.log('[FileOperationToolView] Extracted file content length:', fileContent?.length);
@ -223,7 +223,7 @@ export function FileOperationToolView({
if ('content' in parsed && operation !== 'delete') {
fileContent = parsed.content;
}
if ('arguments' in parsed && parsed.arguments) {
const args = typeof parsed.arguments === 'string' ? JSON.parse(parsed.arguments) : parsed.arguments;
if (args.file_path) {
@ -241,7 +241,7 @@ export function FileOperationToolView({
/<(?:create-file|delete-file|full-file-rewrite)\s+file_path=["']([^"']*)/i,
/<file_path>([^<]*)/i,
];
for (const pattern of filePathPatterns) {
const match = assistantContent.match(pattern);
if (match && match[1]) {
@ -250,7 +250,7 @@ export function FileOperationToolView({
break;
}
}
if (operation !== 'delete' && !fileContent) {
const tagName = operation === 'create' ? 'create-file' : 'full-file-rewrite';
const contentAfterTag = assistantContent.split(`<${tagName}`)[1];
@ -259,7 +259,7 @@ export function FileOperationToolView({
if (tagEndIndex !== -1) {
const potentialContent = contentAfterTag.substring(tagEndIndex + 1);
const closingTagIndex = potentialContent.indexOf(`</${tagName}>`);
fileContent = closingTagIndex !== -1
fileContent = closingTagIndex !== -1
? potentialContent.substring(0, closingTagIndex)
: potentialContent;
}
@ -268,27 +268,27 @@ export function FileOperationToolView({
}
}
}
// console.log('[FileOperationToolView] After initial extraction:', { filePath, hasFileContent: !!fileContent });
if (!filePath) {
filePath = extractFilePath(assistantContent);
// console.log('[FileOperationToolView] After extractFilePath utility:', filePath);
}
if (!fileContent && operation !== 'delete') {
fileContent = isStreaming
? extractStreamingFileContent(
assistantContent,
operation === 'create' ? 'create-file' : 'full-file-rewrite',
) || ''
assistantContent,
operation === 'create' ? 'create-file' : 'full-file-rewrite',
) || ''
: extractFileContent(
assistantContent,
operation === 'create' ? 'create-file' : 'full-file-rewrite',
);
assistantContent,
operation === 'create' ? 'create-file' : 'full-file-rewrite',
);
// console.log('[FileOperationToolView] After content extraction utilities:', { hasFileContent: !!fileContent, contentLength: fileContent?.length });
}
const toolTitle = getToolTitle(name || `file-${operation}`);
const processedFilePath = filePath
@ -303,14 +303,14 @@ export function FileOperationToolView({
const isMarkdown = fileExtension === 'md';
const isHtml = fileExtension === 'html' || fileExtension === 'htm';
const isCsv = fileExtension === 'csv';
const language = getLanguageFromFileName(fileName);
const hasHighlighting = language !== 'text';
const contentLines = fileContent
? fileContent.replace(/\\n/g, '\n').split('\n')
: [];
const htmlPreviewUrl =
isHtml && project?.sandbox?.sandbox_url && processedFilePath
? constructHtmlPreviewUrl(project.sandbox.sandbox_url, processedFilePath)
@ -364,7 +364,7 @@ export function FileOperationToolView({
if (fileName.endsWith('.html')) return FileCode;
return File;
};
const FileIcon = getFileIcon();
if (!isStreaming && !processedFilePath && !fileContent) {
@ -395,17 +395,17 @@ export function FileOperationToolView({
if (isHtml && htmlPreviewUrl) {
return (
<div className="flex flex-col h-[calc(100vh-16rem)]">
<iframe
<div className="flex flex-col h-[calc(100vh-16rem)]">
<iframe
src={htmlPreviewUrl}
title={`HTML Preview of ${fileName}`}
className="flex-grow border-0"
className="flex-grow border-0"
sandbox="allow-same-origin allow-scripts"
/>
</div>
);
}
if (isMarkdown) {
return (
<div className="p-1 py-0 prose dark:prose-invert prose-zinc max-w-none">
@ -415,7 +415,7 @@ export function FileOperationToolView({
</div>
);
}
if (isCsv) {
return (
<div className="h-full w-full p-4">
@ -429,9 +429,9 @@ export function FileOperationToolView({
return (
<div className="p-4">
<div className='w-full h-full bg-muted/20 border rounded-xl px-4 py-2 pb-6'>
<pre className="text-sm font-mono text-zinc-800 dark:text-zinc-300 whitespace-pre-wrap break-words">
{processUnicodeContent(fileContent)}
</pre>
<pre className="text-sm font-mono text-zinc-800 dark:text-zinc-300 whitespace-pre-wrap break-words">
{processUnicodeContent(fileContent)}
</pre>
</div>
</div>
);
@ -479,7 +479,7 @@ export function FileOperationToolView({
<TabsContent value="code" className="flex-1 h-full mt-0 p-0 overflow-hidden">
<ScrollArea className="h-screen w-full min-h-0">
{isStreaming && !fileContent ? (
<LoadingState
<LoadingState
icon={Icon}
iconColor={config.color}
bgColor={config.bgColor}
@ -551,11 +551,11 @@ export function FileOperationToolView({
)}
</ScrollArea>
</TabsContent>
<TabsContent value="preview" className="w-full flex-1 h-full mt-0 p-0 overflow-hidden">
<ScrollArea className="h-full w-full min-h-0">
{isStreaming && !fileContent ? (
<LoadingState
<LoadingState
icon={Icon}
iconColor={config.color}
bgColor={config.bgColor}
@ -584,7 +584,7 @@ export function FileOperationToolView({
) : (
renderFilePreview()
)}
{/* Streaming indicator overlay */}
{isStreaming && fileContent && (
<div className="sticky bottom-4 right-4 float-right mr-4 mb-4">
@ -597,7 +597,7 @@ export function FileOperationToolView({
</ScrollArea>
</TabsContent>
</CardContent>
<div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<Badge variant="outline" className="py-0.5 h-6">
@ -605,7 +605,7 @@ export function FileOperationToolView({
{hasHighlighting ? language.toUpperCase() : fileExtension.toUpperCase() || 'TEXT'}
</Badge>
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{toolTimestamp && !isStreaming
? formatTimestamp(toolTimestamp)

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);
@ -73,7 +74,7 @@ export function WebSearchToolView({
} else {
parsedContent = {};
}
// Check if it's the response format with answer
if (parsedContent.answer && typeof parsedContent.answer === 'string') {
setAnswer(parsedContent.answer);
@ -100,7 +101,7 @@ export function WebSearchToolView({
const getResultType = (result: any) => {
const { url, title } = result;
if (url.includes('news') || url.includes('article') || title.includes('News')) {
return { icon: FileText, label: 'Article' };
} else if (url.includes('wiki')) {
@ -126,13 +127,13 @@ export function WebSearchToolView({
</CardTitle>
</div>
</div>
{!isStreaming && (
<Badge
variant="secondary"
<Badge
variant="secondary"
className={
isSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
isSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
}
>
@ -149,7 +150,7 @@ export function WebSearchToolView({
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
{isStreaming ? (
<LoadingState
<LoadingState
icon={Search}
iconColor="text-blue-500 dark:text-blue-400"
bgColor="bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60 dark:shadow-blue-950/20"
@ -226,24 +227,25 @@ 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);
return (
<div
key={idx}
<div
key={idx}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg shadow-sm hover:shadow transition-shadow overflow-hidden"
>
<div className="p-4">
<div className="flex items-start gap-3 mb-2">
{favicon && (
<img
src={favicon}
alt=""
<img
src={favicon}
alt=""
className="w-5 h-5 mt-1 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
}}
/>
)}
<div className="flex-1 min-w-0">
@ -284,25 +286,36 @@ export function WebSearchToolView({
</Tooltip>
</TooltipProvider>
</div>
{result.snippet && (
<p className={cn(
"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>
{isExpanded && (
<div className="bg-zinc-50 px-4 dark:bg-zinc-800/50 border-t border-zinc-200 dark:border-zinc-800 p-3 flex justify-between items-center">
<div className="text-xs text-zinc-500 dark:text-zinc-400">
Source: {cleanUrl(result.url)}
</div>
<Button
variant="outline"
size="sm"
<Button
variant="outline"
size="sm"
className="h-7 text-xs bg-white dark:bg-zinc-900"
asChild
>
@ -338,7 +351,7 @@ export function WebSearchToolView({
</div>
)}
</CardContent>
<div className="px-4 py-2 h-10 bg-gradient-to-r from-zinc-50/90 to-zinc-100/90 dark:from-zinc-900/90 dark:to-zinc-800/90 backdrop-blur-sm border-t border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-4">
<div className="h-full flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
{!isStreaming && searchResults.length > 0 && (
@ -348,7 +361,7 @@ export function WebSearchToolView({
</Badge>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{toolTimestamp && !isStreaming
? formatTimestamp(toolTimestamp)

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 {
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
};
}