// Findings panel — lists detected assumptions / risks / inconsistencies. // Lives in the bottom-right corner above the IssuesPanel; collapsible; // has a "detect" button that triggers a fresh background pass. // // Items are clickable: clicking focuses the first linked element across // the rail + diagram (using the same setFocusBlockId path as the rail). "use client"; import { useCallback, useEffect, useState } from "react"; export type FindingKind = "assumption" | "risk" | "inconsistency"; export interface FindingDTO { id: string; kind: FindingKind; text: string; linkedElementIds: string[]; confidence: number; severity?: "low" | "medium" | "high" | null; validationCode?: string | null; modelVersion: number; provider?: string | null; llmModel?: string | null; } interface FindingsPanelProps { projectId: string; /** Re-fetch whenever this changes. Pass the model version so we know when to refresh. */ modelVersion: number; onSelectAnchor?: (elementId: string) => void; } const KIND_GLYPH: Record = { assumption: "●", risk: "▲", inconsistency: "!", }; const KIND_LABEL: Record = { assumption: "asm", risk: "risk", inconsistency: "inc", }; export function FindingsPanel({ projectId, modelVersion, onSelectAnchor }: FindingsPanelProps) { const [findings, setFindings] = useState(null); const [collapsed, setCollapsed] = useState(false); const [running, setRunning] = useState(false); const [error, setError] = useState(null); const [meta, setMeta] = useState<{ provider?: string; model?: string; durationMs?: number; modelVersion?: number } | null>(null); // Initial load useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`); if (!res.ok) throw new Error(String(res.status)); const data = (await res.json()) as { findings: FindingDTO[] }; if (cancelled) return; setFindings(data.findings); if (data.findings.length > 0) { const first = data.findings[0]!; setMeta({ provider: first.provider ?? undefined, model: first.llmModel ?? undefined, modelVersion: first.modelVersion }); } } catch (err) { if (!cancelled) setError((err as Error).message); } })(); return () => { cancelled = true; }; }, [projectId]); const detect = useCallback(async () => { if (running) return; setRunning(true); setError(null); try { const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`, { method: "POST", }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? `HTTP ${res.status}`); } const data = (await res.json()) as { findings: FindingDTO[]; meta: { provider: string; model: string; durationMs: number; strippedRefs: number; droppedFindings: number }; }; setFindings(data.findings); setMeta({ provider: data.meta.provider, model: data.meta.model, durationMs: data.meta.durationMs, modelVersion, }); } catch (err) { setError((err as Error).message); } finally { setRunning(false); } }, [projectId, modelVersion, running]); const counts = { assumption: findings?.filter(f => f.kind === "assumption").length ?? 0, risk: findings?.filter(f => f.kind === "risk").length ?? 0, inconsistency: findings?.filter(f => f.kind === "inconsistency").length ?? 0, }; const total = counts.assumption + counts.risk + counts.inconsistency; const stale = meta?.modelVersion !== undefined && meta.modelVersion !== modelVersion; return (
{!collapsed && ( <>
{meta?.durationMs && ( {meta.provider} · {meta.model?.split("/").pop()} · {(meta.durationMs / 1000).toFixed(1)}s )}
{error &&
⚠ {error}
} {findings && findings.length > 0 && (
    {(["inconsistency", "risk", "assumption"] as const).flatMap(kind => findings.filter(f => f.kind === kind).map(f => (
  • { if (f.linkedElementIds.length > 0 && onSelectAnchor) { onSelectAnchor(f.linkedElementIds[0]!); } }} style={{ cursor: f.linkedElementIds.length > 0 ? "pointer" : "default" }} > {KIND_GLYPH[f.kind]} {KIND_LABEL[f.kind]} {f.severity && {f.severity}} {f.validationCode && {f.validationCode}} {f.text} {f.confidence.toFixed(2)} {f.linkedElementIds.length > 0 && ( [{f.linkedElementIds.slice(0, 3).join(", ")}{f.linkedElementIds.length > 3 ? "…" : ""}] )}
  • )) )}
)} )}
); }