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:
2025-12-17 14:16:52 +01:00
parent 960f2e137d
commit cfee1711dc
9 changed files with 2122 additions and 467 deletions

View File

@@ -1,310 +1,112 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useClaudeSession } from './hooks/useClaudeSession';
import { MessageList } from './components/MessageList';
import { ChatInput } from './components/ChatInput';
import { useState, useCallback, useEffect } from 'react';
import { SessionProvider, useSessionManager } from './contexts/SessionContext';
import { Sidebar } from './components/Sidebar';
import { Header } from './components/Header';
import { StatusBar } from './components/StatusBar';
import { PermissionDialog } from './components/PermissionDialog';
import { TabBar } from './components/TabBar';
import { ChatPanel } from './components/ChatPanel';
import { SplitLayout } from './components/SplitLayout';
import { Menu } from 'lucide-react';
// Slash command definitions
const SLASH_COMMANDS = {
clear: {
description: 'Clear chat history (UI only)',
execute: ({ clearMessages, addSystemMessage }) => {
clearMessages();
addSystemMessage('Chat cleared');
}
},
help: {
description: 'Show available commands',
execute: ({ addSystemMessage }) => {
const helpText = Object.entries(SLASH_COMMANDS)
.map(([cmd, { description }]) => `/${cmd} - ${description}`)
.join('\n');
addSystemMessage(`Available commands:\n${helpText}`);
}
},
export: {
description: 'Export chat as Markdown',
execute: ({ messages, addSystemMessage }) => {
const markdown = messages.map(m => {
if (m.type === 'user') return `**You:** ${m.content}`;
if (m.type === 'assistant') return `**Claude:** ${m.content}`;
if (m.type === 'tool_use') return `> Tool: ${m.tool}`;
if (m.type === 'system') return `_${m.content}_`;
return '';
}).filter(Boolean).join('\n\n');
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `claude-chat-${new Date().toISOString().slice(0,10)}.md`;
a.click();
URL.revokeObjectURL(url);
addSystemMessage('Chat exported as Markdown');
}
},
scroll: {
description: 'Scroll to top or bottom (/scroll top|bottom)',
execute: ({ args, addSystemMessage }) => {
const direction = args[0] || 'bottom';
const container = document.querySelector('.overflow-y-auto');
if (container) {
if (direction === 'top') {
container.scrollTo({ top: 0, behavior: 'smooth' });
} else {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}
}
addSystemMessage(`Scrolled to ${direction}`);
}
},
new: {
description: 'Start a new session (clears history)',
execute: ({ clearMessages, stopSession, startSession, selectedProject, addSystemMessage }) => {
stopSession();
clearMessages();
setTimeout(() => {
startSession(selectedProject, false); // false = don't resume
}, 500);
addSystemMessage('Starting new session...');
}
},
info: {
description: 'Show session info',
execute: ({ connected, sessionActive, currentProject, messages, addSystemMessage }) => {
const info = [
`Connected: ${connected ? 'Yes' : 'No'}`,
`Session: ${sessionActive ? 'Active' : 'Inactive'}`,
`Project: ${currentProject || 'None'}`,
`Messages: ${messages.length}`
].join('\n');
addSystemMessage(info);
}
},
history: {
description: 'Search input history (/history <term>)',
execute: ({ args, addSystemMessage }) => {
const HISTORY_KEY = 'claude-webui-input-history';
try {
const stored = localStorage.getItem(HISTORY_KEY);
const history = stored ? JSON.parse(stored) : [];
const searchTerm = args.join(' ').toLowerCase();
if (!searchTerm) {
// Show recent history
const recent = history.slice(0, 10);
if (recent.length === 0) {
addSystemMessage('No input history found.');
} else {
addSystemMessage(`Recent inputs:\n${recent.map((h, i) => `${i + 1}. ${h.length > 80 ? h.slice(0, 80) + '...' : h}`).join('\n')}`);
}
} else {
// Search history
const matches = history.filter(h => h.toLowerCase().includes(searchTerm)).slice(0, 10);
if (matches.length === 0) {
addSystemMessage(`No history entries matching "${searchTerm}"`);
} else {
addSystemMessage(`History matching "${searchTerm}":\n${matches.map((h, i) => `${i + 1}. ${h.length > 80 ? h.slice(0, 80) + '...' : h}`).join('\n')}`);
}
}
} catch {
addSystemMessage('Failed to read input history.');
}
}
}
};
function App() {
function AppContent() {
const {
connected,
sessionActive,
sessionId,
messages,
currentProject,
currentHost,
isProcessing,
error,
sessionStats,
permissionMode,
controlInitialized,
pendingPermission,
startSession,
sendMessage,
stopSession,
stopGeneration,
clearMessages,
changePermissionMode,
respondToPermission,
setError,
setMessages
} = useClaudeSession();
sessions,
tabOrder,
splitSessions,
focusedSessionId,
createSession,
} = useSessionManager();
const [selectedProject, setSelectedProject] = useState('/home/sumdex/projects');
const [selectedHost, setSelectedHost] = useState('neko');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [resumeSession, setResumeSession] = useState(true);
// Add system message helper
const addSystemMessage = useCallback((content) => {
setMessages(prev => [...prev, {
type: 'system',
content,
timestamp: Date.now()
}]);
}, [setMessages]);
const handleStartSession = useCallback(() => {
startSession(selectedProject, resumeSession, selectedHost);
}, [startSession, selectedProject, resumeSession, selectedHost]);
const handleSelectProject = useCallback((path) => {
setSelectedProject(path);
}, []);
const handleSelectHost = useCallback((host) => {
setSelectedHost(host);
}, []);
// Create initial session if none exists
useEffect(() => {
if (tabOrder.length === 0) {
createSession('neko', '/home/sumdex/projects');
}
}, [tabOrder.length, createSession]);
const handleToggleSidebar = useCallback(() => {
setSidebarOpen(prev => !prev);
}, []);
const handleToggleResume = useCallback(() => {
setResumeSession(prev => !prev);
}, []);
const handleClearError = useCallback(() => {
setError(null);
}, [setError]);
// Handle slash commands
const handleCommand = useCallback((command, args) => {
const cmd = SLASH_COMMANDS[command.toLowerCase()];
if (cmd) {
cmd.execute({
clearMessages,
addSystemMessage,
messages,
stopSession,
startSession,
selectedProject,
connected,
sessionActive,
currentProject,
args
});
return true;
}
return false;
}, [clearMessages, addSystemMessage, messages, stopSession, startSession, selectedProject, connected, sessionActive, currentProject]);
const handleSendMessage = useCallback((message, attachedFiles = []) => {
// Check for slash command (only if no files attached)
if (message.startsWith('/') && attachedFiles.length === 0) {
const parts = message.slice(1).split(' ');
const command = parts[0];
const args = parts.slice(1);
if (handleCommand(command, args)) {
return; // Command handled
} else {
addSystemMessage(`Unknown command: /${command}. Type /help for available commands.`);
return;
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
// Ctrl+T: New tab
if (e.ctrlKey && e.key === 't') {
e.preventDefault();
createSession();
}
}
// Ctrl+B: Toggle sidebar
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
setSidebarOpen(prev => !prev);
}
};
// Regular message (with optional attachments)
if (message.trim() || attachedFiles.length > 0) {
sendMessage(message, attachedFiles);
}
}, [handleCommand, addSystemMessage, sendMessage]);
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [createSession]);
// Render panel content for a session
const renderPanel = useCallback((sessionId) => {
return <ChatPanel sessionId={sessionId} />;
}, []);
return (
<div className="flex h-screen bg-dark-950">
{/* Sidebar */}
<Sidebar
open={sidebarOpen}
onToggle={handleToggleSidebar}
selectedProject={selectedProject}
onSelectProject={handleSelectProject}
selectedHost={selectedHost}
onSelectHost={handleSelectHost}
sessionActive={sessionActive}
activeHost={currentHost}
onStartSession={handleStartSession}
onStopSession={stopSession}
onClearMessages={clearMessages}
resumeSession={resumeSession}
onToggleResume={handleToggleResume}
/>
<Sidebar open={sidebarOpen} onToggle={handleToggleSidebar} />
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0">
<Header
connected={connected}
sessionActive={sessionActive}
currentProject={currentProject}
isProcessing={isProcessing}
onToggleSidebar={handleToggleSidebar}
/>
{/* Header with TabBar */}
<div className="flex items-center bg-dark-900 border-b border-dark-800">
{/* Sidebar toggle for mobile */}
<button
onClick={handleToggleSidebar}
className="p-3 hover:bg-dark-800 lg:hidden"
>
<Menu className="w-5 h-5" />
</button>
{/* Error Banner */}
{error && (
<div className="bg-red-900/50 border-b border-red-800 px-4 py-2 flex justify-between items-center">
<span className="text-red-200 text-sm">{error}</span>
<button
onClick={handleClearError}
className="text-red-400 hover:text-red-300"
>
×
</button>
{/* Tab Bar */}
<div className="flex-1 min-w-0">
<TabBar />
</div>
)}
</div>
{/* Messages */}
<MessageList
messages={messages}
isProcessing={isProcessing}
/>
{/* Status Bar */}
<StatusBar
sessionStats={sessionStats}
isProcessing={isProcessing}
connected={connected}
permissionMode={permissionMode}
controlInitialized={controlInitialized}
onChangeMode={changePermissionMode}
/>
{/* Input */}
<ChatInput
onSend={handleSendMessage}
onStop={stopGeneration}
disabled={!sessionActive}
isProcessing={isProcessing}
sessionId={sessionId}
placeholder={
!connected
? 'Connecting...'
: !sessionActive
? 'Start a session to begin'
: 'Type your message...'
}
/>
{/* Content Area */}
<div className="flex-1 min-h-0 overflow-hidden">
{splitSessions.length > 0 ? (
// Split view mode
<SplitLayout
splitSessions={splitSessions}
renderPanel={renderPanel}
/>
) : focusedSessionId ? (
// Single panel mode
<ChatPanel sessionId={focusedSessionId} />
) : (
// No session
<div className="flex items-center justify-center h-full text-dark-500">
<div className="text-center">
<p className="text-lg mb-2">No sessions open</p>
<p className="text-sm">Click the + button to create a new session</p>
</div>
</div>
)}
</div>
</div>
{/* Permission Dialog */}
<PermissionDialog
permission={pendingPermission}
onAllow={(requestId) => respondToPermission(requestId, true)}
onDeny={(requestId) => respondToPermission(requestId, false)}
/>
</div>
);
}
function App() {
return (
<SessionProvider>
<AppContent />
</SessionProvider>
);
}
export default App;