Files
confidence-engine/components/investigation-summary-panel.jsx
T
robbond c4f5744c30 feat: Phase 2-5 UX enhancements — recovery cards, session persistence, summary panel, contract backlog
Phase 2: Recovery state components (ProviderUnavailableCard,
MalformedResponseCard, UnexpectedStateCard, ContinueLaterBanner) with
automatic error detection for provider/network/malformed/unexpected states.

Phase 3: Session persistence via sessionStorage — save after each
successful turn, restore on mount, clear on restart/reset. Continuelater banner shown when session is restored.

Phase 4: InvestigationSummaryPanel component displaying current status,
understanding summary, questions answered/remaining, investigation timestamps.

Phase 5: docs/reasoning-contract-backlog.md documenting all mocked
fields (60+ rows across 7 categories) with feature/UI need/mock/desired
output/stage/notes columns.

Also: wired onRestart through ReasoningWorkspace → ScenarioForm, fixed
getErrorType scope issues, removed broken window.__restartInvestigation.
2026-08-05 06:48:59 +01:00

172 lines
7.3 KiB
React

/**
* 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 (
<div className={`rounded-lg border ${colors.border} ${colors.bg} p-5 space-y-4`}>
{/* Status */}
<div className="flex items-center gap-2">
<span className={`inline-block h-2.5 w-2.5 rounded-full bg-current ${colors.text}`} />
<span className={`text-sm font-medium ${colors.text}`}>{currentStatus.label}</span>
</div>
{/* Current understanding */}
{currentUnderstanding && (
<div>
<h3 className="mb-1 text-xs font-bold uppercase tracking-wider text-gray-400">
What we understand so far
</h3>
<p className="text-sm leading-relaxed text-gray-700">{currentUnderstanding}</p>
</div>
)}
{/* Questions */}
<div className="grid grid-cols-2 gap-4">
<div>
<span className="block text-xs text-gray-400">Questions answered</span>
<span className={`text-lg font-semibold ${colors.text}`}>{questionsAnswered}</span>
</div>
<div>
<span className="block text-xs text-gray-400">Still working on</span>
{/* TODO: avoid implying 1 unknown = 1 remaining question */}
{isInvestigating ? (
<span className={`text-lg font-semibold ${colors.text}`}>{questionsRemaining > 0 ? questionsRemaining + " items" : "—"}</span>
) : (
<span className={`text-lg font-semibold ${colors.text}`}></span>
)}
</div>
</div>
{/* Timestamps */}
<div className="space-y-1 text-xs text-gray-400">
<div className="flex justify-between">
<span>Investigation started</span>
<span>{formatTimestamp(investigationStartTime)}</span>
</div>
<div className="flex justify-between">
<span>Last updated</span>
<span>{formatTimestamp(lastUpdatedAt)}</span>
</div>
{elapsedSeconds > 0 && (
<div className="flex justify-between">
<span>Elapsed since last update</span>
<span>{humaniseDuration(elapsedSeconds)}</span>
</div>
)}
</div>
</div>
);
}
export default InvestigationSummaryPanel;