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.
172 lines
7.3 KiB
React
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;
|