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