2 Commits

Author SHA1 Message Date
simos
36f8f50d63 feat(editor): add sidebar mode to CodeEditor component
Add an optional  prop to the CodeEditor component to support
rendering in both modal and sidebar layouts. When enabled, the editor
adapts its container styling and removes the fixed overlay positioning,
allowing it to be embedded inline. This includes conditional rendering
of the loading state and main container to properly display within a
sidebar context while maintaining the existing modal behavior as the
default.
2025-10-31 09:47:30 +00:00
simos
4e14222487 feat(ui): add collapsible sidebar functionality
Implement a collapsible sidebar feature for the desktop view that allows
users to toggle between expanded and collapsed states. The sidebar state
is persisted using localStorage to maintain user preference across
sessions.

Changes include:
- Add sidebarVisible state with localStorage persistence
- Import Sparkles and SettingsIcon from lucide-react
- Implement smooth transition animation (300ms) for sidebar collapse
- Add collapsed sidebar view with icon-only navigation buttons
- Pass onToggleSidebar prop to Sidebar component
- Adjust sidebar width dynamically (80 -> 14 when collapsed)

This improves the user experience by providing more screen real estate
for the main content area when needed, while keeping quick access to
essential navigation through the collapsed icon view.
2025-10-31 09:36:14 +00:00
5 changed files with 259 additions and 92 deletions

View File

