Files
claude-web-ui/frontend/src/components/ChatPanel.jsx
Nikolas Syring cfee1711dc 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>
2025-12-17 14:16:52 +01:00

198 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
});