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