/** * 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 (
{/* ── Header strip (always visible) ── */}
{/* Type pill */} {type || kind} {/* Label */} {label} {/* File path */} {file && ( {file} )} {/* Streaming indicator */} {streaming && messages.length === 0 && ( )} {/* Collapse/expand button */} {/* Close button */}
{/* ── Scrollable content (hidden when collapsed via parent height clip) ── */}
{/* Static description from AST */} {description && (

{description}

)} {/* Method / export pills */} {items.length > 0 && (
Methods / Exports
{items.map((item, i) => ( {item} ))}
)}
{/* AI Summary */}
AI Summary
{summary ? (
{summary} {streaming && messages.length === 0 && }
) : (
Generating…
)}
{/* Follow-up messages */} {messages.length > 0 && (
{messages.map((m, i) => (
{m.role === "user" ? "You" : "Claude"}
{m.content} {m.role === "assistant" && streaming && i === messages.length - 1 && ( )}
))}
)}
{/* ── Pinned footer: input + action (hidden when collapsed) ── */} {!collapsed &&
setInput(e.target.value)} onKeyDown={handleKeyDown} disabled={streaming} placeholder={`Ask about ${label}…`} style={{ flex: 1, background: "var(--accent-dim)", border: "1px solid var(--accent-border)", borderRadius: "var(--radius-sm)", color: "var(--text)", fontSize: 10.5, padding: "6px 8px", fontFamily: "var(--mono)", outline: "none", opacity: streaming ? 0.5 : 1, }} />
}
); }