experiment: facilitator progress panel (Version B)
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* InvestigationSummaryPanelV2 — Phase 4, Experiment 11
|
||||
* A facilitator-style progress panel that translates the reasoning graph
|
||||
* into a human-friendly "what is known / what remains" view.
|
||||
*
|
||||
* Design principle:
|
||||
* The UI should progressively become a translation layer over the
|
||||
* reasoning graph rather than maintaining separate duplicated summaries.
|
||||
* Internal graph concepts remain available for developers, while end
|
||||
* users see a facilitator-style explanation of what is currently understood
|
||||
* and what remains uncertain.
|
||||
*
|
||||
* This component uses exactly the same graph data as InvestigationSummaryPanel
|
||||
* (Version A). No new backend fields or API contracts are required.
|
||||
*/
|
||||
|
||||
/* ── 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`;
|
||||
}
|
||||
|
||||
/* ── Data extraction helpers ─────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Classify nodes into "known" (resolved / observations with values) and
|
||||
* "still investigating" (unresolved unknowns and assumptions needing validation).
|
||||
*/
|
||||
function classifyNodes(graph, resolvedIds) {
|
||||
if (!graph?.nodes) return { known: [], stillInvestigating: [] };
|
||||
|
||||
const resolved = new Set(resolvedIds || []);
|
||||
|
||||
const known = [];
|
||||
const stillInvestigating = [];
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const isResolved = resolved.has(node.id) || node.status === "resolved";
|
||||
|
||||
// Resolved nodes become known facts
|
||||
if (isResolved) {
|
||||
known.push({
|
||||
label: node.label,
|
||||
description: node.description,
|
||||
kind: node.kind,
|
||||
confidence: node.confidence,
|
||||
});
|
||||
} else {
|
||||
// Unresolved unknowns and assumptions go into "still investigating"
|
||||
stillInvestigating.push({
|
||||
label: node.label,
|
||||
description: node.description,
|
||||
kind: node.kind,
|
||||
confidence: node.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { known, stillInvestigating };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map graph node kinds to end-user-friendly group labels.
|
||||
*/
|
||||
function groupLabelForKind(kind) {
|
||||
const map = {
|
||||
unknown: "Still investigating",
|
||||
assumption: "Assumptions to validate",
|
||||
observation: "Observations",
|
||||
state: "Current states",
|
||||
metric: "Metrics",
|
||||
conclusion: "Conclusions",
|
||||
};
|
||||
return map[kind] || kind.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/* ── Rendering helpers ───────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Render a single item from the known or still-investigating lists.
|
||||
* Show only meaningful content — hide labels that duplicate description.
|
||||
*/
|
||||
function renderListItem(item) {
|
||||
// Prefer description if it adds something beyond the label
|
||||
const text = (item.description && item.description !== item.label)
|
||||
? item.description
|
||||
: item.label;
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/* ── Component ────────────────────────────────────────────── */
|
||||
|
||||
function InvestigationSummaryPanelV2({ graph, selectedQuestion, result, updateStatus }) {
|
||||
// ── Status (same derivation logic as Version A) ──────────
|
||||
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 {
|
||||
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 (same source as Version A) ────
|
||||
const currentUnderstanding =
|
||||
result?.summary ||
|
||||
result?.updatedSituationGraph?.currentSummary ||
|
||||
graph?.currentSummary ||
|
||||
null;
|
||||
|
||||
// ── Classify graph data ─────────────────────────────────
|
||||
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
|
||||
const { known, stillInvestigating } = classifyNodes(graph, resolvedIds);
|
||||
|
||||
// Group still-investigating items by kind for a cleaner view
|
||||
const investigatingByGroup = {};
|
||||
for (const item of stillInvestigating) {
|
||||
const key = groupLabelForKind(item.kind);
|
||||
if (!investigatingByGroup[key]) investigatingByGroup[key] = [];
|
||||
investigatingByGroup[key].push(item);
|
||||
}
|
||||
|
||||
// ── Reasoning summary counts (quiet, at bottom) ─────────
|
||||
const reasonCounts = {
|
||||
observations: graph?.nodes?.filter((n) => n.kind === "observation").length || 0,
|
||||
unknowns: stillInvestigating.filter((n) => n.kind === "unknown").length || 0,
|
||||
assumptions: graph?.nodes?.filter((n) => n.kind === "assumption" && !resolvedIds.has(n.id)).length || 0,
|
||||
relationships: graph?.edges?.length || 0,
|
||||
metrics: graph?.nodes?.filter((n) => n.kind === "metric").length || 0,
|
||||
states: graph?.nodes?.filter((n) => n.kind === "state").length || 0,
|
||||
conclusions: graph?.nodes?.filter((n) => n.kind === "conclusion").length || 0,
|
||||
};
|
||||
|
||||
// Only show non-zero counts in the reasoning summary
|
||||
const reasonEntries = Object.entries(reasonCounts).filter(([_, v]) => v > 0);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${colors.border} ${colors.bg} p-5 space-y-4`}>
|
||||
{/* Status — minimal indicator */}
|
||||
<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 (if any) ──────────────── */}
|
||||
{currentUnderstanding && (
|
||||
<div>
|
||||
<p className="text-sm leading-relaxed text-gray-600">{currentUnderstanding}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Still investigating — primary focus ─────────── */}
|
||||
{(stillInvestigating.length > 0 || known.length === 0) && (
|
||||
<div>
|
||||
{stillInvestigating.length > 1 ? (
|
||||
<>
|
||||
<h3 className="mb-2 text-xs font-medium text-gray-400">Still investigating</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{Object.entries(investigatingByGroup).map(([group, items]) => (
|
||||
<li key={group}>
|
||||
<span className="text-xs font-medium text-gray-500">{group}</span>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{items.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-400/60" />
|
||||
<span className="text-sm text-gray-700">
|
||||
{renderListItem(item)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : stillInvestigating.length === 1 ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-400/60" />
|
||||
<p className="text-sm text-gray-700">{renderListItem(stillInvestigating[0])}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What we have learned ────────────────────────── */}
|
||||
{known.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-medium text-gray-400">What we know</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{known.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="mt-1 h-4 w-4 shrink-0 rounded-full bg-green-400/30" style={{ fontSize: "8px", lineHeight: "1" }}>✓</span>
|
||||
<span className="text-sm text-gray-700">
|
||||
{renderListItem(item)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Quiet reasoning summary — secondary ─────────── */}
|
||||
<div className="pt-2 border-t border-gray-200/40">
|
||||
<p className="text-[10px] font-medium tracking-widest uppercase text-gray-300 mb-1.5">Reasoning</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400">
|
||||
{reasonEntries.map(([label, count]) => (
|
||||
<span key={label}>
|
||||
{count} {label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InvestigationSummaryPanelV2;
|
||||
@@ -5,6 +5,7 @@ import DiagnosticsView from "@/components/diagnostics-view";
|
||||
import GraphUpdateView from "@/components/graph-update-view";
|
||||
import SituationGraphView from "@/components/situation-graph-view";
|
||||
import InvestigationSummaryPanel from "@/components/investigation-summary-panel";
|
||||
import InvestigationSummaryPanelV2 from "@/components/investigation-summary-panel-v2";
|
||||
import InvestigationMap from "@/components/investigation-map";
|
||||
|
||||
// ── Technical summary detector (main view filters these) ───
|
||||
@@ -523,6 +524,8 @@ export default function ReasoningWorkspace({
|
||||
const [investigationHistory, setInvestigationHistory] = useState([]);
|
||||
const turnCounter = useRef(0);
|
||||
const pendingTurnRef = useRef(null);
|
||||
// ── Experiment 11: toggle between progress panel versions ──
|
||||
const [showVersionB, setShowVersionB] = useState(false);
|
||||
const { saveSession, loadSession } = useSessionPersistence();
|
||||
|
||||
// Persist workspace state on every successful update (Phase 3)
|
||||
@@ -714,9 +717,32 @@ export default function ReasoningWorkspace({
|
||||
{hasCurrentSummaryCondition && (
|
||||
<>
|
||||
<CurrentUnderstandingCard currentSummary={graph?.currentSummary || result?.updatedSituationGraph?.currentSummary} plainLanguage={propUnderstanding || null} />
|
||||
<div className="opacity-75">
|
||||
<InvestigationSummaryPanel graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
|
||||
</div>
|
||||
{/* ── Experiment 11: progress panel A / B toggle ── */}
|
||||
{hasGraph && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowVersionB(false)}
|
||||
className={`text-xs transition ${!showVersionB ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
|
||||
>
|
||||
Panel A
|
||||
</button>
|
||||
<span className="text-gray-300">/</span>
|
||||
<button
|
||||
onClick={() => setShowVersionB(true)}
|
||||
className={`text-xs transition ${showVersionB ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
|
||||
>
|
||||
Panel B
|
||||
</button>
|
||||
</div>
|
||||
<div className="opacity-75">
|
||||
{showVersionB
|
||||
? <InvestigationSummaryPanelV2 graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
|
||||
: <InvestigationSummaryPanel graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user