diff --git a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx index 030074ef..422bf3ea 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx @@ -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(null); + const [filePathList, setFilePathList] = useState(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} diff --git a/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx b/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx index ae61e78d..b891a1a0 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx @@ -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({ )}
)} diff --git a/frontend/src/app/share/[threadId]/page.tsx b/frontend/src/app/share/[threadId]/page.tsx index 7ecf6caf..6e816c23 100644 --- a/frontend/src/app/share/[threadId]/page.tsx +++ b/frontend/src/app/share/[threadId]/page.tsx @@ -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; }); diff --git a/frontend/src/components/thread/attachment-group.tsx b/frontend/src/components/thread/attachment-group.tsx index f28fb716..9ceb17c6 100644 --- a/frontend/src/components/thread/attachment-group.tsx +++ b/frontend/src/components/thread/attachment-group.tsx @@ -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); } }; diff --git a/frontend/src/components/thread/content/ThreadContent.tsx b/frontend/src/components/thread/content/ThreadContent.tsx index d3fea3a3..594cfad5 100644 --- a/frontend/src/components/thread/content/ThreadContent.tsx +++ b/frontend/src/components/thread/content/ThreadContent.tsx @@ -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 = ({ 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 = ({ } // 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 = ({ } } }); - + // 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 = ({ is_llm_message: true, thread_id: 'streamingTextContent', sequence: Infinity, - }], + }], key: `assistant-group-${assistantGroupCounter}-streaming` }); } else if (lastGroup.type === 'assistant_group') { diff --git a/frontend/src/components/thread/file-attachment.tsx b/frontend/src/components/thread/file-attachment.tsx index 4465a17a..a98f137f 100644 --- a/frontend/src/components/thread/file-attachment.tsx +++ b/frontend/src/components/thread/file-attachment.tsx @@ -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; diff --git a/frontend/src/components/thread/file-viewer-modal.tsx b/frontend/src/components/thread/file-viewer-modal.tsx index b9c921a5..8ab0d61c 100644 --- a/frontend/src/components/thread/file-viewer-modal.tsx +++ b/frontend/src/components/thread/file-viewer-modal.tsx @@ -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(-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 ( - + Workspace Files +
+ {/* 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; + })() && ( + <> + +
+ {currentFileIndex + 1} / {filePathList.length} +
+ + + )} +
{/* Navigation Bar */} diff --git a/frontend/src/components/thread/tool-views/AskToolView.tsx b/frontend/src/components/thread/tool-views/AskToolView.tsx index 7c2e1a0c..90d260fe 100644 --- a/frontend/src/components/thread/tool-views/AskToolView.tsx +++ b/frontend/src/components/thread/tool-views/AskToolView.tsx @@ -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({
- + {!isStreaming && ( - @@ -129,12 +129,12 @@ export function AskToolView({ Files ({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 (
- + {assistantTimestamp && (
@@ -230,7 +230,7 @@ export function AskToolView({ User Interaction
- +
{assistantTimestamp ? formatTimestamp(assistantTimestamp) : ''}
diff --git a/frontend/src/components/thread/tool-views/CompleteToolView.tsx b/frontend/src/components/thread/tool-views/CompleteToolView.tsx index faff5278..26a4d5f0 100644 --- a/frontend/src/components/thread/tool-views/CompleteToolView.tsx +++ b/frontend/src/components/thread/tool-views/CompleteToolView.tsx @@ -54,7 +54,7 @@ export function CompleteToolView({ if (assistantContent) { try { const contentStr = normalizeContentToString(assistantContent); - + // Try to extract content from tag const completeMatch = contentStr.match(/]*>([^<]*)<\/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({
- + {!isStreaming && ( - @@ -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 (