// Inline mini-Socrates thread anchored to a single finding. Replaces the // global SocratesDock for the conversational surface. "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Sigil } from "./Sigil"; interface ApiMessage { id: string; role: "user" | "assistant" | string; text: string; options?: Array<{ n: number; label: string; sub?: string }>; } interface ContextualSocratesThreadProps { projectId: string; findingId: string; findingText: string; onResolved?: () => void; } export function ContextualSocratesThread({ projectId, findingId, onResolved }: ContextualSocratesThreadProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [loaded, setLoaded] = useState(false); const endRef = useRef(null); const url = `/api/projects/${encodeURIComponent(projectId)}/findings/${encodeURIComponent(findingId)}/socrates`; // Initial load + open the conversation if empty. useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch(url); if (!res.ok) throw new Error(`load: ${res.status}`); const body = (await res.json()) as { messages?: ApiMessage[] }; if (cancelled) return; const initial = body.messages ?? []; setMessages(initial); setLoaded(true); if (initial.length === 0) { await sendImpl(""); } } catch (err) { console.error("[ContextualSocratesThread] load failed:", err); if (!cancelled) setLoaded(true); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [url]); useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, [messages]); const sendImpl = useCallback( async (text: string) => { setSending(true); try { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); if (!res.ok) throw new Error(`send: ${res.status}`); const body = (await res.json()) as { assistant: { id: string; turn: { text: string; options?: Array<{ n: number; label: string; sub?: string }> } }; user: { id: string }; }; setMessages(prev => { const next = [...prev]; if (text.trim().length > 0) { next.push({ id: body.user.id, role: "user", text }); } next.push({ id: body.assistant.id, role: "assistant", text: body.assistant.turn.text, options: body.assistant.turn.options, }); return next; }); } catch (err) { console.error("[ContextualSocratesThread] send failed:", err); } finally { setSending(false); } }, [url] ); const onSend = useCallback(async () => { const text = input.trim(); if (!text || sending) return; setInput(""); await sendImpl(text); }, [input, sending, sendImpl]); const onResolve = useCallback(async () => { try { await fetch(url, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "resolved" }), }); onResolved?.(); } catch (err) { console.error("[ContextualSocratesThread] resolve failed:", err); } }, [url, onResolved]); const onDismiss = useCallback(async () => { try { await fetch(url, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "dismissed" }), }); onResolved?.(); } catch (err) { console.error("[ContextualSocratesThread] dismiss failed:", err); } }, [url, onResolved]); return (
{!loaded ? (
) : ( messages.map(m => (
{m.role === "assistant" ? : null}
{m.text}
{m.options && m.options.length > 0 ? (
{m.options.map(o => ( ))}
) : null}
)) )}