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:
2025-12-17 14:16:52 +01:00
parent 960f2e137d
commit cfee1711dc
9 changed files with 2122 additions and 467 deletions

View 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>
);
});