Implement per-tab sidecars with resizing and original styling

- Added TabSidecar component with proper drop shadow, rounded corners, and border styling
- Implemented ResizableSplitter for sidecar resizing functionality (30-80% width range)
- Added view mode toggle for diff viewers (split/unified)
- Extended TabContext with sidecar management functions (showDiffViewer, showLocalhostViewer, etc.)
- Created TabSidecarInvoker to replace global SidecarInvoker
- Updated TabbedChatContainer to use ResizableSplitter with floatingRight styling
- Removed global sidecar dependencies from BaseChat2
- Each tab now maintains independent sidecar state and content
This commit is contained in:
spencrmartin
2025-11-20 13:55:31 -05:00
parent e55cd174e0
commit acea40314d
6 changed files with 718 additions and 111 deletions
+11 -90
View File
@@ -23,8 +23,7 @@ import RecipeActivities from './recipes/RecipeActivities';
import { useToolCount } from './alerts/useToolCount';
import { getThinkingMessage } from '../types/message';
import ParameterInputModal from './ParameterInputModal';
import { SidecarInvoker } from './Layout/SidecarInvoker';
import { useSidecar } from './SidecarLayout';
import { TabSidecarInvoker } from './TabSidecarInvoker';
import ParticipantsBar from './ParticipantsBar';
import PendingInvitesInHistory from './PendingInvitesInHistory';
@@ -48,6 +47,8 @@ interface BaseChatProps {
disableSearch?: boolean;
showPopularTopics?: boolean;
loadingChat?: boolean;
// Tab-specific sidecar props
tabId?: string;
}
function BaseChatContent({
@@ -66,6 +67,7 @@ function BaseChatContent({
disableSearch = false,
showPopularTopics = true,
loadingChat = false,
tabId,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null);
@@ -84,88 +86,7 @@ function BaseChatContent({
// Hover state for sidecar dock
const [isHoveringChatInput, setIsHoveringChatInput] = useState(false);
// Sidecar functionality
const sidecar = useSidecar();
const handleShowLocalhost = () => {
console.log('Localhost viewer requested');
if (sidecar) {
sidecar.showLocalhostViewer('http://localhost:3000', 'Localhost Viewer');
}
};
const handleShowFileViewer = (filePath: string) => {
console.log('File viewer requested for:', filePath);
if (sidecar) {
sidecar.showFileViewer(filePath);
}
};
const handleAddContainer = (type: 'sidecar' | 'localhost' | 'file' | 'document-editor' | 'web-viewer' | 'app-installer', filePath?: string) => {
console.log('Add container requested:', type, filePath);
if (!sidecar) {
console.error('No sidecar context available');
return;
}
// Use sidecar context directly instead of dispatching events
switch (type) {
case 'sidecar':
// Show a generic sidecar view
sidecar.showView({
id: `sidecar-${Date.now()}`,
title: 'Sidecar',
icon: <div className="w-4 h-4 bg-blue-500 rounded" />,
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 will go here</p>
</div>
),
});
break;
case 'localhost':
sidecar.showLocalhostViewer('http://localhost:3000', 'Localhost Viewer');
break;
case 'file':
if (filePath) {
sidecar.showFileViewer(filePath);
}
break;
case 'document-editor':
sidecar.showDocumentEditor(filePath);
break;
case 'web-viewer':
// Show a proper web viewer
sidecar.showView({
id: `web-viewer-${Date.now()}`,
title: 'Web Viewer',
icon: <div className="w-4 h-4 bg-cyan-500 rounded" />,
content: null, // Will be rendered by contentType
contentType: 'web-viewer',
contentProps: {
initialUrl: 'https://google.com',
allowAllSites: true
}
});
break;
case 'app-installer':
// Show a generic app installer view
sidecar.showView({
id: `app-installer-${Date.now()}`,
title: 'App Installer',
icon: <div className="w-4 h-4 bg-green-500 rounded" />,
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>App installer will go here</p>
</div>
),
});
break;
default:
console.warn('Unknown container type:', type);
}
};
// Use shared file drop
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
@@ -534,14 +455,14 @@ function BaseChatContent({
onMouseEnter={() => setIsHoveringChatInput(true)}
onMouseLeave={() => setIsHoveringChatInput(false)}
>
{/* Sidecar Invoker Dock - positioned above ChatInput with proper spacing */}
{/* Tab Sidecar Invoker Dock - positioned above ChatInput with proper spacing */}
<div className="relative max-w-4xl mx-auto w-full">
<SidecarInvoker
onShowLocalhost={handleShowLocalhost}
onShowFileViewer={handleShowFileViewer}
onAddContainer={handleAddContainer}
isVisible={isHoveringChatInput}
/>
{tabId && (
<TabSidecarInvoker
tabId={tabId}
isVisible={isHoveringChatInput}
/>
)}
</div>
<ChatInput
+18
View File
@@ -3,6 +3,22 @@ import { X, Plus, MessageCircle, Bot, Users } from 'lucide-react';
import { cn } from '../utils';
import '../styles/tabs.css';
// Sidecar view interface for tab-specific sidecars
export interface TabSidecarView {
id: string;
title: string;
icon: React.ReactNode;
content: React.ReactNode;
fileName?: string;
instanceId?: string;
}
// Sidecar state for each tab
export interface TabSidecarState {
activeViews: string[]; // Array of active view IDs
views: TabSidecarView[]; // All available views for this tab
}
export interface Tab {
id: string;
title: string;
@@ -12,6 +28,8 @@ export interface Tab {
hasUnsavedChanges?: boolean;
matrixRoomId?: string;
recipeTitle?: string;
// Add sidecar state to each tab
sidecarState?: TabSidecarState;
}
interface TabBarProps {
+125
View File
@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { X, SquareSplitHorizontal, BetweenHorizontalStart } from 'lucide-react';
import { Button } from './ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/Tooltip';
import { TabSidecarState } from './TabBar';
interface TabSidecarProps {
sidecarState: TabSidecarState;
onHideView: (viewId: string) => void;
className?: string;
}
export const TabSidecar: React.FC<TabSidecarProps> = ({
sidecarState,
onHideView,
className = ''
}) => {
const [viewMode, setViewMode] = useState<'split' | 'unified'>('unified');
// Get the first active view (for now, we'll show one at a time)
const currentViewId = sidecarState.activeViews[0];
const currentView = sidecarState.views.find(v => v.id === currentViewId);
if (!currentView || !sidecarState.activeViews.includes(currentView.id)) {
return null;
}
// Check if current view is diff viewer
const isDiffViewer = currentView.id.startsWith('diff');
// Update the diff viewer when view mode changes
React.useEffect(() => {
if (isDiffViewer && (window as any).diffViewerControls) {
(window as any).diffViewerControls.setViewMode(viewMode);
}
}, [viewMode, isDiffViewer]);
return (
<div className="h-full w-full rounded-2xl shadow-2xl drop-shadow-2xl border border-border-subtle overflow-hidden">
<div
className={`bg-background-default overflow-hidden flex flex-col h-full ${className}`}
>
{/* 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">
{currentView.icon}
<div className="flex flex-col">
<span className="text-textStandard font-medium">{currentView.title}</span>
{currentView.fileName && (
<span className="text-xs font-mono text-text-muted">{currentView.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(currentView.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">
{currentView.content}
</div>
</div>
</div>
);
};
@@ -0,0 +1,287 @@
import React, { useState, useRef, useEffect } from 'react';
import { Plus, Globe, FileText, Edit, ExternalLink, Download, Code, Folder, Terminal, Monitor } from 'lucide-react';
import { Button } from './ui/button';
import { useTabContext } from '../contexts/TabContext';
interface TabSidecarInvokerProps {
tabId: string;
isVisible: boolean;
}
export const TabSidecarInvoker: React.FC<TabSidecarInvokerProps> = ({
tabId,
isVisible
}) => {
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL LOGIC
const [isHovering, setIsHovering] = useState(false);
const [iframeBackdrops, setIframeBackdrops] = useState<any[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
// Get tab-specific sidecar functions
const {
showLocalhostViewer,
showFileViewer,
showDocumentEditor,
showDiffViewer
} = useTabContext();
// Handle click outside to close dock
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsHovering(false);
}
};
if (isHovering) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isHovering]);
// Component is always rendered but hidden/shown with CSS transitions
const handleLocalhostClick = () => {
console.log('🔍 TabSidecarInvoker: Localhost button clicked for tab:', tabId);
showLocalhostViewer(tabId, 'http://localhost:3000', 'Localhost Viewer');
setIsHovering(false);
};
const handleFileViewerClick = async () => {
try {
console.log('File viewer button clicked for tab:', tabId);
// Use Electron's selectFileOrDirectory API
const filePath = await window.electron.selectFileOrDirectory();
console.log('Selected file path:', filePath);
if (filePath) {
showFileViewer(tabId, filePath);
} else {
console.log('No file selected');
}
} catch (error) {
console.error('Error opening file dialog:', error);
}
setIsHovering(false);
};
const handleDocumentEditorClick = () => {
console.log('🔍 TabSidecarInvoker: Document Editor button clicked for tab:', tabId);
showDocumentEditor(tabId, undefined, 'Start writing your document...', 'new-doc');
setIsHovering(false);
};
const handleEditFileClick = async () => {
try {
console.log('Edit file button clicked for tab:', tabId);
// Use Electron's selectFileOrDirectory API
const filePath = await window.electron.selectFileOrDirectory();
console.log('Selected file path for editing:', filePath);
if (filePath) {
showDocumentEditor(tabId, filePath, undefined, 'edit-file');
} else {
console.log('No file selected for editing');
}
} catch (error) {
console.error('Error opening file dialog for editing:', error);
}
setIsHovering(false);
};
const handleWebViewerClick = () => {
console.log('🔍 TabSidecarInvoker: Web Viewer button clicked for tab:', tabId);
showLocalhostViewer(tabId, 'https://google.com', 'Web Browser', 'web-viewer');
setIsHovering(false);
};
const handleDiffViewerClick = () => {
console.log('🔍 TabSidecarInvoker: Diff Viewer button clicked for tab:', tabId);
// For demo purposes, show a sample diff
const sampleDiff = `--- a/example.js
+++ b/example.js
@@ -1,3 +1,4 @@
function hello() {
+ console.log("Hello world!");
return "Hello";
}`;
showDiffViewer(tabId, sampleDiff, 'example.js', 'sample-diff');
setIsHovering(false);
};
const handleMouseEnter = async () => {
setIsHovering(true);
// Create iframe backdrops to show live content behind the dock
try {
const result = await window.electron.createIframeBackdrop();
if (result.success && result.backdropData) {
setIframeBackdrops(result.backdropData);
console.log('🎬 Created iframe backdrops:', result.backdropData.length);
}
} catch (error) {
console.error('Failed to create iframe backdrop:', error);
}
};
const handleMouseLeave = async () => {
setIsHovering(false);
// Remove iframe backdrops and restore BrowserViews
try {
await window.electron.removeIframeBackdrop();
setIframeBackdrops([]);
console.log('🎬 Removed iframe backdrops');
} catch (error) {
console.error('Failed to remove iframe backdrop:', error);
}
};
// Define dock apps with proper icons and colors
const dockApps = [
{
id: 'document-editor',
name: 'TextEdit',
icon: Edit,
color: 'from-gray-400 to-gray-600',
onClick: handleDocumentEditorClick,
description: 'Create new document'
},
{
id: 'file-editor',
name: 'Code Editor',
icon: Code,
color: 'from-indigo-500 to-purple-600',
onClick: handleEditFileClick,
description: 'Edit existing file'
},
{
id: 'web-viewer',
name: 'Safari',
icon: Monitor,
color: 'from-blue-400 to-cyan-500',
onClick: handleWebViewerClick,
description: 'Browse the web'
},
{
id: 'localhost',
name: 'Terminal',
icon: Terminal,
color: 'from-gray-800 to-black',
onClick: handleLocalhostClick,
description: 'View localhost apps'
},
{
id: 'file-viewer',
name: 'Finder',
icon: Folder,
color: 'from-blue-500 to-blue-700',
onClick: handleFileViewerClick,
description: 'Browse files'
},
{
id: 'diff-viewer',
name: 'Diff',
icon: FileText,
color: 'from-green-500 to-green-600',
onClick: handleDiffViewerClick,
description: 'View code diffs'
}
];
return (
<div
ref={containerRef}
className={`absolute bottom-full left-0 right-0 z-50 transition-all duration-300 ease-out ${
isVisible
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
}`}
style={{ marginBottom: '-16px' }} // Position dock closer to the floating input
>
{/* Screenshot backdrops - positioned behind the dock */}
{iframeBackdrops.map((backdrop) => (
<div
key={backdrop.viewId}
className="fixed pointer-events-none"
style={{
left: backdrop.bounds.x,
top: backdrop.bounds.y,
width: backdrop.bounds.width,
height: backdrop.bounds.height,
zIndex: 99998, // Just below the dock
backgroundImage: `url(${backdrop.screenshot})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
title={`Screenshot backdrop for ${backdrop.viewId}`}
/>
))}
{/* Horizontal dock above chat input */}
<div className="flex justify-start">
<div
className={`transition-all duration-300 ease-out ${
isVisible
? 'opacity-100 scale-100'
: 'opacity-80 scale-95'
}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Dock container - horizontal layout with proper padding */}
<div className="px-6 py-3">
<div className="flex flex-row space-x-2">
{dockApps.map((app, index) => (
<div
key={app.id}
className="group relative"
>
{/* App icon with staggered animations */}
<button
onClick={app.onClick}
className={`
w-10 h-10 rounded-xl bg-gradient-to-br ${app.color}
shadow-lg hover:shadow-xl
transform hover:scale-110 hover:-translate-y-1
transition-all duration-200 ease-out
flex items-center justify-center
border border-white/20
${isVisible
? 'animate-in slide-in-from-bottom-2 fade-in'
: 'animate-out slide-out-to-bottom-2 fade-out'
}
`}
style={{
animationDelay: isVisible ? `${index * 50}ms` : `${(dockApps.length - index - 1) * 30}ms`,
animationFillMode: 'both'
}}
title={app.name}
>
<app.icon className="w-5 h-5 text-white drop-shadow-sm" />
</button>
{/* Tooltip */}
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
<div className="bg-gray-900/90 backdrop-blur-sm text-white text-xs px-2 py-1 rounded-md whitespace-nowrap shadow-lg">
{app.description}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900/90"></div>
</div>
</div>
{/* Active indicator dot (like macOS dock) */}
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2 w-1 h-1 bg-white/60 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200"></div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
@@ -1,7 +1,9 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { TabBar } from './TabBar';
import BaseChat2 from './BaseChat2';
import { TabSidecar } from './TabSidecar';
import { useTabContext } from '../contexts/TabContext';
import { ResizableSplitter } from './Layout/ResizableSplitter';
interface TabbedChatContainerProps {
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
@@ -29,7 +31,10 @@ export const TabbedChatContainer: React.FC<TabbedChatContainerProps> = ({
getActiveTabState,
syncTabTitleWithBackend,
updateTabTitleFromMessage,
updateSessionId
updateSessionId,
// Sidecar functions
hideSidecarView,
getSidecarState
} = useTabContext();
const handleMessageSubmitWrapper = useCallback(async (message: string, tabId: string) => {
@@ -169,7 +174,12 @@ export const TabbedChatContainer: React.FC<TabbedChatContainerProps> = ({
}
}, [getActiveTabState, updateSessionId, handleChatUpdate]);
// Sidecar resizing state
const [chatWidth, setChatWidth] = useState(60); // Default 60% for chat, 40% for sidecar
const activeTabState = getActiveTabState();
const sidecarState = activeTabState ? getSidecarState(activeTabState.tab.id) : undefined;
const hasSidecar = sidecarState && sidecarState.activeViews.length > 0;
return (
<div className={`flex flex-col h-full bg-background-default ${className || ''}`}>
@@ -185,25 +195,64 @@ export const TabbedChatContainer: React.FC<TabbedChatContainerProps> = ({
/>
</div>
{/* Active Chat - Takes remaining space with rounded top corners */}
{/* Main Content Area - Chat and Sidecar */}
<div className="flex-1 min-h-0 relative overflow-hidden rounded-t-lg bg-background-default">
{activeTabState && (
<BaseChat2
key={activeTabState.tab.id} // Use stable tab ID instead of changing session ID
sessionId={activeTabState.tab.sessionId}
setChat={handleSetChat}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
onMessageSubmit={(message) => handleMessageSubmitWrapper(message, activeTabState.tab.id)}
onSessionIdChange={(newSessionId) => updateSessionId(activeTabState.tab.id, newSessionId)}
suppressEmptyState={false}
showPopularTopics={true}
loadingChat={activeTabState.loadingChat}
initialMessage={initialMessage}
// Matrix props (if this tab is a Matrix session)
showParticipantsBar={activeTabState.tab.type === 'matrix'}
matrixRoomId={activeTabState.tab.matrixRoomId}
showPendingInvites={activeTabState.tab.type === 'matrix'}
/>
<>
{hasSidecar ? (
/* Resizable Split Layout: Chat + Sidecar */
<ResizableSplitter
leftContent={
<BaseChat2
key={activeTabState.tab.id}
sessionId={activeTabState.tab.sessionId}
setChat={handleSetChat}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
onMessageSubmit={(message) => handleMessageSubmitWrapper(message, activeTabState.tab.id)}
onSessionIdChange={(newSessionId) => updateSessionId(activeTabState.tab.id, newSessionId)}
suppressEmptyState={false}
showPopularTopics={true}
loadingChat={activeTabState.loadingChat}
initialMessage={initialMessage}
showParticipantsBar={activeTabState.tab.type === 'matrix'}
matrixRoomId={activeTabState.tab.matrixRoomId}
showPendingInvites={activeTabState.tab.type === 'matrix'}
tabId={activeTabState.tab.id}
/>
}
rightContent={
<TabSidecar
sidecarState={sidecarState}
onHideView={(viewId) => hideSidecarView(activeTabState.tab.id, viewId)}
/>
}
initialLeftWidth={chatWidth}
minLeftWidth={30}
maxLeftWidth={80}
onResize={setChatWidth}
className="h-full"
floatingRight={true}
/>
) : (
/* Full Width Chat */
<BaseChat2
key={activeTabState.tab.id}
sessionId={activeTabState.tab.sessionId}
setChat={handleSetChat}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
onMessageSubmit={(message) => handleMessageSubmitWrapper(message, activeTabState.tab.id)}
onSessionIdChange={(newSessionId) => updateSessionId(activeTabState.tab.id, newSessionId)}
suppressEmptyState={false}
showPopularTopics={true}
loadingChat={activeTabState.loadingChat}
initialMessage={initialMessage}
showParticipantsBar={activeTabState.tab.type === 'matrix'}
matrixRoomId={activeTabState.tab.matrixRoomId}
showPendingInvites={activeTabState.tab.type === 'matrix'}
tabId={activeTabState.tab.id}
/>
)}
</>
)}
</div>
</div>
+209 -2
View File
@@ -1,8 +1,9 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { Tab } from '../components/TabBar';
import { Tab, TabSidecarState, TabSidecarView } from '../components/TabBar';
import { ChatType } from '../types/chat';
import { generateSessionId } from '../utils/sessionUtils';
import { getSession, updateSessionDescription } from '../api';
import { FileDiff, Globe, FileText, Edit } from 'lucide-react';
interface TabState {
tab: Tab;
@@ -26,6 +27,15 @@ interface TabContextType {
updateTabTitleFromMessage: (tabId: string, message: string | any) => Promise<void>;
openExistingSession: (sessionId: string, title?: string) => void;
updateSessionId: (tabId: string, newSessionId: string) => void;
// Sidecar management functions
showSidecarView: (tabId: string, view: TabSidecarView) => void;
hideSidecarView: (tabId: string, viewId: string) => void;
hideAllSidecarViews: (tabId: string) => void;
getSidecarState: (tabId: string) => TabSidecarState | undefined;
showDiffViewer: (tabId: string, diffContent: string, fileName?: string, instanceId?: string) => void;
showLocalhostViewer: (tabId: string, url?: string, title?: string, instanceId?: string) => void;
showFileViewer: (tabId: string, filePath: string, instanceId?: string) => void;
showDocumentEditor: (tabId: string, filePath?: string, initialContent?: string, instanceId?: string) => void;
}
const TabContext = createContext<TabContextType | undefined>(undefined);
@@ -395,6 +405,194 @@ export const TabProvider: React.FC<TabProviderProps> = ({ children }) => {
));
}, []);
// Sidecar management functions
const showSidecarView = useCallback((tabId: string, view: TabSidecarView) => {
console.log('🔧 TabContext: Showing sidecar view for tab:', tabId, 'view:', view.id);
setTabStates(prev => prev.map(ts => {
if (ts.tab.id !== tabId) return ts;
const currentSidecarState = ts.tab.sidecarState || { activeViews: [], views: [] };
// Add or update the view
const existingViewIndex = currentSidecarState.views.findIndex(v => v.id === view.id);
let updatedViews;
if (existingViewIndex >= 0) {
updatedViews = [...currentSidecarState.views];
updatedViews[existingViewIndex] = view;
} else {
updatedViews = [...currentSidecarState.views, view];
}
// Add to active views if not already active
const updatedActiveViews = currentSidecarState.activeViews.includes(view.id)
? currentSidecarState.activeViews
: [...currentSidecarState.activeViews, view.id];
return {
...ts,
tab: {
...ts.tab,
sidecarState: {
activeViews: updatedActiveViews,
views: updatedViews
}
}
};
}));
}, []);
const hideSidecarView = useCallback((tabId: string, viewId: string) => {
console.log('🔧 TabContext: Hiding sidecar view for tab:', tabId, 'view:', viewId);
setTabStates(prev => prev.map(ts => {
if (ts.tab.id !== tabId || !ts.tab.sidecarState) return ts;
return {
...ts,
tab: {
...ts.tab,
sidecarState: {
...ts.tab.sidecarState,
activeViews: ts.tab.sidecarState.activeViews.filter(id => id !== viewId)
}
}
};
}));
}, []);
const hideAllSidecarViews = useCallback((tabId: string) => {
console.log('🔧 TabContext: Hiding all sidecar views for tab:', tabId);
setTabStates(prev => prev.map(ts => {
if (ts.tab.id !== tabId || !ts.tab.sidecarState) return ts;
return {
...ts,
tab: {
...ts.tab,
sidecarState: {
...ts.tab.sidecarState,
activeViews: []
}
}
};
}));
}, []);
const getSidecarState = useCallback((tabId: string): TabSidecarState | undefined => {
const tabState = tabStates.find(ts => ts.tab.id === tabId);
return tabState?.tab.sidecarState;
}, [tabStates]);
// Helper function to create sidecar views for specific types
const showDiffViewer = useCallback((tabId: string, diffContent: string, fileName = 'File', instanceId?: string) => {
const id = instanceId ? `diff-${instanceId}` : 'diff';
// Simple diff viewer component
const MonacoDiffViewer = ({ diffContent: content }: { diffContent: string }) => (
<div className="h-full p-4 bg-background-default">
<pre className="text-sm text-textStandard whitespace-pre-wrap">{content}</pre>
</div>
);
const diffView: TabSidecarView = {
id,
title: 'Diff Viewer',
icon: <FileDiff size={16} />,
content: <MonacoDiffViewer diffContent={diffContent} />,
fileName,
instanceId,
};
showSidecarView(tabId, diffView);
}, [showSidecarView]);
const showLocalhostViewer = useCallback((tabId: string, url = 'http://localhost:3000', title = 'Localhost Viewer', instanceId?: string) => {
const id = instanceId ? `localhost-${instanceId}` : 'localhost';
// Simple iframe component for localhost viewer
const LocalhostViewer = ({ url: viewerUrl }: { url: string }) => (
<div className="h-full">
<iframe
src={viewerUrl}
className="w-full h-full border-0"
title={title}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
/>
</div>
);
const localhostView: TabSidecarView = {
id,
title,
icon: <Globe size={16} />,
content: <LocalhostViewer url={url} />,
fileName: url,
instanceId,
};
showSidecarView(tabId, localhostView);
}, [showSidecarView]);
const showFileViewer = useCallback((tabId: string, filePath: string, instanceId?: string) => {
const fileName = filePath.split('/').pop() || filePath;
const id = instanceId ? `file-${instanceId}` : 'file';
// Simple file viewer component
const SimpleFileViewer = ({ path }: { path: string }) => (
<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 fileView: TabSidecarView = {
id,
title: 'File Viewer',
icon: <FileText size={16} />,
content: <SimpleFileViewer path={filePath} />,
fileName,
instanceId,
};
showSidecarView(tabId, fileView);
}, [showSidecarView]);
const showDocumentEditor = useCallback((tabId: string, filePath?: string, initialContent?: string, instanceId?: string) => {
const fileName = filePath ? filePath.split('/').pop() || filePath : 'Untitled Document';
const id = instanceId ? `editor-${instanceId}` : 'editor';
// Simple document editor component
const SimpleDocumentEditor = ({ path, content }: { path?: string; content?: string }) => (
<div className="h-full p-4 bg-background-default">
<div className="text-sm text-textMuted mb-2">
{path ? `Editing: ${path}` : 'New Document'}
</div>
<textarea
className="w-full h-full border border-borderSubtle rounded p-2 text-sm resize-none"
placeholder="Start writing your document..."
defaultValue={content || ''}
/>
</div>
);
const editorView: TabSidecarView = {
id,
title: 'Document Editor',
icon: <Edit size={16} />,
content: <SimpleDocumentEditor path={filePath} content={initialContent} />,
fileName,
instanceId,
};
showSidecarView(tabId, editorView);
}, [showSidecarView]);
const contextValue: TabContextType = {
tabStates,
activeTabId,
@@ -410,7 +608,16 @@ export const TabProvider: React.FC<TabProviderProps> = ({ children }) => {
syncTabTitleWithBackend,
updateTabTitleFromMessage,
openExistingSession,
updateSessionId
updateSessionId,
// Sidecar functions
showSidecarView,
hideSidecarView,
hideAllSidecarViews,
getSidecarState,
showDiffViewer,
showLocalhostViewer,
showFileViewer,
showDocumentEditor
};
return (