diff --git a/MULTI_PANEL_DEMO.md b/MULTI_PANEL_DEMO.md new file mode 100644 index 0000000000..e9ec34ddd2 --- /dev/null +++ b/MULTI_PANEL_DEMO.md @@ -0,0 +1,127 @@ +# Multi-Panel Sidecar System Demo + +## Overview +The new multi-panel sidecar system allows you to have multiple sidecar panels in different layouts within each chat tab. When you have 2 or more sidecar views active, the system automatically switches to the multi-panel mode. + +## Features Implemented + +### 1. **Automatic Layout Switching** +- **Single Panel**: When only 1 sidecar view is active, uses the original `TabSidecar` component +- **Multi-Panel**: When 2+ sidecar views are active, switches to `MultiPanelTabSidecar` component + +### 2. **Layout Modes** +- **Columns**: Two panels side by side (default for 2 panels) +- **Rows**: Two panels stacked vertically +- **Grid**: 2x2 grid layout (default for 3+ panels) +- **Custom**: Flexible positioning (future enhancement) + +### 3. **Panel Management** +- Individual panel headers with close buttons +- Layout switching controls in the main header +- Responsive sizing based on content +- Smooth animations between layout changes + +### 4. **Content Types Supported** +- **Diff Viewer**: Code diffs with unified/split view toggle +- **Web Browser**: External websites and web apps +- **Localhost Viewer**: Local development servers +- **File Viewer**: File content display +- **Document Editor**: Rich text editing + +## How to Test + +### 1. **Open Multiple Sidecar Views** +In a chat, use goose to open multiple sidecar views: + +``` +Hey goose, can you: +1. Show me a diff of some file changes +2. Open a localhost viewer for port 3000 +3. Open a web browser to google.com +``` + +### 2. **Layout Switching** +Once you have 2+ panels: +- Click the column/row/grid icons in the header to change layouts +- Use the dropdown menu for additional options +- Close individual panels using the X button on each panel + +### 3. **Panel Features** +- Each panel maintains its own state +- Diff viewers have unified/split toggle +- Web browsers are fully functional +- File viewers show content appropriately + +## Technical Implementation + +### Key Components +- `MultiPanelTabSidecar.tsx`: Main multi-panel component +- `TabbedChatContainer.tsx`: Updated to conditionally use multi-panel +- `TabContext.tsx`: Manages sidecar state per tab + +### Layout Logic +```typescript +// Auto-switch based on active view count +if (activeViews.length <= 1) { + setLayoutMode('single'); +} else if (activeViews.length === 2 && layoutMode === 'single') { + setLayoutMode('columns'); +} else if (activeViews.length > 2 && (layoutMode === 'single' || layoutMode === 'columns')) { + setLayoutMode('grid'); +} +``` + +### Rendering Strategy +```typescript +// Conditional rendering in TabbedChatContainer +sidecarState && sidecarState.activeViews.length > 1 ? ( + +) : ( + +) +``` + +## Future Enhancements + +### Phase 2 (Planned) +- [ ] Drag and drop panel reordering +- [ ] Individual panel resizing +- [ ] Panel splitting/merging +- [ ] Context menus for panel actions + +### Phase 3 (Planned) +- [ ] Keyboard shortcuts for layout switching +- [ ] Better memory management and cleanup +- [ ] Performance optimizations + +### Phase 4 (Planned) +- [ ] Layout persistence per tab +- [ ] Custom layout templates +- [ ] Visual drop zones for drag operations +- [ ] Animation improvements + +## Benefits + +1. **Better Productivity**: View multiple tools simultaneously +2. **Flexible Layouts**: Arrange panels to suit your workflow +3. **Backward Compatible**: Single panels work exactly as before +4. **Responsive**: Adapts to different screen sizes and content +5. **Extensible**: Easy to add new panel types and layouts + +## Usage Examples + +### Development Workflow +- **Column 1**: Code diff viewer +- **Column 2**: Localhost server preview +- Perfect for reviewing changes while seeing live results + +### Research Workflow +- **Top Row**: Web browser with documentation +- **Bottom Row**: File viewer with notes +- Great for referencing docs while working with files + +### Multi-Tool Workflow +- **2x2 Grid**: Diff viewer, web browser, file viewer, document editor +- Ultimate productivity setup for complex tasks + +The system automatically handles layout switching, content rendering, and state management, making it seamless to work with multiple tools in your sidecar space. diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index c682a08ceb..bc03d5e270 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -6,6 +6,11 @@ import { SidebarProvider } from '../ui/sidebar'; import { SidecarProvider, useSidecar } from '../SidecarLayout'; import { EnhancedBentoBox, SidecarContainer } from './EnhancedBentoBox'; import { ResizableSplitter } from './ResizableSplitter'; +import MultiPanelSplitter, { LayoutMode, PanelConfig } from './MultiPanelSplitter'; +import SidecarTabs from '../SidecarTabs'; +import { FileViewer } from '../FileViewer'; +import DocumentEditor from '../DocumentEditor'; +import WebViewer from '../WebViewer'; import { TopNavigation } from './TopNavigation'; @@ -35,61 +40,109 @@ const AppLayoutContent: React.FC = ({ setIsGoosehintsModalOpen } // Bento box state management const [bentoBoxContainers, setBentoBoxContainers] = useState([]); + // Multi-panel state management + const [panels, setPanels] = useState([]); + const [layoutMode, setLayoutMode] = useState('single'); + const [useMultiPanel, setUseMultiPanel] = useState(false); // Toggle between old and new system + // Resizable splitter state const [chatWidth, setChatWidth] = useState(60); // Default 60% for chat, 40% for sidecars - // Convert sidecar views to bento box containers + // Convert sidecar views to both bento box containers and panels React.useEffect(() => { if (!sidecar) return; - const containers: SidecarContainer[] = sidecar.views - .filter(view => sidecar.activeViews.includes(view.id)) - .map(view => { - // Extract content props based on view type - let contentProps: SidecarContainer['contentProps'] = {}; - let contentType: SidecarContainer['contentType'] = null; + const activeViews = sidecar.views.filter(view => sidecar.activeViews.includes(view.id)); - if (view.id.startsWith('localhost-')) { - contentType = 'localhost'; - contentProps = { - initialUrl: view.fileName || 'http://localhost:3000', - allowAllSites: true - }; - } else if (view.id.startsWith('web-viewer-')) { - contentType = 'web-viewer'; - contentProps = { - initialUrl: view.fileName || 'https://google.com', - allowAllSites: true - }; - } else if (view.id.startsWith('file-')) { - contentType = 'file'; - contentProps = { - filePath: view.fileName || '' - }; - } else if (view.id.startsWith('editor-')) { - contentType = 'document-editor'; - contentProps = { - filePath: view.fileName, - placeholder: 'Start writing your document...' - }; - } else if (view.id.startsWith('diff-')) { - contentType = 'sidecar'; // Treat diff as generic sidecar - } else { - contentType = 'sidecar'; - } + // Convert to bento box containers (existing system) + const containers: SidecarContainer[] = activeViews.map(view => { + // Extract content props based on view type + let contentProps: SidecarContainer['contentProps'] = {}; + let contentType: SidecarContainer['contentType'] = null; - return { - id: view.id, - content: view.content, - contentType, - title: view.title, - size: 'medium' as const, - contentProps + if (view.id.startsWith('localhost-')) { + contentType = 'localhost'; + contentProps = { + initialUrl: view.fileName || 'http://localhost:3000', + allowAllSites: true }; - }); + } else if (view.id.startsWith('web-viewer-')) { + contentType = 'web-viewer'; + contentProps = { + initialUrl: view.fileName || 'https://google.com', + allowAllSites: true + }; + } else if (view.id.startsWith('file-')) { + contentType = 'file'; + contentProps = { + filePath: view.fileName || '' + }; + } else if (view.id.startsWith('editor-')) { + contentType = 'document-editor'; + contentProps = { + filePath: view.fileName, + placeholder: 'Start writing your document...' + }; + } else if (view.id.startsWith('diff-')) { + contentType = 'sidecar'; // Treat diff as generic sidecar + } else { + contentType = 'sidecar'; + } + + return { + id: view.id, + content: view.content, + contentType, + title: view.title, + size: 'medium' as const, + contentProps + }; + }); + + // Convert to panels (new multi-panel system) + const newPanels: PanelConfig[] = activeViews.map((view, index) => { + let content: React.ReactNode; + + // Render content based on view type + if (view.id.startsWith('localhost-')) { + content = ; + } else if (view.id.startsWith('web-viewer-')) { + content = ; + } else if (view.id.startsWith('file-')) { + content = ; + } else if (view.id.startsWith('editor-')) { + content = ; + } else { + content = view.content || ( +
+

