Implement multi-panel sidecar system with drag-and-drop

- Add MultiPanelTabSidecar component for tab-level multi-panel layouts
- Implement drag-and-drop panel reordering with visual drop zones
- Support multiple layout modes: single, columns, rows, grid, custom
- Add drag handles to panel headers for intuitive rearrangement
- Auto-detect layout based on drop position (left/right for columns, top/bottom for rows, center for grid)
- Fix WebBrowser screenshot interference by disabling backdrop functionality
- Remove border strokes from all sidecar components for clean appearance
- Fix drop shadow clipping issues in ResizableSplitter
- Ensure proper rounded edges on all sidecar panels
- Maintain backward compatibility with single-panel TabSidecar
- Add comprehensive documentation in MULTI_PANEL_DEMO.md

Features:
- Drag panels by grip icon in headers
- Visual drop zones with color-coded feedback
- Smooth animations with Framer Motion
- Individual sidecar styling (no wrapper containers)
- Support for web, file, diff, editor, and localhost content types
- Automatic layout switching based on panel count
- Clean, modern UI without borders or clipped shadows
This commit is contained in:
spencrmartin
2025-11-20 15:25:04 -05:00
parent 953acf73bd
commit 765b1c52b0
9 changed files with 1351 additions and 108 deletions
+127
View File
@@ -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 ? (
<MultiPanelTabSidecar {...props} />
) : (
<TabSidecar {...props} />
)
```
## 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.
+145 -62
View File
@@ -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<AppLayoutProps> = ({ setIsGoosehintsModalOpen }
// Bento box state management
const [bentoBoxContainers, setBentoBoxContainers] = useState<SidecarContainer[]>([]);
// Multi-panel state management
const [panels, setPanels] = useState<PanelConfig[]>([]);
const [layoutMode, setLayoutMode] = useState<LayoutMode>('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 = <SidecarTabs initialUrl={view.fileName || 'http://localhost:3000'} />;
} else if (view.id.startsWith('web-viewer-')) {
content = <WebViewer initialUrl={view.fileName || 'https://google.com'} allowAllSites={true} />;
} else if (view.id.startsWith('file-')) {
content = <FileViewer filePath={view.fileName || ''} />;
} else if (view.id.startsWith('editor-')) {
content = <DocumentEditor filePath={view.fileName} placeholder="Start writing your document..." />;
} else {
content = view.content || (
<div className="h-full w-full flex items-center justify-center text-text-muted bg-background-muted border border-border-subtle rounded-lg">
<p>Sidecar content</p>
</div>
);
}
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<AppLayoutProps> = ({ 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<AppLayoutProps> = ({ setIsGoosehintsModalOpen }
{/* Main Content Area */}
<div className="flex-1 overflow-hidden">
{bentoBoxContainers.length > 0 ? (
<ResizableSplitter
leftContent={<Outlet />}
rightContent={
<EnhancedBentoBox
containers={bentoBoxContainers}
onRemoveContainer={handleRemoveFromBentoBox}
onAddContainer={handleAddToBentoBox}
onReorderContainers={handleReorderBentoBox}
/>
}
initialLeftWidth={chatWidth}
minLeftWidth={30}
maxLeftWidth={80}
onResize={setChatWidth}
className="h-full"
floatingRight={true}
/>
{panels.length > 0 ? (
useMultiPanel && panels.length > 1 ? (
<MultiPanelSplitter
leftContent={<Outlet />}
panels={panels}
layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange}
onPanelResize={handlePanelResize}
onPanelReorder={handlePanelReorder}
initialLeftWidth={chatWidth}
className="h-full"
/>
) : (
<ResizableSplitter
leftContent={<Outlet />}
rightContent={
<EnhancedBentoBox
containers={bentoBoxContainers}
onRemoveContainer={handleRemoveFromBentoBox}
onAddContainer={handleAddToBentoBox}
onReorderContainers={handleReorderBentoBox}
/>
}
initialLeftWidth={chatWidth}
minLeftWidth={30}
maxLeftWidth={80}
onResize={setChatWidth}
className="h-full"
floatingRight={true}
/>
)
) : (
<Outlet />
)}
@@ -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<MultiPanelSplitterProps> = ({
leftContent,
panels,
layoutMode = 'single',
onLayoutModeChange,
onPanelResize,
onPanelReorder,
className = '',
initialLeftWidth = 60
}) => {
const [leftWidth, setLeftWidth] = useState(initialLeftWidth);
const [isDragging, setIsDragging] = useState(false);
const [dragHandle, setDragHandle] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Panel layout state for different modes
const [panelSizes, setPanelSizes] = useState<Record<string, { width: number; height: number }>>({});
// Initialize panel sizes
useEffect(() => {
const initialSizes: Record<string, { width: number; height: number }> = {};
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 = () => (
<div className="absolute top-3 right-3 z-50 flex items-center gap-2 bg-background-default/95 backdrop-blur-sm rounded-lg shadow-lg border border-border-subtle p-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={layoutMode === 'columns' ? 'default' : 'ghost'}
size="sm"
onClick={() => onLayoutModeChange?.('columns')}
className="h-8 w-8 p-0"
>
<Columns2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Two Columns</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={layoutMode === 'rows' ? 'default' : 'ghost'}
size="sm"
onClick={() => onLayoutModeChange?.('rows')}
className="h-8 w-8 p-0"
>
<Rows2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Two Rows</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={layoutMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={() => onLayoutModeChange?.('grid')}
className="h-8 w-8 p-0"
>
<Grid2X2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>2x2 Grid</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => onLayoutModeChange?.('single')}>
Single Panel
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onLayoutModeChange?.('custom')}>
Custom Layout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
const renderPanelContent = (panel: PanelConfig, style?: React.CSSProperties) => (
<motion.div
key={panel.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="relative bg-background-default border border-border-subtle rounded-lg overflow-hidden"
style={style}
>
{/* Panel Header */}
{panel.title && (
<div className="absolute top-0 left-0 right-0 z-10 bg-background-muted/95 backdrop-blur-sm px-3 py-2 border-b border-border-subtle">
<span className="text-xs font-medium text-text-standard">{panel.title}</span>
</div>
)}
{/* Panel Content */}
<div className={`h-full w-full ${panel.title ? 'pt-10' : ''} overflow-hidden`}>
{panel.content}
</div>
</motion.div>
);
const renderRightPanel = () => {
if (panels.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center text-text-muted bg-background-muted/50 rounded-lg border border-border-subtle">
<p>No panels active</p>
</div>
);
}
if (panels.length === 1) {
return renderPanelContent(panels[0], { height: '100%' });
}
switch (layoutMode) {
case 'columns':
return (
<div className="h-full flex gap-1">
<AnimatePresence mode="popLayout">
{panels.slice(0, 2).map((panel, index) => (
renderPanelContent(panel, {
flex: '1 1 0%',
height: '100%',
minWidth: panel.minWidth || 200
})
))}
</AnimatePresence>
</div>
);
case 'rows':
return (
<div className="h-full flex flex-col gap-1">
<AnimatePresence mode="popLayout">
{panels.slice(0, 2).map((panel, index) => (
renderPanelContent(panel, {
flex: '1 1 0%',
width: '100%',
minHeight: panel.minHeight || 150
})
))}
</AnimatePresence>
</div>
);
case 'grid':
return (
<div className="h-full grid grid-cols-2 grid-rows-2 gap-1">
<AnimatePresence mode="popLayout">
{panels.slice(0, 4).map((panel, index) => (
renderPanelContent(panel, {
minHeight: panel.minHeight || 150,
minWidth: panel.minWidth || 200
})
))}
</AnimatePresence>
</div>
);
case 'custom':
// For custom layout, use absolute positioning based on panel positions
return (
<div className="h-full relative">
<AnimatePresence mode="popLayout">
{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
});
})}
</AnimatePresence>
</div>
);
default:
return renderPanelContent(panels[0], { height: '100%' });
}
};
const rightWidth = 100 - leftWidth;
return (
<div
ref={containerRef}
className={`flex h-full w-full relative ${className}`}
>
{/* Left Panel (Main Content) */}
<div
className="flex-shrink-0 overflow-hidden"
style={{ width: `${leftWidth}%` }}
>
{leftContent}
</div>
{/* Main Splitter */}
{panels.length > 0 && (
<div
className={`
relative flex-shrink-0 w-1 bg-background-default cursor-col-resize
hover:bg-border-default transition-colors duration-150
group
${isDragging && dragHandle === 'main' ? 'bg-border-default' : ''}
`}
onMouseDown={() => handleMouseDown('main')}
>
{/* Visual grip indicator */}
<div className={`
absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2
opacity-0 group-hover:opacity-100 transition-opacity duration-150
bg-background-default border border-border-subtle rounded p-1
${isDragging && dragHandle === 'main' ? 'opacity-100' : ''}
`}>
<GripVertical className="w-3 h-3 text-text-muted" />
</div>
{/* Invisible wider hit area for easier dragging */}
<div className="absolute inset-y-0 -left-2 -right-2 cursor-col-resize" />
</div>
)}
{/* Right Panel (Multi-Panel Area) */}
{panels.length > 0 && (
<div
className="flex-1 relative bg-background-default overflow-hidden"
style={{ width: `${rightWidth}%` }}
>
<div className="absolute inset-0 p-4">
<div className="h-full w-full rounded-2xl shadow-2xl drop-shadow-2xl border border-border-subtle overflow-hidden relative">
{renderRightPanel()}
{renderLayoutControls()}
</div>
</div>
</div>
)}
</div>
);
};
export default MultiPanelSplitter;
@@ -116,9 +116,7 @@ export const ResizableSplitter: React.FC<ResizableSplitterProps> = ({
>
{floatingRight ? (
<div className="absolute inset-0 p-4">
<div className="h-full w-full rounded-2xl shadow-2xl drop-shadow-2xl border border-border-subtle overflow-hidden">
{rightContent}
</div>
{rightContent}
</div>
) : (
rightContent
@@ -112,28 +112,13 @@ export const SidecarInvoker: React.FC<SidecarInvokerProps> = ({
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
@@ -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 }) => (
<div className="h-full p-4 bg-background-default overflow-auto">
<pre className="text-sm text-textStandard whitespace-pre-wrap font-mono">{diffContent}</pre>
</div>
);
const LocalhostViewer: React.FC<{ url: string; title: string }> = ({ url, title }) => (
<div className="h-full">
<iframe
src={url}
className="w-full h-full border-0"
title={title}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
/>
</div>
);
const SimpleFileViewer: React.FC<{ path: string }> = ({ path }) => (
<div className="h-full p-4 bg-background-default">
<div className="text-sm text-textMuted mb-2">File: {path}</div>
<div className="text-sm text-textStandard">
File viewer for: {path}
<br />
(Content loading would be implemented here)
</div>
</div>
);
const RichDocumentEditor: React.FC<{ path?: string; content?: string }> = ({ path, content }) => (
<div className="h-full">
<DocumentEditor
filePath={path}
initialContent={content}
placeholder="Start writing your document..."
/>
</div>
);
// Icon renderer
const renderIcon = (iconType: string) => {
switch (iconType) {
case 'diff':
return <FileDiff size={16} />;
case 'localhost':
return <Globe size={16} />;
case 'web':
return <Monitor size={16} />;
case 'file':
return <FileText size={16} />;
case 'editor':
return <Edit size={16} />;
default:
return <FileText size={16} />;
}
};
// Content renderer
const renderContent = (contentType: string, contentProps: Record<string, any>, tabId: string, viewId: string) => {
const key = `${contentType}-${tabId}-${viewId}`;
switch (contentType) {
case 'diff':
return <MonacoDiffViewer key={key} diffContent={contentProps.diffContent || ''} />;
case 'localhost':
return <LocalhostViewer key={key} url={contentProps.url || 'http://localhost:3000'} title={contentProps.title || 'Localhost Viewer'} />;
case 'web':
return <WebBrowser key={key} initialUrl={contentProps.url || 'https://google.com'} title={contentProps.title || 'Web Browser'} />;
case 'file':
return <SimpleFileViewer key={key} path={contentProps.path || ''} />;
case 'editor':
return <RichDocumentEditor key={key} path={contentProps.path} content={contentProps.content} />;
default:
return <div key={key} className="h-full p-4 bg-background-default">Unknown content type: {contentType}</div>;
}
};
// Drop zone types
type DropZoneType = 'left' | 'right' | 'top' | 'bottom' | 'center' | 'grid-1' | 'grid-2' | 'grid-3' | 'grid-4';
interface DropZone {
type: DropZoneType;
bounds: { x: number; y: number; width: number; height: number };
layoutMode: SidecarLayoutMode;
position?: number;
}
// Individual Panel Component with drag functionality
interface PanelProps {
view: TabSidecarView;
tabId: string;
onHideView: (viewId: string) => void;
onPanelDrop?: (draggedViewId: string, dropZone: DropZone) => void;
layoutMode: SidecarLayoutMode;
style?: React.CSSProperties;
className?: string;
isDragging?: boolean;
position?: number;
}
const Panel: React.FC<PanelProps> = ({
view,
tabId,
onHideView,
onPanelDrop,
layoutMode,
style,
className,
isDragging = false,
position = 0
}) => {
const [viewMode, setViewMode] = useState<'split' | 'unified'>('unified');
const [isLocalDragging, setIsLocalDragging] = useState(false);
const dragControls = useDragControls();
const panelRef = useRef<HTMLDivElement>(null);
const isDiffViewer = view.contentType === '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]);
const handleDragStart = () => {
setIsLocalDragging(true);
};
const handleDragEnd = (event: any, info: any) => {
setIsLocalDragging(false);
if (!onPanelDrop || !panelRef.current) return;
// Get the drop position
const dropX = info.point.x;
const dropY = info.point.y;
// Find the sidecar container to determine drop zones
const sidecarContainer = panelRef.current.closest('.sidecar-container');
if (!sidecarContainer) return;
const containerRect = sidecarContainer.getBoundingClientRect();
const relativeX = dropX - containerRect.left;
const relativeY = dropY - containerRect.top;
// Determine drop zone based on position
const centerX = containerRect.width / 2;
const centerY = containerRect.height / 2;
const threshold = 100; // pixels from edge to trigger zone
let dropZone: DropZone | null = null;
if (relativeX < threshold) {
// Left zone - columns layout
dropZone = {
type: 'left',
bounds: { x: 0, y: 0, width: centerX, height: containerRect.height },
layoutMode: 'columns',
position: 0
};
} else if (relativeX > containerRect.width - threshold) {
// Right zone - columns layout
dropZone = {
type: 'right',
bounds: { x: centerX, y: 0, width: centerX, height: containerRect.height },
layoutMode: 'columns',
position: 1
};
} else if (relativeY < threshold) {
// Top zone - rows layout
dropZone = {
type: 'top',
bounds: { x: 0, y: 0, width: containerRect.width, height: centerY },
layoutMode: 'rows',
position: 0
};
} else if (relativeY > containerRect.height - threshold) {
// Bottom zone - rows layout
dropZone = {
type: 'bottom',
bounds: { x: 0, y: centerY, width: containerRect.width, height: centerY },
layoutMode: 'rows',
position: 1
};
} else {
// Center zone - determine grid position
const gridX = relativeX < centerX ? 0 : 1;
const gridY = relativeY < centerY ? 0 : 1;
const gridPosition = gridY * 2 + gridX;
dropZone = {
type: `grid-${gridPosition + 1}` as DropZoneType,
bounds: {
x: gridX * centerX,
y: gridY * centerY,
width: centerX,
height: centerY
},
layoutMode: 'grid',
position: gridPosition
};
}
if (dropZone) {
onPanelDrop(view.id, dropZone);
}
};
// Render as a sidecar component (matching TabSidecar structure)
return (
<motion.div
ref={panelRef}
layout
drag
dragControls={dragControls}
dragMomentum={false}
dragElastic={0.1}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
whileDrag={{
scale: 1.05,
zIndex: 1000,
boxShadow: "0 10px 30px rgba(0,0,0,0.3)",
rotate: 2
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{
opacity: isLocalDragging ? 0.8 : 1,
scale: isLocalDragging ? 1.05 : 1
}}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className={`h-full w-full rounded-2xl shadow-2xl drop-shadow-2xl cursor-grab active:cursor-grabbing ${className || ''} ${isLocalDragging ? 'shadow-2xl' : ''}`}
style={style}
>
<div className="bg-background-default overflow-hidden flex flex-col h-full rounded-2xl">
{/* Sidecar Header */}
<div className="flex items-center justify-between p-4 border-b border-borderSubtle flex-shrink-0 flex-grow-0">
<div className="flex items-center space-x-2">
{/* Drag Handle */}
<motion.div
className="cursor-grab active:cursor-grabbing p-1 hover:bg-background-muted rounded mr-1"
onPointerDown={(e) => dragControls.start(e)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<GripVertical size={14} className="text-text-subtle" />
</motion.div>
{renderIcon(view.iconType)}
<div className="flex flex-col">
<span className="text-textStandard font-medium">{view.title}</span>
{view.fileName && (
<span className="text-xs font-mono text-text-muted">{view.fileName}</span>
)}
</div>
</div>
<div className="flex items-center space-x-2">
{/* View Mode Toggle - Only show for diff viewer */}
{isDiffViewer && (
<div className="flex items-center space-x-1 bg-background-muted rounded-lg p-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode('unified')}
className={`px-2 py-1 cursor-pointer focus:outline-none focus:ring-2 focus:ring-borderProminent focus:ring-offset-1 ${
viewMode === 'unified'
? 'bg-background-default text-textStandard hover:bg-background-default dark:hover:bg-background-default'
: 'text-textSubtle'
}`}
>
<BetweenHorizontalStart size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Unified View
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode('split')}
className={`px-2 py-1 cursor-pointer focus:outline-none focus:ring-2 focus:ring-borderProminent focus:ring-offset-1 ${
viewMode === 'split'
? 'bg-background-default text-textStandard hover:bg-background-default dark:hover:bg-background-default'
: 'text-textSubtle'
}`}
>
<SquareSplitHorizontal size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Split View
</TooltipContent>
</Tooltip>
</div>
)}
{/* Close Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => onHideView(view.id)}
className="text-textSubtle hover:text-textStandard cursor-pointer focus:outline-none focus:ring-2 focus:ring-borderProminent focus:ring-offset-1"
>
<X size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
{/* Sidecar Content */}
<div className="flex-1 overflow-hidden">
{renderContent(view.contentType, view.contentProps, tabId)}
</div>
</div>
</motion.div>
);
};
export const MultiPanelTabSidecar: React.FC<MultiPanelTabSidecarProps> = ({
sidecarState,
onHideView,
onShowView,
tabId,
className = ''
}) => {
const [layoutMode, setLayoutMode] = useState<SidecarLayoutMode>('single');
const [panelOrder, setPanelOrder] = useState<string[]>([]);
const [isDragActive, setIsDragActive] = useState(false);
const [dropZones, setDropZones] = useState<DropZone[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
// Get active views - memoize to prevent recreation on every render
const activeViews = React.useMemo(() =>
sidecarState.views.filter(view =>
sidecarState.activeViews.includes(view.id)
),
[sidecarState.views, sidecarState.activeViews]
);
// Initialize panel order when views change
React.useEffect(() => {
const currentViewIds = activeViews.map(v => v.id);
setPanelOrder(prev => {
// Keep existing order for panels that still exist, add new ones at the end
const existingOrder = prev.filter(id => currentViewIds.includes(id));
const newPanels = currentViewIds.filter(id => !prev.includes(id));
return [...existingOrder, ...newPanels];
});
}, [activeViews]);
// Auto-adjust layout mode based on number of active views
React.useEffect(() => {
if (activeViews.length <= 1 && layoutMode !== 'single') {
setLayoutMode('single');
} else if (activeViews.length === 2 && layoutMode === 'single') {
setLayoutMode('columns');
} else if (activeViews.length > 2 && (layoutMode === 'single' || layoutMode === 'columns')) {
setLayoutMode('grid');
}
}, [activeViews.length]); // Remove layoutMode from dependencies to prevent infinite loop
const handlePanelDrop = useCallback((draggedViewId: string, dropZone: DropZone) => {
console.log('Panel dropped:', draggedViewId, 'in zone:', dropZone);
// Update layout mode based on drop zone
setLayoutMode(prev => {
if (dropZone.layoutMode !== prev) {
return dropZone.layoutMode;
}
return prev;
});
// Reorder panels based on drop position
if (dropZone.position !== undefined) {
setPanelOrder(prev => {
const newOrder = prev.filter(id => id !== draggedViewId);
newOrder.splice(dropZone.position!, 0, draggedViewId);
return newOrder;
});
}
setIsDragActive(false);
setDropZones([]);
}, []); // Remove layoutMode dependency
// Calculate drop zones when dragging starts
const calculateDropZones = useCallback(() => {
if (!containerRef.current) return [];
const container = containerRef.current;
const rect = container.getBoundingClientRect();
const zones: DropZone[] = [];
const threshold = 80;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
// Left zone (columns)
zones.push({
type: 'left',
bounds: { x: 0, y: 0, width: threshold, height: rect.height },
layoutMode: 'columns',
position: 0
});
// Right zone (columns)
zones.push({
type: 'right',
bounds: { x: rect.width - threshold, y: 0, width: threshold, height: rect.height },
layoutMode: 'columns',
position: 1
});
// Top zone (rows)
zones.push({
type: 'top',
bounds: { x: 0, y: 0, width: rect.width, height: threshold },
layoutMode: 'rows',
position: 0
});
// Bottom zone (rows)
zones.push({
type: 'bottom',
bounds: { x: 0, y: rect.height - threshold, width: rect.width, height: threshold },
layoutMode: 'rows',
position: 1
});
// Grid zones (center area)
if (activeViews.length > 2) {
for (let i = 0; i < 4; i++) {
const gridX = (i % 2) * centerX;
const gridY = Math.floor(i / 2) * centerY;
zones.push({
type: `grid-${i + 1}` as DropZoneType,
bounds: {
x: gridX + threshold/2,
y: gridY + threshold/2,
width: centerX - threshold,
height: centerY - threshold
},
layoutMode: 'grid',
position: i
});
}
}
return zones;
}, [activeViews.length]);
// Handle drag start globally
React.useEffect(() => {
const handleDragStart = () => {
setIsDragActive(true);
setDropZones(calculateDropZones());
};
const handleDragEnd = () => {
setIsDragActive(false);
setDropZones([]);
};
// Listen for drag events on panels
document.addEventListener('dragstart', handleDragStart);
document.addEventListener('dragend', handleDragEnd);
return () => {
document.removeEventListener('dragstart', handleDragStart);
document.removeEventListener('dragend', handleDragEnd);
};
}, []); // Empty dependency array to prevent infinite loop
if (activeViews.length === 0) {
return null;
}
const renderDropZones = () => {
if (!isDragActive || dropZones.length === 0) return null;
return (
<div className="absolute inset-0 pointer-events-none z-50">
{dropZones.map((zone, index) => (
<motion.div
key={`${zone.type}-${index}`}
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
exit={{ opacity: 0 }}
className={`absolute border-2 border-dashed rounded-lg ${
zone.layoutMode === 'columns' ? 'border-blue-400 bg-blue-400/10' :
zone.layoutMode === 'rows' ? 'border-green-400 bg-green-400/10' :
zone.layoutMode === 'grid' ? 'border-purple-400 bg-purple-400/10' :
'border-gray-400 bg-gray-400/10'
}`}
style={{
left: zone.bounds.x,
top: zone.bounds.y,
width: zone.bounds.width,
height: zone.bounds.height
}}
>
<div className="absolute inset-0 flex items-center justify-center">
<div className={`px-2 py-1 rounded text-xs font-medium ${
zone.layoutMode === 'columns' ? 'bg-blue-400 text-white' :
zone.layoutMode === 'rows' ? 'bg-green-400 text-white' :
zone.layoutMode === 'grid' ? 'bg-purple-400 text-white' :
'bg-gray-400 text-white'
}`}>
{zone.type === 'left' ? 'Left Column' :
zone.type === 'right' ? 'Right Column' :
zone.type === 'top' ? 'Top Row' :
zone.type === 'bottom' ? 'Bottom Row' :
zone.type.startsWith('grid-') ? `Grid ${zone.type.split('-')[1]}` :
'Drop Here'}
</div>
</div>
</motion.div>
))}
</div>
);
};
// Render individual sidecar panels directly without wrapping container
const renderPanels = () => {
if (activeViews.length === 0) return null;
if (activeViews.length === 1 || layoutMode === 'single') {
return (
<Panel
view={activeViews[0]}
tabId={tabId}
onHideView={onHideView}
onPanelDrop={handlePanelDrop}
layoutMode={layoutMode}
className="h-full"
/>
);
}
switch (layoutMode) {
case 'columns':
return (
<div className="h-full flex gap-2">
<AnimatePresence mode="popLayout">
{activeViews.slice(0, 2).map((view) => (
<Panel
key={view.id}
view={view}
tabId={tabId}
onHideView={onHideView}
onPanelDrop={handlePanelDrop}
layoutMode={layoutMode}
style={{ flex: '1 1 0%', minWidth: '200px' }}
/>
))}
</AnimatePresence>
</div>
);
case 'rows':
return (
<div className="h-full flex flex-col gap-2">
<AnimatePresence mode="popLayout">
{activeViews.slice(0, 2).map((view) => (
<Panel
key={view.id}
view={view}
tabId={tabId}
onHideView={onHideView}
onPanelDrop={handlePanelDrop}
layoutMode={layoutMode}
style={{ flex: '1 1 0%', minHeight: '150px' }}
/>
))}
</AnimatePresence>
</div>
);
case 'grid':
return (
<div className="h-full grid grid-cols-2 gap-2" style={{ gridTemplateRows: 'repeat(2, 1fr)' }}>
<AnimatePresence mode="popLayout">
{activeViews.slice(0, 4).map((view) => (
<Panel
key={view.id}
view={view}
tabId={tabId}
onHideView={onHideView}
onPanelDrop={handlePanelDrop}
layoutMode={layoutMode}
style={{ minHeight: '150px', minWidth: '200px' }}
/>
))}
</AnimatePresence>
</div>
);
case 'custom':
// For custom layout, we could implement drag-and-drop positioning
return (
<div className="h-full relative">
<AnimatePresence mode="popLayout">
{activeViews.map((view, index) => (
<Panel
key={view.id}
view={view}
tabId={tabId}
onHideView={onHideView}
onPanelDrop={handlePanelDrop}
layoutMode={layoutMode}
style={{
position: 'absolute',
left: `${(index % 2) * 50}%`,
top: `${Math.floor(index / 2) * 50}%`,
width: '48%',
height: '48%',
minWidth: '200px',
minHeight: '150px'
}}
/>
))}
</AnimatePresence>
</div>
);
default:
return (
<Panel
view={activeViews[0]}
tabId={tabId}
onHideView={onHideView}
onPanelDrop={handlePanelDrop}
layoutMode={layoutMode}
className="h-full"
/>
);
}
};
// Return the panels directly with drop zones overlay, no additional container
return (
<div
ref={containerRef}
className={`h-full w-full relative ${className}`}
>
{renderPanels()}
{renderDropZones()}
</div>
);
};
export default MultiPanelTabSidecar;
+2 -2
View File
@@ -115,9 +115,9 @@ export const TabSidecar: React.FC<TabSidecarProps> = ({
}, [viewMode, isDiffViewer]);
return (
<div className="h-full w-full rounded-2xl shadow-2xl drop-shadow-2xl border border-border-subtle overflow-hidden">
<div className="h-full w-full rounded-2xl shadow-2xl drop-shadow-2xl overflow-hidden">
<div
className={`bg-background-default overflow-hidden flex flex-col h-full ${className}`}
className={`bg-background-default overflow-hidden flex flex-col h-full rounded-2xl ${className}`}
>
{/* Sidecar Header */}
<div className="flex items-center justify-between p-4 border-b border-borderSubtle flex-shrink-0 flex-grow-0">
@@ -118,28 +118,13 @@ export const TabSidecarInvoker: React.FC<TabSidecarInvokerProps> = ({
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
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { TabBar } from './TabBar';
import BaseChat2 from './BaseChat2';
import { TabSidecar } from './TabSidecar';
import MultiPanelTabSidecar from './MultiPanelTabSidecar';
import { useTabContext } from '../contexts/TabContext';
import { ResizableSplitter } from './Layout/ResizableSplitter';
@@ -221,11 +222,19 @@ export const TabbedChatContainer: React.FC<TabbedChatContainerProps> = ({
/>
}
rightContent={
<TabSidecar
sidecarState={sidecarState}
onHideView={(viewId) => hideSidecarView(activeTabState.tab.id, viewId)}
tabId={activeTabState.tab.id}
/>
sidecarState && sidecarState.activeViews.length > 1 ? (
<MultiPanelTabSidecar
sidecarState={sidecarState}
onHideView={(viewId) => hideSidecarView(activeTabState.tab.id, viewId)}
tabId={activeTabState.tab.id}
/>
) : (
<TabSidecar
sidecarState={sidecarState}
onHideView={(viewId) => hideSidecarView(activeTabState.tab.id, viewId)}
tabId={activeTabState.tab.id}
/>
)
}
initialLeftWidth={chatWidth}
minLeftWidth={30}