Emphasise the active investigation card through stronger elevation, clearer borders, and improved spacing. Quiet supporting panels by reducing border opacity, softening heading weight, and lowering text contrast — making them available without competing for attention. Facilitator card receives a warm surface tint to read as a briefing card rather than a generic panel. Documentation: close experiment 05 with findings, add experiment 06 to the design evolution log, add Attention Hierarchy to UX guidelines, defer dark mode to a future Investigation Mode experiment. Presentation changes only — no reasoning, prompts, graph, API, or backend modifications.
153 lines
6.7 KiB
React
153 lines
6.7 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, updateStatus }) {
|
|
// ── 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 (updateStatus === "loading") {
|
|
currentStatus = { label: "Reasoning", level: "investigating" };
|
|
} else 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/60", bg: "bg-gray-50/40", text: "text-gray-400" },
|
|
investigating: { border: "border-blue-200/60", bg: "bg-blue-50/30", text: "text-blue-600" },
|
|
complete: { border: "border-green-200/60", bg: "bg-green-50/30", text: "text-green-600" },
|
|
limit: { border: "border-gray-200", bg: "bg-gray-50/40", text: "text-gray-400" },
|
|
};
|
|
|
|
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-[11px] font-medium tracking-widest uppercase text-gray-400/70">
|
|
What we understand so far
|
|
</h3>
|
|
<p className="text-sm leading-relaxed text-gray-600">{currentUnderstanding}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Questions — hidden when no meaningful value to show */}
|
|
{isInvestigating && questionsRemaining > 0 && (
|
|
<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>
|
|
<span className={`text-lg font-semibold ${colors.text}`}>{questionsRemaining + " items"}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default InvestigationSummaryPanel;
|