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:
@@ -304,28 +304,60 @@ app.get('/api/browse', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// File upload endpoint
|
// File upload endpoint
|
||||||
app.post('/api/upload/:sessionId', upload.array('files', 5), (req, res) => {
|
app.post('/api/upload/:sessionId', upload.array('files', 5), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.files || req.files.length === 0) {
|
if (!req.files || req.files.length === 0) {
|
||||||
return res.status(400).json({ error: 'No files uploaded' });
|
return res.status(400).json({ error: 'No files uploaded' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadedFiles = req.files.map(file => {
|
const sessionId = req.params.sessionId;
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
const isSSH = session?.host?.connection?.type === 'ssh';
|
||||||
|
|
||||||
|
const uploadedFiles = [];
|
||||||
|
|
||||||
|
for (const file of req.files) {
|
||||||
// Convert container path to host path for Claude
|
// Convert container path to host path for Claude
|
||||||
// /projects/.claude-uploads/... -> /home/sumdex/projects/.claude-uploads/...
|
// /projects/.claude-uploads/... -> /home/sumdex/projects/.claude-uploads/...
|
||||||
const hostPath = file.path.replace('/projects/', '/home/sumdex/projects/');
|
let hostPath = file.path.replace('/projects/', '/home/sumdex/projects/');
|
||||||
return {
|
|
||||||
|
// For SSH hosts, transfer file via SCP
|
||||||
|
if (isSSH && session.host) {
|
||||||
|
const { host: sshHost, user, port = 22 } = session.host.connection;
|
||||||
|
const remotePath = `/tmp/.claude-uploads/${file.filename}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create remote directory if needed
|
||||||
|
const { execSync } = await import('child_process');
|
||||||
|
execSync(`ssh -o StrictHostKeyChecking=no -p ${port} ${user}@${sshHost} "mkdir -p /tmp/.claude-uploads"`, {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transfer file via SCP
|
||||||
|
execSync(`scp -o StrictHostKeyChecking=no -P ${port} "${file.path}" ${user}@${sshHost}:"${remotePath}"`, {
|
||||||
|
timeout: 60000 // 60s for large files
|
||||||
|
});
|
||||||
|
|
||||||
|
hostPath = remotePath;
|
||||||
|
console.log(`[Upload] SCP transferred ${file.filename} to ${session.hostId}:${remotePath}`);
|
||||||
|
} catch (scpErr) {
|
||||||
|
console.error(`[Upload] SCP error for ${file.filename}:`, scpErr.message);
|
||||||
|
// Fall back to local path (won't work but at least doesn't fail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedFiles.push({
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
savedName: file.filename,
|
savedName: file.filename,
|
||||||
path: hostPath, // Use host path so Claude can read it
|
path: hostPath,
|
||||||
containerPath: file.path, // Keep container path for reference
|
containerPath: file.path,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
isImage: file.mimetype.startsWith('image/')
|
isImage: file.mimetype.startsWith('image/')
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Upload] Session ${req.params.sessionId}: ${uploadedFiles.length} files uploaded`);
|
console.log(`[Upload] Session ${sessionId}: ${uploadedFiles.length} files uploaded`);
|
||||||
res.json({ files: uploadedFiles });
|
res.json({ files: uploadedFiles });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Upload] Error:', err);
|
console.error('[Upload] Error:', err);
|
||||||
@@ -582,8 +614,11 @@ wss.on('connection', (ws, req) => {
|
|||||||
const { host: sshHost, user, port = 22 } = host.connection;
|
const { host: sshHost, user, port = 22 } = host.connection;
|
||||||
const sshTarget = `${user}@${sshHost}`;
|
const sshTarget = `${user}@${sshHost}`;
|
||||||
|
|
||||||
|
// Use claudePath from config if specified, otherwise default to 'claude'
|
||||||
|
const claudeBin = host.claudePath || 'claude';
|
||||||
|
|
||||||
// Build the remote command with PATH setup for non-login shells
|
// Build the remote command with PATH setup for non-login shells
|
||||||
const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && claude ${claudeArgs.join(' ')}`;
|
const remoteCmd = `export PATH="$HOME/.local/bin:$PATH" && cd ${projectPath} && ${claudeBin} ${claudeArgs.join(' ')}`;
|
||||||
|
|
||||||
console.log(`[${sessionId}] SSH to ${sshTarget}:${port} - ${remoteCmd}`);
|
console.log(`[${sessionId}] SSH to ${sshTarget}:${port} - ${remoteCmd}`);
|
||||||
|
|
||||||
@@ -607,7 +642,7 @@ wss.on('connection', (ws, req) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.set(sessionId, { process: claudeProcess, project: projectPath });
|
sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId });
|
||||||
|
|
||||||
sendToClient('session_started', {
|
sendToClient('session_started', {
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -1,310 +1,112 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useClaudeSession } from './hooks/useClaudeSession';
|
import { SessionProvider, useSessionManager } from './contexts/SessionContext';
|
||||||
import { MessageList } from './components/MessageList';
|
|
||||||
import { ChatInput } from './components/ChatInput';
|
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Header } from './components/Header';
|
import { TabBar } from './components/TabBar';
|
||||||
import { StatusBar } from './components/StatusBar';
|
import { ChatPanel } from './components/ChatPanel';
|
||||||
import { PermissionDialog } from './components/PermissionDialog';
|
import { SplitLayout } from './components/SplitLayout';
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
|
|
||||||
// Slash command definitions
|
function AppContent() {
|
||||||
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() {
|
|
||||||
const {
|
const {
|
||||||
connected,
|
sessions,
|
||||||
sessionActive,
|
tabOrder,
|
||||||
sessionId,
|
splitSessions,
|
||||||
messages,
|
focusedSessionId,
|
||||||
currentProject,
|
createSession,
|
||||||
currentHost,
|
} = useSessionManager();
|
||||||
isProcessing,
|
|
||||||
error,
|
|
||||||
sessionStats,
|
|
||||||
permissionMode,
|
|
||||||
controlInitialized,
|
|
||||||
pendingPermission,
|
|
||||||
startSession,
|
|
||||||
sendMessage,
|
|
||||||
stopSession,
|
|
||||||
stopGeneration,
|
|
||||||
clearMessages,
|
|
||||||
changePermissionMode,
|
|
||||||
respondToPermission,
|
|
||||||
setError,
|
|
||||||
setMessages
|
|
||||||
} = useClaudeSession();
|
|
||||||
|
|
||||||
const [selectedProject, setSelectedProject] = useState('/home/sumdex/projects');
|
|
||||||
const [selectedHost, setSelectedHost] = useState('neko');
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [resumeSession, setResumeSession] = useState(true);
|
|
||||||
|
|
||||||
// Add system message helper
|
// Create initial session if none exists
|
||||||
const addSystemMessage = useCallback((content) => {
|
useEffect(() => {
|
||||||
setMessages(prev => [...prev, {
|
if (tabOrder.length === 0) {
|
||||||
type: 'system',
|
createSession('neko', '/home/sumdex/projects');
|
||||||
content,
|
}
|
||||||
timestamp: Date.now()
|
}, [tabOrder.length, createSession]);
|
||||||
}]);
|
|
||||||
}, [setMessages]);
|
|
||||||
|
|
||||||
const handleStartSession = useCallback(() => {
|
|
||||||
startSession(selectedProject, resumeSession, selectedHost);
|
|
||||||
}, [startSession, selectedProject, resumeSession, selectedHost]);
|
|
||||||
|
|
||||||
const handleSelectProject = useCallback((path) => {
|
|
||||||
setSelectedProject(path);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectHost = useCallback((host) => {
|
|
||||||
setSelectedHost(host);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleSidebar = useCallback(() => {
|
const handleToggleSidebar = useCallback(() => {
|
||||||
setSidebarOpen(prev => !prev);
|
setSidebarOpen(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleResume = useCallback(() => {
|
// Keyboard shortcuts
|
||||||
setResumeSession(prev => !prev);
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [createSession]);
|
||||||
|
|
||||||
|
// Render panel content for a session
|
||||||
|
const renderPanel = useCallback((sessionId) => {
|
||||||
|
return <ChatPanel sessionId={sessionId} />;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular message (with optional attachments)
|
|
||||||
if (message.trim() || attachedFiles.length > 0) {
|
|
||||||
sendMessage(message, attachedFiles);
|
|
||||||
}
|
|
||||||
}, [handleCommand, addSystemMessage, sendMessage]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-dark-950">
|
<div className="flex h-screen bg-dark-950">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Sidebar
|
<Sidebar open={sidebarOpen} onToggle={handleToggleSidebar} />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<Header
|
{/* Header with TabBar */}
|
||||||
connected={connected}
|
<div className="flex items-center bg-dark-900 border-b border-dark-800">
|
||||||
sessionActive={sessionActive}
|
{/* Sidebar toggle for mobile */}
|
||||||
currentProject={currentProject}
|
|
||||||
isProcessing={isProcessing}
|
|
||||||
onToggleSidebar={handleToggleSidebar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 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
|
<button
|
||||||
onClick={handleClearError}
|
onClick={handleToggleSidebar}
|
||||||
className="text-red-400 hover:text-red-300"
|
className="p-3 hover:bg-dark-800 lg:hidden"
|
||||||
>
|
>
|
||||||
×
|
<Menu className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Tab Bar */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<TabBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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...'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Permission Dialog */}
|
|
||||||
<PermissionDialog
|
|
||||||
permission={pendingPermission}
|
|
||||||
onAllow={(requestId) => respondToPermission(requestId, true)}
|
|
||||||
onDeny={(requestId) => respondToPermission(requestId, false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<AppContent />
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -47,57 +47,30 @@ const COMMANDS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isProcessing, placeholder, sessionId }) {
|
export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isProcessing, placeholder, sessionId }) {
|
||||||
const [message, setMessage] = useState('');
|
// Use uncontrolled input for performance - no React re-render on every keystroke
|
||||||
const [showCommands, setShowCommands] = useState(false);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
||||||
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
|
|
||||||
const [inputHistory, setInputHistory] = useState(() => loadHistory());
|
|
||||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
||||||
const [savedInput, setSavedInput] = useState('');
|
|
||||||
const [showHistorySearch, setShowHistorySearch] = useState(false);
|
|
||||||
const [historySearchResults, setHistorySearchResults] = useState([]);
|
|
||||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
const [uploadError, setUploadError] = useState(null);
|
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
// Auto-resize textarea
|
// These states don't change on every keystroke, so they're fine
|
||||||
useEffect(() => {
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
const textarea = textareaRef.current;
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
if (textarea) {
|
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
|
||||||
textarea.style.height = 'auto';
|
const [inputHistory] = useState(() => loadHistory());
|
||||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
}
|
const [savedInput, setSavedInput] = useState('');
|
||||||
}, [message]);
|
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
// Handle command filtering
|
const [uploadError, setUploadError] = useState(null);
|
||||||
useEffect(() => {
|
|
||||||
if (message.startsWith('/')) {
|
|
||||||
const query = message.slice(1).toLowerCase();
|
|
||||||
const filtered = COMMANDS.filter(cmd =>
|
|
||||||
cmd.name.toLowerCase().startsWith(query)
|
|
||||||
);
|
|
||||||
setFilteredCommands(filtered);
|
|
||||||
setShowCommands(filtered.length > 0 && message.length > 0);
|
|
||||||
setSelectedIndex(0);
|
|
||||||
} else {
|
|
||||||
setShowCommands(false);
|
|
||||||
}
|
|
||||||
}, [message]);
|
|
||||||
|
|
||||||
// Add message to history
|
// Add message to history
|
||||||
const addToHistory = useCallback((msg) => {
|
const addToHistory = useCallback((msg) => {
|
||||||
const trimmed = msg.trim();
|
const trimmed = msg.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
setInputHistory(prev => {
|
const history = loadHistory();
|
||||||
// Remove duplicate if exists
|
const filtered = history.filter(h => h !== trimmed);
|
||||||
const filtered = prev.filter(h => h !== trimmed);
|
|
||||||
const newHistory = [trimmed, ...filtered].slice(0, MAX_HISTORY);
|
const newHistory = [trimmed, ...filtered].slice(0, MAX_HISTORY);
|
||||||
saveHistory(newHistory);
|
saveHistory(newHistory);
|
||||||
return newHistory;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Validate file
|
// Validate file
|
||||||
@@ -131,7 +104,6 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create preview for images
|
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
const fileData = {
|
const fileData = {
|
||||||
file,
|
file,
|
||||||
@@ -208,7 +180,6 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
processFiles(files);
|
processFiles(files);
|
||||||
}
|
}
|
||||||
// Reset input so same file can be selected again
|
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}, [processFiles]);
|
}, [processFiles]);
|
||||||
|
|
||||||
@@ -221,11 +192,19 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Get current message from textarea (uncontrolled)
|
||||||
|
const getMessage = () => textareaRef.current?.value || '';
|
||||||
|
const setMessage = (val) => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.value = val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const message = getMessage();
|
||||||
if (message.trim() || attachedFiles.length > 0) {
|
if (message.trim() || attachedFiles.length > 0) {
|
||||||
addToHistory(message);
|
addToHistory(message);
|
||||||
// Pass both message and files to onSend
|
|
||||||
onSend(message, attachedFiles);
|
onSend(message, attachedFiles);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setAttachedFiles([]);
|
setAttachedFiles([]);
|
||||||
@@ -242,13 +221,21 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Select from history search
|
// Handle input changes for command detection (debounced check, not on every key)
|
||||||
const selectHistoryItem = (item) => {
|
const handleInput = useCallback(() => {
|
||||||
setMessage(item);
|
const message = getMessage();
|
||||||
setShowHistorySearch(false);
|
if (message.startsWith('/')) {
|
||||||
setHistorySearchResults([]);
|
const query = message.slice(1).toLowerCase();
|
||||||
textareaRef.current?.focus();
|
const filtered = COMMANDS.filter(cmd =>
|
||||||
};
|
cmd.name.toLowerCase().startsWith(query)
|
||||||
|
);
|
||||||
|
setFilteredCommands(filtered);
|
||||||
|
setShowCommands(filtered.length > 0 && message.length > 0);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
} else if (showCommands) {
|
||||||
|
setShowCommands(false);
|
||||||
|
}
|
||||||
|
}, [showCommands]);
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
// ESC to stop generation
|
// ESC to stop generation
|
||||||
@@ -262,29 +249,6 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
setShowCommands(false);
|
setShowCommands(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (showHistorySearch) {
|
|
||||||
setShowHistorySearch(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle history search results navigation
|
|
||||||
if (showHistorySearch && historySearchResults.length > 0) {
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex(i => Math.min(i + 1, historySearchResults.length - 1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex(i => Math.max(i - 1, 0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
selectHistoryItem(historySearchResults[selectedIndex]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle command selection
|
// Handle command selection
|
||||||
@@ -306,10 +270,10 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow up/down for history navigation (only when not in command/search mode)
|
// Arrow up/down for history navigation
|
||||||
if (!showCommands && !showHistorySearch && inputHistory.length > 0) {
|
if (!showCommands && inputHistory.length > 0) {
|
||||||
|
const message = getMessage();
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
// Only navigate history if at start of input or input is empty
|
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (textarea && (textarea.selectionStart === 0 || message === '')) {
|
if (textarea && (textarea.selectionStart === 0 || message === '')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -465,49 +429,19 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* History search results dropdown */}
|
|
||||||
{showHistorySearch && historySearchResults.length > 0 && (
|
|
||||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-dark-800 border border-dark-700 rounded-lg shadow-xl overflow-hidden z-10 max-h-64 overflow-y-auto">
|
|
||||||
<div className="px-3 py-2 text-xs text-dark-500 border-b border-dark-700 flex items-center gap-2">
|
|
||||||
<History className="w-3 h-3" />
|
|
||||||
History search results ({historySearchResults.length})
|
|
||||||
</div>
|
|
||||||
{historySearchResults.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectHistoryItem(item)}
|
|
||||||
className={`w-full px-4 py-2.5 text-left transition-colors text-sm truncate
|
|
||||||
${index === selectedIndex ? 'bg-orange-600/20 text-orange-400' : 'hover:bg-dark-700 text-dark-200'}`}
|
|
||||||
>
|
|
||||||
{item.length > 100 ? item.slice(0, 100) + '...' : item}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={message}
|
defaultValue=""
|
||||||
onChange={(e) => {
|
onInput={handleInput}
|
||||||
setMessage(e.target.value);
|
|
||||||
setHistoryIndex(-1); // Reset history navigation on manual edit
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
rows={1}
|
rows={1}
|
||||||
className={`
|
className="w-full bg-dark-800 border border-dark-700 rounded-xl px-4 py-3 pr-12 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/20 resize-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
w-full bg-dark-800 border border-dark-700 rounded-xl
|
|
||||||
px-4 py-3 pr-12 text-dark-100 placeholder-dark-500
|
|
||||||
focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/20
|
|
||||||
resize-none transition-colors
|
|
||||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
|
||||||
`}
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
||||||
{isProcessing ? 'ESC to stop' : attachedFiles.length > 0 ? `${attachedFiles.length} file(s)` : message.startsWith('/') ? 'Tab to complete' : '↑↓ history'}
|
{isProcessing ? 'ESC to stop' : '↑↓ history'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -523,14 +457,12 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled || (!message.trim() && attachedFiles.length === 0)}
|
disabled={disabled}
|
||||||
className={`
|
className={`p-3 rounded-xl transition-all ${
|
||||||
p-3 rounded-xl transition-all
|
disabled
|
||||||
${disabled || (!message.trim() && attachedFiles.length === 0)
|
|
||||||
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
|
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
|
||||||
: 'bg-orange-600 hover:bg-orange-500 text-white shadow-lg shadow-orange-600/20'
|
: 'bg-orange-600 hover:bg-orange-500 text-white shadow-lg shadow-orange-600/20'
|
||||||
}
|
}`}
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<Send className="w-5 h-5" />
|
<Send className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
197
frontend/src/components/ChatPanel.jsx
Normal file
197
frontend/src/components/ChatPanel.jsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
|
import { MessageList } from './MessageList';
|
||||||
|
import { ChatInput } from './ChatInput';
|
||||||
|
import { StatusBar } from './StatusBar';
|
||||||
|
import { PermissionDialog } from './PermissionDialog';
|
||||||
|
import { useSessionManager } from '../contexts/SessionContext';
|
||||||
|
import { Bot } from 'lucide-react';
|
||||||
|
|
||||||
|
// Wrapper to compute placeholder inside and prevent parent re-renders from affecting input
|
||||||
|
const MemoizedChatInput = memo(function MemoizedChatInput({
|
||||||
|
onSend, onStop, disabled, isProcessing, sessionId, connected, active
|
||||||
|
}) {
|
||||||
|
const placeholder = !connected
|
||||||
|
? 'Connecting...'
|
||||||
|
: !active
|
||||||
|
? 'Start session to begin'
|
||||||
|
: 'Type your message...';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatInput
|
||||||
|
onSend={onSend}
|
||||||
|
onStop={onStop}
|
||||||
|
disabled={disabled}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
sessionId={sessionId}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, (prevProps, nextProps) => {
|
||||||
|
// Custom comparison - only re-render if these specific props change
|
||||||
|
return (
|
||||||
|
prevProps.disabled === nextProps.disabled &&
|
||||||
|
prevProps.isProcessing === nextProps.isProcessing &&
|
||||||
|
prevProps.sessionId === nextProps.sessionId &&
|
||||||
|
prevProps.connected === nextProps.connected &&
|
||||||
|
prevProps.active === nextProps.active &&
|
||||||
|
prevProps.onSend === nextProps.onSend &&
|
||||||
|
prevProps.onStop === nextProps.onStop
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Welcome screen when no messages
|
||||||
|
const WelcomeScreen = memo(function WelcomeScreen({ session, onStart }) {
|
||||||
|
const hostName = session.host.charAt(0).toUpperCase() + session.host.slice(1);
|
||||||
|
const projectName = session.project.split('/').pop() || session.project;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 flex items-center justify-center p-8">
|
||||||
|
<div className="text-center text-dark-500 max-w-md">
|
||||||
|
<Bot className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||||
|
<h2 className="text-xl font-semibold mb-2 text-dark-300">
|
||||||
|
{hostName}: {projectName}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mb-6">
|
||||||
|
{session.active
|
||||||
|
? 'Session is active. Start chatting with Claude.'
|
||||||
|
: 'Click "Start Session" in the sidebar or press the button below to begin.'}
|
||||||
|
</p>
|
||||||
|
{!session.active && (
|
||||||
|
<button
|
||||||
|
onClick={onStart}
|
||||||
|
disabled={!session.connected}
|
||||||
|
className={`
|
||||||
|
px-6 py-3 rounded-lg font-medium transition-colors
|
||||||
|
${session.connected
|
||||||
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
|
: 'bg-dark-700 text-dark-500 cursor-not-allowed'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{session.connected ? 'Start Session' : 'Connecting...'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error banner
|
||||||
|
const ErrorBanner = memo(function ErrorBanner({ error, onClear }) {
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-red-900/50 border-b border-red-800 px-4 py-2 flex justify-between items-center flex-shrink-0">
|
||||||
|
<span className="text-red-200 text-sm">{error}</span>
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="text-red-400 hover:text-red-300 text-lg leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use a separate hook that memoizes everything to prevent unnecessary re-renders
|
||||||
|
function useMemoizedSession(sessionId) {
|
||||||
|
const manager = useSessionManager();
|
||||||
|
const session = manager.sessions[sessionId];
|
||||||
|
const messages = manager.sessionMessages[sessionId] || [];
|
||||||
|
|
||||||
|
// Memoize the combined session object
|
||||||
|
const sessionWithMessages = useMemo(() => {
|
||||||
|
return session ? { ...session, messages } : null;
|
||||||
|
}, [session, messages]);
|
||||||
|
|
||||||
|
// Memoize all action functions
|
||||||
|
const actions = useMemo(() => ({
|
||||||
|
start: () => manager.startClaudeSession(sessionId),
|
||||||
|
stop: () => manager.stopClaudeSession(sessionId),
|
||||||
|
send: (msg, attachments) => manager.sendMessage(sessionId, msg, attachments),
|
||||||
|
stopGeneration: () => manager.stopGeneration(sessionId),
|
||||||
|
clearMessages: () => manager.clearMessages(sessionId),
|
||||||
|
changePermissionMode: (mode) => manager.changePermissionMode(sessionId, mode),
|
||||||
|
respondToPermission: (reqId, allow) => manager.respondToPermission(sessionId, reqId, allow),
|
||||||
|
}), [sessionId, manager]);
|
||||||
|
|
||||||
|
return { session: sessionWithMessages, ...actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
||||||
|
const {
|
||||||
|
session,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
send,
|
||||||
|
stopGeneration,
|
||||||
|
clearMessages,
|
||||||
|
changePermissionMode,
|
||||||
|
respondToPermission,
|
||||||
|
} = useMemoizedSession(sessionId);
|
||||||
|
|
||||||
|
const handleClearError = useCallback(() => {
|
||||||
|
// We'd need to add this to the session context
|
||||||
|
// For now, errors auto-clear on next action
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSendMessage = useCallback((message, attachments = []) => {
|
||||||
|
if (message.trim() || attachments.length > 0) {
|
||||||
|
send(message, attachments);
|
||||||
|
}
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-dark-500">
|
||||||
|
Session not found
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMessages = session.messages && session.messages.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-dark-950">
|
||||||
|
{/* Error Banner */}
|
||||||
|
<ErrorBanner error={session.error} onClear={handleClearError} />
|
||||||
|
|
||||||
|
{/* Messages or Welcome */}
|
||||||
|
{hasMessages || session.active ? (
|
||||||
|
<MessageList
|
||||||
|
messages={session.messages || []}
|
||||||
|
isProcessing={session.isProcessing}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<WelcomeScreen session={session} onStart={start} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Bar */}
|
||||||
|
<StatusBar
|
||||||
|
sessionStats={session.stats}
|
||||||
|
isProcessing={session.isProcessing}
|
||||||
|
connected={session.connected}
|
||||||
|
permissionMode={session.permissionMode}
|
||||||
|
controlInitialized={session.controlInitialized}
|
||||||
|
onChangeMode={(mode) => changePermissionMode(mode)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Input - memoized props to prevent re-renders during streaming */}
|
||||||
|
<MemoizedChatInput
|
||||||
|
onSend={handleSendMessage}
|
||||||
|
onStop={stopGeneration}
|
||||||
|
disabled={!session.active}
|
||||||
|
isProcessing={session.isProcessing}
|
||||||
|
sessionId={session.claudeSessionId}
|
||||||
|
connected={session.connected}
|
||||||
|
active={session.active}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Permission Dialog */}
|
||||||
|
<PermissionDialog
|
||||||
|
permission={session.pendingPermission}
|
||||||
|
onAllow={(requestId) => respondToPermission(requestId, true)}
|
||||||
|
onDeny={(requestId) => respondToPermission(requestId, false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -72,7 +72,7 @@ function SystemHints({ reminders, inline = false }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({ messages, isProcessing }) {
|
export const MessageList = memo(function MessageList({ messages, isProcessing }) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||||
@@ -215,7 +215,7 @@ export function MessageList({ messages, isProcessing }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const Message = memo(function Message({ message }) {
|
const Message = memo(function Message({ message }) {
|
||||||
const { type, content, tool, input, timestamp, toolUseId, attachments } = message;
|
const { type, content, tool, input, timestamp, toolUseId, attachments } = message;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2 } from 'lucide-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 API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||||
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
|
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
|
||||||
@@ -28,31 +29,23 @@ function saveRecentDirs(dirs) {
|
|||||||
function addRecentDir(hostId, path) {
|
function addRecentDir(hostId, path) {
|
||||||
const recent = loadRecentDirs();
|
const recent = loadRecentDirs();
|
||||||
const hostRecent = recent[hostId] || [];
|
const hostRecent = recent[hostId] || [];
|
||||||
|
|
||||||
// Remove if already exists, then add to front
|
|
||||||
const filtered = hostRecent.filter(p => p !== path);
|
const filtered = hostRecent.filter(p => p !== path);
|
||||||
const updated = [path, ...filtered].slice(0, MAX_RECENT_DIRS);
|
const updated = [path, ...filtered].slice(0, MAX_RECENT_DIRS);
|
||||||
|
|
||||||
recent[hostId] = updated;
|
recent[hostId] = updated;
|
||||||
saveRecentDirs(recent);
|
saveRecentDirs(recent);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({ open, onToggle }) {
|
||||||
open,
|
const {
|
||||||
onToggle,
|
focusedSessionId,
|
||||||
selectedProject,
|
focusedSession,
|
||||||
onSelectProject,
|
startClaudeSession,
|
||||||
selectedHost,
|
stopClaudeSession,
|
||||||
onSelectHost,
|
clearMessages,
|
||||||
sessionActive,
|
updateSessionConfig,
|
||||||
activeHost,
|
} = useSessionManager();
|
||||||
onStartSession,
|
|
||||||
onStopSession,
|
|
||||||
onClearMessages,
|
|
||||||
resumeSession,
|
|
||||||
onToggleResume
|
|
||||||
}) {
|
|
||||||
const [hosts, setHosts] = useState([]);
|
const [hosts, setHosts] = useState([]);
|
||||||
const [recentDirs, setRecentDirs] = useState([]);
|
const [recentDirs, setRecentDirs] = useState([]);
|
||||||
const [showBrowser, setShowBrowser] = useState(false);
|
const [showBrowser, setShowBrowser] = useState(false);
|
||||||
@@ -62,43 +55,59 @@ export function Sidebar({
|
|||||||
const [browserError, setBrowserError] = useState(null);
|
const [browserError, setBrowserError] = useState(null);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
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
|
// Fetch hosts on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_URL}/api/hosts`)
|
fetch(`${API_URL}/api/hosts`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setHosts(data.hosts || []);
|
setHosts(data.hosts || []);
|
||||||
if (!selectedHost && data.defaultHost) {
|
|
||||||
onSelectHost(data.defaultHost);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load recent directories when host changes
|
// Load recent directories when host changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedHost) return;
|
if (!currentHost) return;
|
||||||
const recent = loadRecentDirs();
|
const recent = loadRecentDirs();
|
||||||
setRecentDirs(recent[selectedHost] || []);
|
setRecentDirs(recent[currentHost] || []);
|
||||||
}, [selectedHost]);
|
}, [currentHost]);
|
||||||
|
|
||||||
// Handle selecting a directory (from dropdown or browser)
|
// Handle selecting a directory
|
||||||
const handleSelectDir = useCallback((path) => {
|
const handleSelectDir = useCallback((path) => {
|
||||||
onSelectProject(path);
|
if (!focusedSessionId) return;
|
||||||
const updated = addRecentDir(selectedHost, path);
|
updateSessionConfig(focusedSessionId, { project: path });
|
||||||
|
const updated = addRecentDir(currentHost, path);
|
||||||
setRecentDirs(updated);
|
setRecentDirs(updated);
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
setShowBrowser(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
|
// Browse directories on host
|
||||||
const browsePath = useCallback(async (path) => {
|
const browsePath = useCallback(async (path) => {
|
||||||
if (!selectedHost) return;
|
if (!currentHost) return;
|
||||||
setBrowserLoading(true);
|
setBrowserLoading(true);
|
||||||
setBrowserError(null);
|
setBrowserError(null);
|
||||||
|
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
@@ -113,7 +122,7 @@ export function Sidebar({
|
|||||||
} finally {
|
} finally {
|
||||||
setBrowserLoading(false);
|
setBrowserLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedHost]);
|
}, [currentHost]);
|
||||||
|
|
||||||
// Open browser
|
// Open browser
|
||||||
const openBrowser = useCallback(() => {
|
const openBrowser = useCallback(() => {
|
||||||
@@ -128,6 +137,51 @@ export function Sidebar({
|
|||||||
return parts[parts.length - 1] || path;
|
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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`
|
className={`
|
||||||
@@ -152,30 +206,30 @@ export function Sidebar({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{hosts.map((host) => {
|
{hosts.map((host) => {
|
||||||
const isActive = sessionActive && activeHost === host.id;
|
const isSelected = currentHost === host.id;
|
||||||
const isDisabled = sessionActive && activeHost && activeHost !== host.id;
|
const isDisabled = sessionActive && currentHost !== host.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={host.id}
|
key={host.id}
|
||||||
onClick={() => !isDisabled && onSelectHost(host.id)}
|
onClick={() => !isDisabled && handleSelectHost(host.id)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||||
transition-colors border relative
|
transition-colors border relative
|
||||||
${isDisabled
|
${isDisabled
|
||||||
? 'border-dark-800 text-dark-600 cursor-not-allowed opacity-50'
|
? 'border-dark-800 text-dark-600 cursor-not-allowed opacity-50'
|
||||||
: selectedHost === host.id
|
: isSelected
|
||||||
? 'border-orange-500/50 text-white'
|
? 'border-orange-500/50 text-white'
|
||||||
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
style={{
|
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 }} />
|
<Server className="w-3.5 h-3.5" style={{ color: isDisabled ? '#4a4a4a' : host.color }} />
|
||||||
<span>{host.name}</span>
|
<span>{host.name}</span>
|
||||||
{isActive && (
|
{sessionActive && isSelected && (
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" title="Active session" />
|
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" title="Active session" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -198,13 +252,17 @@ export function Sidebar({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{/* Dropdown button */}
|
{/* Dropdown button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onClick={() => !sessionActive && 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"
|
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" />
|
<FolderOpen className="w-4 h-4 text-orange-400 flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm text-dark-200 truncate">{getDisplayName(selectedProject)}</div>
|
<div className="text-sm text-dark-200 truncate">{getDisplayName(currentProject)}</div>
|
||||||
<div className="text-xs text-dark-500 truncate">{selectedProject}</div>
|
<div className="text-xs text-dark-500 truncate">{currentProject}</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className={`w-4 h-4 text-dark-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`w-4 h-4 text-dark-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -212,7 +270,11 @@ export function Sidebar({
|
|||||||
{/* Browse button */}
|
{/* Browse button */}
|
||||||
<button
|
<button
|
||||||
onClick={openBrowser}
|
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"
|
title="Browse directories"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
@@ -220,7 +282,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dropdown menu */}
|
{/* 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">
|
<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 ? (
|
{recentDirs.length === 0 ? (
|
||||||
<div className="px-3 py-4 text-sm text-dark-500 text-center">
|
<div className="px-3 py-4 text-sm text-dark-500 text-center">
|
||||||
@@ -232,14 +294,14 @@ export function Sidebar({
|
|||||||
key={path}
|
key={path}
|
||||||
onClick={() => handleSelectDir(path)}
|
onClick={() => handleSelectDir(path)}
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-dark-700 transition-colors
|
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" />
|
<Folder className="w-4 h-4 flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm truncate">{getDisplayName(path)}</div>
|
<div className="text-sm truncate">{getDisplayName(path)}</div>
|
||||||
<div className="text-xs text-dark-500 truncate">{path}</div>
|
<div className="text-xs text-dark-500 truncate">{path}</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedProject === path && (
|
{currentProject === path && (
|
||||||
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -249,11 +311,15 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sessionActive && (
|
||||||
|
<p className="text-xs text-dark-500">Stop session to change directory</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Resume toggle */}
|
{/* Resume toggle */}
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<label className="flex items-center gap-3 cursor-pointer group">
|
<label className="flex items-center gap-3 cursor-pointer group">
|
||||||
<div
|
<div
|
||||||
onClick={onToggleResume}
|
onClick={handleToggleResume}
|
||||||
className={`
|
className={`
|
||||||
relative w-10 h-5 rounded-full transition-colors
|
relative w-10 h-5 rounded-full transition-colors
|
||||||
${resumeSession ? 'bg-orange-600' : 'bg-dark-700'}
|
${resumeSession ? 'bg-orange-600' : 'bg-dark-700'}
|
||||||
@@ -282,17 +348,21 @@ export function Sidebar({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{!sessionActive ? (
|
{!sessionActive ? (
|
||||||
<button
|
<button
|
||||||
onClick={onStartSession}
|
onClick={handleStartSession}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
disabled={!focusedSession?.connected}
|
||||||
bg-green-600 hover:bg-green-500 rounded-lg
|
className={`
|
||||||
font-medium transition-colors"
|
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" />
|
<Play className="w-4 h-4" />
|
||||||
Start Session
|
{focusedSession?.connected ? 'Start Session' : 'Connecting...'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={onStopSession}
|
onClick={handleStopSession}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
||||||
bg-red-600 hover:bg-red-500 rounded-lg
|
bg-red-600 hover:bg-red-500 rounded-lg
|
||||||
font-medium transition-colors"
|
font-medium transition-colors"
|
||||||
@@ -303,7 +373,7 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onClearMessages}
|
onClick={handleClearMessages}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2
|
className="w-full flex items-center justify-center gap-2 px-4 py-2
|
||||||
bg-dark-800 hover:bg-dark-700 rounded-lg
|
bg-dark-800 hover:bg-dark-700 rounded-lg
|
||||||
text-dark-300 hover:text-dark-100 transition-colors"
|
text-dark-300 hover:text-dark-100 transition-colors"
|
||||||
@@ -317,8 +387,8 @@ export function Sidebar({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-dark-800 text-xs text-dark-500">
|
<div className="p-4 border-t border-dark-800 text-xs text-dark-500">
|
||||||
<div>Claude Code Web UI POC</div>
|
<div>Claude Code Web UI</div>
|
||||||
<div>JSON Stream Mode</div>
|
<div>Multi-Session Mode</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Directory Browser Modal */}
|
{/* Directory Browser Modal */}
|
||||||
|
|||||||
388
frontend/src/components/SplitLayout.jsx
Normal file
388
frontend/src/components/SplitLayout.jsx
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import { memo, useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { X, Maximize2, GripHorizontal, GripVertical } from 'lucide-react';
|
||||||
|
import { useSessionManager } from '../contexts/SessionContext';
|
||||||
|
|
||||||
|
// Resizable divider - uses DOM manipulation during drag for smooth resize
|
||||||
|
const Divider = memo(function Divider({ direction, panelRef, onResizeEnd }) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const startPos = useRef(0);
|
||||||
|
const startSize = useRef(0);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
setIsDragging(true);
|
||||||
|
startPos.current = direction === 'horizontal' ? e.clientX : e.clientY;
|
||||||
|
startSize.current = direction === 'horizontal'
|
||||||
|
? panel.offsetWidth
|
||||||
|
: panel.offsetHeight;
|
||||||
|
|
||||||
|
// Add will-change for GPU acceleration
|
||||||
|
panel.style.willChange = 'width, height';
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
|
||||||
|
const delta = currentPos - startPos.current;
|
||||||
|
const newSize = startSize.current + delta;
|
||||||
|
|
||||||
|
// Get parent size for percentage calculation
|
||||||
|
const parent = panel.parentElement;
|
||||||
|
const parentSize = direction === 'horizontal'
|
||||||
|
? parent.offsetWidth
|
||||||
|
: parent.offsetHeight;
|
||||||
|
|
||||||
|
// Clamp between 20% and 80%
|
||||||
|
const minSize = parentSize * 0.2;
|
||||||
|
const maxSize = parentSize * 0.8;
|
||||||
|
const clampedSize = Math.max(minSize, Math.min(maxSize, newSize));
|
||||||
|
|
||||||
|
// Direct DOM manipulation - no React re-render
|
||||||
|
if (direction === 'horizontal') {
|
||||||
|
panel.style.width = `${clampedSize}px`;
|
||||||
|
} else {
|
||||||
|
panel.style.height = `${clampedSize}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (e) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
panel.style.willChange = 'auto';
|
||||||
|
|
||||||
|
// Calculate final percentage and update React state
|
||||||
|
const parent = panel.parentElement;
|
||||||
|
const parentSize = direction === 'horizontal'
|
||||||
|
? parent.offsetWidth
|
||||||
|
: parent.offsetHeight;
|
||||||
|
const currentSize = direction === 'horizontal'
|
||||||
|
? panel.offsetWidth
|
||||||
|
: panel.offsetHeight;
|
||||||
|
const percentage = (currentSize / parentSize) * 100;
|
||||||
|
|
||||||
|
// Clear inline style and let React take over
|
||||||
|
if (direction === 'horizontal') {
|
||||||
|
panel.style.width = '';
|
||||||
|
} else {
|
||||||
|
panel.style.height = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onResizeEnd(percentage);
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
}, [direction, panelRef, onResizeEnd]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
className={`
|
||||||
|
flex items-center justify-center bg-dark-800 hover:bg-dark-700 transition-colors flex-shrink-0
|
||||||
|
${direction === 'horizontal' ? 'w-2 cursor-col-resize' : 'h-2 cursor-row-resize'}
|
||||||
|
${isDragging ? 'bg-orange-500' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{direction === 'horizontal' ? (
|
||||||
|
<GripVertical className="w-3 h-3 text-dark-600" />
|
||||||
|
) : (
|
||||||
|
<GripHorizontal className="w-3 h-3 text-dark-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Panel wrapper with header
|
||||||
|
const PanelWrapper = memo(function PanelWrapper({ sessionId, children, onRemove, onMaximize }) {
|
||||||
|
const { sessions, setFocusedSessionId, focusedSessionId } = useSessionManager();
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
const isFocused = focusedSessionId === sessionId;
|
||||||
|
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
// Get host color
|
||||||
|
const getHostColor = () => {
|
||||||
|
switch (session.host) {
|
||||||
|
case 'neko': return '#f97316';
|
||||||
|
case 'mochi': return '#22c55e';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get display name
|
||||||
|
const getDisplayName = () => {
|
||||||
|
if (session.name) return session.name;
|
||||||
|
const hostName = session.host.charAt(0).toUpperCase() + session.host.slice(1);
|
||||||
|
const context = session.currentContext || session.project.split('/').pop() || 'New';
|
||||||
|
return `${hostName}: ${context}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex flex-col h-full overflow-hidden rounded-lg border
|
||||||
|
${isFocused ? 'border-orange-500/50' : 'border-dark-700'}
|
||||||
|
`}
|
||||||
|
onClick={() => setFocusedSessionId(sessionId)}
|
||||||
|
>
|
||||||
|
{/* Mini header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-2 py-1 bg-dark-800/80 border-b border-dark-700 flex-shrink-0"
|
||||||
|
style={{ borderLeftColor: getHostColor(), borderLeftWidth: '3px' }}
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${session.active ? 'bg-green-500' : 'bg-dark-500'}`} />
|
||||||
|
<span className="flex-1 text-xs font-medium text-dark-300 truncate">
|
||||||
|
{getDisplayName()}
|
||||||
|
</span>
|
||||||
|
{session.isProcessing && (
|
||||||
|
<span className="text-[10px] text-orange-400 animate-pulse">Processing...</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMaximize(sessionId);
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded hover:bg-dark-600 text-dark-500 hover:text-dark-300"
|
||||||
|
title="Maximize"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(sessionId);
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded hover:bg-red-500/20 text-dark-500 hover:text-red-400"
|
||||||
|
title="Remove from split"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function SplitLayout({ splitSessions, renderPanel }) {
|
||||||
|
const { removeFromSplit, clearSplit, setFocusedSessionId } = useSessionManager();
|
||||||
|
const [sizes, setSizes] = useState({ h: 50, v: 50 });
|
||||||
|
|
||||||
|
// Refs for direct DOM manipulation during resize
|
||||||
|
const leftPanelRef = useRef(null);
|
||||||
|
const topPanelRef = useRef(null);
|
||||||
|
|
||||||
|
const handleRemove = useCallback((sessionId) => {
|
||||||
|
removeFromSplit(sessionId);
|
||||||
|
}, [removeFromSplit]);
|
||||||
|
|
||||||
|
const handleMaximize = useCallback((sessionId) => {
|
||||||
|
clearSplit();
|
||||||
|
setFocusedSessionId(sessionId);
|
||||||
|
}, [clearSplit, setFocusedSessionId]);
|
||||||
|
|
||||||
|
const handleHorizontalResizeEnd = useCallback((percentage) => {
|
||||||
|
setSizes(prev => ({ ...prev, h: percentage }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVerticalResizeEnd = useCallback((percentage) => {
|
||||||
|
setSizes(prev => ({ ...prev, v: percentage }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const count = splitSessions.length;
|
||||||
|
|
||||||
|
if (count === 0) return null;
|
||||||
|
|
||||||
|
// Single panel (shouldn't happen in split mode, but handle it)
|
||||||
|
if (count === 1) {
|
||||||
|
return (
|
||||||
|
<div className="h-full p-1">
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[0]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[0])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two panels - horizontal split
|
||||||
|
if (count === 2) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex p-1 gap-0">
|
||||||
|
<div
|
||||||
|
ref={leftPanelRef}
|
||||||
|
style={{ width: `${sizes.h}%` }}
|
||||||
|
className="min-w-0 h-full"
|
||||||
|
>
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[0]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[0])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
<Divider
|
||||||
|
direction="horizontal"
|
||||||
|
panelRef={leftPanelRef}
|
||||||
|
onResizeEnd={handleHorizontalResizeEnd}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[1]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[1])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three panels - 2 on left, 1 on right
|
||||||
|
if (count === 3) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex p-1 gap-0">
|
||||||
|
{/* Left column - 2 panels stacked */}
|
||||||
|
<div
|
||||||
|
ref={leftPanelRef}
|
||||||
|
style={{ width: `${sizes.h}%` }}
|
||||||
|
className="flex flex-col min-w-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={topPanelRef}
|
||||||
|
style={{ height: `${sizes.v}%` }}
|
||||||
|
className="min-h-0"
|
||||||
|
>
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[0]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[0])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
<Divider
|
||||||
|
direction="vertical"
|
||||||
|
panelRef={topPanelRef}
|
||||||
|
onResizeEnd={handleVerticalResizeEnd}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }} className="min-h-0">
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[1]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[1])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider
|
||||||
|
direction="horizontal"
|
||||||
|
panelRef={leftPanelRef}
|
||||||
|
onResizeEnd={handleHorizontalResizeEnd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right column - 1 panel full height */}
|
||||||
|
<div style={{ flex: 1 }} className="min-w-0">
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[2]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[2])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four panels - 2x2 grid
|
||||||
|
if (count >= 4) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col p-1 gap-0">
|
||||||
|
{/* Top row */}
|
||||||
|
<div
|
||||||
|
ref={topPanelRef}
|
||||||
|
style={{ height: `${sizes.v}%` }}
|
||||||
|
className="flex min-h-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={leftPanelRef}
|
||||||
|
style={{ width: `${sizes.h}%` }}
|
||||||
|
className="min-w-0 h-full"
|
||||||
|
>
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[0]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[0])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
<Divider
|
||||||
|
direction="horizontal"
|
||||||
|
panelRef={leftPanelRef}
|
||||||
|
onResizeEnd={handleHorizontalResizeEnd}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[1]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[1])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider
|
||||||
|
direction="vertical"
|
||||||
|
panelRef={topPanelRef}
|
||||||
|
onResizeEnd={handleVerticalResizeEnd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom row */}
|
||||||
|
<div style={{ flex: 1 }} className="flex min-h-0">
|
||||||
|
<div style={{ width: `${sizes.h}%` }} className="min-w-0 h-full">
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[2]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[2])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
<Divider
|
||||||
|
direction="horizontal"
|
||||||
|
panelRef={leftPanelRef}
|
||||||
|
onResizeEnd={handleHorizontalResizeEnd}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }} className="min-w-0 h-full">
|
||||||
|
<PanelWrapper
|
||||||
|
sessionId={splitSessions[3]}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onMaximize={handleMaximize}
|
||||||
|
>
|
||||||
|
{renderPanel(splitSessions[3])}
|
||||||
|
</PanelWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
305
frontend/src/components/TabBar.jsx
Normal file
305
frontend/src/components/TabBar.jsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { useState, useRef, useCallback, memo } from 'react';
|
||||||
|
import { Plus, X, Columns, Grid2X2, Maximize2, GripVertical, Circle } from 'lucide-react';
|
||||||
|
import { useSessionManager } from '../contexts/SessionContext';
|
||||||
|
|
||||||
|
// Tab component
|
||||||
|
const Tab = memo(function Tab({
|
||||||
|
session,
|
||||||
|
isActive,
|
||||||
|
isSplit,
|
||||||
|
index,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onFocus,
|
||||||
|
onClose,
|
||||||
|
onSplit,
|
||||||
|
onRename,
|
||||||
|
}) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
// Get display name
|
||||||
|
const getDisplayName = () => {
|
||||||
|
if (session.name) return session.name;
|
||||||
|
|
||||||
|
// Auto-generate from host and context/project
|
||||||
|
const hostName = session.host.charAt(0).toUpperCase() + session.host.slice(1);
|
||||||
|
const context = session.currentContext || session.project.split('/').pop() || 'New';
|
||||||
|
return `${hostName}: ${context}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get host color
|
||||||
|
const getHostColor = () => {
|
||||||
|
switch (session.host) {
|
||||||
|
case 'neko': return '#f97316';
|
||||||
|
case 'mochi': return '#22c55e';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoubleClick = () => {
|
||||||
|
setEditName(session.name || '');
|
||||||
|
setIsEditing(true);
|
||||||
|
setTimeout(() => inputRef.current?.select(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
if (editName.trim()) {
|
||||||
|
onRename(session.id, editName.trim());
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleRename();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable={!isEditing}
|
||||||
|
onDragStart={(e) => onDragStart(e, index)}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={(e) => onDrop(e, index)}
|
||||||
|
onClick={() => onFocus(session.id)}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
className={`
|
||||||
|
group flex items-center gap-2 px-3 py-2 rounded-t-lg cursor-pointer
|
||||||
|
transition-colors relative min-w-[120px] max-w-[200px]
|
||||||
|
${isActive
|
||||||
|
? 'bg-dark-800 text-white border-t-2'
|
||||||
|
: 'bg-dark-900/50 text-dark-400 hover:bg-dark-800/50 hover:text-dark-300 border-t-2 border-transparent'
|
||||||
|
}
|
||||||
|
${isSplit ? 'ring-1 ring-inset ring-blue-500/30' : ''}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
borderTopColor: isActive ? getHostColor() : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<GripVertical className="w-3 h-3 text-dark-600 opacity-0 group-hover:opacity-100 cursor-grab flex-shrink-0" />
|
||||||
|
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<Circle
|
||||||
|
className="w-2 h-2"
|
||||||
|
fill={session.active ? '#22c55e' : session.connected ? '#f97316' : '#6b7280'}
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
{session.isProcessing && (
|
||||||
|
<span className="absolute inset-0 w-2 h-2 rounded-full bg-orange-400 animate-ping" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab name */}
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onBlur={handleRename}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="flex-1 min-w-0 bg-dark-700 border border-dark-600 rounded px-1 py-0.5 text-xs text-white focus:outline-none focus:border-orange-500"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 min-w-0 text-xs font-medium truncate">
|
||||||
|
{getDisplayName()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unread badge */}
|
||||||
|
{session.unreadCount > 0 && !isActive && (
|
||||||
|
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] font-bold bg-orange-500 text-white rounded-full">
|
||||||
|
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Split button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSplit(session.id);
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded hover:bg-dark-600 text-dark-500 hover:text-dark-300 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
|
title={isSplit ? 'Remove from split' : 'Add to split view'}
|
||||||
|
>
|
||||||
|
<Columns className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose(session.id);
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded hover:bg-red-500/20 text-dark-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
|
title="Close tab"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function TabBar() {
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
tabOrder,
|
||||||
|
splitSessions,
|
||||||
|
focusedSessionId,
|
||||||
|
createSession,
|
||||||
|
removeSession,
|
||||||
|
renameSession,
|
||||||
|
setFocusedSessionId,
|
||||||
|
markAsRead,
|
||||||
|
reorderTabs,
|
||||||
|
addToSplit,
|
||||||
|
removeFromSplit,
|
||||||
|
clearSplit,
|
||||||
|
} = useSessionManager();
|
||||||
|
|
||||||
|
const [dragIndex, setDragIndex] = useState(null);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((e, index) => {
|
||||||
|
setDragIndex(index);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e, toIndex) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragIndex !== null && dragIndex !== toIndex) {
|
||||||
|
reorderTabs(dragIndex, toIndex);
|
||||||
|
}
|
||||||
|
setDragIndex(null);
|
||||||
|
}, [dragIndex, reorderTabs]);
|
||||||
|
|
||||||
|
const handleFocus = useCallback((sessionId) => {
|
||||||
|
setFocusedSessionId(sessionId);
|
||||||
|
markAsRead(sessionId);
|
||||||
|
}, [setFocusedSessionId, markAsRead]);
|
||||||
|
|
||||||
|
const handleClose = useCallback((sessionId) => {
|
||||||
|
removeSession(sessionId);
|
||||||
|
}, [removeSession]);
|
||||||
|
|
||||||
|
const handleSplit = useCallback((sessionId) => {
|
||||||
|
if (splitSessions.includes(sessionId)) {
|
||||||
|
removeFromSplit(sessionId);
|
||||||
|
} else {
|
||||||
|
addToSplit(sessionId);
|
||||||
|
}
|
||||||
|
}, [splitSessions, addToSplit, removeFromSplit]);
|
||||||
|
|
||||||
|
const handleNewTab = useCallback(() => {
|
||||||
|
createSession();
|
||||||
|
}, [createSession]);
|
||||||
|
|
||||||
|
// Get split layout info
|
||||||
|
const getSplitInfo = () => {
|
||||||
|
const count = splitSessions.length;
|
||||||
|
if (count === 0) return null;
|
||||||
|
if (count === 1) return '1 panel';
|
||||||
|
if (count === 2) return '2 panels';
|
||||||
|
if (count === 3) return '3 panels';
|
||||||
|
return '2x2 grid';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 bg-dark-900 border-b border-dark-800 overflow-x-auto">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-end gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-thin">
|
||||||
|
{tabOrder.map((sessionId, index) => {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab
|
||||||
|
key={sessionId}
|
||||||
|
session={session}
|
||||||
|
isActive={focusedSessionId === sessionId}
|
||||||
|
isSplit={splitSessions.includes(sessionId)}
|
||||||
|
index={index}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onRename={renameSession}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onClose={handleClose}
|
||||||
|
onSplit={handleSplit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* New tab button */}
|
||||||
|
<button
|
||||||
|
onClick={handleNewTab}
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-dark-800 text-dark-500 hover:text-dark-300 transition-colors flex-shrink-0"
|
||||||
|
title="New session (Ctrl+T)"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Split view controls */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 pl-2 border-l border-dark-700">
|
||||||
|
{splitSessions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-dark-500">{getSplitInfo()}</span>
|
||||||
|
<button
|
||||||
|
onClick={clearSplit}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-dark-800 text-dark-500 hover:text-dark-300 text-xs transition-colors"
|
||||||
|
title="Exit split view"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3 h-3" />
|
||||||
|
<span>Single</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{splitSessions.length === 0 && tabOrder.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Add first two tabs to split
|
||||||
|
if (tabOrder.length >= 2) {
|
||||||
|
addToSplit(tabOrder[0]);
|
||||||
|
addToSplit(tabOrder[1]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-dark-800 text-dark-500 hover:text-dark-300 text-xs transition-colors"
|
||||||
|
title="Split view"
|
||||||
|
>
|
||||||
|
<Columns className="w-3 h-3" />
|
||||||
|
<span>Split</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{splitSessions.length >= 2 && splitSessions.length < 4 && tabOrder.length > splitSessions.length && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Find next tab not in split
|
||||||
|
const nextTab = tabOrder.find(id => !splitSessions.includes(id));
|
||||||
|
if (nextTab) addToSplit(nextTab);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-dark-800 text-dark-500 hover:text-dark-300 text-xs transition-colors"
|
||||||
|
title="Add panel"
|
||||||
|
>
|
||||||
|
<Grid2X2 className="w-3 h-3" />
|
||||||
|
<span>+Panel</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
926
frontend/src/contexts/SessionContext.jsx
Normal file
926
frontend/src/contexts/SessionContext.jsx
Normal file
@@ -0,0 +1,926 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://100.105.142.13:3001';
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||||
|
const SESSIONS_STORAGE_KEY = 'claude-webui-sessions';
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
// Generate unique session ID
|
||||||
|
function generateSessionId() {
|
||||||
|
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial session state (messages are stored separately for performance)
|
||||||
|
function createSessionState(id, host = 'neko', project = '/home/sumdex/projects') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: null, // Auto-generated from context, or user-defined
|
||||||
|
host,
|
||||||
|
project,
|
||||||
|
connected: false,
|
||||||
|
active: false, // Claude session started
|
||||||
|
// messages: [], // Stored in separate sessionMessages state for performance
|
||||||
|
isProcessing: false,
|
||||||
|
error: null,
|
||||||
|
claudeSessionId: null,
|
||||||
|
stats: {
|
||||||
|
totalCost: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheCreationTokens: 0,
|
||||||
|
numTurns: 0,
|
||||||
|
},
|
||||||
|
permissionMode: 'default',
|
||||||
|
controlInitialized: false,
|
||||||
|
pendingPermission: null,
|
||||||
|
unreadCount: 0,
|
||||||
|
currentContext: null, // For dynamic tab naming
|
||||||
|
resumeOnStart: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionContext = createContext(null);
|
||||||
|
|
||||||
|
export function SessionProvider({ children }) {
|
||||||
|
// All sessions keyed by ID (excludes messages for performance)
|
||||||
|
const [sessions, setSessions] = useState({});
|
||||||
|
|
||||||
|
// Messages stored separately to avoid re-rendering entire context on every message
|
||||||
|
const [sessionMessages, setSessionMessages] = useState({});
|
||||||
|
|
||||||
|
// Currently focused session ID
|
||||||
|
const [focusedSessionId, setFocusedSessionId] = useState(null);
|
||||||
|
|
||||||
|
// Sessions shown in split view (array of session IDs)
|
||||||
|
const [splitSessions, setSplitSessions] = useState([]);
|
||||||
|
|
||||||
|
// Tab order (array of session IDs)
|
||||||
|
const [tabOrder, setTabOrder] = useState([]);
|
||||||
|
|
||||||
|
// WebSocket refs keyed by session ID
|
||||||
|
const wsRefs = useRef({});
|
||||||
|
|
||||||
|
// Current assistant message refs keyed by session ID
|
||||||
|
const currentAssistantMessages = useRef({});
|
||||||
|
|
||||||
|
// Track if initial load is done (for auto-connecting restored sessions)
|
||||||
|
const initialLoadDone = useRef(false);
|
||||||
|
const sessionsToConnect = useRef([]);
|
||||||
|
|
||||||
|
// Load sessions from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SESSIONS_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const data = JSON.parse(stored);
|
||||||
|
// Restore sessions but reset connection state
|
||||||
|
const restored = {};
|
||||||
|
const toConnect = [];
|
||||||
|
Object.entries(data.sessions || {}).forEach(([id, session]) => {
|
||||||
|
restored[id] = {
|
||||||
|
...session,
|
||||||
|
connected: false,
|
||||||
|
active: false,
|
||||||
|
isProcessing: false,
|
||||||
|
pendingPermission: null,
|
||||||
|
controlInitialized: false,
|
||||||
|
};
|
||||||
|
toConnect.push(id);
|
||||||
|
});
|
||||||
|
setSessions(restored);
|
||||||
|
setTabOrder(data.tabOrder || []);
|
||||||
|
setSplitSessions(data.splitSessions || []);
|
||||||
|
setFocusedSessionId(data.focusedSessionId || null);
|
||||||
|
sessionsToConnect.current = toConnect;
|
||||||
|
}
|
||||||
|
initialLoadDone.current = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to restore sessions:', e);
|
||||||
|
initialLoadDone.current = true;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save sessions to localStorage on change (debounced, excluding messages)
|
||||||
|
const saveTimeoutRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
// Debounce saves to avoid performance issues during streaming
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
// Only save essential config, not transient state
|
||||||
|
const sessionsToSave = {};
|
||||||
|
Object.entries(sessions).forEach(([id, session]) => {
|
||||||
|
sessionsToSave[id] = {
|
||||||
|
id: session.id,
|
||||||
|
name: session.name,
|
||||||
|
host: session.host,
|
||||||
|
project: session.project,
|
||||||
|
permissionMode: session.permissionMode,
|
||||||
|
resumeOnStart: session.resumeOnStart,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
sessions: sessionsToSave,
|
||||||
|
tabOrder,
|
||||||
|
splitSessions,
|
||||||
|
focusedSessionId,
|
||||||
|
};
|
||||||
|
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(data));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save sessions:', e);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [sessions, tabOrder, splitSessions, focusedSessionId]);
|
||||||
|
|
||||||
|
// Update a specific session
|
||||||
|
const updateSession = useCallback((sessionId, updates) => {
|
||||||
|
setSessions(prev => {
|
||||||
|
if (!prev[sessionId]) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[sessionId]: {
|
||||||
|
...prev[sessionId],
|
||||||
|
...(typeof updates === 'function' ? updates(prev[sessionId]) : updates),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add message to session (uses separate state for performance)
|
||||||
|
const addMessage = useCallback((sessionId, message) => {
|
||||||
|
setSessionMessages(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: [...(prev[sessionId] || []), { ...message, timestamp: Date.now() }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update unread count in session state only if not focused
|
||||||
|
const isFocused = sessionId === focusedSessionId || splitSessions.includes(sessionId);
|
||||||
|
if (!isFocused) {
|
||||||
|
setSessions(prev => {
|
||||||
|
if (!prev[sessionId]) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[sessionId]: {
|
||||||
|
...prev[sessionId],
|
||||||
|
unreadCount: (prev[sessionId].unreadCount || 0) + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [focusedSessionId, splitSessions]);
|
||||||
|
|
||||||
|
// Throttled streaming update refs
|
||||||
|
const pendingStreamUpdates = useRef({});
|
||||||
|
const streamUpdateTimers = useRef({});
|
||||||
|
|
||||||
|
// Update last assistant message (for streaming) - throttled to reduce re-renders
|
||||||
|
const updateLastAssistantMessage = useCallback((sessionId, content) => {
|
||||||
|
// Store the latest content
|
||||||
|
pendingStreamUpdates.current[sessionId] = content;
|
||||||
|
|
||||||
|
// If no timer is running, start one
|
||||||
|
if (!streamUpdateTimers.current[sessionId]) {
|
||||||
|
streamUpdateTimers.current[sessionId] = setTimeout(() => {
|
||||||
|
const latestContent = pendingStreamUpdates.current[sessionId];
|
||||||
|
delete pendingStreamUpdates.current[sessionId];
|
||||||
|
delete streamUpdateTimers.current[sessionId];
|
||||||
|
|
||||||
|
// Update only sessionMessages, not sessions (for performance)
|
||||||
|
setSessionMessages(prev => {
|
||||||
|
const messages = [...(prev[sessionId] || [])];
|
||||||
|
|
||||||
|
// Find last assistant message
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].type === 'assistant') {
|
||||||
|
messages[i] = { ...messages[i], content: latestContent };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[sessionId]: messages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, 50); // Update UI max every 50ms
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle Claude events (wrapped in claude_event messages from backend)
|
||||||
|
const handleClaudeEvent = useCallback((sessionId, event) => {
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
const { type } = event;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'assistant': {
|
||||||
|
// Assistant message with content blocks (text + tool_use)
|
||||||
|
const message = event.message;
|
||||||
|
if (!message?.content) break;
|
||||||
|
|
||||||
|
const textBlocks = [];
|
||||||
|
const toolUseBlocks = [];
|
||||||
|
|
||||||
|
for (const block of message.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
textBlocks.push(block.text);
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
toolUseBlocks.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
tool: block.name,
|
||||||
|
input: block.input,
|
||||||
|
toolUseId: block.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add text content as assistant message
|
||||||
|
if (textBlocks.length > 0) {
|
||||||
|
// Check if we're streaming and should update existing message
|
||||||
|
if (currentAssistantMessages.current[sessionId] !== undefined) {
|
||||||
|
updateLastAssistantMessage(sessionId, textBlocks.join('\n'));
|
||||||
|
delete currentAssistantMessages.current[sessionId];
|
||||||
|
} else {
|
||||||
|
addMessage(sessionId, {
|
||||||
|
type: 'assistant',
|
||||||
|
content: textBlocks.join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool use blocks as separate messages
|
||||||
|
for (const toolMsg of toolUseBlocks) {
|
||||||
|
addMessage(sessionId, toolMsg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'user': {
|
||||||
|
// User message with tool results
|
||||||
|
const message = event.message;
|
||||||
|
if (!message?.content) break;
|
||||||
|
|
||||||
|
for (const block of message.content) {
|
||||||
|
if (block.type === 'tool_result') {
|
||||||
|
addMessage(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
toolUseId: block.tool_use_id,
|
||||||
|
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
|
||||||
|
isError: block.is_error || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'content_block_delta':
|
||||||
|
case 'stream_event': {
|
||||||
|
// Streaming text delta
|
||||||
|
const delta = event.delta || event;
|
||||||
|
if (delta?.type === 'text_delta' || delta?.text) {
|
||||||
|
const text = delta.text || '';
|
||||||
|
|
||||||
|
if (currentAssistantMessages.current[sessionId] === undefined) {
|
||||||
|
// Start new streaming message
|
||||||
|
currentAssistantMessages.current[sessionId] = text;
|
||||||
|
addMessage(sessionId, { type: 'assistant', content: text });
|
||||||
|
} else {
|
||||||
|
// Append to existing
|
||||||
|
currentAssistantMessages.current[sessionId] += text;
|
||||||
|
updateLastAssistantMessage(sessionId, currentAssistantMessages.current[sessionId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'content_block_start': {
|
||||||
|
// Starting a new content block (text or tool_use)
|
||||||
|
const contentBlock = event.content_block;
|
||||||
|
if (contentBlock?.type === 'text') {
|
||||||
|
// Initialize streaming for text
|
||||||
|
currentAssistantMessages.current[sessionId] = '';
|
||||||
|
addMessage(sessionId, { type: 'assistant', content: '' });
|
||||||
|
} else if (contentBlock?.type === 'tool_use') {
|
||||||
|
// Tool use start - we'll get the full thing in assistant message
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'content_block_stop': {
|
||||||
|
// Content block finished
|
||||||
|
if (currentAssistantMessages.current[sessionId] !== undefined) {
|
||||||
|
// Clean up streaming state - message is already updated
|
||||||
|
delete currentAssistantMessages.current[sessionId];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'message_start': {
|
||||||
|
// Message is starting
|
||||||
|
updateSession(sessionId, { isProcessing: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'message_stop': {
|
||||||
|
// Message is complete
|
||||||
|
if (currentAssistantMessages.current[sessionId] !== undefined) {
|
||||||
|
delete currentAssistantMessages.current[sessionId];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'result': {
|
||||||
|
// Final result with stats
|
||||||
|
const defaultStats = { totalCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, numTurns: 0 };
|
||||||
|
updateSession(sessionId, (session) => ({
|
||||||
|
isProcessing: false,
|
||||||
|
stats: {
|
||||||
|
...(session.stats || defaultStats),
|
||||||
|
totalCost: event.totalCost ?? session.stats?.totalCost ?? 0,
|
||||||
|
inputTokens: event.inputTokens ?? session.stats?.inputTokens ?? 0,
|
||||||
|
outputTokens: event.outputTokens ?? session.stats?.outputTokens ?? 0,
|
||||||
|
cacheReadTokens: event.cacheReadTokens ?? session.stats?.cacheReadTokens ?? 0,
|
||||||
|
cacheCreationTokens: event.cacheCreationTokens ?? session.stats?.cacheCreationTokens ?? 0,
|
||||||
|
numTurns: event.numTurns ?? session.stats?.numTurns ?? 0,
|
||||||
|
},
|
||||||
|
currentContext: event.currentContext || session.currentContext,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`[${sessionId}] Unhandled claude event:`, type, event);
|
||||||
|
}
|
||||||
|
}, [updateSession, addMessage, updateLastAssistantMessage]);
|
||||||
|
|
||||||
|
// Handle incoming WebSocket message for a session
|
||||||
|
const handleWsMessage = useCallback((sessionId, data) => {
|
||||||
|
const { type } = data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'session_started':
|
||||||
|
updateSession(sessionId, {
|
||||||
|
active: true,
|
||||||
|
claudeSessionId: data.sessionId,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_stopped':
|
||||||
|
updateSession(sessionId, {
|
||||||
|
active: false,
|
||||||
|
isProcessing: false,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'control_initialized': {
|
||||||
|
updateSession(sessionId, {
|
||||||
|
controlInitialized: true,
|
||||||
|
});
|
||||||
|
// Restore saved permission mode after control is initialized
|
||||||
|
// Read from localStorage since sessions state may be stale in this callback
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SESSIONS_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const savedData = JSON.parse(stored);
|
||||||
|
const savedSession = savedData.sessions?.[sessionId];
|
||||||
|
if (savedSession?.permissionMode && savedSession.permissionMode !== 'default') {
|
||||||
|
const ws = wsRefs.current[sessionId];
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'set_permission_mode', mode: savedSession.permissionMode }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to restore permission mode:', e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'permission_mode_changed':
|
||||||
|
updateSession(sessionId, {
|
||||||
|
permissionMode: data.mode,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'permission_request':
|
||||||
|
updateSession(sessionId, {
|
||||||
|
pendingPermission: data,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'permission_resolved':
|
||||||
|
updateSession(sessionId, {
|
||||||
|
pendingPermission: null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'assistant_message':
|
||||||
|
if (data.partial) {
|
||||||
|
// Streaming update
|
||||||
|
if (!currentAssistantMessages.current[sessionId]) {
|
||||||
|
currentAssistantMessages.current[sessionId] = '';
|
||||||
|
addMessage(sessionId, { type: 'assistant', content: '' });
|
||||||
|
}
|
||||||
|
currentAssistantMessages.current[sessionId] = data.content;
|
||||||
|
updateLastAssistantMessage(sessionId, data.content);
|
||||||
|
} else {
|
||||||
|
// Final message
|
||||||
|
if (currentAssistantMessages.current[sessionId] !== undefined) {
|
||||||
|
updateLastAssistantMessage(sessionId, data.content);
|
||||||
|
delete currentAssistantMessages.current[sessionId];
|
||||||
|
} else {
|
||||||
|
addMessage(sessionId, { type: 'assistant', content: data.content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user_message':
|
||||||
|
addMessage(sessionId, {
|
||||||
|
type: 'user',
|
||||||
|
content: data.content,
|
||||||
|
attachments: data.attachments,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_use':
|
||||||
|
addMessage(sessionId, {
|
||||||
|
type: 'tool_use',
|
||||||
|
tool: data.tool,
|
||||||
|
input: data.input,
|
||||||
|
toolUseId: data.toolUseId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_result':
|
||||||
|
addMessage(sessionId, {
|
||||||
|
type: 'tool_result',
|
||||||
|
content: data.content,
|
||||||
|
toolUseId: data.toolUseId,
|
||||||
|
isError: data.isError,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'processing':
|
||||||
|
updateSession(sessionId, { isProcessing: data.isProcessing });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'result': {
|
||||||
|
const defaultStats = { totalCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, numTurns: 0 };
|
||||||
|
updateSession(sessionId, (session) => ({
|
||||||
|
isProcessing: false,
|
||||||
|
stats: {
|
||||||
|
...(session.stats || defaultStats),
|
||||||
|
totalCost: data.totalCost ?? session.stats?.totalCost ?? 0,
|
||||||
|
inputTokens: data.inputTokens ?? session.stats?.inputTokens ?? 0,
|
||||||
|
outputTokens: data.outputTokens ?? session.stats?.outputTokens ?? 0,
|
||||||
|
cacheReadTokens: data.cacheReadTokens ?? session.stats?.cacheReadTokens ?? 0,
|
||||||
|
cacheCreationTokens: data.cacheCreationTokens ?? session.stats?.cacheCreationTokens ?? 0,
|
||||||
|
numTurns: data.numTurns ?? session.stats?.numTurns ?? 0,
|
||||||
|
},
|
||||||
|
currentContext: data.currentContext || session.currentContext,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
updateSession(sessionId, {
|
||||||
|
error: data.message,
|
||||||
|
isProcessing: false,
|
||||||
|
});
|
||||||
|
addMessage(sessionId, { type: 'error', content: data.message });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'history':
|
||||||
|
if (data.messages && Array.isArray(data.messages)) {
|
||||||
|
setSessionMessages(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: data.messages,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'session_ended':
|
||||||
|
updateSession(sessionId, {
|
||||||
|
active: false,
|
||||||
|
isProcessing: false,
|
||||||
|
});
|
||||||
|
addMessage(sessionId, {
|
||||||
|
type: 'system',
|
||||||
|
content: `Session ended (code: ${data.code})`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'claude_event':
|
||||||
|
handleClaudeEvent(sessionId, data.event);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'generation_stopped':
|
||||||
|
updateSession(sessionId, { isProcessing: false });
|
||||||
|
if (data.message) {
|
||||||
|
addMessage(sessionId, { type: 'system', content: data.message });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`[${sessionId}] Unhandled message type:`, type, data);
|
||||||
|
}
|
||||||
|
}, [updateSession, addMessage, updateLastAssistantMessage, handleClaudeEvent]);
|
||||||
|
|
||||||
|
// Connect WebSocket for a session
|
||||||
|
const connectSession = useCallback((sessionId) => {
|
||||||
|
if (wsRefs.current[sessionId]?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
wsRefs.current[sessionId] = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log(`[${sessionId}] WebSocket connected`);
|
||||||
|
updateSession(sessionId, { connected: true, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log(`[${sessionId}] WebSocket disconnected`);
|
||||||
|
updateSession(sessionId, {
|
||||||
|
connected: false,
|
||||||
|
active: false,
|
||||||
|
isProcessing: false,
|
||||||
|
});
|
||||||
|
delete wsRefs.current[sessionId];
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
console.error(`[${sessionId}] WebSocket error:`, err);
|
||||||
|
updateSession(sessionId, { error: 'Connection error' });
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleWsMessage(sessionId, data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${sessionId}] Failed to parse message:`, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [updateSession, handleWsMessage]);
|
||||||
|
|
||||||
|
// Auto-connect restored sessions
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionsToConnect.current.length > 0) {
|
||||||
|
const toConnect = [...sessionsToConnect.current];
|
||||||
|
sessionsToConnect.current = [];
|
||||||
|
toConnect.forEach(sessionId => {
|
||||||
|
connectSession(sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [connectSession]);
|
||||||
|
|
||||||
|
// Disconnect WebSocket for a session
|
||||||
|
const disconnectSession = useCallback((sessionId) => {
|
||||||
|
const ws = wsRefs.current[sessionId];
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
delete wsRefs.current[sessionId];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Create a new session
|
||||||
|
const createSession = useCallback((host = 'neko', project = '/home/sumdex/projects') => {
|
||||||
|
const id = generateSessionId();
|
||||||
|
const session = createSessionState(id, host, project);
|
||||||
|
|
||||||
|
setSessions(prev => ({ ...prev, [id]: session }));
|
||||||
|
setTabOrder(prev => [...prev, id]);
|
||||||
|
setFocusedSessionId(id);
|
||||||
|
|
||||||
|
// Auto-connect
|
||||||
|
setTimeout(() => connectSession(id), 0);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}, [connectSession]);
|
||||||
|
|
||||||
|
// Close a session (stops Claude but keeps in tabs unless removed)
|
||||||
|
const closeSession = useCallback((sessionId) => {
|
||||||
|
const ws = wsRefs.current[sessionId];
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'stop_session' }));
|
||||||
|
}
|
||||||
|
disconnectSession(sessionId);
|
||||||
|
}, [disconnectSession]);
|
||||||
|
|
||||||
|
// Remove session completely
|
||||||
|
const removeSession = useCallback((sessionId) => {
|
||||||
|
closeSession(sessionId);
|
||||||
|
|
||||||
|
setSessions(prev => {
|
||||||
|
const { [sessionId]: removed, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTabOrder(prev => prev.filter(id => id !== sessionId));
|
||||||
|
setSplitSessions(prev => prev.filter(id => id !== sessionId));
|
||||||
|
|
||||||
|
// Focus another session if this was focused
|
||||||
|
setFocusedSessionId(prev => {
|
||||||
|
if (prev === sessionId) {
|
||||||
|
const remaining = tabOrder.filter(id => id !== sessionId);
|
||||||
|
return remaining[0] || null;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [closeSession, tabOrder]);
|
||||||
|
|
||||||
|
// Start Claude session
|
||||||
|
const startClaudeSession = useCallback(async (sessionId) => {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const ws = wsRefs.current[sessionId];
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
connectSession(sessionId);
|
||||||
|
// Wait for connection
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWs = wsRefs.current[sessionId];
|
||||||
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||||
|
// Load history if resuming
|
||||||
|
if (session.resumeOnStart) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/api/history/${encodeURIComponent(session.project)}?host=${session.host}`
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.messages && Array.isArray(data.messages)) {
|
||||||
|
setSessionMessages(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: data.messages,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load history:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentWs.send(JSON.stringify({
|
||||||
|
type: 'start_session',
|
||||||
|
project: session.project,
|
||||||
|
resume: session.resumeOnStart,
|
||||||
|
host: session.host,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [sessions, connectSession, updateSession]);
|
||||||
|
|
||||||
|
// Stop Claude session
|
||||||
|
const stopClaudeSession = useCallback((sessionId) => {
|
||||||
|
const ws = wsRefs.current[sessionId];
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'stop_session' }));
|
||||||
|
}
|
||||||
|
updateSession(sessionId, { active: false, isProcessing: false });
|
||||||
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Send message to session
|
||||||
|
const sendMessage = useCallback(async (sessionId, message, attachments = []) => {
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
if (!session?.active) return;
|
||||||
|
|
||||||
|
const ws = wsRefs.current[sessionId];
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
// Handle file uploads if needed
|
||||||
|
let uploadedFiles = [];
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of attachments) {
|
||||||
|
formData.append('files', file.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/upload/${session.claudeSessionId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
uploadedFiles = data.files || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Upload failed:', e);
|
||||||
|
updateSession(sessionId, { error: `Upload failed: ${e.message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message with file references
|
||||||
|
let finalMessage = message;
|
||||||
|
if (uploadedFiles.length > 0) {
|
||||||
|
const fileRefs = uploadedFiles.map(f =>
|
||||||
|
f.isImage ? `[Image: ${f.path}]` : `[File: ${f.path}]`
|
||||||
|
).join('\n');
|
||||||
|
finalMessage = `${fileRefs}\n\n${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user message to local state immediately for instant feedback
|
||||||
|
addMessage(sessionId, {
|
||||||
|
type: 'user',
|
||||||
|
content: message,
|
||||||
|
attachments: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSession(sessionId, { isProcessing: true });
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'user_message',
|
||||||
|
message: finalMessage,
|
||||||
|
}));
|
||||||
|
}, [sessions, updateSession, addMessage]);
|
||||||
|
|
||||||
|
// Stop generation
|
||||||
|
const stopGeneration = useCallback((sessionId) => {
|
||||||
|
const ws = wsRefs.current[sessionId];
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'stop_generation' }));
|
||||||
|
}
|
||||||
|
updateSession(sessionId, { isProcessing: false });
|
||||||
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Clear messages
|
||||||
|
const clearMessages = useCallback((sessionId) => {
|
||||||
|
setSessionMessages(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: [],
|
||||||
|
}));
|
||||||
|
updateSession(sessionId, { unreadCount: 0 });
|
||||||
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Change permission mode
|
||||||
|
const changePermissionMode = useCallback((sessionId, mode) => {
|
||||||
|
const ws = wsRefs.current[sessionId];
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'set_permission_mode', mode }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Respond to permission request
|
||||||
|
const respondToPermission = useCallback((sessionId, requestId, allow) => {
|
||||||
|
const ws = wsRefs.current[sessionId];
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'permission_response',
|
||||||
|
requestId,
|
||||||
|
allow,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
updateSession(sessionId, { pendingPermission: null });
|
||||||
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Reorder tabs
|
||||||
|
const reorderTabs = useCallback((fromIndex, toIndex) => {
|
||||||
|
setTabOrder(prev => {
|
||||||
|
const result = [...prev];
|
||||||
|
const [removed] = result.splice(fromIndex, 1);
|
||||||
|
result.splice(toIndex, 0, removed);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add session to split view
|
||||||
|
const addToSplit = useCallback((sessionId) => {
|
||||||
|
setSplitSessions(prev => {
|
||||||
|
if (prev.includes(sessionId)) return prev;
|
||||||
|
if (prev.length >= 4) return prev; // Max 4 panels
|
||||||
|
return [...prev, sessionId];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Remove session from split view
|
||||||
|
const removeFromSplit = useCallback((sessionId) => {
|
||||||
|
setSplitSessions(prev => prev.filter(id => id !== sessionId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear split view
|
||||||
|
const clearSplit = useCallback(() => {
|
||||||
|
setSplitSessions([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mark session as read
|
||||||
|
const markAsRead = useCallback((sessionId) => {
|
||||||
|
updateSession(sessionId, { unreadCount: 0 });
|
||||||
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Rename session
|
||||||
|
const renameSession = useCallback((sessionId, name) => {
|
||||||
|
updateSession(sessionId, { name });
|
||||||
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Update session config (host, project, resume)
|
||||||
|
const updateSessionConfig = useCallback((sessionId, config) => {
|
||||||
|
updateSession(sessionId, config);
|
||||||
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Memoize focused session to prevent unnecessary re-renders
|
||||||
|
const focusedSession = useMemo(() => {
|
||||||
|
return focusedSessionId ? sessions[focusedSessionId] : null;
|
||||||
|
}, [focusedSessionId, sessions]);
|
||||||
|
|
||||||
|
// Memoize the entire context value to prevent re-renders when nothing changed
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
// State
|
||||||
|
sessions,
|
||||||
|
sessionMessages,
|
||||||
|
tabOrder,
|
||||||
|
splitSessions,
|
||||||
|
focusedSessionId,
|
||||||
|
focusedSession,
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
removeSession,
|
||||||
|
renameSession,
|
||||||
|
updateSessionConfig,
|
||||||
|
|
||||||
|
// Focus & view
|
||||||
|
setFocusedSessionId,
|
||||||
|
markAsRead,
|
||||||
|
reorderTabs,
|
||||||
|
addToSplit,
|
||||||
|
removeFromSplit,
|
||||||
|
clearSplit,
|
||||||
|
|
||||||
|
// Claude session control
|
||||||
|
connectSession,
|
||||||
|
disconnectSession,
|
||||||
|
startClaudeSession,
|
||||||
|
stopClaudeSession,
|
||||||
|
|
||||||
|
// Messaging
|
||||||
|
sendMessage,
|
||||||
|
stopGeneration,
|
||||||
|
clearMessages,
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
changePermissionMode,
|
||||||
|
respondToPermission,
|
||||||
|
}), [
|
||||||
|
sessions, sessionMessages, tabOrder, splitSessions, focusedSessionId, focusedSession,
|
||||||
|
createSession, closeSession, removeSession, renameSession, updateSessionConfig,
|
||||||
|
setFocusedSessionId, markAsRead, reorderTabs, addToSplit, removeFromSplit, clearSplit,
|
||||||
|
connectSession, disconnectSession, startClaudeSession, stopClaudeSession,
|
||||||
|
sendMessage, stopGeneration, clearMessages, changePermissionMode, respondToPermission,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SessionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionManager() {
|
||||||
|
const context = useContext(SessionContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSessionManager must be used within SessionProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to get a specific session's data and actions
|
||||||
|
export function useSession(sessionId) {
|
||||||
|
const manager = useSessionManager();
|
||||||
|
const session = manager.sessions[sessionId];
|
||||||
|
const messages = manager.sessionMessages[sessionId] || [];
|
||||||
|
|
||||||
|
// Combine session data with messages for backward compatibility
|
||||||
|
const sessionWithMessages = session ? { ...session, messages } : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: sessionWithMessages,
|
||||||
|
messages, // Also expose separately for components that only need messages
|
||||||
|
start: () => manager.startClaudeSession(sessionId),
|
||||||
|
stop: () => manager.stopClaudeSession(sessionId),
|
||||||
|
send: (msg, attachments) => manager.sendMessage(sessionId, msg, attachments),
|
||||||
|
stopGeneration: () => manager.stopGeneration(sessionId),
|
||||||
|
clearMessages: () => manager.clearMessages(sessionId),
|
||||||
|
changePermissionMode: (mode) => manager.changePermissionMode(sessionId, mode),
|
||||||
|
respondToPermission: (reqId, allow) => manager.respondToPermission(sessionId, reqId, allow),
|
||||||
|
close: () => manager.closeSession(sessionId),
|
||||||
|
remove: () => manager.removeSession(sessionId),
|
||||||
|
rename: (name) => manager.renameSession(sessionId, name),
|
||||||
|
updateConfig: (config) => manager.updateSessionConfig(sessionId, config),
|
||||||
|
addToSplit: () => manager.addToSplit(sessionId),
|
||||||
|
removeFromSplit: () => manager.removeFromSplit(sessionId),
|
||||||
|
focus: () => manager.setFocusedSessionId(sessionId),
|
||||||
|
markAsRead: () => manager.markAsRead(sessionId),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user