@@ -20,6 +20,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
import Sidebar from './components/Sidebar';
import MainContent from './components/MainContent';
import MobileNav from './components/MobileNav';
@@ -60,6 +61,7 @@ function AppContent() {
const [showThinking, setShowThinking] = useLocalStorage('showThinking', true);
const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true);
const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false);
const [sidebarVisible, setSidebarVisible] = useLocalStorage('sidebarVisible', true);
// Session Protection System: Track sessions with active conversations to prevent
// automatic project updates from interrupting ongoing chats. When a user sends
// a message, the session is marked as "active" and project updates are paused
@@ -734,28 +736,78 @@ function AppContent() {
<div className="fixed inset-0 flex bg-background">
{/* Fixed Desktop Sidebar */}
{!isMobile && (
<div className="w-80 flex-shrink-0 border-r border-border bg-card">
<div
className={`flex-shrink-0 border-r border-border bg-card transition-all duration-300 ${
sidebarVisible ? 'w-80' : 'w-14'
}`}
>
<div className="h-full overflow-hidden">
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
/>
{sidebarVisible ? (
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
onToggleSidebar={() => setSidebarVisible(false)}
/>
) : (
/* Collapsed Sidebar */
<div className="h-full flex flex-col items-center py-4 gap-4">
{/* Expand Button */}
<button
onClick={() => setSidebarVisible(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
aria-label="Show sidebar"
title="Show sidebar"
>
<svg
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
{/* Settings Icon */}
<button
onClick={() => setShowSettings(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Settings"
title="Settings"
>
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
</button>
{/* Update Indicator */}
{updateAvailable && (
<button
onClick={() => setShowVersionModal(true)}
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Update available"
title="Update available"
>
<Sparkles className="w-5 h-5 text-blue-500" />
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</button>
)}
</div>
)}
</div>
</div>
)}
@@ -805,6 +857,7 @@ function AppContent() {
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
onToggleSidebar={() => setSidebarVisible(false)}
/>
</div>
</div>

View File

@@ -514,7 +514,6 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span className="flex items-center gap-2">
<span className="text-lg leading-none">📝</span>
<span>View edit diff for</span>
</span>
<button

View File

@@ -13,7 +13,7 @@ import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
import { api } from '../utils/api';
function CodeEditor({ file, onClose, projectPath }) {
function CodeEditor({ file, onClose, projectPath, isSidebar = false }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -321,14 +321,23 @@ function CodeEditor({ file, onClose, projectPath }) {
}
`}
</style>
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
{isSidebar ? (
<div className="w-full h-full flex items-center justify-center bg-white dark:bg-gray-900">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
</div>
</div>
</div>
) : (
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
</div>
</div>
</div>
)}
</>
);
}
@@ -403,33 +412,32 @@ function CodeEditor({ file, onClose, projectPath }) {
}
`}
</style>
<div className={`fixed inset-0 z-50 ${
<div className={isSidebar ?
'w-full h-full flex flex-col' :
`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
<div className={isSidebar ?
'bg-white dark:bg-gray-900 flex flex-col w-full h-full' :
`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0 min-w-0">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
<span className="text-white text-sm font-mono">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-medium text-gray-900 truncate">{file.name}</h3>
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
📝 Has changes
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate">{file.path}</p>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
@@ -496,13 +504,15 @@ function CodeEditor({ file, onClose, projectPath }) {
)}
</button>
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
{!isSidebar && (
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<button
onClick={onClose}
@@ -565,7 +575,6 @@ function CodeEditor({ file, onClose, projectPath }) {
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<span>Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">

View File

@@ -11,7 +11,7 @@
* No session protection logic is implemented here - it's purely a props bridge.
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import ChatInterface from './ChatInterface';
import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
@@ -28,13 +28,13 @@ import { useTaskMaster } from '../contexts/TaskMasterContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { api } from '../utils/api';
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
messages,
isMobile,
isPWA,
@@ -61,6 +61,9 @@ function MainContent({
const [editingFile, setEditingFile] = useState(null);
const [selectedTask, setSelectedTask] = useState(null);
const [showTaskDetail, setShowTaskDetail] = useState(false);
const [editorWidth, setEditorWidth] = useState(600);
const [isResizing, setIsResizing] = useState(false);
const resizeRef = useRef(null);
// PRD Editor state
const [showPRDEditor, setShowPRDEditor] = useState(false);
@@ -153,6 +156,52 @@ function MainContent({
console.log('Update task status:', taskId, newStatus);
refreshTasks?.();
};
// Handle resize functionality
const handleMouseDown = (e) => {
if (isMobile) return; // Disable resize on mobile
setIsResizing(true);
e.preventDefault();
};
useEffect(() => {
const handleMouseMove = (e) => {
if (!isResizing) return;
const container = resizeRef.current?.parentElement;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const newWidth = containerRect.right - e.clientX;
// Min width: 300px, Max width: 80% of container
const minWidth = 300;
const maxWidth = containerRect.width * 0.8;
if (newWidth >= minWidth && newWidth <= maxWidth) {
setEditorWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing]);
if (isLoading) {
return (
<div className="h-full flex flex-col">
@@ -234,10 +283,10 @@ function MainContent({
return (
<div className="h-full flex flex-col">
{/* Header with tabs */}
<div
<div
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between relative">
<div className="flex items-center space-x-2 sm:space-x-3">
{isMobile && (
<button
@@ -409,11 +458,13 @@ function MainContent({
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails={true}>
<ChatInterface
{/* Content Area with Right Sidebar */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Main Content */}
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editingFile ? 'mr-0' : ''}`}>
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails={true}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
@@ -513,14 +564,45 @@ function MainContent({
onClearLogs={() => setServerLogs([])}
/> */}
</div>
</div>
{/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */}
{editingFile && !isMobile && (
<>
{/* Resize Handle */}
<div
ref={resizeRef}
onMouseDown={handleMouseDown}
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
title="Drag to resize"
>
{/* Visual indicator on hover */}
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* Editor Sidebar */}
<div
className="flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden"
style={{ width: `${editorWidth}px` }}
>
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
isSidebar={true}
/>
</div>
</>
)}
</div>
{/* Code Editor Modal */}
{editingFile && (
{/* Code Editor Modal for Mobile */}
{editingFile && isMobile && (
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
isSidebar={false}
/>
)}

View File

@@ -57,7 +57,8 @@ function Sidebar({
releaseInfo,
onShowVersionModal,
isPWA,
isMobile
isMobile,
onToggleSidebar
}) {
const [expandedProjects, setExpandedProjects] = useState(new Set());
const [editingProject, setEditingProject] = useState(null);
@@ -587,34 +588,24 @@ function Sidebar({
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
</div>
</div>
<div className="flex gap-2">
{onToggleSidebar && (
<Button
variant="ghost"
size="sm"
className="h-9 w-9 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200"
onClick={onToggleSidebar}
title="Hide sidebar"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Button>
<Button
variant="default"
size="sm"
className="h-9 w-9 px-0 bg-primary hover:bg-primary/90 transition-all duration-200 shadow-sm hover:shadow-md"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-4 h-4" />
</Button>
</div>
)}
</div>
{/* Mobile Header */}
@@ -914,9 +905,9 @@ function Sidebar({
</div>
)}
{/* Search Filter */}
{/* Search Filter and Actions */}
{projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
@@ -935,6 +926,39 @@ function Sidebar({
</button>
)}
</div>
{/* Action Buttons - Desktop only */}
{!isMobile && (
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
)}
</div>
)}