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 { useChatContextStats } from '@/hooks/useChatContextStats'; import { MessageList } from '@/components/MessageList'; import { ChatInput } from '@/components/ChatInput'; import { ChatContextPopover } from '@/components/ChatContextPopover'; 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'); const contextStats = useChatContextStats(chatId, chatMessages); // 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 (
{/* 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 && (
)}
); }