import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useParams, useNavigate, useLocation } from "react-router"; import { ArrowLeft, Sparkles } from "lucide-react"; import { motion, AnimatePresence } from "motion/react"; import { client } from "@/client"; import { PromptInput, PromptInputBody, PromptInputTextarea, PromptInputSubmit, PromptInputFooter, PromptInputTools, PromptInputProvider, usePromptInputController, } from "@/components/ai-elements/prompt-input"; import type { Message, Conversation, Phase, StreamingState, ToolCallState, } from "./chat/types"; import { EMPTY_STREAM } from "./chat/types"; import { ModelSelectorPopover } from "./chat/model-selector-popover"; import { RepoMentionPopover } from "./chat/repo-mention-popover"; import { MessageBubble } from "./chat/message-bubble"; import { StreamingView } from "./chat/streaming-view"; import { CodePreviewProvider } from "./chat/code-preview-context"; import { CodePreviewPanel } from "./chat/code-preview-panel"; import { MessageNavDots } from "./chat/message-nav-dots"; import { mentionAtCursor } from "@/components/ai-elements/mention-textarea-overlay"; import { extractMentions } from "@/lib/ir/parser"; import { MentionChip } from "@/lib/ir/mention-chip"; // ---- Helpers ---- const formatDateLabel = (date: string) => new Date(date).toLocaleDateString([], { month: "long", day: "numeric", year: "numeric", }); function parseSSELine(line: string): Record | null { const trimmed = line.trim(); if (!trimmed.startsWith("data: ")) return null; try { return JSON.parse(trimmed.slice(6)); } catch { return null; } } function drainSSEBuffer(buffer: string): { chunks: Record[]; remainder: string; } { const lines = buffer.split("\n"); const remainder = lines.pop() ?? ""; const chunks: Record[] = []; for (const line of lines) { const parsed = parseSSELine(line); if (parsed) chunks.push(parsed); } return { chunks, remainder }; } function makeUserMessage(text: string, conversationId: string): Message { return { id: crypto.randomUUID(), conversation_id: conversationId, role: "user", content: text, content_type: "text", created_at: new Date().toISOString(), }; } function makeAssistantMessage( id: string, content: string, conversationId: string, ): Message { return { id, conversation_id: conversationId, role: "assistant", content, content_type: "text", created_at: new Date().toISOString(), }; } function safeStringify(v: unknown): string | undefined { if (typeof v === "string") return v; if (v == null) return undefined; try { return JSON.stringify(v); } catch { return String(v); } } // ---- Main component ---- export default function WorkplanChatConversation() { const { projectName = "", conversationId = "" } = useParams(); const navigate = useNavigate(); const location = useLocation(); const prefill = (location.state as { prefill?: string } | null)?.prefill; const [conversation, setConversation] = useState(null); const [messages, setMessages] = useState([]); const [stream, setStream] = useState(EMPTY_STREAM); const scrollRef = useRef(null); const abortRef = useRef(null); const sendingRef = useRef(false); const [sending, setSending] = useState(false); const [modelProvider, setModelProvider] = useState(""); const setSendingBoth = useCallback((v: boolean) => { sendingRef.current = v; setSending(v); }, []); // ---- Data fetching ---- const loadData = useCallback(async () => { if (!conversationId) return; try { const [convRes, msgRes] = await Promise.all([ client.agentGetConversation(conversationId), client.agentListMessages(conversationId, { limit: 100 }), ]); setConversation(convRes.data); setMessages(msgRes.data as unknown as Message[]); requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "instant", }); }); } catch { // Non-critical; retried on next navigation. } }, [conversationId]); useEffect(() => { loadData(); }, [loadData]); // ---- Auto-scroll ---- const scrollToBottom = useCallback(() => { requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth", }); }); }, []); useEffect(() => { scrollToBottom(); }, [messages, stream, scrollToBottom]); // ---- SSE stream processing ---- function reduceStreamChunk( prev: StreamingState, chunk: Record, ): { next: StreamingState; addAssistantMessage?: { id: string; content: string }; reloadData?: boolean; } { const next: StreamingState = { ...prev, toolCalls: prev.toolCalls.map((tc) => ({ ...tc })), }; switch (chunk.type) { case "phase_change": next.phase = chunk.phase as Phase; return { next }; case "thinking": if (typeof chunk.content === "string") { next.phase = "think"; next.thinkText += chunk.content; } return { next }; case "delta": if (typeof chunk.content === "string") { next.phase = "answer"; next.answerText += chunk.content; } return { next }; case "tool_call_started": { next.phase = "act"; const tc: ToolCallState = { toolCallId: chunk.tool_call_id as string, toolName: chunk.tool_name as string, arguments: chunk.arguments as string, }; if (!next.toolCalls.some((t) => t.toolCallId === tc.toolCallId)) { next.toolCalls = [...next.toolCalls, tc]; } return { next }; } case "tool_call_finished": { next.phase = "act"; const idx = next.toolCalls.findIndex( (t) => t.toolCallId === (chunk.tool_call_id as string), ); if (idx !== -1) { const updated = { ...next.toolCalls[idx] }; updated.output = safeStringify(chunk.output); updated.error = chunk.error as string | null; next.toolCalls = [ ...next.toolCalls.slice(0, idx), updated, ...next.toolCalls.slice(idx + 1), ]; } return { next }; } case "done": { const output = safeStringify(chunk.output) ?? next.answerText; const messageId = (chunk.message_id as string) || crypto.randomUUID(); return { next: EMPTY_STREAM, addAssistantMessage: { id: messageId, content: output }, reloadData: !!chunk.message_id, }; } case "title_updated": return { next, reloadData: true }; case "subagent_started": return { next: { ...next, phase: "act" as Phase, toolCalls: [ ...next.toolCalls, { toolCallId: chunk.subagent_id as string, toolName: `Subagent: ${chunk.role || "specialist"}`, arguments: chunk.task as string, }, ], }, }; case "subagent_completed": { next.phase = "act"; const saIdx = next.toolCalls.findIndex( (t) => t.toolCallId === (chunk.subagent_id as string), ); if (saIdx !== -1) { const updated = { ...next.toolCalls[saIdx] }; updated.output = safeStringify(chunk.output); next.toolCalls = [ ...next.toolCalls.slice(0, saIdx), updated, ...next.toolCalls.slice(saIdx + 1), ]; } return { next }; } case "subagent_failed": return { next, reloadData: false }; case "error": return { next: EMPTY_STREAM, addAssistantMessage: { id: crypto.randomUUID(), content: `Error: ${chunk.error || "Stream error"}`, }, }; default: return { next }; } } const processChunk = useCallback( (chunk: Record) => { setStream((prev) => { const { next, addAssistantMessage, reloadData } = reduceStreamChunk( prev, chunk, ); if (addAssistantMessage && !reloadData) { const msg = makeAssistantMessage( addAssistantMessage.id, addAssistantMessage.content, conversationId, ); queueMicrotask(() => { setMessages((prevMessages) => [...prevMessages, msg]); }); } if (reloadData) { queueMicrotask(() => loadData()); } return next; }); }, [conversationId, loadData], ); // ---- Send message ---- const sendMessage = useCallback( async (text: string) => { if (!text.trim() || !conversation || sendingRef.current) return; setSendingBoth(true); setStream(EMPTY_STREAM); const controller = new AbortController(); abortRef.current = controller; setMessages((prev) => [ ...prev, makeUserMessage(text, conversationId), ]); try { const response = await fetch( `/api/v1/agent/conversations/${conversationId}/stream`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ session_id: conversation.session_id, conversation_id: conversationId, input: text, stream: true, }), signal: controller.signal, }, ); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const reader = response.body?.getReader(); if (!reader) throw new Error("No response body"); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const { chunks, remainder } = drainSSEBuffer(buffer); buffer = remainder; for (const chunk of chunks) { processChunk(chunk); } } if (buffer.trim()) { const { chunks } = drainSSEBuffer(buffer + "\n"); for (const chunk of chunks) { processChunk(chunk); } } } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") return; setMessages((prev) => [ ...prev, makeAssistantMessage( crypto.randomUUID(), err instanceof Error ? `Error: ${err.message}` : "Request failed", conversationId, ), ]); } finally { setSendingBoth(false); abortRef.current = null; } }, [conversation, conversationId, processChunk, setSendingBoth], ); // ---- Prefill (quick-start) ---- const prefilledRef = useRef(false); useEffect(() => { if (prefill && !prefilledRef.current && conversation && !sending) { prefilledRef.current = true; const timer = setTimeout(() => { sendMessage(prefill); }, 100); return () => clearTimeout(timer); } }, [prefill, conversation, sending, sendMessage]); // ---- Stop generation ---- const handleStop = useCallback(() => { abortRef.current?.abort(); }, []); // ---- Computed values ---- const messageGroups = useMemo(() => { const groups: { date: string; messages: Message[] }[] = []; for (const msg of messages) { const ds = formatDateLabel(msg.created_at); const last = groups[groups.length - 1]; if (last?.date === ds) { last.messages.push(msg); } else { groups.push({ date: ds, messages: [msg] }); } } return groups; }, [messages]); const hasActiveStream = sending || stream.phase !== "idle"; return (
{ setModelProvider(provider); }} scrollRef={scrollRef} sendMessage={sendMessage} handleStop={handleStop} />
); } // ---- Inner component ---- function ChatInner({ projectName, navigate, conversation, messageGroups, stream, hasActiveStream, sending, modelProvider, onModelChange, scrollRef, sendMessage, handleStop, }: { projectName: string; navigate: ReturnType; conversation: Conversation | null; messageGroups: { date: string; messages: Message[] }[]; stream: StreamingState; hasActiveStream: boolean; sending: boolean; modelProvider: string; onModelChange: (name: string, provider: string) => void; scrollRef: React.RefObject; sendMessage: (text: string) => Promise; handleStop: () => void; }) { const { textInput } = usePromptInputController(); const allMessages = useMemo( () => messageGroups.flatMap((g) => g.messages), [messageGroups], ); const handleSubmit = useCallback( (text: string) => { if (text.trim()) { sendMessage(text); textInput.clear(); } }, [sendMessage, textInput], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { // ---- Mention-aware backspace ---- if (e.key === "Backspace") { const ta = e.currentTarget; const { selectionStart, selectionEnd } = ta; if (selectionStart === selectionEnd && selectionStart > 0) { const hit = mentionAtCursor(textInput.value, selectionStart); if (hit) { e.preventDefault(); const newText = textInput.value.slice(0, hit.start) + textInput.value.slice(hit.end); textInput.setInput(newText); // Restore cursor to the position where the mention was. requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = hit.start; }); return; } } } // ---- Enter to submit ---- if (e.key === "Enter" && !e.shiftKey && !sending) { e.preventDefault(); const text = textInput.value; if (text.trim()) { sendMessage(text); textInput.clear(); } } }, [sendMessage, textInput, sending], ); return (
{/* Header */}
navigate(`/${projectName}/workplan/chat`)} aria-label="Back to chat list" >

{conversation?.title && conversation.title.trim().length > 2 ? conversation.title : "New conversation"}

{/* Messages */}
{messageGroups.length === 0 && !hasActiveStream && ( )}
{messageGroups.map((group) => (
{/* Elegant date separator */}
{group.date}
{group.messages.map((msg) => ( ))}
))} {hasActiveStream && ( )}
{/* Composer */}
{/* Repo mention popover — positioned above the input */}
{ const before = textInput.value.slice(0, replaceFrom); const after = textInput.value.slice(replaceTo); textInput.setInput(before + mentionText + after); }} />
{/* Active mention chips — shows which repos are referenced */} {(() => { const mentions = extractMentions(textInput.value); if (mentions.length === 0) return null; return (
{mentions.map((m, i) => ( ))}
); })()} handleSubmit(text)} >

AI responses may be inaccurate. Verify important information.

); } /** Empty state shown when conversation has no messages. */ function EmptyConversationState() { return (

Start a conversation

Type a message below to begin

); }