// Active-thread dock with Sigil header, conversation bubbles, numbered options. // // M6: live thread loaded from /api/projects/[id]/socrates. The reply input // POSTs each user turn and renders Socrates' response when it arrives. // Numbered options pre-fill the input when clicked / pressed (1–3). "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Sigil } from "./Sigil"; export type SocratesPresence = "subtle" | "default" | "prominent"; export type Density = "comfortable" | "compact"; export interface DockMessage { id: string; role: "user" | "assistant"; text: string; options?: Array<{ n: number; label: string; sub?: string }>; pending?: boolean; } interface SocratesDockProps { projectId: string; presence: SocratesPresence; density: Density; } interface ApiMessage { id: string; role: "user" | "assistant"; text: string; options?: Array<{ n: number; label: string; sub?: string }>; createdAt: string; } export function SocratesDock({ projectId, presence }: SocratesDockProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [loaded, setLoaded] = useState(false); const [meta, setMeta] = useState<{ provider?: string; model?: string }>({}); const threadEndRef = useRef(null); // Load thread on mount and trigger an opening turn if the thread is empty. useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`); if (!res.ok) throw new Error(`load thread: ${res.status}`); const data = (await res.json()) as { messages?: ApiMessage[] }; if (cancelled) return; const initial: DockMessage[] = (data.messages ?? []).map(m => ({ id: m.id, role: m.role, text: m.text, options: m.options, })); setMessages(initial); setLoaded(true); if (initial.length === 0 && !cancelled) { // Trigger Socrates' opening turn await sendImpl("", true); } } catch { if (!cancelled) setLoaded(true); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); // Auto-scroll on new turns useEffect(() => { threadEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, [messages]); const sendImpl = useCallback( async (text: string, isOpening = false) => { setSending(true); try { // Add the user bubble locally (optimistic) — unless this is the opening if (!isOpening) { setMessages(curr => [ ...curr, { id: `tmp-u-${Date.now()}`, role: "user", text }, ]); } // Show a pending Socrates bubble const pendingId = `tmp-a-${Date.now()}`; setMessages(curr => [ ...curr, { id: pendingId, role: "assistant", text: "thinking…", pending: true }, ]); const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error ?? `${res.status}`); } const data = await res.json(); const a = data.assistant as { id: string; text: string; options?: Array<{ n: number; label: string; sub?: string }> }; if (data.meta) setMeta({ provider: data.meta.provider, model: data.meta.model }); // Replace the pending bubble with the real one setMessages(curr => curr.map(m => m.id === pendingId ? { id: a.id, role: "assistant", text: a.text, options: a.options } : m )); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setMessages(curr => curr.map(m => m.pending ? { ...m, text: `⚠ ${msg}`, pending: false } : m )); } finally { setSending(false); } }, [projectId] ); const onSubmit = useCallback(async () => { const text = input.trim(); if (!text || sending) return; setInput(""); await sendImpl(text); }, [input, sending, sendImpl]); const pickOption = useCallback(async (option: { n: number; label: string; sub?: string }) => { if (sending) return; const text = `[${option.n}] ${option.label}${option.sub ? ` — ${option.sub}` : ""}`; await sendImpl(text); }, [sending, sendImpl]); // Number-key shortcuts on the most recent assistant turn with options useEffect(() => { const onKey = (e: KeyboardEvent) => { // Don't hijack number keys when typing in any input/contenteditable const target = e.target as HTMLElement | null; if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; if (sending) return; const last = [...messages].reverse().find(m => m.role === "assistant" && m.options?.length); if (!last?.options) return; const n = parseInt(e.key, 10); if (Number.isNaN(n) || n < 1 || n > last.options.length) return; e.preventDefault(); const opt = last.options.find(o => o.n === n); if (opt) void pickOption(opt); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [messages, pickOption, sending]); if (presence === "subtle") { return (
{messages.filter(m => m.role === "assistant").length > 0 && (
{messages.filter(m => m.role === "assistant").length}
)}
); } return ( ); }