From acea40314de6ded31fb009ac28eec8d3bfb7feba Mon Sep 17 00:00:00 2001 From: spencrmartin Date: Thu, 20 Nov 2025 13:55:31 -0500 Subject: [PATCH] Implement per-tab sidecars with resizing and original styling - Added TabSidecar component with proper drop shadow, rounded corners, and border styling - Implemented ResizableSplitter for sidecar resizing functionality (30-80% width range) - Added view mode toggle for diff viewers (split/unified) - Extended TabContext with sidecar management functions (showDiffViewer, showLocalhostViewer, etc.) - Created TabSidecarInvoker to replace global SidecarInvoker - Updated TabbedChatContainer to use ResizableSplitter with floatingRight styling - Removed global sidecar dependencies from BaseChat2 - Each tab now maintains independent sidecar state and content --- ui/desktop/src/components/BaseChat2.tsx | 101 +----- ui/desktop/src/components/TabBar.tsx | 18 ++ ui/desktop/src/components/TabSidecar.tsx | 125 ++++++++ .../src/components/TabSidecarInvoker.tsx | 287 ++++++++++++++++++ .../src/components/TabbedChatContainer.tsx | 87 ++++-- ui/desktop/src/contexts/TabContext.tsx | 211 ++++++++++++- 6 files changed, 718 insertions(+), 111 deletions(-) create mode 100644 ui/desktop/src/components/TabSidecar.tsx create mode 100644 ui/desktop/src/components/TabSidecarInvoker.tsx diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index f6e6f1f1da..dd8fed3889 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -23,8 +23,7 @@ import RecipeActivities from './recipes/RecipeActivities'; import { useToolCount } from './alerts/useToolCount'; import { getThinkingMessage } from '../types/message'; import ParameterInputModal from './ParameterInputModal'; -import { SidecarInvoker } from './Layout/SidecarInvoker'; -import { useSidecar } from './SidecarLayout'; +import { TabSidecarInvoker } from './TabSidecarInvoker'; import ParticipantsBar from './ParticipantsBar'; import PendingInvitesInHistory from './PendingInvitesInHistory'; @@ -48,6 +47,8 @@ interface BaseChatProps { disableSearch?: boolean; showPopularTopics?: boolean; loadingChat?: boolean; + // Tab-specific sidecar props + tabId?: string; } function BaseChatContent({ @@ -66,6 +67,7 @@ function BaseChatContent({ disableSearch = false, showPopularTopics = true, loadingChat = false, + tabId, }: BaseChatProps) { const location = useLocation(); const scrollRef = useRef(null); @@ -84,88 +86,7 @@ function BaseChatContent({ // Hover state for sidecar dock const [isHoveringChatInput, setIsHoveringChatInput] = useState(false); - // Sidecar functionality - const sidecar = useSidecar(); - const handleShowLocalhost = () => { - console.log('Localhost viewer requested'); - if (sidecar) { - sidecar.showLocalhostViewer('http://localhost:3000', 'Localhost Viewer'); - } - }; - - const handleShowFileViewer = (filePath: string) => { - console.log('File viewer requested for:', filePath); - if (sidecar) { - sidecar.showFileViewer(filePath); - } - }; - - const handleAddContainer = (type: 'sidecar' | 'localhost' | 'file' | 'document-editor' | 'web-viewer' | 'app-installer', filePath?: string) => { - console.log('Add container requested:', type, filePath); - - if (!sidecar) { - console.error('No sidecar context available'); - return; - } - - // Use sidecar context directly instead of dispatching events - switch (type) { - case 'sidecar': - // Show a generic sidecar view - sidecar.showView({ - id: `sidecar-${Date.now()}`, - title: 'Sidecar', - icon:
, - content: ( -
-

Sidecar content will go here

-
- ), - }); - break; - case 'localhost': - sidecar.showLocalhostViewer('http://localhost:3000', 'Localhost Viewer'); - break; - case 'file': - if (filePath) { - sidecar.showFileViewer(filePath); - } - break; - case 'document-editor': - sidecar.showDocumentEditor(filePath); - break; - case 'web-viewer': - // Show a proper web viewer - sidecar.showView({ - id: `web-viewer-${Date.now()}`, - title: 'Web Viewer', - icon:
, - content: null, // Will be rendered by contentType - contentType: 'web-viewer', - contentProps: { - initialUrl: 'https://google.com', - allowAllSites: true - } - }); - break; - case 'app-installer': - // Show a generic app installer view - sidecar.showView({ - id: `app-installer-${Date.now()}`, - title: 'App Installer', - icon:
, - content: ( -
-

App installer will go here

-
- ), - }); - break; - default: - console.warn('Unknown container type:', type); - } - }; // Use shared file drop const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); @@ -534,14 +455,14 @@ function BaseChatContent({ onMouseEnter={() => setIsHoveringChatInput(true)} onMouseLeave={() => setIsHoveringChatInput(false)} > - {/* Sidecar Invoker Dock - positioned above ChatInput with proper spacing */} + {/* Tab Sidecar Invoker Dock - positioned above ChatInput with proper spacing */}
- + {tabId && ( + + )}
void; + className?: string; +} + +export const TabSidecar: React.FC = ({ + sidecarState, + onHideView, + className = '' +}) => { + const [viewMode, setViewMode] = useState<'split' | 'unified'>('unified'); + + // Get the first active view (for now, we'll show one at a time) + const currentViewId = sidecarState.activeViews[0]; + const currentView = sidecarState.views.find(v => v.id === currentViewId); + + if (!currentView || !sidecarState.activeViews.includes(currentView.id)) { + return null; + } + + // Check if current view is diff viewer + const isDiffViewer = currentView.id.startsWith('diff'); + + // Update the diff viewer when view mode changes + React.useEffect(() => { + if (isDiffViewer && (window as any).diffViewerControls) { + (window as any).diffViewerControls.setViewMode(viewMode); + } + }, [viewMode, isDiffViewer]); + + return ( +
+
+ {/* Sidecar Header */} +
+
+ {currentView.icon} +
+ {currentView.title} + {currentView.fileName && ( + {currentView.fileName} + )} +
+
+ +
+ {/* View Mode Toggle - Only show for diff viewer */} + {isDiffViewer && ( +
+ + + + + + Unified View + + + + + + + + + Split View + + +
+ )} + + {/* Close Button */} + + + + + Close + +
+
+ + {/* Sidecar Content */} +
+ {currentView.content} +
+
+
+ ); +}; diff --git a/ui/desktop/src/components/TabSidecarInvoker.tsx b/ui/desktop/src/components/TabSidecarInvoker.tsx new file mode 100644 index 0000000000..fb7687c3aa --- /dev/null +++ b/ui/desktop/src/components/TabSidecarInvoker.tsx @@ -0,0 +1,287 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Plus, Globe, FileText, Edit, ExternalLink, Download, Code, Folder, Terminal, Monitor } from 'lucide-react'; +import { Button } from './ui/button'; +import { useTabContext } from '../contexts/TabContext'; + +interface TabSidecarInvokerProps { + tabId: string; + isVisible: boolean; +} + +export const TabSidecarInvoker: React.FC = ({ + tabId, + isVisible +}) => { + // ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL LOGIC + const [isHovering, setIsHovering] = useState(false); + const [iframeBackdrops, setIframeBackdrops] = useState([]); + const containerRef = useRef(null); + + // Get tab-specific sidecar functions + const { + showLocalhostViewer, + showFileViewer, + showDocumentEditor, + showDiffViewer + } = useTabContext(); + + // Handle click outside to close dock + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsHovering(false); + } + }; + + if (isHovering) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isHovering]); + + // Component is always rendered but hidden/shown with CSS transitions + + const handleLocalhostClick = () => { + console.log('🔍 TabSidecarInvoker: Localhost button clicked for tab:', tabId); + showLocalhostViewer(tabId, 'http://localhost:3000', 'Localhost Viewer'); + setIsHovering(false); + }; + + const handleFileViewerClick = async () => { + try { + console.log('File viewer button clicked for tab:', tabId); + + // Use Electron's selectFileOrDirectory API + const filePath = await window.electron.selectFileOrDirectory(); + + console.log('Selected file path:', filePath); + + if (filePath) { + showFileViewer(tabId, filePath); + } else { + console.log('No file selected'); + } + } catch (error) { + console.error('Error opening file dialog:', error); + } + + setIsHovering(false); + }; + + const handleDocumentEditorClick = () => { + console.log('🔍 TabSidecarInvoker: Document Editor button clicked for tab:', tabId); + showDocumentEditor(tabId, undefined, 'Start writing your document...', 'new-doc'); + setIsHovering(false); + }; + + const handleEditFileClick = async () => { + try { + console.log('Edit file button clicked for tab:', tabId); + + // Use Electron's selectFileOrDirectory API + const filePath = await window.electron.selectFileOrDirectory(); + + console.log('Selected file path for editing:', filePath); + + if (filePath) { + showDocumentEditor(tabId, filePath, undefined, 'edit-file'); + } else { + console.log('No file selected for editing'); + } + } catch (error) { + console.error('Error opening file dialog for editing:', error); + } + + setIsHovering(false); + }; + + const handleWebViewerClick = () => { + console.log('🔍 TabSidecarInvoker: Web Viewer button clicked for tab:', tabId); + showLocalhostViewer(tabId, 'https://google.com', 'Web Browser', 'web-viewer'); + setIsHovering(false); + }; + + const handleDiffViewerClick = () => { + console.log('🔍 TabSidecarInvoker: Diff Viewer button clicked for tab:', tabId); + // For demo purposes, show a sample diff + const sampleDiff = `--- a/example.js ++++ b/example.js +@@ -1,3 +1,4 @@ + function hello() { ++ console.log("Hello world!"); + return "Hello"; + }`; + showDiffViewer(tabId, sampleDiff, 'example.js', 'sample-diff'); + setIsHovering(false); + }; + + const handleMouseEnter = async () => { + setIsHovering(true); + // Create iframe backdrops to show live content behind the dock + try { + const result = await window.electron.createIframeBackdrop(); + if (result.success && result.backdropData) { + setIframeBackdrops(result.backdropData); + console.log('🎬 Created iframe backdrops:', result.backdropData.length); + } + } catch (error) { + console.error('Failed to create iframe backdrop:', error); + } + }; + + const handleMouseLeave = async () => { + setIsHovering(false); + // Remove iframe backdrops and restore BrowserViews + try { + await window.electron.removeIframeBackdrop(); + setIframeBackdrops([]); + console.log('🎬 Removed iframe backdrops'); + } catch (error) { + console.error('Failed to remove iframe backdrop:', error); + } + }; + + // Define dock apps with proper icons and colors + const dockApps = [ + { + id: 'document-editor', + name: 'TextEdit', + icon: Edit, + color: 'from-gray-400 to-gray-600', + onClick: handleDocumentEditorClick, + description: 'Create new document' + }, + { + id: 'file-editor', + name: 'Code Editor', + icon: Code, + color: 'from-indigo-500 to-purple-600', + onClick: handleEditFileClick, + description: 'Edit existing file' + }, + { + id: 'web-viewer', + name: 'Safari', + icon: Monitor, + color: 'from-blue-400 to-cyan-500', + onClick: handleWebViewerClick, + description: 'Browse the web' + }, + { + id: 'localhost', + name: 'Terminal', + icon: Terminal, + color: 'from-gray-800 to-black', + onClick: handleLocalhostClick, + description: 'View localhost apps' + }, + { + id: 'file-viewer', + name: 'Finder', + icon: Folder, + color: 'from-blue-500 to-blue-700', + onClick: handleFileViewerClick, + description: 'Browse files' + }, + { + id: 'diff-viewer', + name: 'Diff', + icon: FileText, + color: 'from-green-500 to-green-600', + onClick: handleDiffViewerClick, + description: 'View code diffs' + } + ]; + + return ( +
+ {/* Screenshot backdrops - positioned behind the dock */} + {iframeBackdrops.map((backdrop) => ( +
+ ))} + + {/* Horizontal dock above chat input */} +
+
+ {/* Dock container - horizontal layout with proper padding */} +
+
+ {dockApps.map((app, index) => ( +
+ {/* App icon with staggered animations */} + + + {/* Tooltip */} +
+
+ {app.description} +
+
+
+ + {/* Active indicator dot (like macOS dock) */} +
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/ui/desktop/src/components/TabbedChatContainer.tsx b/ui/desktop/src/components/TabbedChatContainer.tsx index 5788747786..d2fc186a29 100644 --- a/ui/desktop/src/components/TabbedChatContainer.tsx +++ b/ui/desktop/src/components/TabbedChatContainer.tsx @@ -1,7 +1,9 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { TabBar } from './TabBar'; import BaseChat2 from './BaseChat2'; +import { TabSidecar } from './TabSidecar'; import { useTabContext } from '../contexts/TabContext'; +import { ResizableSplitter } from './Layout/ResizableSplitter'; interface TabbedChatContainerProps { setIsGoosehintsModalOpen?: (isOpen: boolean) => void; @@ -29,7 +31,10 @@ export const TabbedChatContainer: React.FC = ({ getActiveTabState, syncTabTitleWithBackend, updateTabTitleFromMessage, - updateSessionId + updateSessionId, + // Sidecar functions + hideSidecarView, + getSidecarState } = useTabContext(); const handleMessageSubmitWrapper = useCallback(async (message: string, tabId: string) => { @@ -169,7 +174,12 @@ export const TabbedChatContainer: React.FC = ({ } }, [getActiveTabState, updateSessionId, handleChatUpdate]); + // Sidecar resizing state + const [chatWidth, setChatWidth] = useState(60); // Default 60% for chat, 40% for sidecar + const activeTabState = getActiveTabState(); + const sidecarState = activeTabState ? getSidecarState(activeTabState.tab.id) : undefined; + const hasSidecar = sidecarState && sidecarState.activeViews.length > 0; return (
@@ -185,25 +195,64 @@ export const TabbedChatContainer: React.FC = ({ />
- {/* Active Chat - Takes remaining space with rounded top corners */} + {/* Main Content Area - Chat and Sidecar */}
{activeTabState && ( - handleMessageSubmitWrapper(message, activeTabState.tab.id)} - onSessionIdChange={(newSessionId) => updateSessionId(activeTabState.tab.id, newSessionId)} - suppressEmptyState={false} - showPopularTopics={true} - loadingChat={activeTabState.loadingChat} - initialMessage={initialMessage} - // Matrix props (if this tab is a Matrix session) - showParticipantsBar={activeTabState.tab.type === 'matrix'} - matrixRoomId={activeTabState.tab.matrixRoomId} - showPendingInvites={activeTabState.tab.type === 'matrix'} - /> + <> + {hasSidecar ? ( + /* Resizable Split Layout: Chat + Sidecar */ + handleMessageSubmitWrapper(message, activeTabState.tab.id)} + onSessionIdChange={(newSessionId) => updateSessionId(activeTabState.tab.id, newSessionId)} + suppressEmptyState={false} + showPopularTopics={true} + loadingChat={activeTabState.loadingChat} + initialMessage={initialMessage} + showParticipantsBar={activeTabState.tab.type === 'matrix'} + matrixRoomId={activeTabState.tab.matrixRoomId} + showPendingInvites={activeTabState.tab.type === 'matrix'} + tabId={activeTabState.tab.id} + /> + } + rightContent={ + hideSidecarView(activeTabState.tab.id, viewId)} + /> + } + initialLeftWidth={chatWidth} + minLeftWidth={30} + maxLeftWidth={80} + onResize={setChatWidth} + className="h-full" + floatingRight={true} + /> + ) : ( + /* Full Width Chat */ + handleMessageSubmitWrapper(message, activeTabState.tab.id)} + onSessionIdChange={(newSessionId) => updateSessionId(activeTabState.tab.id, newSessionId)} + suppressEmptyState={false} + showPopularTopics={true} + loadingChat={activeTabState.loadingChat} + initialMessage={initialMessage} + showParticipantsBar={activeTabState.tab.type === 'matrix'} + matrixRoomId={activeTabState.tab.matrixRoomId} + showPendingInvites={activeTabState.tab.type === 'matrix'} + tabId={activeTabState.tab.id} + /> + )} + )}
diff --git a/ui/desktop/src/contexts/TabContext.tsx b/ui/desktop/src/contexts/TabContext.tsx index e20f2fe558..bfa76b94cc 100644 --- a/ui/desktop/src/contexts/TabContext.tsx +++ b/ui/desktop/src/contexts/TabContext.tsx @@ -1,8 +1,9 @@ import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; -import { Tab } from '../components/TabBar'; +import { Tab, TabSidecarState, TabSidecarView } from '../components/TabBar'; import { ChatType } from '../types/chat'; import { generateSessionId } from '../utils/sessionUtils'; import { getSession, updateSessionDescription } from '../api'; +import { FileDiff, Globe, FileText, Edit } from 'lucide-react'; interface TabState { tab: Tab; @@ -26,6 +27,15 @@ interface TabContextType { updateTabTitleFromMessage: (tabId: string, message: string | any) => Promise; openExistingSession: (sessionId: string, title?: string) => void; updateSessionId: (tabId: string, newSessionId: string) => void; + // Sidecar management functions + showSidecarView: (tabId: string, view: TabSidecarView) => void; + hideSidecarView: (tabId: string, viewId: string) => void; + hideAllSidecarViews: (tabId: string) => void; + getSidecarState: (tabId: string) => TabSidecarState | undefined; + showDiffViewer: (tabId: string, diffContent: string, fileName?: string, instanceId?: string) => void; + showLocalhostViewer: (tabId: string, url?: string, title?: string, instanceId?: string) => void; + showFileViewer: (tabId: string, filePath: string, instanceId?: string) => void; + showDocumentEditor: (tabId: string, filePath?: string, initialContent?: string, instanceId?: string) => void; } const TabContext = createContext(undefined); @@ -395,6 +405,194 @@ export const TabProvider: React.FC = ({ children }) => { )); }, []); + // Sidecar management functions + const showSidecarView = useCallback((tabId: string, view: TabSidecarView) => { + console.log('🔧 TabContext: Showing sidecar view for tab:', tabId, 'view:', view.id); + + setTabStates(prev => prev.map(ts => { + if (ts.tab.id !== tabId) return ts; + + const currentSidecarState = ts.tab.sidecarState || { activeViews: [], views: [] }; + + // Add or update the view + const existingViewIndex = currentSidecarState.views.findIndex(v => v.id === view.id); + let updatedViews; + if (existingViewIndex >= 0) { + updatedViews = [...currentSidecarState.views]; + updatedViews[existingViewIndex] = view; + } else { + updatedViews = [...currentSidecarState.views, view]; + } + + // Add to active views if not already active + const updatedActiveViews = currentSidecarState.activeViews.includes(view.id) + ? currentSidecarState.activeViews + : [...currentSidecarState.activeViews, view.id]; + + return { + ...ts, + tab: { + ...ts.tab, + sidecarState: { + activeViews: updatedActiveViews, + views: updatedViews + } + } + }; + })); + }, []); + + const hideSidecarView = useCallback((tabId: string, viewId: string) => { + console.log('🔧 TabContext: Hiding sidecar view for tab:', tabId, 'view:', viewId); + + setTabStates(prev => prev.map(ts => { + if (ts.tab.id !== tabId || !ts.tab.sidecarState) return ts; + + return { + ...ts, + tab: { + ...ts.tab, + sidecarState: { + ...ts.tab.sidecarState, + activeViews: ts.tab.sidecarState.activeViews.filter(id => id !== viewId) + } + } + }; + })); + }, []); + + const hideAllSidecarViews = useCallback((tabId: string) => { + console.log('🔧 TabContext: Hiding all sidecar views for tab:', tabId); + + setTabStates(prev => prev.map(ts => { + if (ts.tab.id !== tabId || !ts.tab.sidecarState) return ts; + + return { + ...ts, + tab: { + ...ts.tab, + sidecarState: { + ...ts.tab.sidecarState, + activeViews: [] + } + } + }; + })); + }, []); + + const getSidecarState = useCallback((tabId: string): TabSidecarState | undefined => { + const tabState = tabStates.find(ts => ts.tab.id === tabId); + return tabState?.tab.sidecarState; + }, [tabStates]); + + // Helper function to create sidecar views for specific types + const showDiffViewer = useCallback((tabId: string, diffContent: string, fileName = 'File', instanceId?: string) => { + const id = instanceId ? `diff-${instanceId}` : 'diff'; + + // Simple diff viewer component + const MonacoDiffViewer = ({ diffContent: content }: { diffContent: string }) => ( +
+
{content}
+
+ ); + + const diffView: TabSidecarView = { + id, + title: 'Diff Viewer', + icon: , + content: , + fileName, + instanceId, + }; + + showSidecarView(tabId, diffView); + }, [showSidecarView]); + + const showLocalhostViewer = useCallback((tabId: string, url = 'http://localhost:3000', title = 'Localhost Viewer', instanceId?: string) => { + const id = instanceId ? `localhost-${instanceId}` : 'localhost'; + + // Simple iframe component for localhost viewer + const LocalhostViewer = ({ url: viewerUrl }: { url: string }) => ( +
+