feat: Multi-session support with tabs, split view, and Mochi integration
- Add SessionContext for central state management - Add TabBar component for session tabs - Add SplitLayout for side-by-side session viewing - Add ChatPanel wrapper component - Refactor ChatInput to uncontrolled input for performance - Add SCP file transfer for SSH hosts (Mochi) - Fix stats undefined crash on session restore - Store host info in sessions for upload routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2 } from 'lucide-react';
|
||||
import { useSessionManager } from '../contexts/SessionContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
|
||||
@@ -28,31 +29,23 @@ function saveRecentDirs(dirs) {
|
||||
function addRecentDir(hostId, path) {
|
||||
const recent = loadRecentDirs();
|
||||
const hostRecent = recent[hostId] || [];
|
||||
|
||||
// Remove if already exists, then add to front
|
||||
const filtered = hostRecent.filter(p => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, MAX_RECENT_DIRS);
|
||||
|
||||
recent[hostId] = updated;
|
||||
saveRecentDirs(recent);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
open,
|
||||
onToggle,
|
||||
selectedProject,
|
||||
onSelectProject,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
sessionActive,
|
||||
activeHost,
|
||||
onStartSession,
|
||||
onStopSession,
|
||||
onClearMessages,
|
||||
resumeSession,
|
||||
onToggleResume
|
||||
}) {
|
||||
export function Sidebar({ open, onToggle }) {
|
||||
const {
|
||||
focusedSessionId,
|
||||
focusedSession,
|
||||
startClaudeSession,
|
||||
stopClaudeSession,
|
||||
clearMessages,
|
||||
updateSessionConfig,
|
||||
} = useSessionManager();
|
||||
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [recentDirs, setRecentDirs] = useState([]);
|
||||
const [showBrowser, setShowBrowser] = useState(false);
|
||||
@@ -62,43 +55,59 @@ export function Sidebar({
|
||||
const [browserError, setBrowserError] = useState(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
// Current session values
|
||||
const currentHost = focusedSession?.host || 'neko';
|
||||
const currentProject = focusedSession?.project || '/home/sumdex/projects';
|
||||
const sessionActive = focusedSession?.active || false;
|
||||
const resumeSession = focusedSession?.resumeOnStart ?? true;
|
||||
|
||||
// Fetch hosts on mount
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/hosts`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setHosts(data.hosts || []);
|
||||
if (!selectedHost && data.defaultHost) {
|
||||
onSelectHost(data.defaultHost);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Load recent directories when host changes
|
||||
useEffect(() => {
|
||||
if (!selectedHost) return;
|
||||
if (!currentHost) return;
|
||||
const recent = loadRecentDirs();
|
||||
setRecentDirs(recent[selectedHost] || []);
|
||||
}, [selectedHost]);
|
||||
setRecentDirs(recent[currentHost] || []);
|
||||
}, [currentHost]);
|
||||
|
||||
// Handle selecting a directory (from dropdown or browser)
|
||||
// Handle selecting a directory
|
||||
const handleSelectDir = useCallback((path) => {
|
||||
onSelectProject(path);
|
||||
const updated = addRecentDir(selectedHost, path);
|
||||
if (!focusedSessionId) return;
|
||||
updateSessionConfig(focusedSessionId, { project: path });
|
||||
const updated = addRecentDir(currentHost, path);
|
||||
setRecentDirs(updated);
|
||||
setDropdownOpen(false);
|
||||
setShowBrowser(false);
|
||||
}, [selectedHost, onSelectProject]);
|
||||
}, [focusedSessionId, currentHost, updateSessionConfig]);
|
||||
|
||||
// Handle host change
|
||||
const handleSelectHost = useCallback((hostId) => {
|
||||
if (!focusedSessionId || sessionActive) return;
|
||||
updateSessionConfig(focusedSessionId, { host: hostId });
|
||||
}, [focusedSessionId, sessionActive, updateSessionConfig]);
|
||||
|
||||
// Handle resume toggle
|
||||
const handleToggleResume = useCallback(() => {
|
||||
if (!focusedSessionId) return;
|
||||
updateSessionConfig(focusedSessionId, { resumeOnStart: !resumeSession });
|
||||
}, [focusedSessionId, resumeSession, updateSessionConfig]);
|
||||
|
||||
// Browse directories on host
|
||||
const browsePath = useCallback(async (path) => {
|
||||
if (!selectedHost) return;
|
||||
if (!currentHost) return;
|
||||
setBrowserLoading(true);
|
||||
setBrowserError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/browse?host=${selectedHost}&path=${encodeURIComponent(path)}`);
|
||||
const res = await fetch(`${API_URL}/api/browse?host=${currentHost}&path=${encodeURIComponent(path)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
@@ -113,7 +122,7 @@ export function Sidebar({
|
||||
} finally {
|
||||
setBrowserLoading(false);
|
||||
}
|
||||
}, [selectedHost]);
|
||||
}, [currentHost]);
|
||||
|
||||
// Open browser
|
||||
const openBrowser = useCallback(() => {
|
||||
@@ -128,6 +137,51 @@ export function Sidebar({
|
||||
return parts[parts.length - 1] || path;
|
||||
};
|
||||
|
||||
// Start/stop session handlers
|
||||
const handleStartSession = useCallback(() => {
|
||||
if (focusedSessionId) {
|
||||
startClaudeSession(focusedSessionId);
|
||||
}
|
||||
}, [focusedSessionId, startClaudeSession]);
|
||||
|
||||
const handleStopSession = useCallback(() => {
|
||||
if (focusedSessionId) {
|
||||
stopClaudeSession(focusedSessionId);
|
||||
}
|
||||
}, [focusedSessionId, stopClaudeSession]);
|
||||
|
||||
const handleClearMessages = useCallback(() => {
|
||||
if (focusedSessionId) {
|
||||
clearMessages(focusedSessionId);
|
||||
}
|
||||
}, [focusedSessionId, clearMessages]);
|
||||
|
||||
// No session selected
|
||||
if (!focusedSession) {
|
||||
return (
|
||||
<aside
|
||||
className={`
|
||||
${open ? 'w-72' : 'w-0'}
|
||||
bg-dark-900 border-r border-dark-800 flex flex-col
|
||||
transition-all duration-300 overflow-hidden
|
||||
lg:relative fixed inset-y-0 left-0 z-40
|
||||
`}
|
||||
>
|
||||
<div className="p-4 border-b border-dark-800">
|
||||
<h2 className="font-semibold text-dark-200 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Session Control
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<p className="text-dark-500 text-sm text-center">
|
||||
Create or select a session tab to configure
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`
|
||||
@@ -152,30 +206,30 @@ export function Sidebar({
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{hosts.map((host) => {
|
||||
const isActive = sessionActive && activeHost === host.id;
|
||||
const isDisabled = sessionActive && activeHost && activeHost !== host.id;
|
||||
const isSelected = currentHost === host.id;
|
||||
const isDisabled = sessionActive && currentHost !== host.id;
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
onClick={() => !isDisabled && onSelectHost(host.id)}
|
||||
onClick={() => !isDisabled && handleSelectHost(host.id)}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
transition-colors border relative
|
||||
${isDisabled
|
||||
? 'border-dark-800 text-dark-600 cursor-not-allowed opacity-50'
|
||||
: selectedHost === host.id
|
||||
: isSelected
|
||||
? 'border-orange-500/50 text-white'
|
||||
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
|
||||
backgroundColor: isSelected ? `${host.color}30` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<Server className="w-3.5 h-3.5" style={{ color: isDisabled ? '#4a4a4a' : host.color }} />
|
||||
<span>{host.name}</span>
|
||||
{isActive && (
|
||||
{sessionActive && isSelected && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" title="Active session" />
|
||||
)}
|
||||
</button>
|
||||
@@ -198,13 +252,17 @@ export function Sidebar({
|
||||
<div className="flex gap-2">
|
||||
{/* Dropdown button */}
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="flex-1 flex items-center gap-2 px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-left hover:border-dark-600 transition-colors"
|
||||
onClick={() => !sessionActive && setDropdownOpen(!dropdownOpen)}
|
||||
disabled={sessionActive}
|
||||
className={`
|
||||
flex-1 flex items-center gap-2 px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-left transition-colors
|
||||
${sessionActive ? 'opacity-50 cursor-not-allowed' : 'hover:border-dark-600'}
|
||||
`}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 text-orange-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-dark-200 truncate">{getDisplayName(selectedProject)}</div>
|
||||
<div className="text-xs text-dark-500 truncate">{selectedProject}</div>
|
||||
<div className="text-sm text-dark-200 truncate">{getDisplayName(currentProject)}</div>
|
||||
<div className="text-xs text-dark-500 truncate">{currentProject}</div>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-dark-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
@@ -212,7 +270,11 @@ export function Sidebar({
|
||||
{/* Browse button */}
|
||||
<button
|
||||
onClick={openBrowser}
|
||||
className="px-3 py-2.5 bg-dark-800 hover:bg-dark-700 border border-dark-700 rounded-lg text-dark-400 hover:text-dark-200 transition-colors"
|
||||
disabled={sessionActive}
|
||||
className={`
|
||||
px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-dark-400 transition-colors
|
||||
${sessionActive ? 'opacity-50 cursor-not-allowed' : 'hover:bg-dark-700 hover:text-dark-200'}
|
||||
`}
|
||||
title="Browse directories"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -220,7 +282,7 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{dropdownOpen && (
|
||||
{dropdownOpen && !sessionActive && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-800 border border-dark-700 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
|
||||
{recentDirs.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-dark-500 text-center">
|
||||
@@ -232,14 +294,14 @@ export function Sidebar({
|
||||
key={path}
|
||||
onClick={() => handleSelectDir(path)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-dark-700 transition-colors
|
||||
${selectedProject === path ? 'bg-orange-500/10 text-orange-400' : 'text-dark-300'}`}
|
||||
${currentProject === path ? 'bg-orange-500/10 text-orange-400' : 'text-dark-300'}`}
|
||||
>
|
||||
<Folder className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{getDisplayName(path)}</div>
|
||||
<div className="text-xs text-dark-500 truncate">{path}</div>
|
||||
</div>
|
||||
{selectedProject === path && (
|
||||
{currentProject === path && (
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
@@ -249,11 +311,15 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessionActive && (
|
||||
<p className="text-xs text-dark-500">Stop session to change directory</p>
|
||||
)}
|
||||
|
||||
{/* Resume toggle */}
|
||||
<div className="pt-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer group">
|
||||
<div
|
||||
onClick={onToggleResume}
|
||||
onClick={handleToggleResume}
|
||||
className={`
|
||||
relative w-10 h-5 rounded-full transition-colors
|
||||
${resumeSession ? 'bg-orange-600' : 'bg-dark-700'}
|
||||
@@ -282,17 +348,21 @@ export function Sidebar({
|
||||
<div className="space-y-2">
|
||||
{!sessionActive ? (
|
||||
<button
|
||||
onClick={onStartSession}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||
bg-green-600 hover:bg-green-500 rounded-lg
|
||||
font-medium transition-colors"
|
||||
onClick={handleStartSession}
|
||||
disabled={!focusedSession?.connected}
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-colors
|
||||
${focusedSession?.connected
|
||||
? 'bg-green-600 hover:bg-green-500'
|
||||
: 'bg-dark-700 text-dark-500 cursor-not-allowed'}
|
||||
`}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Start Session
|
||||
{focusedSession?.connected ? 'Start Session' : 'Connecting...'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onStopSession}
|
||||
onClick={handleStopSession}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||
bg-red-600 hover:bg-red-500 rounded-lg
|
||||
font-medium transition-colors"
|
||||
@@ -303,7 +373,7 @@ export function Sidebar({
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClearMessages}
|
||||
onClick={handleClearMessages}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2
|
||||
bg-dark-800 hover:bg-dark-700 rounded-lg
|
||||
text-dark-300 hover:text-dark-100 transition-colors"
|
||||
@@ -317,8 +387,8 @@ export function Sidebar({
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-dark-800 text-xs text-dark-500">
|
||||
<div>Claude Code Web UI POC</div>
|
||||
<div>JSON Stream Mode</div>
|
||||
<div>Claude Code Web UI</div>
|
||||
<div>Multi-Session Mode</div>
|
||||
</div>
|
||||
|
||||
{/* Directory Browser Modal */}
|
||||
|
||||
Reference in New Issue
Block a user