diff --git a/components/investigation-summary-panel.jsx b/components/investigation-summary-panel.jsx new file mode 100644 index 0000000..875d794 --- /dev/null +++ b/components/investigation-summary-panel.jsx @@ -0,0 +1,171 @@ +/** + * InvestigationSummaryPanel — Phase 4 + * Displays key investigation metrics in a compact card. + * Some fields are currently mocked; TODO comments identify what the + * reasoning engine must eventually provide. + */ + +/* ── Helpers ──────────────────────────────────────────────── */ + +function formatTimestamp(iso) { + if (!iso) return "—"; + try { + const d = new Date(iso); + if (isNaN(d)) return iso; + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } catch { + return iso; + } +} + +function humaniseDuration(seconds) { + if (!seconds || seconds < 0) return "—"; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins === 0) return `${secs}s`; + return `${mins}m ${secs}s`; +} + +/* ── Component ────────────────────────────────────────────── */ + +function InvestigationSummaryPanel({ graph, selectedQuestion, result }) { + // ── Current status ──────────────────────────────────────────── + // TODO: reasoning should emit an explicit status field such as + // "investigating", "evidence_limit_reached", "resolution_achieved". + // Currently derived heuristically from graph state. + const isInvestigating = Boolean(selectedQuestion); + const hasGraph = Boolean(graph); + + let currentStatus; + if (!hasGraph) { + currentStatus = { label: "Not started", level: "idle" }; + } else if (isInvestigating) { + currentStatus = { label: "Investigation in progress", level: "investigating" }; + } else if (graph.resolvedNodeIds?.length > 0 && graph.nodes) { + const unresolvedUnknowns = graph.nodes.filter( + (n) => n.kind === "unknown" && !graph.resolvedNodeIds.includes(n.id) + ); + if (unresolvedUnknowns.length === 0) { + currentStatus = { label: "Investigation complete", level: "complete" }; + } else { + // TODO: reasoning should emit a terminal "evidence_limit_reached" + // status when it stops selecting questions because no unknown has + // sufficient upstream evidence. Currently we infer this from the + // absence of an active question combined with unresolved unknowns. + currentStatus = { label: "Current evidence limit reached", level: "limit" }; + } + } else { + currentStatus = { label: "Analysis complete", level: "complete" }; + } + + const statusColors = { + idle: { border: "border-gray-200", bg: "bg-gray-50", text: "text-gray-600" }, + investigating: { border: "border-blue-200", bg: "bg-blue-50", text: "text-blue-700" }, + complete: { border: "border-green-200", bg: "bg-green-50", text: "text-green-700" }, + limit: { border: "border-gray-300", bg: "bg-gray-100", text: "text-gray-500" }, + }; + + const colors = statusColors[currentStatus.level] || statusColors.idle; + + // ── Current understanding ──────────────────────────────── + // TODO: reasoning should provide a durable summary field that is + // guaranteed to be the latest plain-language synthesis. + // Currently falls back to graph.currentSummary which may not exist + // in all mock scenarios. + const currentUnderstanding = + result?.summary || + result?.updatedSituationGraph?.currentSummary || + graph?.currentSummary || + null; + + // ── Questions answered / remaining ─────────────────────── + // TODO: reasoning should emit a list of resolved unknown node IDs + // and the total set of unknown nodes it identified at start. + // Currently we count from the graph snapshot: every unknown whose + // status is "resolved" (or whose ID appears in resolvedNodeIds). + let questionsAnswered = 0; + let questionsRemaining = 0; + + if (graph?.nodes) { + const allUnknowns = graph.nodes.filter((n) => n.kind === "unknown"); + const resolvedCount = allUnknowns.filter( + (n) => n.status === "resolved" || (graph.resolvedNodeIds && graph.resolvedNodeIds.includes(n.id)) + ).length; + questionsAnswered = resolvedCount; + // TODO: this is a rough heuristic — the reasoning engine should + // explicitly track which unknowns were proposed for questioning. + questionsRemaining = allUnknowns.length - resolvedCount; + } + + // ── Timestamps ─────────────────────────────────────────── + // TODO: reasoning should provide investigationStartedAt and + // lastUpdatedAt as part of the start/update contract. + // Currently we use the session updatedAt timestamp (persisted by + // the UI layer) as a best-effort approximation. + const investigationStartTime = result?.updatedAt || null; + const lastUpdatedAt = result?.updatedAt || null; + + // Derive elapsed time since last update + let elapsedSeconds = 0; + if (lastUpdatedAt) { + elapsedSeconds = Math.floor((Date.now() - new Date(lastUpdatedAt).getTime()) / 1000); + } + + return ( +
{currentUnderstanding}
++ The reasoning service could not be reached. This is usually temporary — check that the local model is running and try again. +
+ {onRestart && ( + + )} ++ The reasoning service returned a response we could not interpret. This may indicate a temporary issue with the model output format. +
+ {onRestart && ( + + )} ++ {stateName ? `The system is in an unexpected state (${stateName}).` : "An unexpected internal error occurred."} + Please restart the investigation to continue. +
++ Your previous investigation state is still saved. You can continue where you left off or start fresh. +
+ {onRestart && ( + + )} +