import { useCallback, useEffect, useRef, useState } from 'react'; import { ChevronDown, 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; 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.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 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)); } 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} { /* default: queued, nothing to do */ }}> Send when done void forceSendQueued(i)}> Force send now
))}
)} {/* Stop button when streaming */} {streaming && (
)} 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} />
); }