'use client' import { useEffect, useCallback, useState, useRef } from 'react' import { useMissionControl, type Conversation, type ChatAttachment } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' import { createClientLogger } from '@/lib/client-logger' import { ConversationList } from './conversation-list' import { MessageList } from './message-list' import { ChatInput } from './chat-input' import { Button } from '@/components/ui/button' import { SessionMessage, shouldShowTimestamp, type SessionTranscriptMessage } from './session-message' import { getSessionKindLabel, SessionKindAvatar } from './session-kind-brand' const log = createClientLogger('ChatWorkspace') declare global { interface Window { __mcWebSocket?: WebSocket } } interface ChatWorkspaceProps { mode?: 'overlay' | 'embedded' onClose?: () => void } export function ChatWorkspace({ mode = 'embedded', onClose }: ChatWorkspaceProps) { const { activeConversation, setActiveConversation, setChatMessages, setConversations, addChatMessage, replacePendingMessage, updatePendingMessage, agents, conversations, setAgents, notifications, } = useMissionControl() const pendingIdRef = useRef(-1) const [showConversations, setShowConversations] = useState(true) const [isMobile, setIsMobile] = useState(false) const [focusMode, setFocusMode] = useState(false) const [isGenerating, setIsGenerating] = useState(false) const [sessionTranscript, setSessionTranscript] = useState([]) const [sessionTranscriptLoading, setSessionTranscriptLoading] = useState(false) const [sessionTranscriptError, setSessionTranscriptError] = useState(null) const [sessionReloadNonce, setSessionReloadNonce] = useState(0) const isOverlay = mode === 'overlay' const selectedConversation = conversations.find((c) => c.id === activeConversation) const selectedSession = selectedConversation?.session // Detect mobile useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) check() window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) }, []) // On mobile, hide conversations when a conversation is selected useEffect(() => { if (isMobile && activeConversation) { setShowConversations(false) } }, [isMobile, activeConversation]) // Load agents list useEffect(() => { async function loadAgents() { try { const res = await fetch('/api/agents') if (!res.ok) return const data = await res.json() if (data.agents) setAgents(data.agents) } catch (err) { log.error('Failed to load agents:', err) } } loadAgents() }, [setAgents]) // Load messages when conversation changes const loadMessages = useCallback(async () => { if (!activeConversation) return if (activeConversation.startsWith('session:')) { setChatMessages([]) return } try { const res = await fetch(`/api/chat/messages?conversation_id=${encodeURIComponent(activeConversation)}&limit=100`) if (!res.ok) return const data = await res.json() if (data.messages) setChatMessages(data.messages) } catch (err) { log.error('Failed to load messages:', err) } }, [activeConversation, setChatMessages]) useEffect(() => { loadMessages() }, [loadMessages]) // Poll for new messages (visibility-aware) useSmartPoll(loadMessages, 15000, { enabled: !!activeConversation && !activeConversation.startsWith('session:'), pauseWhenSseConnected: true, }) // Close on Escape (overlay mode) useEffect(() => { if (!isOverlay || !onClose) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [isOverlay, onClose]) // Send message handler with optimistic updates const handleSend = async (content: string, attachments?: ChatAttachment[]) => { if (!activeConversation) return const mentionMatch = content.match(/^@(\w+)\s/) let to = mentionMatch ? mentionMatch[1] : null const cleanContent = mentionMatch ? content.slice(mentionMatch[0].length) : content if (!to && activeConversation.startsWith('agent_')) { to = activeConversation.replace('agent_', '') } // Create optimistic message with negative temp ID pendingIdRef.current -= 1 const tempId = pendingIdRef.current const optimisticMessage = { id: tempId, conversation_id: activeConversation, from_agent: 'human', to_agent: to, content: cleanContent, message_type: 'text' as const, attachments, created_at: Math.floor(Date.now() / 1000), pendingStatus: 'sending' as const, } addChatMessage(optimisticMessage) setIsGenerating(true) try { const res = await fetch('/api/chat/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 'human', to, content: cleanContent, conversation_id: activeConversation, message_type: 'text', attachments, forward: true, }), }) if (res.ok) { const data = await res.json() if (data.message) { replacePendingMessage(tempId, data.message) } } else { updatePendingMessage(tempId, { pendingStatus: 'failed' }) } } catch (err) { log.error('Failed to send message:', err) updatePendingMessage(tempId, { pendingStatus: 'failed' }) } finally { setIsGenerating(false) } } // Abort active generation const handleAbort = useCallback(() => { if (!activeConversation) return // Try to send cancel RPC via websocket if available try { const ws = window.__mcWebSocket if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'req', method: 'chat.cancel', id: `mc-cancel-${Date.now()}`, params: { sessionId: activeConversation }, })) } } catch (err) { log.error('Failed to send abort:', err) } setIsGenerating(false) }, [activeConversation]) const handleNewConversation = (agentName: string) => { const convId = `agent_${agentName}` setActiveConversation(convId) if (isMobile) setShowConversations(false) } const handleBackToList = () => { setShowConversations(true) if (isMobile) setActiveConversation(null) } const canSendMessage = !!activeConversation && !activeConversation.startsWith('session:') useEffect(() => { const sessionMeta = selectedSession if (!sessionMeta) { setSessionTranscript([]) setSessionTranscriptError(null) return } let cancelled = false setSessionTranscriptLoading(true) setSessionTranscriptError(null) // Gateway sessions use the gateway transcript API const url = sessionMeta.sessionKind === 'gateway' ? `/api/sessions/transcript/gateway?key=${encodeURIComponent(sessionMeta.sessionKey || sessionMeta.sessionId)}&limit=50` : `/api/sessions/transcript?kind=${encodeURIComponent(sessionMeta.sessionKind)}&id=${encodeURIComponent(sessionMeta.sessionId)}&limit=40` fetch(url) .then(async (res) => { if (!res.ok) { const payload = await res.json().catch(() => ({})) throw new Error(payload?.error || 'Failed to load transcript') } return res.json() }) .then((data) => { if (cancelled) return setSessionTranscript(Array.isArray(data?.messages) ? data.messages : []) }) .catch((err) => { if (cancelled) return setSessionTranscript([]) setSessionTranscriptError(err instanceof Error ? err.message : 'Failed to load transcript') }) .finally(() => { if (!cancelled) setSessionTranscriptLoading(false) }) return () => { cancelled = true } }, [selectedSession, sessionReloadNonce]) const refreshSessionTranscript = useCallback(() => { setSessionReloadNonce((v) => v + 1) }, []) const handleSaveSessionPreferences = useCallback(async (payload: { prefKey: string displayName?: string colorTag?: string }) => { const body = { key: payload.prefKey, name: payload.displayName || null, color: payload.colorTag || null, } const res = await fetch('/api/chat/session-prefs', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const data = await res.json().catch(() => ({})) if (!res.ok) { throw new Error(data?.error || 'Failed to save session preferences') } if (!activeConversation) return setConversations( conversations.map((conv) => { if (conv.id !== activeConversation || !conv.session) return conv return { ...conv, name: payload.displayName || conv.name, session: { ...conv.session, displayName: payload.displayName || conv.session.displayName, colorTag: payload.colorTag || undefined, }, } }) ) }, [activeConversation, conversations, setConversations]) return (
{/* Header */}
{/* Back button on mobile when in chat view */} {isMobile && !showConversations && ( )}
Agent Chat
{agents.filter(a => a.status === 'busy' || a.status === 'idle').length} online
{/* Focus mode toggle */} {/* Toggle conversations sidebar (desktop) */} {isOverlay && onClose && ( )}
{/* Body */}
{/* Conversations sidebar */} {showConversations && !focusMode && (
)} {/* Message area */} {(!isMobile || !showConversations) && (
{/* Conversation header */} {activeConversation && (
{(selectedConversation?.name || activeConversation).replace('agent_', '')}
{getConversationStatus(agents, activeConversation)}
)} {selectedConversation?.source === 'session' && selectedConversation.session ? ( ) : ( <> ({ name: a.name, role: a.role }))} isGenerating={isGenerating} /> )}
)}
) } function SessionConversationView({ session, messages, loading, error, onRefreshTranscript, onSavePreferences, }: { session: NonNullable messages: SessionTranscriptMessage[] loading: boolean error: string | null onRefreshTranscript: () => void onSavePreferences: (payload: { prefKey: string; displayName?: string; colorTag?: string }) => Promise }) { const isGatewaySession = session.sessionKind === 'gateway' const transcriptScrollRef = useRef(null) const [continuePrompt, setContinuePrompt] = useState('') const [continueBusy, setContinueBusy] = useState(false) const [continueError, setContinueError] = useState(null) const [lastReply, setLastReply] = useState(null) const [nameDraft, setNameDraft] = useState(session.displayName || '') const [colorDraft, setColorDraft] = useState(session.colorTag || '') const [prefBusy, setPrefBusy] = useState(false) const [prefError, setPrefError] = useState(null) const hasPrefChanges = nameDraft.trim() !== (session.displayName || '').trim() || colorDraft !== (session.colorTag || '') useEffect(() => { setNameDraft(session.displayName || '') setColorDraft(session.colorTag || '') setPrefError(null) setContinueError(null) setLastReply(null) }, [session.prefKey, session.displayName, session.colorTag]) useEffect(() => { const container = transcriptScrollRef.current if (!container) return container.scrollTop = container.scrollHeight }, [messages, loading, lastReply]) const handleContinueSession = async () => { const prompt = continuePrompt.trim() if (!prompt || continueBusy) return setContinueBusy(true) setContinueError(null) setLastReply(null) try { if (isGatewaySession) { // Gateway sessions: forward message to the agent via chat messages API const agentName = session.agent || session.sessionId.split(':')[1] || 'unknown' const res = await fetch('/api/chat/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 'human', to: agentName, content: prompt, conversation_id: `agent_${agentName}`, message_type: 'text', forward: true, sessionKey: session.sessionKey || undefined, }), }) const data = await res.json().catch(() => ({})) if (!res.ok) { throw new Error(data?.error || 'Failed to send message') } const fwd = data?.forward || data?.message?.metadata?.forwardInfo if (fwd?.attempted && !fwd?.delivered) { setContinueError(`Message saved but not delivered: ${fwd.reason || 'unknown'}`) } setContinuePrompt('') // Refresh transcript after a short delay to capture the response setTimeout(() => onRefreshTranscript(), 2000) } else { const res = await fetch('/api/sessions/continue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: session.sessionKind, id: session.sessionId, prompt, }), }) const data = await res.json().catch(() => ({})) if (!res.ok) { throw new Error(data?.error || 'Failed to continue session') } setContinuePrompt('') if (typeof data?.reply === 'string' && data.reply.trim()) { setLastReply(data.reply.trim()) } onRefreshTranscript() } } catch (err) { setContinueError(err instanceof Error ? err.message : 'Failed to continue session') } finally { setContinueBusy(false) } } const handleSavePrefs = async () => { if (!session.prefKey || prefBusy) return setPrefBusy(true) setPrefError(null) try { await onSavePreferences({ prefKey: session.prefKey, displayName: nameDraft.trim() || undefined, colorTag: colorDraft || undefined, }) } catch (err) { setPrefError(err instanceof Error ? err.message : 'Failed to save preferences') } finally { setPrefBusy(false) } } return (
{/* Compact session info bar */}
{!isGatewaySession && ( )} {session.active ? 'active' : 'idle'} {getSessionKindLabel(session.sessionKind)} {session.model && {session.model}} {session.tokens && {session.tokens}} {session.workingDir && {session.workingDir}} {session.age && {session.age} ago}
{/* Collapsible settings */} {!isGatewaySession && (
Settings
setNameDraft(e.target.value)} placeholder="Rename session" maxLength={80} className="h-7 rounded border border-border/60 bg-surface-1 px-2 text-xs text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/30" />
{prefError &&
{prefError}
}
)}
{/* Transcript */}
{loading && (
Loading transcript...
)} {!loading && error && (
{error}
)} {!loading && !error && messages.length === 0 && (
{isGatewaySession ? 'No messages loaded for this gateway session.' : 'No transcript snippets found for this session.'}
)} {!loading && !error && messages.length > 0 && (
{messages.map((msg, idx) => ( ))}
)}
{/* Continue session input */}
{isGatewaySession ? '>' : '$'} setContinuePrompt(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() void handleContinueSession() } }} placeholder={isGatewaySession ? 'Send message to this agent session...' : 'Send prompt to this local session...'} className="h-7 flex-1 rounded border border-border/40 bg-surface-1 px-2 font-mono-tight text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-primary/30" />
{continueError &&
{continueError}
} {lastReply && (
{lastReply}
)}
) } /** Inline toast indicators for compaction and model fallback events */ function ChatIndicators({ notifications }: { notifications: Array<{ id: number; type: string; title: string; message: string; created_at: number }> }) { const TOAST_DURATION_MS = 8000 const now = Math.floor(Date.now() / 1000) // Show recent compaction/fallback notifications as inline toasts const recentToasts = notifications.filter(n => { const age = now - n.created_at if (age > TOAST_DURATION_MS / 1000) return false return n.title === 'Context Compaction' || n.title === 'Model Fallback' }).slice(0, 3) if (recentToasts.length === 0) return null return (
{recentToasts.map(toast => { const isCompaction = toast.title === 'Context Compaction' const isFallback = toast.title === 'Model Fallback' return (
{toast.title} {toast.message}
) })}
) } function AgentAvatar({ name, size = 'md' }: { name: string; size?: 'sm' | 'md' }) { const colors: Record = { coordinator: 'bg-purple-500/20 text-purple-400', aegis: 'bg-red-500/20 text-red-400', research: 'bg-green-500/20 text-green-400', ops: 'bg-orange-500/20 text-orange-400', reviewer: 'bg-teal-500/20 text-teal-400', content: 'bg-indigo-500/20 text-indigo-400', human: 'bg-primary/20 text-primary', } const colorClass = colors[name.toLowerCase()] || 'bg-muted text-muted-foreground' const sizeClass = size === 'sm' ? 'w-6 h-6 text-[10px]' : 'w-8 h-8 text-xs' return (
{name.charAt(0).toUpperCase()}
) } function getConversationStatus(agents: Array<{ name: string; status: string }>, conversationId: string): string { if (conversationId.startsWith('session:')) { if (conversationId.includes('claude-code')) return 'Local Claude session' if (conversationId.includes('codex-cli')) return 'Local Codex session' if (conversationId.includes('hermes')) return 'Local Hermes session' return 'Gateway session' } const name = conversationId.replace('agent_', '') const agent = agents.find(a => a.name.toLowerCase() === name.toLowerCase()) if (!agent) return 'Unknown' return agent.status === 'idle' || agent.status === 'busy' ? 'Online' : 'Offline' }