- 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>
198 lines
6.4 KiB
JavaScript
198 lines
6.4 KiB
JavaScript
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>
|
||
);
|
||
});
|