/** * NodeDetailPanel.jsx — Bottom-tray detail panel for diagram nodes and edges. * * Layout: sits below the diagram canvas, collapses vertically. * Collapse = shrinks to a 44px header strip; expand = 280px tray. * * Features: * - Module-level summary cache (Map) — AI answers are stable for a session, * so we never re-fetch the same node in the same browser session. * - Auto-generates a concise AI summary on first open for each unique node. * - Inline mini-chat scoped to the selected node. * - "Open in full chat →" escape hatch. * - Consistent design: uses CSS custom properties (--surface-2, --border, etc.) * and the same button classes as the rest of the app (diagram-ask-btn, session-delete). */ import { useState, useEffect, useRef } from "react"; import { streamQuery } from "../api"; // ── Module-level cache ──────────────────────────────────────────────────────── // Keyed by `repo::label` — stable for the browser session since indexed // repo content doesn't change between diagram interactions. const summaryCache = new Map(); // ── Type colours (matches GraphDiagram.jsx TYPE_STYLE dots) ────────────────── const TYPE_COLOR = { module: "#818CF8", class: "#5B8FF9", abstract: "#7DABFF", mixin: "#60A5FA", service: "#2DD4BF", database: "#38BDF8", external: "#4E5E80", input: "#2DD4BF", transform: "#818CF8", output: "#5B8FF9", edge: "#4E5E80", }; // ── Chevron SVGs — same viewport as DiagramView fullscreen icons ────────────── function ChevronDown() { return ( ); } function ChevronUp() { return ( ); } export default function NodeDetailPanel({ subject, repo, onClose, onOpenInChat }) { const { kind = "node", label, type, file, description, items = [], autoQuestion } = subject; const typeColor = TYPE_COLOR[type] || "#818CF8"; const cacheKey = `${repo}::${label}`; const [summary, setSummary] = useState(() => summaryCache.get(cacheKey) || ""); const [streaming, setStreaming] = useState(false); const [input, setInput] = useState(""); const [messages, setMessages] = useState([]); const [collapsed, setCollapsed] = useState(false); const stopRef = useRef(null); const bottomRef = useRef(null); // ── Auto-summary on open — skipped if already cached ───────────────────── useEffect(() => { setSummary(summaryCache.get(cacheKey) || ""); setMessages([]); setInput(""); setCollapsed(false); // always expand when switching to a new subject if (summaryCache.has(cacheKey)) { setStreaming(false); return; } setStreaming(true); const q = autoQuestion || `Give a concise 3–4 sentence explanation of "${label}" in ${repo}: what it does, its key responsibilities, and what other parts of the codebase depend on it. Be specific.`; let content = ""; const stop = streamQuery({ question: q, repo, mode: "hybrid", onToken: (t) => { content += t; setSummary(content); }, onSources: () => {}, onDone: () => { summaryCache.set(cacheKey, content); // cache on completion setStreaming(false); stopRef.current = null; }, onError: () => { setStreaming(false); stopRef.current = null; }, }); stopRef.current = stop; return () => { stopRef.current?.(); }; }, [label, repo]); // re-run only when a different node is selected // Auto-scroll on new messages useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, summary]); // ── Inline chat ─────────────────────────────────────────────────────────── function handleAsk(e) { e?.preventDefault(); const q = input.trim(); if (!q || streaming) return; setInput(""); const assistantId = Date.now(); setMessages(prev => [ ...prev, { role: "user", content: q }, { id: assistantId, role: "assistant", content: "" }, ]); setStreaming(true); // Expand tray if collapsed so user can see the answer setCollapsed(false); const history = messages.map(m => ({ role: m.role, content: m.content })); let content = ""; const stop = streamQuery({ question: `Regarding "${label}" (${file || type}) in ${repo}: ${q}`, repo, mode: "hybrid", history, onToken: (t) => { content += t; setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content } : m)); }, onSources: () => {}, onDone: () => { setStreaming(false); stopRef.current = null; }, onError: (err) => { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${err}` } : m )); setStreaming(false); stopRef.current = null; }, }); stopRef.current = stop; } function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleAsk(); } } return (
{description}
)} {/* Method / export pills */} {items.length > 0 && (