"use client"; import type { UIMessage } from "ai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChatMessage } from "./chat-message"; import { createStreamParser } from "./chat-panel"; import { UnicodeSpinner } from "./unicode-spinner"; import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor"; type SubagentPanelProps = { sessionKey: string; task: string; label?: string; onBack: () => void; }; type QueuedMessage = { id: string; text: string; mentionedFiles: Array<{ name: string; path: string }>; }; function taskMessage(sessionKey: string, task: string): UIMessage { return { id: `task-${sessionKey}`, role: "user", parts: [{ type: "text", text: task }], } as UIMessage; } function buildMessagesFromParsed( sessionKey: string, task: string, parts: Array>, ): UIMessage[] { const messages: UIMessage[] = [taskMessage(sessionKey, task)]; let assistantParts: UIMessage["parts"] = []; let assistantCount = 0; let userCount = 0; const pushAssistant = () => { if (assistantParts.length === 0) {return;} messages.push({ id: `assistant-${sessionKey}-${assistantCount++}`, role: "assistant", parts: assistantParts, } as UIMessage); assistantParts = []; }; for (const part of parts) { if (part.type === "user-message") { pushAssistant(); messages.push({ id: (part.id as string | undefined) ?? `user-${sessionKey}-${userCount++}`, role: "user", parts: [{ type: "text", text: (part.text as string) ?? "" }], } as UIMessage); continue; } assistantParts.push(part as UIMessage["parts"][number]); } pushAssistant(); return messages; } export function SubagentPanel({ sessionKey, task, label, onBack }: SubagentPanelProps) { const editorRef = useRef(null); const [editorEmpty, setEditorEmpty] = useState(true); const [messages, setMessages] = useState(() => [taskMessage(sessionKey, task)]); const [queuedMessages, setQueuedMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); const [connected, setConnected] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); const userScrolledAwayRef = useRef(false); const streamAbortRef = useRef(null); const scrollRafRef = useRef(0); const displayLabel = label || (task.length > 60 ? task.slice(0, 60) + "..." : task); const streamFromResponse = useCallback( async ( res: Response, onUpdate: (parts: Array>) => void, signal: AbortSignal, ) => { if (!res.body) {return;} const parser = createStreamParser(); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let frameRequested = false; while (true) { const { done, value } = await reader.read(); if (done) {break;} buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf("\n\n")) !== -1) { const chunk = buffer.slice(0, idx); buffer = buffer.slice(idx + 2); if (chunk.startsWith("data: ")) { try { const event = JSON.parse(chunk.slice(6)) as Record; parser.processEvent(event); } catch { // ignore malformed event } } } if (!frameRequested) { frameRequested = true; requestAnimationFrame(() => { frameRequested = false; if (!signal.aborted) { onUpdate(parser.getParts() as Array>); } }); } } if (!signal.aborted) { onUpdate(parser.getParts() as Array>); } }, [], ); const reconnect = useCallback(async () => { streamAbortRef.current?.abort(); const abort = new AbortController(); streamAbortRef.current = abort; setIsReconnecting(true); try { const res = await fetch(`/api/chat/stream?sessionKey=${encodeURIComponent(sessionKey)}`, { signal: abort.signal, }); if (!res.ok || !res.body) { setConnected(false); setIsStreaming(false); return; } setConnected(true); setIsStreaming(res.headers.get("X-Run-Active") !== "false"); await streamFromResponse( res, (parts) => setMessages(buildMessagesFromParsed(sessionKey, task, parts)), abort.signal, ); } catch (err) { if ((err as Error).name !== "AbortError") { console.error("Subagent reconnect error:", err); } } finally { setIsReconnecting(false); if (!abort.signal.aborted) { setIsStreaming(false); streamAbortRef.current = null; } } }, [sessionKey, task, streamFromResponse]); const sendSubagentMessage = useCallback( async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => { const trimmed = text.trim(); const hasMentions = mentionedFiles.length > 0; if (!trimmed && !hasMentions) {return;} const allFilePaths = mentionedFiles.map((f) => f.path); const payloadText = allFilePaths.length > 0 ? `[Attached files: ${allFilePaths.join(", ")}]\n\n${trimmed}` : trimmed; const optimisticUser: UIMessage = { id: `user-${Date.now()}-${Math.random().toString(36).slice(2)}`, role: "user", parts: [{ type: "text", text: payloadText }], } as UIMessage; const baseMessages = [...messages, optimisticUser]; setMessages(baseMessages); streamAbortRef.current?.abort(); const abort = new AbortController(); streamAbortRef.current = abort; setIsStreaming(true); setConnected(true); try { const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, signal: abort.signal, body: JSON.stringify({ sessionKey, messages: [optimisticUser], }), }); if (!res.ok || !res.body) { setIsStreaming(false); return; } await streamFromResponse( res, (parts) => { const assistantMsg: UIMessage = { id: `assistant-${sessionKey}-${Date.now()}`, role: "assistant", parts: parts as UIMessage["parts"], } as UIMessage; setMessages([...baseMessages, assistantMsg]); }, abort.signal, ); } catch (err) { if ((err as Error).name !== "AbortError") { console.error("Subagent send error:", err); } } finally { if (!abort.signal.aborted) { setIsStreaming(false); streamAbortRef.current = null; } } }, [messages, sessionKey, streamFromResponse], ); const handleEditorSubmit = useCallback( async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => { if (isStreaming || isReconnecting) { setQueuedMessages((prev) => [ ...prev, { id: crypto.randomUUID(), text, mentionedFiles, }, ]); return; } await sendSubagentMessage(text, mentionedFiles); }, [isStreaming, isReconnecting, sendSubagentMessage], ); const handleStop = useCallback(async () => { streamAbortRef.current?.abort(); streamAbortRef.current = null; setIsStreaming(false); setIsReconnecting(false); try { await fetch("/api/chat/stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionKey }), }); } catch { // ignore } }, [sessionKey]); useEffect(() => { void reconnect(); return () => { streamAbortRef.current?.abort(); }; }, [reconnect]); useEffect(() => { const wasBusy = isStreaming || isReconnecting; if (wasBusy || queuedMessages.length === 0) {return;} const [next, ...rest] = queuedMessages; setQueuedMessages(rest); queueMicrotask(() => { void sendSubagentMessage(next.text, next.mentionedFiles); }); }, [isStreaming, isReconnecting, queuedMessages, sendSubagentMessage]); useEffect(() => { const el = scrollContainerRef.current; if (!el) {return;} const onScroll = () => { const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; userScrolledAwayRef.current = distanceFromBottom > 80; }; el.addEventListener("scroll", onScroll, { passive: true }); return () => el.removeEventListener("scroll", onScroll); }, []); useEffect(() => { if (userScrolledAwayRef.current) {return;} if (scrollRafRef.current) {return;} scrollRafRef.current = requestAnimationFrame(() => { scrollRafRef.current = 0; messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }); }, [messages]); const statusLabel = useMemo(() => { if (!connected && (isStreaming || isReconnecting)) {return Connecting;} if (isReconnecting) {return Resuming;} if (isStreaming) {return ;} return "Completed"; }, [connected, isStreaming, isReconnecting]); return (

{displayLabel}

{statusLabel}

{messages.map((message, i) => ( ))}
{queuedMessages.length > 0 && (
Queued ({queuedMessages.length})
{queuedMessages.map((msg) => (

{msg.text}

))}
)} setEditorEmpty(isEmpty)} placeholder={isStreaming || isReconnecting ? "Type to queue a message..." : "Type @ to mention files..."} />
{(isStreaming || isReconnecting) && ( )}
); }