import { useState, useEffect, useRef, useContext, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { socketClient } from '../lib/socket'; import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatSessionMode, type AiProvider, type Project } from '../lib/api'; import { WorkspaceMode } from '../lib/types'; import WorkspaceModeIndicator from '../components/WorkspaceModeIndicator'; import FilesPanel from '../components/FilesPanel'; import LogPanel from '../components/LogPanel'; import ChatTurnComponent from '../components/ChatTurn'; import { AppContext } from '../App'; interface LogEntry { id: string; timestamp: Date; level: string; component: string; message: string; metadata?: unknown; } interface StreamingState { currentMode: 'thinking' | 'responding' | null; thinkingContent: string; respondingContent: string; currentBlockIndex: number | null; } export default function ChatSessionView() { const { projectId, sessionId } = useParams<{ projectId: string; sessionId: string }>(); const navigate = useNavigate(); const appContext = useContext(AppContext); const [project, setProject] = useState(null); const [session, setSession] = useState(null); const [turns, setTurns] = useState([]); const [promptInput, setPromptInput] = useState(''); const [isProcessing, setIsProcessing] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [sessionLocked, setSessionLocked] = useState(true); const [workspaceMode, setWorkspaceMode] = useState(WorkspaceMode.Idle); const [isUpdatingMode, setIsUpdatingMode] = useState(false); const [isUpdatingProvider, setIsUpdatingProvider] = useState(false); const [isUpdatingReasoning, setIsUpdatingReasoning] = useState(false); const [logExpanded, setLogExpanded] = useState(false); const [logs, setLogs] = useState([]); const [toast, setToast] = useState(null); const [providers, setProviders] = useState([]); const [selectedProviderId, setSelectedProviderId] = useState(''); const [selectedModelId, setSelectedModelId] = useState(''); const [sessionReasoningEffort, setSessionReasoningEffort] = useState('off'); const [isEditingProvider, setIsEditingProvider] = useState(false); const [editProviderId, setEditProviderId] = useState(''); const [editModelId, setEditModelId] = useState(''); const [isEditingName, setIsEditingName] = useState(false); const [editName, setEditName] = useState(''); const [isUpdatingName, setIsUpdatingName] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); const toastTimerRef = useRef | null>(null); const pendingUpdatesRef = useRef>>(new Map()); const updateRafRef = useRef(null); const currentTurnIdRef = useRef(null); const streamingStateRef = useRef>(new Map()); const sessionRef = useRef(null); const projectRef = useRef(null); useEffect(() => { loadSessionData(); }, [projectId, sessionId]); useEffect(() => { setupSocketListeners(); return () => cleanupSocketListeners(); }, [sessionId]); useEffect(() => { scrollToBottom(); }, [turns]); useEffect(() => { return () => { if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (updateRafRef.current) { cancelAnimationFrame(updateRafRef.current); } }; }, []); // Keep refs in sync with state for use in cleanup closures useEffect(() => { sessionRef.current = session; }, [session]); useEffect(() => { projectRef.current = project; }, [project]); // Start heartbeat when session+project are loaded useEffect(() => { if (session && project) { socketClient.startSessionHeartbeat(); } return () => { socketClient.stopSessionHeartbeat(); }; }, [session, project]); // Re-lock on socket reconnect to restore lock on a new CodeSession const handleSocketReconnect = useCallback(async () => { if (!sessionRef.current || !projectRef.current) return; const droneJson = localStorage.getItem('dtp_drone_registration'); if (!droneJson) return; try { const registration = JSON.parse(droneJson); const success = await socketClient.requestSessionLock( registration, projectRef.current, sessionRef.current, ); if (!success) { console.warn('Failed to re-lock drone after socket reconnect'); } } catch (err) { console.error('Failed to re-lock drone after socket reconnect', err); } }, []); useEffect(() => { socketClient.on('connect', handleSocketReconnect); return () => { socketClient.off('connect', handleSocketReconnect); }; }, [handleSocketReconnect]); // Release session lock on unmount only useEffect(() => { return () => { const droneJson = localStorage.getItem('dtp_drone_registration'); if (droneJson && sessionRef.current && projectRef.current) { try { const registration = JSON.parse(droneJson); socketClient.releaseSessionLock( registration, projectRef.current, sessionRef.current, ); } catch (err) { console.error('Failed to release session lock', err); } } }; }, []); const loadSessionData = async () => { try { if (sessionId) { const sessionData = await chatSessionApi.get(sessionId); setSession(sessionData); const projectRef = sessionData.project; const projectObjectId = typeof projectRef === 'string' ? projectRef : projectRef?._id; if (projectObjectId) { const projectData = await projectApi.get(projectObjectId); setProject(projectData); } const turnsData = await chatSessionApi.getTurns(sessionId); setTurns(turnsData); setSessionLocked(true); const allProviders = await providerApi.getAll(); setProviders(allProviders.sort((a, b) => a.name.localeCompare(b.name))); const providerId = typeof sessionData.provider === 'string' ? sessionData.provider : sessionData.provider?._id; setSelectedProviderId(providerId || ''); setSelectedModelId(sessionData.selectedModel || ''); setSessionReasoningEffort(sessionData.reasoningEffort || 'off'); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load session'); } finally { setLoading(false); } }; const handleSessionUpdated = useCallback((updates: Partial) => { setSession(prev => prev ? { ...prev, ...updates } : null); }, []); const setupSocketListeners = () => { socketClient.on('thinking', handleThinking); socketClient.on('response', handleResponse); socketClient.on('toolCall', handleToolCall); socketClient.on('workOrderComplete', handleWorkOrderComplete); socketClient.on('workspaceModeChanged', handleWorkspaceModeChanged); socketClient.on('sessionUpdated', handleSessionUpdated); socketClient.on('log:entry', handleLogEntry); socketClient.on('status', handleStatus); }; const cleanupSocketListeners = () => { socketClient.off('thinking', handleThinking); socketClient.off('response', handleResponse); socketClient.off('toolCall', handleToolCall); socketClient.off('workOrderComplete', handleWorkOrderComplete); socketClient.off('workspaceModeChanged', handleWorkspaceModeChanged); socketClient.off('sessionUpdated', handleSessionUpdated); socketClient.off('log:entry', handleLogEntry); socketClient.off('status', handleStatus); }; const scheduleUpdate = useCallback(() => { if (updateRafRef.current) return; updateRafRef.current = requestAnimationFrame(() => { const pendingEntries = Array.from(pendingUpdatesRef.current.entries()); if (pendingEntries.length === 0) { updateRafRef.current = null; return; } setTurns(prevTurns => { const newTurns = [...prevTurns]; let changed = false; for (const [turnId, turnUpdates] of pendingEntries) { const turnIndex = newTurns.findIndex(t => t._id === turnId); if (turnIndex === -1) continue; const oldTurn = newTurns[turnIndex]; const newTurn = { ...oldTurn, ...turnUpdates }; if (turnUpdates.blocks !== undefined) { const state = streamingStateRef.current.get(turnId); const updatedBlocks = [...(oldTurn.blocks || [])]; for (const updateBlock of turnUpdates.blocks) { let blockIndex = state?.currentBlockIndex ?? null; if (updateBlock.mode === 'tool') { blockIndex = null; } else if ( blockIndex === null || updatedBlocks[blockIndex]?.mode !== updateBlock.mode ) { const lastIndex = updatedBlocks.length - 1; blockIndex = updatedBlocks[lastIndex]?.mode === updateBlock.mode ? lastIndex : null; } if (blockIndex !== null) { updatedBlocks[blockIndex] = updateBlock; } else { updatedBlocks.push(updateBlock); blockIndex = updatedBlocks.length - 1; } if (state) { state.currentBlockIndex = blockIndex; } } newTurn.blocks = updatedBlocks; } if (turnUpdates.toolCalls !== undefined) { newTurn.toolCalls = [...(oldTurn.toolCalls || []), ...turnUpdates.toolCalls]; newTurn.stats = { ...oldTurn.stats, toolCallCount: newTurn.toolCalls.length }; } if (turnUpdates.status !== undefined) { newTurn.status = turnUpdates.status; } newTurns[turnIndex] = newTurn; changed = true; } return changed ? newTurns : prevTurns; }); pendingUpdatesRef.current.clear(); updateRafRef.current = null; }); }, []); const mergePendingUpdate = useCallback((turnId: string, updates: Partial) => { const existing = pendingUpdatesRef.current.get(turnId); const merged: Partial = { ...existing, ...updates, }; if (existing?.blocks || updates.blocks) { merged.blocks = [ ...(existing?.blocks || []), ...(updates.blocks || []), ]; } if (existing?.toolCalls || updates.toolCalls) { merged.toolCalls = [ ...(existing?.toolCalls || []), ...(updates.toolCalls || []), ]; } pendingUpdatesRef.current.set(turnId, merged); }, []); const handleThinking = useCallback((content: string) => { const turnId = currentTurnIdRef.current; if (!turnId) return; let state = streamingStateRef.current.get(turnId); if (!state) { state = { currentMode: null, thinkingContent: '', respondingContent: '', currentBlockIndex: null }; streamingStateRef.current.set(turnId, state); } // Check for mode transition if (state.currentMode !== 'thinking') { // Flush previous mode if (state.currentMode === 'responding' && state.respondingContent) { mergePendingUpdate(turnId, { blocks: [{ mode: 'responding' as const, createdAt: new Date().toISOString(), content: state.respondingContent, }], }); state.respondingContent = ''; state.currentBlockIndex = null; } state.currentMode = 'thinking'; } // Aggregate content state.thinkingContent += content; // Update with aggregated content mergePendingUpdate(turnId, { blocks: [{ mode: 'thinking' as const, createdAt: new Date().toISOString(), content: state.thinkingContent, }], }); scheduleUpdate(); }, [mergePendingUpdate, scheduleUpdate]); const handleResponse = useCallback((content: string) => { const turnId = currentTurnIdRef.current; if (!turnId) return; let state = streamingStateRef.current.get(turnId); if (!state) { state = { currentMode: null, thinkingContent: '', respondingContent: '', currentBlockIndex: null }; streamingStateRef.current.set(turnId, state); } // Check for mode transition if (state.currentMode !== 'responding') { // Flush previous mode if (state.currentMode === 'thinking' && state.thinkingContent) { mergePendingUpdate(turnId, { blocks: [{ mode: 'thinking' as const, createdAt: new Date().toISOString(), content: state.thinkingContent, }], }); state.thinkingContent = ''; state.currentBlockIndex = null; } state.currentMode = 'responding'; } // Aggregate content state.respondingContent += content; // Update with aggregated content mergePendingUpdate(turnId, { blocks: [{ mode: 'responding' as const, createdAt: new Date().toISOString(), content: state.respondingContent, }], }); scheduleUpdate(); }, [mergePendingUpdate, scheduleUpdate]); const handleToolCall = useCallback((callId: string, name: string, params: string, response: string) => { const turnId = currentTurnIdRef.current; if (!turnId) return; // Flush current streaming state const state = streamingStateRef.current.get(turnId); if (state) { const blocksToFlush: ChatTurnBlock[] = []; if (state.currentMode === 'thinking' && state.thinkingContent) { blocksToFlush.push({ mode: 'thinking' as const, createdAt: new Date().toISOString(), content: state.thinkingContent, }); state.thinkingContent = ''; } if (state.currentMode === 'responding' && state.respondingContent) { blocksToFlush.push({ mode: 'responding' as const, createdAt: new Date().toISOString(), content: state.respondingContent, }); state.respondingContent = ''; } if (blocksToFlush.length > 0) { mergePendingUpdate(turnId, { blocks: blocksToFlush, }); scheduleUpdate(); } state.currentMode = null; state.currentBlockIndex = null; } // Add tool block mergePendingUpdate(turnId, { blocks: [{ mode: 'tool' as const, createdAt: new Date().toISOString(), content: { callId, name, parameters: params, response }, }], toolCalls: [{ callId, name, parameters: params, response }], }); scheduleUpdate(); }, [mergePendingUpdate, scheduleUpdate]); const handleWorkOrderComplete = useCallback((turnId: string, success: boolean, message?: string) => { // Backend has already flushed and persisted all streaming content // Just clean up frontend streaming state and update status if (streamingStateRef.current.has(turnId)) { if (pendingUpdatesRef.current.has(turnId) || updateRafRef.current) { requestAnimationFrame(() => streamingStateRef.current.delete(turnId)); } else { streamingStateRef.current.delete(turnId); } } setTurns(prevTurns => prevTurns.map(turn => turn._id === turnId ? { ...turn, status: success ? 'finished' : 'error', errorMessage: message && !success ? message : turn.errorMessage } : turn ) ); setIsProcessing(false); currentTurnIdRef.current = null; if (!success) { setError(message || 'Work order failed'); } }, []); const handleWorkspaceModeChanged = useCallback((mode: string) => { setWorkspaceMode(mode as WorkspaceMode); }, []); const handleLogEntry = useCallback((data: { level: string; component: string; message: string; metadata?: unknown; timestamp: number }) => { setLogs(prev => [ ...prev, { id: `log-${Date.now()}-${Math.random()}`, timestamp: new Date(data.timestamp), level: data.level, component: data.component, message: data.message, metadata: data.metadata, }, ]); }, []); const handleStatus = useCallback((content: string) => { appContext?.setStatusMessage(content); }, [appContext]); const showToast = useCallback((message: string) => { setToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } toastTimerRef.current = setTimeout(() => { setToast(null); }, 4000); }, []); const handleWorkspaceModeChange = async (mode: WorkspaceMode) => { if (!session || !project) return; if (mode === workspaceMode) return; try { const droneJson = localStorage.getItem('dtp_drone_registration'); if (!droneJson) { showToast('No drone registration found'); return; } const registration = JSON.parse(droneJson); const result = await socketClient.requestWorkspaceMode( registration, project, session, mode, ); if (!result.success) { showToast(result.reason || `Cannot switch to ${mode} mode`); setWorkspaceMode(workspaceMode); } } catch (err) { showToast(`Failed to change workspace mode: ${err instanceof Error ? err.message : 'Unknown error'}`); } }; const handleModeChange = async (e: React.ChangeEvent) => { if (!session) return; const newMode = e.target.value as ChatSessionMode; if (newMode === session.mode) return; setIsUpdatingMode(true); try { const updatedSession = await chatSessionApi.update(session._id, { mode: newMode }); setSession(updatedSession); showToast(`Session mode changed to ${newMode}`); } catch (err) { showToast(`Failed to change mode: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setIsUpdatingMode(false); } }; const getSelectedModelCapabilities = useCallback(() => { const provider = providers.find(p => p._id === selectedProviderId); if (!provider) return null; const model = provider.models.find(m => m.id === selectedModelId); return model?.capabilities || null; }, [providers, selectedProviderId, selectedModelId]); const handleReasoningChange = async (e: React.ChangeEvent) => { if (!session) return; const newValue = e.target.value; if (newValue === sessionReasoningEffort) return; setIsUpdatingReasoning(true); try { const updatedSession = await chatSessionApi.update(session._id, { reasoningEffort: newValue }); setSession(updatedSession); setSessionReasoningEffort(newValue); showToast(`Reasoning effort set to ${newValue}`); } catch (err) { showToast(`Failed to change reasoning effort: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setIsUpdatingReasoning(false); } }; const getProviderName = (id: string) => { return providers.find(p => p._id === id)?.name ?? 'None'; }; const getModelName = (providerId: string, modelId: string) => { if (!providerId || !modelId) return 'None'; const provider = providers.find(p => p._id === providerId); return provider?.models.find(m => m.id === modelId)?.name ?? modelId; }; const getSortedModels = (providerId: string) => { const provider = providers.find(p => p._id === providerId); if (!provider) return []; return [...provider.models].sort((a, b) => a.name.localeCompare(b.name)); }; const truncateModelName = (name: string, maxLength = 25) => { if (name.length <= maxLength) return name; return name.slice(0, maxLength - 3) + '...'; }; const handleStartEditProvider = () => { setEditProviderId(selectedProviderId); setEditModelId(selectedModelId); setIsEditingProvider(true); }; const handleSaveProvider = async () => { if (!session || !editProviderId || !editModelId) return; setIsUpdatingProvider(true); try { const updatedSession = await chatSessionApi.update(session._id, { provider: editProviderId, selectedModel: editModelId, }); setSession(updatedSession); setSelectedProviderId(editProviderId); setSelectedModelId(editModelId); setIsEditingProvider(false); showToast('Provider and model updated'); } catch (err) { showToast(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setIsUpdatingProvider(false); } }; const handleCancelEditProvider = () => { setEditProviderId(selectedProviderId); setEditModelId(selectedModelId); setIsEditingProvider(false); }; const handleStartEditName = () => { setEditName(session?.name || ''); setIsEditingName(true); }; const handleSaveName = async () => { if (!session || !editName.trim()) return; setIsUpdatingName(true); try { const updatedSession = await chatSessionApi.update(session._id, { name: editName.trim() }); setSession(updatedSession); setIsEditingName(false); showToast('Session name updated'); } catch (err) { showToast(`Failed to update name: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setIsUpdatingName(false); } }; const handleCancelEditName = () => { setIsEditingName(false); }; const handleSubmitPrompt = async (e: React.FormEvent) => { e.preventDefault(); if (!promptInput.trim() || isProcessing || !socketClient.connected || !sessionLocked) return; if (workspaceMode !== WorkspaceMode.Agent) return; if (!session?.selectedModel) { showToast('Please select a model before submitting a prompt'); return; } const tempTurnId = `temp-${Date.now()}`; const userTurn: ChatTurn = { _id: tempTurnId, createdAt: new Date().toISOString(), user: session?.user?._id || '', project: session?.project?._id || projectId || '', session: sessionId || '', provider: session?.provider?._id || '', llm: session?.selectedModel || '', mode: session?.mode || 'develop', status: 'processing', prompts: { user: promptInput.trim() }, thinking: '', response: '', toolCalls: [], subagents: [], stats: { toolCallCount: 0, inputTokens: 0, thinkingTokenCount: 0, responseTokens: 0, durationMs: 0, durationLabel: '0ms' } }; setTurns(prev => [...prev, userTurn]); currentTurnIdRef.current = tempTurnId; setPromptInput(''); setIsProcessing(true); setError(''); socketClient.submitPrompt(promptInput.trim(), (success, data) => { if (success && data.turnId) { setTurns(prevTurns => prevTurns.map(turn => turn._id === tempTurnId ? { ...turn, _id: data.turnId! } : turn ) ); currentTurnIdRef.current = data.turnId; } else if (!success) { setTurns(prevTurns => prevTurns.map(turn => turn._id === tempTurnId ? { ...turn, status: 'error', errorMessage: data.message || 'Failed to submit prompt' } : turn ) ); setIsProcessing(false); currentTurnIdRef.current = null; } }); }; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; const handleTurnUpdate = useCallback((turnId: string, updates: Partial) => { setTurns(prevTurns => prevTurns.map(turn => turn._id === turnId ? { ...turn, ...updates } : turn ) ); }, []); const promptDisabled = isProcessing || workspaceMode !== WorkspaceMode.Agent || !sessionLocked || !session?.selectedModel || isEditingProvider; const promptPlaceholder = isEditingProvider ? 'Save or cancel provider changes to continue.' : workspaceMode !== WorkspaceMode.Agent ? 'Select Agent mode to enter a prompt.' : !session?.selectedModel ? 'Please select a model first.' : 'Enter your prompt... (Shift+Enter for new line)'; if (loading) { return (

Loading chat session...

); } if (error && !session) { return (

{error}

); } return (
{/* Toast notification */} {toast && (
{toast}
)} {/* Content Area */}
{isEditingProvider && (
)} {/* Chat View (75%) */}
{/* Messages */}
{turns.map((turn) => ( ))}
{/* Prompt Input */}