import { useCallback, useEffect, useRef, useState } from 'react'; import { Pencil, Send, Square, X } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import { useSessionStream } from '@/hooks/useSessionStream'; import { MessageList } from '@/components/MessageList'; import { ChatInput } from '@/components/ChatInput'; import { StaleStreamBanner } from '@/components/StaleStreamBanner'; import { sendToChat } from '@/lib/events'; interface Props { sessionId: string; chatId: string; projectId: string; // Batch 9: optional, threaded down to ChatInput's agent picker. agentId?: string | null; onAgentChange?: (agentId: string | null) => void | Promise; sessionChats?: import('@/api/types').Chat[]; // v1.9: threaded down to ChatInput's + menu (Web search quick toggle). // null means "inherit project default" — ChatInput PATCHes with the // opposite of the effective value. webSearchEnabled?: boolean | null; } export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) { const stream = useSessionStream(sessionId); const lastErrorRef = useRef(null); const [queue, setQueue] = useState([]); const processingRef = useRef(false); useEffect(() => { if (stream.error && stream.error !== lastErrorRef.current) { lastErrorRef.current = stream.error; toast.error(stream.error); } if (!stream.error) { lastErrorRef.current = null; } }, [stream.error]); const chatMessages = stream.messages.filter((m) => m.chat_id === chatId); const streaming = chatMessages.some((m) => m.status === 'streaming'); // v1.12.3: stale-stream detection. Watches the (at most one) streaming // assistant row. If its content length doesn't grow for STALE_THRESHOLD_MS, // assume the upstream call is dead and surface the recovery banner. We use // content length as the activity signal because every token delta extends // it; last_seq isn't currently bumped per delta. const STALE_THRESHOLD_MS = 60_000; const streamingMsg = chatMessages.find((m) => m.status === 'streaming' && m.role === 'assistant'); const streamingId = streamingMsg?.id ?? null; const streamingLen = streamingMsg?.content.length ?? 0; const lastActivityRef = useRef<{ id: string; len: number; at: number } | null>(null); const [stale, setStale] = useState(false); useEffect(() => { if (!streamingId) { lastActivityRef.current = null; setStale(false); return; } const prev = lastActivityRef.current; if (!prev || prev.id !== streamingId || prev.len !== streamingLen) { lastActivityRef.current = { id: streamingId, len: streamingLen, at: Date.now() }; setStale(false); } const interval = setInterval(() => { const a = lastActivityRef.current; if (!a) return; if (Date.now() - a.at >= STALE_THRESHOLD_MS) { setStale(true); } }, 5_000); return () => clearInterval(interval); }, [streamingId, streamingLen]); // v1.11.5: per-chat model context limit comes from chat.model_context_limit // populated by GET /api/sessions/:id/chats. Threaded into ChatInput so // ContextBar can render a zero-state before the first assistant message. const modelContextLimit = sessionChats?.find((c) => c.id === chatId)?.model_context_limit ?? null; // Auto-send next queued message when streaming completes useEffect(() => { if (streaming || queue.length === 0 || processingRef.current) return; processingRef.current = true; const next = queue[0]!; setQueue((prev) => prev.slice(1)); api.messages.send(chatId, next) .catch((err) => toast.error(err instanceof Error ? err.message : 'queue send failed')) .finally(() => { processingRef.current = false; }); }, [streaming, queue, chatId]); const handleSend = useCallback(async (content: string) => { const trimmed = content.trim(); if (!trimmed) return; if (trimmed === '/compact') { try { await api.chats.compact(chatId); } catch (err) { toast.error(err instanceof Error ? err.message : 'compact failed'); } return; } if (streaming) { setQueue((prev) => [...prev, trimmed]); return; } await api.messages.send(chatId, trimmed); }, [chatId, streaming]); async function handleStop() { try { await api.chats.stop(chatId); } catch (err) { toast.error(err instanceof Error ? err.message : 'stop failed'); } } const handleDiscardStale = useCallback(async () => { if (!streamingId) return; try { await api.chats.discardStale(chatId, streamingId); setStale(false); lastActivityRef.current = null; } catch (err) { // 409 (race) is benign — the row already terminated some other way. const msg = err instanceof Error ? err.message : 'discard failed'; if (!msg.includes('409')) toast.error(msg); setStale(false); } }, [chatId, streamingId]); const handleRetryStale = useCallback(async () => { if (!streamingId) return; const lastUser = [...chatMessages].reverse().find((m) => m.role === 'user' && m.kind === 'message'); if (!lastUser) { toast.error('no prior user message to retry'); return; } try { await api.chats.discardStale(chatId, streamingId); } catch (err) { const msg = err instanceof Error ? err.message : 'discard failed'; if (!msg.includes('409')) { toast.error(msg); return; } } setStale(false); lastActivityRef.current = null; try { await api.messages.send(chatId, lastUser.content); } catch (err) { toast.error(err instanceof Error ? err.message : 'retry send failed'); } }, [chatId, streamingId, chatMessages]); const handleForceSend = useCallback(async (content: string) => { const trimmed = content.trim(); if (!trimmed) return; try { await api.chats.forceSend(chatId, trimmed); setQueue([]); } catch (err) { toast.error(err instanceof Error ? err.message : 'force send failed'); } }, [chatId]); // Batch 9.6: slash-command dispatch. Sent regardless of streaming state — // matches the existing /compact precedent (which also fires immediately). // Empty args go to the server as null; the server fills in a default user // message ("Apply this skill.") so the model has something to act on. const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => { try { await api.chats.skillInvoke(chatId, skillName, userMessage.length > 0 ? userMessage : null); } catch (err) { toast.error(err instanceof Error ? err.message : `/${skillName} failed`); } }, [chatId]); function removeQueued(idx: number) { setQueue((prev) => prev.filter((_, i) => i !== idx)); } // v1.13.12: edit a queued message — pop it off the queue and push its text // into ChatInput via sendToChat. ChatInput appends (or sets, if empty) and // focuses; user re-sends, which re-queues if streaming is still active. function editQueued(idx: number) { const msg = queue[idx]; if (!msg) return; setQueue((prev) => prev.filter((_, i) => i !== idx)); sendToChat.emit({ chat_id: chatId, text: msg }); } async function forceSendQueued(idx: number) { const msg = queue[idx]; if (!msg) return; setQueue((prev) => prev.filter((_, i) => i !== idx)); try { await api.chats.forceSend(chatId, msg); } catch (err) { toast.error(err instanceof Error ? err.message : 'force send failed'); } } return (
{/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */} {/* Queued messages */} {queue.length > 0 && (
{queue.map((msg, i) => (
Queued: {msg}
))}
)} {/* Stop button when streaming */} {streaming && (
)} {stale && streamingId && ( void handleRetryStale()} onDiscard={() => void handleDiscardStale()} /> )} c.id === chatId)?.name ?? 'Chat'} // v1.11.5: feed ContextBar (mounted inside ChatInput). messages // drives latest-pair walk; modelContextLimit powers the zero-state. messages={chatMessages} modelContextLimit={modelContextLimit} />
); }