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,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;
|
||||
|
||||
Reference in New Issue
Block a user