mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user