Sidecar content

+
+ ); + } + + return { + id: view.id, + content, + title: view.title, + minWidth: 200, + minHeight: 150, + size: { width: 50, height: 50 }, // Default size percentages + position: { row: Math.floor(index / 2), col: index % 2 } + }; + }); setBentoBoxContainers(containers); - }, [sidecar?.views, sidecar?.activeViews]); + setPanels(newPanels); + + // Auto-enable multi-panel mode when we have multiple panels + if (newPanels.length > 1 && !useMultiPanel) { + setUseMultiPanel(true); + setLayoutMode('columns'); // Default to columns layout + } else if (newPanels.length <= 1 && useMultiPanel) { + setUseMultiPanel(false); + setLayoutMode('single'); + } + }, [sidecar?.views, sidecar?.activeViews, useMultiPanel]); // Bento box handlers const handleAddToBentoBox = useCallback((type: 'sidecar' | 'localhost' | 'file' | 'document-editor' | 'web-viewer', filePath?: string, url?: string, title?: string) => { @@ -148,6 +201,23 @@ const AppLayoutContent: React.FC = ({ setIsGoosehintsModalOpen } setBentoBoxContainers(containers); }, []); + // Multi-panel handlers + const handleLayoutModeChange = useCallback((mode: LayoutMode) => { + setLayoutMode(mode); + }, []); + + const handlePanelResize = useCallback((panelId: string, size: { width: number; height: number }) => { + setPanels(prevPanels => + prevPanels.map(panel => + panel.id === panelId ? { ...panel, size } : panel + ) + ); + }, []); + + const handlePanelReorder = useCallback((newPanels: PanelConfig[]) => { + setPanels(newPanels); + }, []); + const handleNewWindow = () => { window.electron.createChatWindow( undefined, @@ -206,24 +276,37 @@ const AppLayoutContent: React.FC = ({ setIsGoosehintsModalOpen } {/* Main Content Area */}
- {bentoBoxContainers.length > 0 ? ( - } - rightContent={ - - } - initialLeftWidth={chatWidth} - minLeftWidth={30} - maxLeftWidth={80} - onResize={setChatWidth} - className="h-full" - floatingRight={true} - /> + {panels.length > 0 ? ( + useMultiPanel && panels.length > 1 ? ( + } + panels={panels} + layoutMode={layoutMode} + onLayoutModeChange={handleLayoutModeChange} + onPanelResize={handlePanelResize} + onPanelReorder={handlePanelReorder} + initialLeftWidth={chatWidth} + className="h-full" + /> + ) : ( + } + rightContent={ + + } + initialLeftWidth={chatWidth} + minLeftWidth={30} + maxLeftWidth={80} + onResize={setChatWidth} + className="h-full" + floatingRight={true} + /> + ) ) : ( )} diff --git a/ui/desktop/src/components/Layout/MultiPanelSplitter.tsx b/ui/desktop/src/components/Layout/MultiPanelSplitter.tsx new file mode 100644 index 0000000000..df9aa5c923 --- /dev/null +++ b/ui/desktop/src/components/Layout/MultiPanelSplitter.tsx @@ -0,0 +1,352 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + GripVertical, + GripHorizontal, + Grid2X2, + Columns2, + Rows2, + LayoutGrid, + Maximize2, + Minimize2, + MoreVertical +} from 'lucide-react'; +import { Button } from '../ui/button'; +import { Tooltip, TooltipTrigger, TooltipContent } from '../ui/Tooltip'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; + +export type LayoutMode = 'single' | 'columns' | 'rows' | 'grid' | 'custom'; + +export interface PanelConfig { + id: string; + content: React.ReactNode; + title?: string; + minWidth?: number; + minHeight?: number; + position?: { row: number; col: number }; + size?: { width: number; height: number }; // Percentages +} + +interface MultiPanelSplitterProps { + leftContent: React.ReactNode; + panels: PanelConfig[]; + layoutMode?: LayoutMode; + onLayoutModeChange?: (mode: LayoutMode) => void; + onPanelResize?: (panelId: string, size: { width: number; height: number }) => void; + onPanelReorder?: (panels: PanelConfig[]) => void; + className?: string; + initialLeftWidth?: number; // Percentage for main content +} + +interface ResizeHandle { + type: 'vertical' | 'horizontal'; + position: number; // Percentage + onResize: (delta: number) => void; +} + +export const MultiPanelSplitter: React.FC = ({ + leftContent, + panels, + layoutMode = 'single', + onLayoutModeChange, + onPanelResize, + onPanelReorder, + className = '', + initialLeftWidth = 60 +}) => { + const [leftWidth, setLeftWidth] = useState(initialLeftWidth); + const [isDragging, setIsDragging] = useState(false); + const [dragHandle, setDragHandle] = useState(null); + const containerRef = useRef(null); + + // Panel layout state for different modes + const [panelSizes, setPanelSizes] = useState>({}); + + // Initialize panel sizes + useEffect(() => { + const initialSizes: Record = {}; + panels.forEach(panel => { + if (!panelSizes[panel.id]) { + initialSizes[panel.id] = panel.size || { width: 50, height: 50 }; + } + }); + if (Object.keys(initialSizes).length > 0) { + setPanelSizes(prev => ({ ...prev, ...initialSizes })); + } + }, [panels, panelSizes]); + + const handleMainSplitterResize = useCallback((e: MouseEvent) => { + if (!isDragging || !containerRef.current) return; + + const containerRect = containerRef.current.getBoundingClientRect(); + const containerWidth = containerRect.width; + const mouseX = e.clientX - containerRect.left; + + const newLeftWidth = Math.min(Math.max((mouseX / containerWidth) * 100, 20), 80); + setLeftWidth(newLeftWidth); + }, [isDragging]); + + const handleMouseDown = useCallback((handleId: string) => { + setIsDragging(true); + setDragHandle(handleId); + }, []); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + setDragHandle(null); + }, []); + + useEffect(() => { + if (isDragging) { + document.addEventListener('mousemove', handleMainSplitterResize); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + + return () => { + document.removeEventListener('mousemove', handleMainSplitterResize); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isDragging, handleMainSplitterResize, handleMouseUp]); + + const renderLayoutControls = () => ( +
+ + + + + Two Columns + + + + + + + Two Rows + + + + + + + 2x2 Grid + + + + + + + + onLayoutModeChange?.('single')}> + Single Panel + + onLayoutModeChange?.('custom')}> + Custom Layout + + + +
+ ); + + const renderPanelContent = (panel: PanelConfig, style?: React.CSSProperties) => ( + + {/* Panel Header */} + {panel.title && ( +
+ {panel.title} +
+ )} + + {/* Panel Content */} +
+ {panel.content} +
+
+ ); + + const renderRightPanel = () => { + if (panels.length === 0) { + return ( +
+

No panels active

+
+ ); + } + + if (panels.length === 1) { + return renderPanelContent(panels[0], { height: '100%' }); + } + + switch (layoutMode) { + case 'columns': + return ( +
+ + {panels.slice(0, 2).map((panel, index) => ( + renderPanelContent(panel, { + flex: '1 1 0%', + height: '100%', + minWidth: panel.minWidth || 200 + }) + ))} + +
+ ); + + case 'rows': + return ( +
+ + {panels.slice(0, 2).map((panel, index) => ( + renderPanelContent(panel, { + flex: '1 1 0%', + width: '100%', + minHeight: panel.minHeight || 150 + }) + ))} + +
+ ); + + case 'grid': + return ( +
+ + {panels.slice(0, 4).map((panel, index) => ( + renderPanelContent(panel, { + minHeight: panel.minHeight || 150, + minWidth: panel.minWidth || 200 + }) + ))} + +
+ ); + + case 'custom': + // For custom layout, use absolute positioning based on panel positions + return ( +
+ + {panels.map((panel) => { + const size = panelSizes[panel.id] || { width: 50, height: 50 }; + const position = panel.position || { row: 0, col: 0 }; + + return renderPanelContent(panel, { + position: 'absolute', + left: `${position.col * 50}%`, + top: `${position.row * 50}%`, + width: `${size.width}%`, + height: `${size.height}%`, + minWidth: panel.minWidth || 200, + minHeight: panel.minHeight || 150 + }); + })} + +
+ ); + + default: + return renderPanelContent(panels[0], { height: '100%' }); + } + }; + + const rightWidth = 100 - leftWidth; + + return ( +
+ {/* Left Panel (Main Content) */} +
+ {leftContent} +
+ + {/* Main Splitter */} + {panels.length > 0 && ( +
handleMouseDown('main')} + > + {/* Visual grip indicator */} +
+ +
+ + {/* Invisible wider hit area for easier dragging */} +
+
+ )} + + {/* Right Panel (Multi-Panel Area) */} + {panels.length > 0 && ( +
+
+
+ {renderRightPanel()} + {renderLayoutControls()} +
+
+
+ )} +
+ ); +}; + +export default MultiPanelSplitter; diff --git a/ui/desktop/src/components/Layout/ResizableSplitter.tsx b/ui/desktop/src/components/Layout/ResizableSplitter.tsx index b5055816ee..3b66f446c0 100644 --- a/ui/desktop/src/components/Layout/ResizableSplitter.tsx +++ b/ui/desktop/src/components/Layout/ResizableSplitter.tsx @@ -116,9 +116,7 @@ export const ResizableSplitter: React.FC = ({ > {floatingRight ? (
-
- {rightContent} -
+ {rightContent}
) : ( rightContent diff --git a/ui/desktop/src/components/Layout/SidecarInvoker.tsx b/ui/desktop/src/components/Layout/SidecarInvoker.tsx index a4de498ddc..9daf102435 100644 --- a/ui/desktop/src/components/Layout/SidecarInvoker.tsx +++ b/ui/desktop/src/components/Layout/SidecarInvoker.tsx @@ -112,28 +112,13 @@ export const SidecarInvoker: React.FC = ({ 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); - } + // Disabled screenshot backdrop functionality to prevent WebBrowser interference + // The screenshot capture was causing weird behavior when hovering over dock }; 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); - } + // Disabled screenshot backdrop functionality to prevent WebBrowser interference }; // Define dock apps with proper icons and colors diff --git a/ui/desktop/src/components/MultiPanelTabSidecar.tsx b/ui/desktop/src/components/MultiPanelTabSidecar.tsx new file mode 100644 index 0000000000..781ffd16c3 --- /dev/null +++ b/ui/desktop/src/components/MultiPanelTabSidecar.tsx @@ -0,0 +1,704 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { motion, AnimatePresence, useDragControls } from 'framer-motion'; +import { + X, + SquareSplitHorizontal, + BetweenHorizontalStart, + FileDiff, + Globe, + FileText, + Edit, + Monitor, + MoreVertical, + Plus, + GripVertical, + Move +} from 'lucide-react'; +import { Button } from './ui/button'; +import { Tooltip, TooltipTrigger, TooltipContent } from './ui/Tooltip'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; +import { TabSidecarState, TabSidecarView } from './TabBar'; +import DocumentEditor from './DocumentEditor'; +import WebBrowser from './WebBrowser'; + +export type SidecarLayoutMode = 'single' | 'columns' | 'rows' | 'grid' | 'custom'; + +interface MultiPanelTabSidecarProps { + sidecarState: TabSidecarState; + onHideView: (viewId: string) => void; + onShowView?: (view: TabSidecarView) => void; + tabId: string; + className?: string; +} + +// Component renderers for different sidecar types +const MonacoDiffViewer: React.FC<{ diffContent: string }> = ({ diffContent }) => ( +
+
{diffContent}
+
+); + +const LocalhostViewer: React.FC<{ url: string; title: string }> = ({ url, title }) => ( +
+