Files
confidence-engine/components/reasoning-workspace.jsx
T
robbond 99b3d26817 feat(confidence-engine): v0.59a — correct Investigation revision provenance
Semantic revision tracking ensures every meaningful persisted
Investigation change advances investigationRevision exactly once,
while Report generation records (but does not advance) the current
revision as generatedFromRevision for provenance integrity.

Corrections:
- updateFindingDisposition: add setInvestigationRevision(+1) for
  semantic transitions (eligible→not_relevant, restore)
- updateFindingProposition: add no-op guard + setInvestigationRevision(+1)
- onRestart/ContinueLaterBanner/reset button: add setInvestigationRevision(0)
- onSituationGraphChange (Re-open seam): already had revision +1 in dirty impl

Established behaviour preserved:
- Re-open via reopenResolvedUnknown → onSituationGraphChange → revision +1
- Empty Done via handleDoneForNowPromotion → revision +1
- Report generation records generatedFromRevision, advances by 0
- Autosave passes revision but does not increment it
- clearInvestigation() ownership intact

Tests: targeted Vitest suite (17 tests) covering all provenance boundaries.

Durable rule documented in current-handoff.md §v0.59a.
2026-09-03 13:39:40 +01:00

2360 lines
109 KiB
React

"use client";
import React, { useState, useRef, useEffect, useMemo } from "react";
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 InvestigationSummaryPanelV3 from "@/components/investigation-summary-panel-v3";
import InvestigationMap from "@/components/investigation-map";
import { reopenResolvedUnknown } from "@/lib/graph/reopen-resolved-unknown.js";
// ── Technical summary detector (main view filters these) ───
const TECHNICAL_PATTERNS = [
/nodes?\s*[:\d]/i,
/edges?\s*[:\d]/i,
/\b(?:unknown|observation|conclusion)\b\s/i,
/\bsorted\b/i,
/by_kind/i,
/\b(?:node|edge|unknown|state)\s+count/i,
];
function isTechnicalSummary(summary) {
if (!summary || typeof summary !== "string") return false;
const trimmed = summary.trim();
if (!trimmed) return false;
for (const p of TECHNICAL_PATTERNS) {
if (p.test(trimmed)) return true;
}
return false;
}
// ── Recovery state components (Phase 2) ───────────────────────
function ProviderUnavailableCard({ onRestart }) {
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-5 py-6 text-center">
<h2 className="mb-1 text-sm font-bold uppercase tracking-wide text-amber-700">Provider unavailable</h2>
<p className="text-sm text-amber-800 mb-4">
The reasoning service could not be reached. This is usually temporary check that the local model is running and try again.
</p>
{onRestart && (
<button
onClick={onRestart}
className="rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-100"
>
Restart investigation
</button>
)}
</div>
);
}
function MalformedResponseCard({ onRestart }) {
return (
<div className="rounded-lg border border-orange-300 bg-orange-50 px-5 py-6 text-center">
<h2 className="mb-1 text-sm font-bold uppercase tracking-wide text-orange-700">Unexpected response</h2>
<p className="text-sm text-orange-800 mb-4">
The reasoning service returned a response we could not interpret. This may indicate a temporary issue with the model output format.
</p>
{onRestart && (
<button
onClick={onRestart}
className="rounded-lg border border-orange-300 bg-white px-4 py-2 text-sm font-medium text-orange-800 hover:bg-orange-100"
>
Try again
</button>
)}
</div>
);
}
function UnexpectedStateCard({ stateName, onRetry, onRestart }) {
return (
<div className="rounded-lg border border-red-300 bg-red-50 px-5 py-6 text-center">
<h2 className="mb-1 text-sm font-bold uppercase tracking-wide text-red-700">Unexpected state</h2>
<p className="text-sm text-red-800 mb-4">
{stateName ? `The system is in an unexpected state (${stateName}).` : "An unexpected internal error occurred."}
Please restart the investigation to continue.
</p>
<div className="flex items-center justify-center gap-3">
{onRetry && (
<button
onClick={onRetry}
className="rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-800 hover:bg-red-100"
>
Retry update
</button>
)}
{onRestart && (
<button
onClick={onRestart}
className="rounded-lg bg-red-700 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Restart investigation
</button>
)}
</div>
</div>
);
}
function ContinueLaterBanner({ onRestart }) {
return (
<div className="rounded-lg border border-blue-200/60 bg-blue-50/40 px-5 py-4 text-center">
<p className="text-sm text-blue-700/70">
Your previous investigation state is still saved. You can continue where you left off or start fresh.
</p>
{onRestart && (
<button
onClick={onRestart}
className="mt-2 text-sm font-medium text-blue-700 underline hover:text-blue-900"
>
Restart investigation
</button>
)}
</div>
);
}
// ── Shared focused-question body (extracted from OpenQuestionsPanel) ──
function FocusedQuestionBody({
nodeId,
isFocused,
hasCompletedInvestigation: hasCompleted,
focused,
formulationStep,
formulateMsg,
processingStep,
deconstructMsg,
focusedAnswer,
handleDeconstructSubmit,
retryFormulation,
setFocusedAnswer,
setSelectedPresentationItemId,
setFocusedPresentationItemId,
setDoneForNowIds,
setFollowUpQuestion,
focusedContributions,
currentFindings,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
// ── Active-thread contribution scoping (v0.52) ──
// focusedContributions is scenario-wide; present only the active node's contributions.
const threadContribs = (focusedContributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
const hasResult = Boolean(focused?.result);
const hasAnswer = Boolean(focused?.answer);
// A non-null result means we are still in a completed-context state even after the user selects a follow-up (which clears answer).
// Without this guard, selecting a follow-up question would erase "Previously answered" + "Your response".
// Completed context: result (primary) OR prior contributions (fallback during processing/error).
// Processing and error are transient states — they must NOT collapse completed context.
const hasCompletedContext = Boolean(focused?.result) || (() => {
const pc = [...threadContribs].reverse().find((c) => c?.question && c?.answer);
return !!pc;
})();
// ── Source of completed context: latest canonical Contribution when follow-up is active ──
// After setFollowUpQuestion() mutates focused.question/answer, derive from the
// latest completed Contribution so the narrative remains correct.
// Active follow-up detection: primary via result (when result exists), fallback via priorContribs (error state may have null result).
const priorContribs = [...threadContribs].reverse();
// Follow-ups from result are primary; priorContribs is fallback when result is null.
const followUpsFromResult = focused?.result?.possibleFollowUpQuestions || [];
const hasActiveFollowUpFromResult =
!hasAnswer && followUpsFromResult.length > 0 && followUpsFromResult.some((q) => q === focused?.question);
const hasActiveFollowUpFromPrior = priorContribs.length > 0
? priorContribs.find((c) => (c.possibleFollowUpQuestions || []).length > 0)?.possibleFollowUpQuestions?.includes(focused?.question) ?? false
: false;
// Active follow-up requires either: a matched follow-up in result, OR priorContribs with a valid possibleFollowUp.
const hasActiveFollowUp = (hasActiveFollowUpFromResult || hasActiveFollowUpFromPrior);
const latestCompletedContrib = priorContribs.find((c) => c?.question && c?.answer);
const displayedCompletedQuestion = hasActiveFollowUp
? (latestCompletedContrib?.question ?? focused?.question)
: focused?.question;
const displayedCompletedAnswer = hasActiveFollowUp
? (latestCompletedContrib?.answer ?? focused?.answer ?? "")
: focused?.answer;
// ── Local correction state (FQB-owned, not propagated upward) ─
const [editingFindingId, setEditingFindingId] = useState(null);
const [draft, setDraft] = useState("");
function startEditing(id, proposition) {
setEditingFindingId(id);
setDraft(proposition ?? "");
}
function cancelEditing() {
setEditingFindingId(null);
setDraft("");
}
function saveEditing() {
const trimmed = (draft ?? "").trim();
if (!trimmed || !editingFindingId) {
cancelEditing();
return;
}
onUpdateFindingProposition?.(editingFindingId, trimmed);
cancelEditing();
}
return (
<>
{isFocused && hasContent && (
<div className="mt-4 space-y-4">
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
{(hasAnswer || hasCompletedContext) && focused?.question?.trim() ? (
<div className="space-y-3">
{/* Previously answered question — sourced from contribution when follow-up is active */}
<div>
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Previously answered</h3>
<p className="text-base font-medium leading-relaxed text-gray-900">{displayedCompletedQuestion}</p>
</div>
{/* User's verbatim response — distinct provenance from Engine-derived content */}
<div>
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Your response</h3>
<p className="text-sm leading-relaxed text-gray-800">{displayedCompletedAnswer}</p>
</div>
</div>
) : focused?.question?.trim() ? (
<div>
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Question</h3>
<p className="text-base font-medium leading-relaxed text-gray-900">{focused.question}</p>
</div>
) : formulationStep === "active" ? (
<p className="text-sm text-blue-600/70">{formulateMsg}</p>
) : null}
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
<div data-testid="completed-narrative">
<label htmlFor={`rw-answer-${nodeId}`} className="mb-2 block text-sm font-medium text-gray-700">Your response</label>
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} data-testid="response-textarea" className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60" placeholder="What do you know about this?" />
<button onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }} disabled={!focusedAnswer.trim() || processingStep === "active"} style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }} className="mt-3 rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50">Submit response</button>
</div>
)}
{processingStep === "active" && !hasActiveFollowUp && (
<div data-testid="processing-indicator" className="flex items-center gap-2 text-sm text-blue-600/70">
<svg className="h-4 w-4 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
<span className="sr-only">Processing:</span>
{deconstructMsg}
</div>
)}
{(hasActiveFollowUp || hasCompletedContext) && (
<>
{/* Derived sections fallback to priorContribs data during processing/error when result is null */}
{(() => {
const effectiveObservations = currentFindings?.length ? currentFindings :
(focused?.result?.observations ?? priorContribs.find((c) => c?.observations)?.observations);
const effectiveUncertainties = focused?.result?.uncertainties ?? priorContribs.find((c) => c?.uncertainties)?.uncertainties;
const effectiveFollowUps = focused?.result?.possibleFollowUpQuestions || priorContribs.find((c) => c?.possibleFollowUpQuestions)?.possibleFollowUpQuestions;
const effectiveAssumptions = focused?.result?.assumptions || priorContribs.find((c) => c?.assumptions)?.assumptions;
const effectiveRelationships = focused?.result?.relationships || priorContribs.find((c) => c?.relationships)?.relationships;
return (
<>
{/* Prior accumulated learning removed from left pane — SecondaryPreviousLearning on the right owns historical Previous Learning exclusively */}
{/* PriorContributionsSummary was causing duplication in the two-column focused workspace */}
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h3><ul className="list-disc pl-5 space-y-2">{(effectiveObservations || []).map((item, i) => {
const isFinding = typeof item === "object" && item !== null && "id" in item;
const disposition = isFinding ? item.userDisposition : null;
const isEditing = isFinding && editingFindingId === item.id;
if (!isFinding) {
return (
<li key={i} className="text-sm leading-relaxed text-gray-700">{item}</li>
);
}
if (isEditing) {
return (
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={2}
data-testid="proposition-editor"
className="flex-1 rounded border border-blue-300 bg-blue-50/40 px-2 py-1 text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-300"
/>
<div className="flex gap-1 shrink-0 mt-[2px]">
<button onClick={(e) => { e.stopPropagation(); saveEditing(); }} data-testid="proposition-save" className="text-[10px] font-medium text-blue-600 underline shrink-0 hover:text-blue-700">Save</button>
<button onClick={(e) => { e.stopPropagation(); cancelEditing(); }} data-testid="proposition-cancel" className="text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-gray-500">Cancel</button>
</div>
</li>
);
}
return (
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
<span className="flex-1">{item.proposition}</span>
{onUpdateFindingProposition && (
<button onClick={(e) => { e.stopPropagation(); startEditing(item.id, item.proposition); }} data-testid={`not-quite-${item.id}`} className="mt-[2px] text-[10px] font-medium text-amber-500 underline shrink-0 hover:text-amber-600">Not quite</button>
)}
{isFinding && onUpdateFindingDisposition && (
disposition === "not_relevant" ? (
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, null); }} data-testid={`restore-${item.id}`} className="mt-[2px] text-[10px] font-medium text-teal-600 underline shrink-0 hover:text-teal-700" title="Restore to understanding">restore</button>
) : (
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, "not_relevant"); }} data-testid={`not-relevant-${item.id}`} className="mt-[2px] text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-red-500" title="Remove from understanding">not relevant</button>
)
)}
</li>
);
})}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h3><ul className="list-disc pl-5 space-y-1">{(effectiveUncertainties || []).map((u, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{u}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Questions this raises</h3>
{(effectiveFollowUps || []).length > 0 ? (
<div className="space-y-1 mt-1">
{hasActiveFollowUp
? effectiveFollowUps.filter((q) => q !== focused.question).map((q, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setFollowUpQuestion(q); }}
className="w-full text-left rounded-lg border border-blue-200/60 bg-blue-50/40 px-3 py-2.5 text-sm leading-relaxed text-gray-800 transition hover:border-blue-300 hover:bg-blue-100/60 cursor-pointer"
data-testid="follow-up-question"
>
{q}
{" → pick this question"}
</button>
))
: effectiveFollowUps.map((q, i) => {
const isCurrentQuestion = q === focused?.question;
return (
<button
key={i}
onClick={(e) => { if (!isCurrentQuestion) { e.stopPropagation(); setFollowUpQuestion(q); } }}
style={{ cursor: isCurrentQuestion ? "default" : "pointer" }}
className={`w-full text-left rounded-lg border px-3 py-2.5 text-sm leading-relaxed transition ${
isCurrentQuestion
? "border-gray-200 bg-gray-100/60 text-gray-400 cursor-default"
: "border-blue-200/60 bg-blue-50/40 text-gray-800 hover:border-blue-300 hover:bg-blue-100/60"
}`}
data-testid="follow-up-question"
>
{q}
{isCurrentQuestion ? " (current question)" : " → pick this question"}
</button>
);
})}
</div>
) : (
<p className="text-xs text-gray-400">None yet</p>
)}
{/* In-place answer textarea for the active follow-up */}
{hasActiveFollowUp ? (
<div className="mt-3 space-y-2" data-testid="follow-up-block">
<p className="text-sm font-medium text-gray-900">{focused.question}</p>
<textarea
id={`rw-answer-fu-${nodeId}`}
value={focusedAnswer}
onChange={(e) => setFocusedAnswer(e.target.value)}
rows={4}
data-testid="follow-up-textarea"
disabled={processingStep === "active"}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60"
placeholder="What do you know about this?"
/>
<button
onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }}
disabled={!focusedAnswer.trim() || processingStep === "active"}
style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }}
className="rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50"
>
Submit response
</button>
{processingStep === "active" && (
<div data-testid="processing-indicator" className="mt-1 flex items-center gap-2 text-sm text-blue-600/70">
<svg className="h-4 w-4 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
<span className="sr-only">Processing:</span>
{deconstructMsg}
</div>
)}
</div>
) : null}
</div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h3><ul className="list-disc pl-5 space-y-1">{(effectiveAssumptions || []).map((a, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{a}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Connections</h3><ul className="list-disc pl-5 space-y-1">{(effectiveRelationships || []).map((r, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{r.from} {r.to} ({r.type})</li>))}</ul></div>
</>
);
})()}
</>
)}
{focused?.error && processingStep !== "active" && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">We were unable to process your request right now. Please try again later.<button onClick={(e) => { e.stopPropagation(); retryFormulation(); }} className="ml-2 font-medium underline">Retry</button></div>
)}
</div>
</div>
)}
</>
);
}
// ── Persistent navigation controls (overlay-level, outside content grid) ──
function FocusedWorkspaceNavigation({ nodeId, doneForNow, isDoneForNowActive, isProcessing, onImmediateGraphChange }) {
const canDoneForNow = Boolean(isDoneForNowActive) && !isProcessing;
return (
<div className="mt-6 flex items-center justify-end border-t border-gray-200 pt-5">
<button
onClick={(e) => { e.stopPropagation(); doneForNow?.(); }}
disabled={canDoneForNow ? false : true}
style={{ cursor: canDoneForNow ? "pointer" : "not-allowed" }}
className={`rounded-lg border px-4 py-2 text-sm font-medium transition whitespace-nowrap ${canDoneForNow ? 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50' : 'border-gray-100 bg-gray-50 text-gray-300'}`}
>
Done for now
</button>
</div>
);
}
// ── Current understanding card ────────────────────────────────
// Evidence-limit text that must not appear inside Current understanding
// when the terminal outcome already communicates that state.
const EVIDENCE_LIMIT_PHRASES = [
"The available evidence has reached its current limit",
"evidence has reached its current limit",
"evidence limit reached",
"has reached its current limit",
];
function resolveCurrentSummary(currentSummary) {
if (!currentSummary || typeof currentSummary !== "string") return null;
const trimmed = currentSummary.trim();
if (!trimmed) return null;
// Filter out technical graph summaries
for (const p of TECHNICAL_PATTERNS) {
if (p.test(trimmed)) return null;
}
// Don't show evidence-limit text in Current understanding when
// the terminal outcome card already communicates that state.
const lower = trimmed.toLowerCase();
for (const phrase of EVIDENCE_LIMIT_PHRASES) {
if (lower.includes(phrase)) return null;
}
return trimmed;
}
// ── Status message pools for loading feedback ────────────────
const INITIAL_MESSAGES = [
{ min: 0, text: "Reading your situation" },
{ min: 10, text: "Building a structured understanding" },
{ min: 25, text: "Identifying what is known and still unclear" },
{ min: 45, text: "Selecting the next useful question" },
];
const UPDATE_MESSAGES = [
{ min: 0, text: "Considering your answer" },
{ min: 10, text: "Updating the situation" },
{ min: 25, text: "Checking what changed" },
{ min: 45, text: "Choosing the next question" },
];
function useLoadingStatus(messages, isLoading) {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(null);
useEffect(() => {
if (isLoading) {
startRef.current = Date.now();
const iv = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(iv);
} else {
setElapsed(0);
startRef.current = null;
}
}, [isLoading]);
const currentMessage = useMemo(() => {
if (!messages || messages.length === 0) return "";
let msg = messages[0].text;
for (const m of messages) {
if (elapsed >= m.min) msg = m.text;
}
return msg;
}, [messages, elapsed]);
return { elapsed, currentMessage };
}
// ── Spinner component ───────────────────────────────────────
function ActivitySpinner() {
return (
<span
className="inline-block h-4 w-4 border-[2px] border-gray-300 border-t-gray-600 rounded-full"
style={{ animation: "spin 1s linear infinite" }}
/>
);
}
// ── Current investigation card (prominent hero section) ──────
function CurrentInvestigationCard({ selectedQuestion, graph }) {
if (!selectedQuestion) return null;
const q = typeof selectedQuestion === "string" ? selectedQuestion : selectedQuestion.question;
if (!q) return null;
// Derive meaningful context from the active node only when it adds value
let whyMattersText = null;
if (graph?.activeUnknownNodeId && graph.nodes) {
const activeNode = graph.nodes.find((n) => n.id === graph.activeUnknownNodeId);
if (activeNode?.description && activeNode.description !== activeNode.label) {
whyMattersText = activeNode.description;
}
}
return (
<div className="investigation-card rounded-lg border-[2.5px] border-green-400 bg-gradient-to-b from-green-50 to-white p-8 shadow-sm">
<h2 className="mb-3 text-xs font-bold tracking-widest uppercase text-green-600/70">
Investigation
</h2>
<p className="text-2xl font-semibold leading-tight text-gray-900">{q}</p>
{whyMattersText && (
<p className="mt-5 text-sm leading-relaxed text-green-800/80">
{whyMattersText}
</p>
)}
</div>
);
}
// ── Outcome helpers ───────────────────────────────────────────
function hasGenuineCompletion(graph) {
if (!graph || !graph.nodes?.length) return false;
const resolvedIds = new Set(graph.resolvedNodeIds || []);
const unresolvedCount = graph.nodes.filter(
(n) => n.kind === "unknown" && n.status !== "resolved" && !resolvedIds.has(n.id),
).length;
if (unresolvedCount > 0) return false;
if (graph.activeUnknownNodeId) {
const active = graph.nodes.find((n) => n.id === graph.activeUnknownNodeId);
if (active && active.status !== "resolved" && !resolvedIds.has(active.id)) return false;
}
return true;
}
// ── Completion card (terminal state when all unknowns resolved) ─
function CompletionCard({ summary }) {
return (
<div className="rounded-lg border border-green-300 bg-green-50 px-5 py-6 text-center">
<h2 className="mb-1 text-sm font-bold uppercase tracking-wide text-green-700">Investigation complete</h2>
<p className="text-base text-gray-800 mb-3">The available evidence supports the following understanding.</p>
{summary && (
<div className="mt-4 text-left rounded-md bg-white/60 px-4 py-3 border border-green-100">
<p className="text-sm leading-relaxed text-gray-700">{summary}</p>
</div>
)}
</div>
);
}
// ── Evidence-limit card (terminal state: no next question) ───────
function EvidenceLimitCard({ summary }) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-6 text-center">
<h2 className="mb-1 text-sm font-bold uppercase tracking-wide text-gray-500">Current evidence limit reached</h2>
{summary && (
<div className="mt-4 text-left rounded-md bg-white/60 px-4 py-3 border border-gray-100">
<p className="text-sm leading-relaxed text-gray-700">{summary}</p>
</div>
)}
<p className="mt-3 text-base text-gray-700">Further progress requires additional evidence.</p>
</div>
);
}
// ── Canonical findings resolver for Previous Learning ──────────────
function getHistoricalPropositions(contribution, findings) {
const matching = (findings || []).filter(
(f) => f.contributionId === contribution.id,
);
if (matching.length === 0) {
return contribution.observations || [];
}
return matching
.filter((f) => f.userDisposition !== "not_relevant")
.map((f) => f.proposition);
}
// ── Prior contribution summary (embedded within FocusedQuestionBody) ───
function PriorContributionsSummary({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
if (!threadContribs.length) return null;
// Exclude the most recent contribution — it is already shown as the current result above.
const priorContribs = threadContribs.slice(0, -1);
if (!priorContribs.length) return null;
// Presentation-reversal: render newest prior turn first so user sees what was learned most recently at the top.
// This is a presentation-only decision; chronological order in data is preserved elsewhere.
const reversedPrior = [...priorContribs].reverse();
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
<h4 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Previous learning
</h4>
{reversedPrior.map((c, idx) => (
<details key={c.id || idx} className="mb-2 border-b border-gray-200/40 last:border-0 pb-2 last:pb-0" open={idx === 0}>
<summary className="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 select-none py-1">
Turn {c.sequence || idx + 1} contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
</summary>
<div className="pt-2 space-y-3">
{(() => {
const propositions = getHistoricalPropositions(c, findings);
return propositions.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{propositions.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null;
})()}
{c.uncertainties?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h5>
<ul className="list-disc pl-5 space-y-0.5">
{c.uncertainties.map((u, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{u}</li>
))}
</ul>
</div>
) : null}
</div>
</details>
))}
</div>
);
}
// ── Standalone previous learning block (for two-column secondary placement) ───
function SecondaryPreviousLearning({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
if (!threadContribs.length) return null;
// Exclude the most recent contribution — it is already shown as the current result above.
const priorContribs = threadContribs.slice(0, -1);
if (!priorContribs.length) return null;
// Presentation-reversal: render newest prior turn first so user sees what was learned most recently at the top.
// This is a presentation-only decision; chronological order in data is preserved elsewhere.
const reversedPrior = [...priorContribs].reverse();
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
<h4 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Previous learning
</h4>
{reversedPrior.map((c, idx) => (
<details key={c.id || idx} className="mb-2 border-b border-gray-200/40 last:border-0 pb-2 last:pb-0" open={idx === 0}>
<summary className="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 select-none py-1">
Turn {c.sequence || idx + 1} contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
</summary>
<div className="pt-2 space-y-3">
{(() => {
const propositions = getHistoricalPropositions(c, findings);
return propositions.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{propositions.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null;
})()}
{c.uncertainties?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h5>
<ul className="list-disc pl-5 space-y-0.5">
{c.uncertainties.map((u, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{u}</li>
))}
</ul>
</div>
) : null}
</div>
</details>
))}
</div>
);
}
// ── Thread contributions badge (standalone — used outside focused body) ───
function ThreadContributionsBadge({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
if (!threadContribs.length) return null;
return (
<span className="mt-2 block text-[9px] uppercase tracking-widest font-semibold text-amber-500/70">
INVESTIGATING · {threadContribs.length} learned contribution{threadContribs.length !== 1 ? "s" : ""}
</span>
);
}
// ── Quiet facilitator state (experiment mode) ─────────────────────
function QuietStateCard() {
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-5 py-5 text-center">
<p className="text-sm leading-relaxed text-gray-500 mb-1">
Nothing more to add here right now
</p>
<p className="text-xs leading-relaxed text-gray-400">
You can continue with another question, switch branches, or return when
you have more information.
</p>
</div>
);
}
// ── New connection / late-result indicator ────────────────────────
function UpdatedConnectionCard({ update }) {
return (
<div className="space-y-2">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
New update
</h2>
<div className="rounded-lg border border-blue-200/60 bg-blue-50/40 px-4 py-3">
<p className="text-sm leading-relaxed text-blue-800/80">{update.text}</p>
</div>
</div>
);
}
// ── Branch notebook content (RTO.27A) ────────────────────────────
// Smallest useful branch-scoped composition from fixture data.
// Only renders sections grounded in existing records.
function BranchNotebookContent({
branchContext,
contributions,
openQuestions,
lateResults,
inactiveBranchNewResults,
doneForNowBranchIds,
onDoneForNow,
branchId,
}) {
const hasOpenItems = openQuestions && openQuestions.length > 0;
const hasContributions = contributions && contributions.length > 0;
const isPaused = doneForNowBranchIds?.includes(branchId) || false;
const isBranchActive = !isPaused;
return (
<div className="space-y-6">
{/* Why this branch exists — origin question is primary */}
{branchContext && (
<div className="rounded-lg border border-blue-200/60 bg-blue-50/40 px-5 py-4">
<h2 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Investigating
</h2>
{branchContext.originQuestion ? (
<>
<p className="text-base leading-relaxed text-gray-900">{branchContext.originQuestion.text}</p>
{branchContext.label && branchContext.originQuestion.text !== branchContext.label && (
<p className="mt-2 text-xs text-gray-500/70">Branch: {branchContext.label}</p>
)}
</>
) : branchContext.origin ? (
<>
<p className="text-base font-medium leading-tight text-gray-900">{branchContext.label}</p>
<p className="mt-2 text-sm leading-relaxed text-gray-600">
Trying to understand: {branchContext.origin}
</p>
</>
) : (
<p className="text-base font-medium leading-tight text-gray-900">{branchContext.label}</p>
)}
</div>
)}
{/* What we know here */}
{hasContributions && (
<div className="space-y-2">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
What we know here
</h2>
<div className="space-y-2">
{contributions.map((c, i) => (
<div key={i} className="rounded-lg border border-gray-200/80 bg-gray-50/60 px-4 py-3">
<p className="text-sm leading-relaxed text-gray-700">{c.text}</p>
</div>
))}
</div>
</div>
)}
{/* Still uncertain here */}
{hasOpenItems && (
<div className="space-y-2">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Still uncertain / open questions
</h2>
<div className="space-y-1">
{openQuestions.map((q, i) => (
<QuestionRow key={q.id} question={q} />
))}
</div>
</div>
)}
{/* New connection / updates (active branch) */}
{lateResults && lateResults.length > 0 && (
<UpdatedConnectionCard update={lateResults[0]} />
)}
{/* Done for now control — only when branch is not already paused */}
{isBranchActive && onDoneForNow && contributions.length > 0 && (
<div className="flex justify-end pt-1">
<button
onClick={(e) => { e.stopPropagation(); onDoneForNow(); }}
style={{ cursor: "pointer" }}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 transition whitespace-nowrap"
>
Done for now
</button>
</div>
)}
{/* Quiet state */}
{!hasOpenItems && !lateResults?.length && (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-5 py-5 text-center">
{isPaused ? (
<>
<p className="text-sm leading-relaxed text-gray-500 mb-1">
Nothing more to add here right now
</p>
<p className="text-xs leading-relaxed text-gray-400">
This branch is paused. Reasoning preserved return when you have more information.
</p>
</>
) : (
<QuietStateCard />
)}
</div>
)}
</div>
);
}
// ── Question row (renders clickable card with optional focused content) ─
function QuestionRow({ question }) {
const [isFocused, setIsFocused] = useState(false);
if (!isFocused) {
return (
<button
onClick={() => setIsFocused(true)}
style={{ cursor: "pointer" }}
className="w-full text-left rounded-lg border border-gray-200 px-4 py-3 transition hover:border-gray-300 hover:bg-white"
>
<p className="text-sm leading-snug text-gray-600">{question.label}</p>
</button>
);
}
return (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-4 space-y-3">
<p className="text-sm font-medium text-gray-900 leading-snug">{question.label}</p>
<div className="space-y-2">
<p className="text-sm leading-relaxed text-gray-500">
We have not explored this yet. Do you want to work through it?
</p>
<button
onClick={(e) => {
e.stopPropagation();
// Placeholder — production would call API for focused investigation
}}
style={{ cursor: "pointer" }}
className="rounded-lg border border-blue-600 bg-white px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 transition"
>
Work through this
</button>
</div>
<button
onClick={() => setIsFocused(false)}
style={{ cursor: "pointer" }}
className="text-xs text-gray-400 underline hover:text-gray-600 transition"
>
Back to open questions
</button>
</div>
);
}
// ── Current understanding card ────────────────────────────────
function CurrentUnderstandingCard({ currentSummary, plainLanguage }) {
if (plainLanguage) return <PlainLanguageCard summary={plainLanguage} />;
const summary = resolveCurrentSummary(currentSummary);
if (!summary) return null;
return (
<div className="rounded-lg border-[2.5px] border-teal-300/60 bg-gradient-to-b from-teal-50/40 to-white px-7 pt-6 pb-7">
<h2 className="mb-3 text-[11px] font-bold tracking-widest uppercase text-teal-700/60">
Understanding
</h2>
<p className="text-base leading-relaxed text-gray-800">{summary}</p>
</div>
);
}
// ── Plain-language understanding card (from pipeline summary) ──
function PlainLanguageCard({ summary }) {
if (!summary) return null;
return (
<div className="rounded-lg border-[2.5px] border-teal-300/60 bg-gradient-to-b from-teal-50/40 to-white px-7 pt-6 pb-7">
<h2 className="mb-3 text-[11px] font-bold tracking-widest uppercase text-teal-700/60">
Understanding
</h2>
<p className="text-base leading-relaxed text-gray-800">{summary}</p>
</div>
);
}
// ── Investigation history card (readable notebook style) ──────
function InvestigationHistoryCard({ turn }) {
const isCollapsed = turn._collapsed;
const isAnswered = Boolean(turn.answer?.trim());
const displayedQuestion = turn.question;
return (
<details
className="rounded-lg border border-gray-200/60 bg-gray-50/30"
key={turn.id}
open={!isCollapsed}
data-testid="investigation-turn"
>
<summary className="cursor-pointer px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
{isAnswered && <span aria-hidden="true"> </span>}
{displayedQuestion}
</summary>
<div className="space-y-2 px-4 pb-4 pt-2">
<p className="text-gray-700">{turn.answer}</p>
{turn.acknowledgement && (
<p className="italic text-gray-500">
{turn.acknowledgement}
</p>
)}
</div>
</details>
);
}
// ── Investigation history section ─────────────────────────────
function InvestigationHistory({ turns }) {
if (!turns || turns.length === 0) return null;
const latestId = turns[turns.length - 1].id;
return (
<div className="space-y-3" data-testid="investigation-history">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
History
</h2>
<div className="space-y-2">
{turns.map((turn) => (
<InvestigationHistoryCard key={turn.id} data-testid="investigation-turn" turn={{ ...turn, _collapsed: turn.id !== latestId }} />
))}
</div>
</div>
);
}
// ── Original situation (always-visible reference card) ────────
function OriginalSituation({ scenario, centralStatement }) {
const text = scenario || centralStatement;
if (!text) return null;
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-5 py-4">
<h2 className="mb-2 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Situation
</h2>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-600">
{text}
</p>
</div>
);
}
// ── Transient acknowledgement (auto-dismisses after 3s) ─────────
function useAutoDismiss(duration = 3000) {
const [visible, setVisible] = useState(true);
useEffect(() => {
if (!visible) return;
const timer = setTimeout(() => setVisible(false), duration);
return () => clearTimeout(timer);
}, [visible, duration]);
return visible;
}
function UpdateAcknowledgement({ updateResult }) {
const visible = useAutoDismiss(3000);
if (!updateResult || !visible) return null;
const summary = updateResult.summary;
return (
<div
className="transition-all duration-1500 ease-in"
style={{ opacity: visible ? 0.7 : 0, maxHeight: visible ? "4rem" : "0", marginBottom: visible ? "1rem" : "0" }}
>
<div className="rounded-md border border-blue-200/60 bg-blue-50/30 px-4 py-2 text-xs text-blue-700/60">
{summary}
</div>
</div>
);
}
// ── Developer details disclosure ──────────────────────────────
function DeveloperDetails({ graph, selectedQuestion, diagnostics, newlySurfacedNodeIds, updateResult }) {
return (
<details className="rounded-lg border border-gray-200/60 bg-gray-50/30">
<summary className="cursor-pointer px-5 py-3 text-sm font-medium text-gray-400 hover:text-gray-600">
Developer details
</summary>
<div className="border-t border-gray-200/60 px-5 pb-4 pt-3 space-y-4">
{graph && (
<SituationGraphView
situationGraph={graph}
selectedQuestion={selectedQuestion}
newlySurfacedNodeIds={newlySurfacedNodeIds}
/>
)}
{updateResult && (
<GraphUpdateView updateResult={{ ...updateResult, previousSituationGraph: graph }} />
)}
{diagnostics && <DiagnosticsView result={{ diagnostics }} />}
</div>
</details>
);
}
// ── Loading overlay (for both start and update) ───────────────
function LoadingOverlay({ isLoading, elapsed, currentMessage, variant }) {
if (!isLoading) return null;
const messages = variant === "update" ? UPDATE_MESSAGES : INITIAL_MESSAGES;
let statusText = messages[0].text;
for (const m of messages) {
if (elapsed >= m.min) statusText = m.text;
}
return (
<div className="rounded-lg border border-blue-200/60 bg-blue-50/40 px-6 py-7" role="status" aria-busy="true" data-testid="loading-overlay">
<div className="flex items-center gap-3">
<ActivitySpinner />
<span className="text-base font-medium text-blue-800/70">Working through your situation</span>
</div>
<p className="mt-3 text-sm text-blue-600/60">{statusText}</p>
<p className="mt-2 text-xs text-blue-400/50" aria-live="polite">
This has been running for {elapsed}s.
{variant === "initial" && elapsed >= 45 && (
<span className="block mt-1">This can take around a minute with the current local model.</span>
)}
</p>
</div>
);
}
// ── Main workspace component ──────────────────────────────────
function getErrorType(errorStr, stage, hasGraph) {
if (!errorStr && !stage) return null;
const lower = (errorStr || "").toLowerCase();
if (/provider|unavailable|network|timeout/.test(lower)) return "provider-unavailable";
if (/malformed|invalid.*format|parse|structured/.test(lower)) return "malformed-response";
if (stage === "provider") return "provider-error";
if (stage === "unexpected") return "unexpected-state";
if (/validation/.test(lower) && !hasGraph) return "no-graph";
return null;
}
// ── Focused investigation overlay workspace container ──────────
function FocusedInvestigationWorkspace({
nodeId,
focused,
formulationStep,
formulateMsg,
processingStep,
deconstructMsg,
focusedAnswer,
handleDeconstructSubmit,
retryFormulation,
setFocusedAnswer,
setSelectedPresentationItemId,
setFocusedPresentationItemId,
setDoneForNowIds,
setFollowUpQuestion,
setIsFocusedWorkspaceOpen,
hasCompletedInvestigation,
focusedContributions,
currentFindings,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
const hasResult = Boolean(focused?.result);
return (
<div className="mx-auto w-full max-w-[1600px]">
{/* Two-column responsive grid: primary active work + secondary context */}
<div className={`grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(280px,0.8fr)]`}>
{/* ── Primary column: active investigation — min-w-0 prevents flex growth in single-column mode ── */}
<div className="min-w-0 overflow-hidden">
<FocusedQuestionBody
nodeId={nodeId}
isFocused={true}
hasCompletedInvestigation={hasCompletedInvestigation}
focused={focused}
formulationStep={formulationStep}
formulateMsg={formulateMsg}
processingStep={processingStep}
deconstructMsg={deconstructMsg}
focusedAnswer={focusedAnswer}
handleDeconstructSubmit={handleDeconstructSubmit}
retryFormulation={retryFormulation}
setFocusedAnswer={setFocusedAnswer}
setSelectedPresentationItemId={setSelectedPresentationItemId}
setFocusedPresentationItemId={setFocusedPresentationItemId}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions || []}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
</div>
{/* ── Secondary context: Previous Learning — visible on all breakpoints, placed in grid column on wide / flows below primary on narrow ── */}
{hasResult && (
<SecondaryPreviousLearning nodeId={nodeId} contributions={focusedContributions || []} findings={findings} />
)}
</div>
</div>
);
}
// ── Open questions panel (production, non-experiment mode) ────────
function OpenQuestionsPanel({
graph, selectedPresentationItemId, focusedPresentationItemId, hasFocusedContent,
focused, formulationStep, formulateMsg, processingStep, deconstructMsg, doneForNowIds, cuSynthesisLoading,
startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId,
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
const openNodes = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && !resolvedIds.has(n.id) && !doneForNowIds.includes(n.id),
);
// Zero open questions: delegate to inline ReasoningWorkspace section for invitation + clarifications rendering
// No Open Questions cards to render when zero — invitation rendered inline instead
// The panel itself returns null; see ReasoningWorkspace inline section for the milestone UI.
// Check whether a node has a completed focused result stored locally.
const hasCompletedInvestigation = (nid) => {
const inv = focusedInvestigations?.[nid];
return Boolean(inv && inv.status === "formulated" && inv.result && typeof inv.question === "string" && inv.question.trim());
};
function handleNodeClick(node) {
if (hasCompletedInvestigation(node.id) && selectedPresentationItemId === node.id) {
setFocusedPresentationItemId(node.id);
return;
}
setSelectedPresentationItemId(
selectedPresentationItemId === node.id ? null : node.id,
);
}
return (
<div className="space-y-4">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Open questions
</h2>
<div className="space-y-1">
{openNodes.map((node) => {
const isSelected = selectedPresentationItemId === node.id;
const isFocused = focusedPresentationItemId === node.id;
return (
<div key={node.id}>
<div
onClick={() => handleNodeClick(node)}
style={{ cursor: "pointer" }}
className="w-full text-left rounded-lg border border-gray-200 px-4 py-3 transition hover:border-gray-300 hover:bg-white"
>
<p className={`leading-snug ${isSelected ? "text-sm font-medium text-gray-900" : "text-sm text-gray-600"}`}>
{node.label}
</p>
{isSelected && !hasFocusedContent() && (
<div className="mt-3 space-y-3">
<p className="text-sm leading-relaxed text-gray-500">
We have not explored this yet. Do you want to work through it?
</p>
<button
onClick={(e) => { e.stopPropagation(); startFocused(node.id); }}
style={{ cursor: "pointer" }}
className="rounded-lg border border-blue-600 bg-white px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 transition"
>
Work through this
</button>
</div>
)}
<FocusedQuestionBody
nodeId={node.id}
isFocused={isFocused}
hasCompletedInvestigation={() => hasCompletedInvestigation(node.id)}
focused={focused}
formulationStep={formulationStep}
formulateMsg={formulateMsg}
processingStep={processingStep}
deconstructMsg={deconstructMsg}
focusedAnswer={focusedAnswer}
handleDeconstructSubmit={handleDeconstructSubmit}
retryFormulation={retryFormulation}
setFocusedAnswer={setFocusedAnswer}
setSelectedPresentationItemId={setSelectedPresentationItemId}
setFocusedPresentationItemId={setFocusedPresentationItemId}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
{/* Thread contributions for this node — compact cue inside card */}
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} findings={findings} />
</div>
</div>
);
})}
</div>
{doneForNowIds.length > 0 && (
<div className="pt-3 border-t border-gray-200">
<h3 className="mb-2 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Done for now</h3>
<div className="space-y-1">
{graph.nodes.filter((n) => doneForNowIds.includes(n.id)).map((node) => (
<div key={node.id} className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3 space-y-1">
<p className="text-sm text-gray-500 leading-snug">{node.label}</p>
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} findings={findings} />
<button onClick={() => setDoneForNowIds(doneForNowIds.filter(id => id !== node.id))} style={{ cursor: "pointer" }} className="mt-1 rounded border border-gray-300 px-3 py-1 text-xs font-medium text-gray-500 hover:bg-white transition">Reopen</button>
</div>
))}
</div>
</div>
)}
</div>
);
}
// Export for testability of in-place follow-up ownership repair
export { FocusedQuestionBody, SecondaryPreviousLearning };
export default function ReasoningWorkspace({
scenario,
status,
updateStatus,
cuSynthesisLoading,
currentUnderstanding: propUnderstanding,
result,
answer,
setAnswer,
onAnswerSubmit,
lastSubmittedAnswer,
onRestart,
focusedContributions,
onFocusedContribution,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
/* ── v0.49 — done-for-now promotion callback ───────── */
onSummaryUpdate,
/* ── immediate graph transition (Done acknowledged before async) ── */
onImmediateGraphChange,
/* ── canonical graph-replacement seam (future Re-open) ── */
onSituationGraphChange,
/* ── v0.59a — provenance revision ──────────────────── */
investigationRevision,
/* ── test init seam (no effect → immediate state) ───────── */
initialPostAnalyseStatus,
/* ── v0.54b — investigation overview transient state ─── */
overviewState,
setOverviewState,
overviewLoading,
handleRequestOverview,
onNavigateToReport,
}) {
const [investigationHistory, setInvestigationHistory] = useState([]);
const turnCounter = useRef(0);
const pendingTurnRef = useRef(null);
// ── Experiment 12: toggle between progress panel versions (temporary experimental UI) ──
const [panelVariant, setPanelVariant] = useState("c");
// ── RTO.01 — presentation-only selection for open investigation items ──
const [selectedPresentationItemId, setSelectedPresentationItemId] = useState(null);
// ── RTO.02 — case / question workspace lifecycle (presentation only) ──
const [focusedPresentationItemId, setFocusedPresentationItemId] = useState(null);
const [doneForNowIds, setDoneForNowIds] = useState([]);
// ── Focused investigation overlay workspace ───────────────
const [isFocusedWorkspaceOpen, setIsFocusedWorkspaceOpen] = useState(false);
// ── RTO.29D — post-Analyse initial reflection surface ─────────
const [initialReflectionActive, setInitialReflectionActive] = useState(false);
const [postAnalyseStatus, setPostAnalyseStatus] = useState(initialPostAnalyseStatus ?? null);
// Capture the current selected question at submit time (not from a stale ref)
const capturePendingTurn = (selectedQuestion, answerText) => {
if (!selectedQuestion || !answerText?.trim()) return null;
const q = typeof selectedQuestion === "string" ? selectedQuestion : selectedQuestion.question;
if (!q) return null;
turnCounter.current += 1;
return {
id: `turn-${turnCounter.current}`,
question: q,
answer: answerText.trim(),
acknowledgement: null,
};
};
// Append the captured pending turn to history after a successful update only
useEffect(() => {
const pending = pendingTurnRef.current;
if (!pending || updateStatus !== "success") return;
setInvestigationHistory((prev) => [
...prev,
{ ...pending, acknowledgement: result?.summary || null },
]);
pendingTurnRef.current = null;
}, [updateStatus, result]);
const handleUpdateCaptureAndSubmit = async (e) => {
e.preventDefault();
if (!answer?.trim() || !result?.selectedQuestion) return;
pendingTurnRef.current = capturePendingTurn(result.selectedQuestion, answer);
await onAnswerSubmit(e);
};
const graph = result?.situationGraph ?? null;
const hasGraph = Boolean(graph);
const diagnostics = result?.diagnostics ?? null;
const newlySurfacedNodeIds = result?.newlySurfacedNodeIds || [];
const genuineCompletion = hasGenuineCompletion(graph);
const errorType = getErrorType(
result?.error || (result?.updateError ? result.updateError.error : null),
result?.stage,
Boolean(graph)
);
const isProviderUnavailable =
errorType === "provider-unavailable" || errorType === "provider-error";
const isMalformedResponse = errorType === "malformed-response";
// ── RTO.13B — focused investigation localized state ───────────
const [formulationStep, setFormulationStep] = useState("idle");
const [processingStep, setProcessingStep] = useState("idle");
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES,
status === "loading"
);
const { elapsed: updateElapsed, currentMessage: updateMsg } = useLoadingStatus(
UPDATE_MESSAGES,
updateStatus === "loading"
);
// ── RTO.13B — focused investigation loading hooks ─────────────
const FORMULATING_MESSAGES = [
{ min: 0, text: "Working out a question…" },
{ min: 15, text: "Still working on that question" },
{ min: 30, text: "Formulating the right question for this" },
{ min: 45, text: "A moment longer — this can take around a minute" },
];
const DECONSTRUCT_MESSAGES = [
{ min: 0, text: "Working through your response…" },
{ min: 15, text: "Still working on that response" },
{ min: 30, text: "A moment longer — this can take around a minute" },
];
const { elapsed: formulateElapsed, currentMessage: formulateMsg } = useLoadingStatus(
FORMULATING_MESSAGES,
formulationStep === "active",
);
const { elapsed: deconstructElapsed, currentMessage: deconstructMsg } = useLoadingStatus(
DECONSTRUCT_MESSAGES,
processingStep === "active",
);
const isUpdating = updateStatus === "loading";
const hasSelectedQuestion = Boolean(result?.selectedQuestion);
// ── RTO.13B — focused investigation localized state (keyed by node ID) ──
const [focusedInvestigations, setFocusedInvestigations] = useState({});
const [focusedAnswer, setFocusedAnswer] = useState("");
function getFocusedInvestigation() {
if (!focusedPresentationItemId) return null;
return focusedInvestigations[focusedPresentationItemId] || null;
}
function focusItem(nodeId) {
setSelectedPresentationItemId(nodeId);
setFocusedPresentationItemId(nodeId);
}
const focused = getFocusedInvestigation();
// ── Derive presentation data: exact existing Findings for the current focused Contribution ──
let currentFindings = [];
if (findings) {
const hasCorrelationId = !!focused?.result?.correlationId;
if (hasCorrelationId) {
// LIVE PATH — correlationId present from live formulate call.
const correlationId = focused.result.correlationId;
const matchedContribution = (focusedContributions || []).find(
(c) => c.correlationId === correlationId,
);
if (matchedContribution) {
currentFindings = findings.filter(
(f) => f.contributionId === matchedContribution.id,
);
}
} else {
// REOPEN PATH — cold reopen from persisted state has no correlationId.
// Use persisted Contribution.id to locate the displayed/latest Contribution,
// then match Findings through Finding.contributionId === Contribution.id.
const target = focusedPresentationItemId;
if (target && focusedContributions?.length) {
const threadContribs = focusedContributions.filter(
(c) => c.targetNodeId === target || c.originatingTargetNodeId === target,
);
if (threadContribs.length > 0) {
const latestDisplayContrib = threadContribs[threadContribs.length - 1];
currentFindings = findings.filter(
(f) => f.contributionId === latestDisplayContrib.id,
);
}
}
}
}
// Gate evidence-limit card: do NOT show when active investigation paths remain.
const showEvidenceLimit = !(
processingStep === "active" ||
(focused?.question?.trim() && !processingStep) ||
(hasGraph && !genuineCompletion)
);
// ── RTO.13B — workflow handlers ──────────────────────────────
function startFocused(nodeId) {
const target = nodeId || focusedPresentationItemId;
if (!target) return;
const priorContribs = (focusedContributions || []).filter(
(c) => c.targetNodeId === target || c.originatingTargetNodeId === target,
);
setFocusedPresentationItemId(target);
setFocusedAnswer("");
// Always open the focused workspace overlay
setIsFocusedWorkspaceOpen(true);
if (priorContribs.length > 0) {
// Reopen path: resume from accumulated contribution history.
// Do NOT call doFormulate — the user's prior investigation direction
// is preserved; only follow-up questions surface for explicit selection.
const latest = priorContribs[priorContribs.length - 1];
setFocusedInvestigations((prev) => ({
...prev,
[target]: {
status: "formulated",
question: latest.question || "",
answer: latest.answer ?? null,
result: latest.possibleFollowUpQuestions
? {
observations: latest.observations || [],
uncertainties: latest.uncertainties || [],
assumptions: latest.assumptions || [],
relationships: latest.relationships || [],
possibleFollowUpQuestions: latest.possibleFollowUpQuestions,
}
: null,
error: null,
},
}));
setFormulationStep("idle");
return;
}
// Fresh thread path — unchanged original behaviour.
setFormulationStep("active");
setFocusedInvestigations((prev) => ({
...prev,
[target]: { status: "formulating", question: "", answer: null, result: null, error: null },
}));
doFormulate(target);
}
async function doFormulate(targetNodeId) {
const nodeId = targetNodeId || focusedPresentationItemId;
if (!nodeId || !hasGraph) return;
try {
const url = "/api/focused-investigation/formulate";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ situationGraph: graph, targetNodeId: nodeId }),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || "Formulation failed");
// Deterministic presentation anchor: explicitly set the focused item to the
// target node so formulation success always renders correctly.
setFocusedPresentationItemId(nodeId);
setFocusedInvestigations((prev) => ({
...prev,
[nodeId]: { ...prev[nodeId], question: data.question, status: "formulated", error: null },
}));
setFormulationStep("idle");
} catch (err) {
setFocusedInvestigations((prev) => ({
...prev,
[focusedPresentationItemId]: { ...prev[focusedPresentationItemId], question: "", error: err.message || "Formulation failed" },
}));
}
}
async function handleDeconstructSubmit(targetNodeId, answerText) {
if (!hasGraph) return;
const targetNode = graph?.nodes?.find((n) => n.id === targetNodeId);
const centralStmt = graph?.centralStatement || scenario || "";
setProcessingStep("active");
const correlationId = crypto.randomUUID();
try {
const url = "/api/focused-investigation/deconstruct";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: targetNodeId,
targetLabel: targetNode?.label || "",
targetDescription: targetNode?.description || "",
centralStatement: centralStmt,
question: focused.question,
answer: answerText,
}),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || "Deconstruction failed");
// Persist contribution to case-level owner (ScenarioForm)
onFocusedContribution?.({
targetNodeId,
originatingTargetNodeId: focusedPresentationItemId,
targetLabel: targetNode?.label || "",
targetDescription: targetNode?.description || "",
question: focused.question,
answer: answerText,
observations: data.observations,
uncertainties: data.uncertainties,
assumptions: data.assumptions,
relationships: data.relationships,
possibleFollowUpQuestions: data.possibleFollowUpQuestions,
correlationId,
});
setProcessingStep("idle");
// Deterministic presentation anchor: explicitly set the focused item to the
// target node so the post-API success path always renders the correct state
// regardless of render timing or concurrent parent updates.
setFocusedPresentationItemId(targetNodeId);
setFocusedInvestigations((prev) => ({
...prev,
[targetNodeId]: { ...prev[targetNodeId], result: { ...data, correlationId }, answer: answerText, error: null },
}));
} catch (err) {
setProcessingStep("idle");
setFocusedPresentationItemId(targetNodeId);
setFocusedInvestigations((prev) => ({
...prev,
[targetNodeId]: { ...prev[targetNodeId], result: null, error: err.message || "Deconstruction failed" },
}));
}
}
async function submitFocusedAnswer() {
if (!focused?.question?.trim() || !hasGraph) return;
const targetNodeId = focusedPresentationItemId;
await handleDeconstructSubmit(targetNodeId, focusedAnswer);
}
function retryFormulation() {
const target = focusedPresentationItemId;
if (!target) return;
setFocusedInvestigations((prev) => ({
...prev,
[target]: { ...prev[target], question: "", error: null },
}));
doFormulate(target);
}
function setFollowUpQuestion(followUpText) {
const target = focusedPresentationItemId;
if (!target || !followUpText?.trim()) return;
// Deterministic presentation anchor: explicitly set after follow-up selection.
setFocusedPresentationItemId(target);
setFocusedInvestigations((prev) => ({
...prev,
[target]: { ...prev[target], question: followUpText.trim(), answer: null },
}));
setFocusedAnswer("");
}
function hasFocusedContent() {
if (!focused) return false;
const q = focused.question;
return Boolean(q?.trim()) || formulationStep === "active" || processingStep === "active" || focused.error;
}
// ── End RTO.13B ──────────────────────────────────────────────
// ── Body scroll lock while overlay is open ────────────────
useEffect(() => {
if (isFocusedWorkspaceOpen) {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}
}, [isFocusedWorkspaceOpen]);
const canAnswer =
status === "success" &&
!isUpdating &&
Boolean(result?.situationGraph) &&
hasSelectedQuestion;
const selectedQ = result?.selectedQuestion ?? null;
// Determine whether the Current Understanding card should render:
// — when there is a durable plain-language understanding, or
// — when there is an actual summary from any graph snapshot, or
// — when the investigation has reached a terminal state with no active question.
const hasCurrentSummaryCondition =
Boolean(propUnderstanding || graph?.currentSummary || result?.updatedSituationGraph?.currentSummary) || !hasSelectedQuestion;
// ── RTO.29D — manage initial reflection lifecycle ────────────
useEffect(() => {
// Entry is now based on successful initial reconstruction state ONLY.
// selectedQuestion is internal reasoning data — it must NOT gate the
// post-Analyse presentation path. Both null and populated
// selectedQuestion enter Run A identically.
const entering = status === "success" && hasGraph && propUnderstanding;
if (entering) {
setPostAnalyseStatus("success");
setInitialReflectionActive(true);
} else if (!entering && initialReflectionActive) {
// Leave initial reflection when something changes (user interacts, status shifts)
setInitialReflectionActive(false);
setPostAnalyseStatus(null);
}
}, [status, result, hasGraph]);
// Deactivate initial reflection when user submits a turn (leaves post-analyse).
// DO NOT deactivate on formulationStep or processingStep transitions — those
// are part of the focused workflow and must not break Run A's lifecycle.
useEffect(() => {
if (!initialReflectionActive) return;
// If we've already transitioned out of post-analyse mode, bail early.
// This prevents the effect from re-triggering on every state change after
// an explicit deactivation and avoids spurious double-fires when multiple
// dependencies change in the same commit.
if (postAnalyseStatus !== "success") return;
// Genuine exit: a turn has been submitted → history grows past zero.
// No other lifecycle state (formulation, processing, focused IDs, etc.)
// deactivates Run A during a focused investigation cycle.
if (investigationHistory.length > 0) {
setInitialReflectionActive(false);
setPostAnalyseStatus(null);
}
// All other lifecycle events within the focused workflow are intentionally
// ignored here — Run A stays active until an explicit turn is submitted.
}, [initialReflectionActive, investigationHistory, postAnalyseStatus]);
return (
<div className="space-y-6" data-testid="reasoning-workspace">
{/* ── Loading overlays ─────────────────────────────── */}
{status === "loading" && (
<LoadingOverlay
isLoading={true}
elapsed={startElapsed}
currentMessage={startMsg}
variant="initial"
/>
)}
{/* ── Provider unavailable recovery (always visible) ───────────── */}
{isProviderUnavailable && (
<ProviderUnavailableCard onRestart={onRestart} />
)}
{/* ── Malformed response recovery (always visible) ──────────────── */}
{isMalformedResponse && (
<MalformedResponseCard onRestart={onRestart} />
)}
{/* ── Unexpected state recovery ──────────────────── */}
{(errorType === "unexpected-state") && result && (
<UnexpectedStateCard
stateName={result.stage || null}
onRetry={updateStatus === "error" ? onRestart : null}
onRestart={onRestart}
/>
)}
{/* ── No graph produced after initial analysis ───────── */}
{(status === "success" || status === "error") && !graph ? (
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
{diagnostics?.noQuestionReason
? "Validation failed — no structured graph output was produced."
: "The analysis completed but did not produce a structured result."}
</div>
) : (
<>
{/* ── RTO.29D — initial post-Analyse reflection ──────── */}
{postAnalyseStatus === "success" && (
<div className="space-y-6" data-testid="initial-reflection-surface">
{/* Current Understanding + Situation — independent vertical flow */}
<div className="flex gap-3 items-start flex-wrap">
{/* Current Understanding — prominent orienting surface */}
<div id="cu-scroll-target" className={`rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-7 pb-8 shadow-sm flex-1 min-w-0 relative ${hasGraph ? 'lg:max-w-2xl' : ''}`}>
<h2 className="mb-4 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/60">
Current Understanding
</h2>
<p className="text-lg leading-relaxed text-gray-800">{propUnderstanding}</p>
{cuSynthesisLoading && (
<div className="absolute inset-0 cu-skeleton-overlay rounded-xl overflow-hidden flex flex-col items-center justify-center bg-gray-50" role="status" aria-live="polite">
<p className="text-sm font-semibold text-teal-800 mb-6 relative z-10 tracking-wide">Clarifying your current understanding</p>
<div className="cu-skeleton-lines w-full max-w-lg px-8 pb-8" aria-hidden="true">
{Array.from({ length: 7 }, (_, i) => (
<div key={i} className={`cu-skeleton-line mb-3 ${['w-[94%]', 'w-[87%]', 'w-[96%]', 'w-[72%]', 'w-[90%]', 'w-[82%]', 'w-[64%]'][i % 7]}`} />
))}
</div>
</div>
)}
</div>
{/* Situation panel during initial reflection */}
{hasGraph && (
<div className="space-y-6 lg:max-w-xs">
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
</div>
)}
{!hasGraph && scenario && (
<div className="space-y-6 lg:max-w-xs">
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-5 py-4">
<h2 className="mb-2 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Situation
</h2>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-600">
{scenario}
</p>
</div>
</div>
)}
</div>
{/* Initial proposed findings — unknowns + plausible interpretations from reconstruction */}
<div className="space-y-6" data-testid="initial-proposed-findings">
{(() => {
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
// Open Questions: unknown nodes NOT in resolvedNodeIds (canonically investigable)
const openUnknowns = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && !resolvedIds.has(n.id),
);
// Clarified questions: unknown nodes that ARE in resolvedNodeIds (read-only historical)
const clarifiedQuestions = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && resolvedIds.has(n.id),
);
// Possible Interpretations: unresolved assumption nodes only (informational, not investigable)
const possibleInterpretations = (graph?.nodes || []).filter(
(n) =>
n.kind === "assumption" &&
n.status !== "resolved" &&
!resolvedIds.has(n.id),
);
return (
<>
{/* OPEN QUESTIONS or zero-Open-Questions milestone invitation (exclusive) */}
{openUnknowns.length > 0 ? (
<div className="space-y-3">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Open Questions
</h2>
{openUnknowns.map((node) => {
const isFocused = focusedPresentationItemId === node.id;
const hasCompleted = Boolean(
focusedInvestigations[node.id] &&
focusedInvestigations[node.id].question
);
function handleNodeClick(n) {
if (hasCompleted && selectedPresentationItemId === n.id) {
setFocusedPresentationItemId(n.id);
setIsFocusedWorkspaceOpen(true);
return;
}
startFocused(n.id);
}
return (
<div key={node.id} className="space-y-1">
<button
onClick={() => handleNodeClick(node)}
style={{ cursor: "pointer" }}
className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50"
>
<span className={`block leading-snug ${isFocused ? "text-sm font-medium text-gray-900" : "text-sm text-gray-600"}`}>{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
{!isFocused && <span className="mt-2 block text-[10px] uppercase tracking-wider text-gray-400">Unclear</span>}
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} findings={findings} />
</button>
</div>
);
})}
{/* Focused content — rendered inline in the initial reflection surface (suppressed when overlay open) */}
{!isFocusedWorkspaceOpen && (() => {
const activeForFocus = openUnknowns.find((n) => focusedPresentationItemId === n.id);
if (!activeForFocus) return null;
const node = activeForFocus;
const hasCompleted = Boolean(
focusedInvestigations[node.id] &&
focusedInvestigations[node.id].question
);
const isFocused = focusedPresentationItemId === node.id;
return (
<FocusedQuestionBody
nodeId={node.id}
isFocused={isFocused}
hasCompletedInvestigation={() => hasCompleted}
focused={getFocusedInvestigation()}
formulationStep={formulationStep}
formulateMsg={formulateMsg}
processingStep={processingStep}
deconstructMsg={deconstructMsg}
focusedAnswer={focusedAnswer}
handleDeconstructSubmit={handleDeconstructSubmit}
retryFormulation={retryFormulation}
setFocusedAnswer={setFocusedAnswer}
setSelectedPresentationItemId={setSelectedPresentationItemId}
setFocusedPresentationItemId={setFocusedPresentationItemId}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
);
})()}
</div>
) : openUnknowns.length === 0 && clarifiedQuestions.length > 0 && !cuSynthesisLoading ? (
<div className="space-y-4">
{/* Milestone invitation + overview action */}
<div className="rounded-xl border-[2.5px] border-teal-300/60 bg-gradient-to-b from-teal-50/40 to-white px-7 pt-5 pb-6">
<p className="text-sm leading-relaxed text-gray-700 mb-4">
{'You\'ve now worked through all of the questions we surfaced. Would you like to see an overview of what we understand so far?'}
</p>
<button
onClick={() => {
onNavigateToReport?.();
}}
disabled={overviewLoading}
style={{ cursor: overviewLoading ? "wait" : "pointer" }}
className="rounded-lg border border-teal-600 bg-white px-4 py-2 text-sm font-medium text-teal-700 hover:bg-teal-50 transition disabled:opacity-60"
>
{overviewLoading ? "Generating overview…" : "Review current understanding"}
</button>
</div>
{/* Investigation overview surface — transient, non-persistent */}
{overviewState && (
<div className="space-y-4" data-testid="investigation-overview">
<div className="rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
<h3 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
What we understand
</h3>
<p className="text-base leading-relaxed text-gray-800">{overviewState.understanding}</p>
</div>
{overviewState.plausibleInterpretations && (
<div className="rounded-xl border border-blue-200/70 bg-blue-50/40 px-8 pt-6 pb-7 shadow-sm">
<h3 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-blue-600/70">
What remains plausible
</h3>
<p className="text-sm leading-relaxed text-gray-700 italic">{overviewState.plausibleInterpretations}</p>
</div>
)}
</div>
)}
</div>
) : null}
{/* QUESTIONS WE HAVE CLARIFIED — resolved unknowns shown post-Done */}
{clarifiedQuestions.length > 0 && (
<div className="space-y-3">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Questions we have clarified
</h2>
{clarifiedQuestions.map((node) => (
<div key={node.id} className="space-y-1">
<div
className="w-full text-left rounded-lg border border-green-200/60 bg-green-50/30 px-5 py-4"
>
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
<div className="mt-2 flex items-center gap-3">
<span className="text-[10px] uppercase tracking-wider text-green-600">Clarified</span>
<button
onClick={() => {
const nextGraph = reopenResolvedUnknown(graph, node.id);
onSituationGraphChange(nextGraph);
setDoneForNowIds((prev) => prev.filter((id) => id !== node.id));
}}
style={{ cursor: "pointer" }}
className="text-[10px] font-medium uppercase tracking-wider text-amber-600 hover:text-amber-700 underline transition"
>
Re-open
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* POSSIBLE INTERPRETATIONS — assumption nodes (informational, not investigable) */}
{possibleInterpretations.length > 0 && (
<div className="space-y-3">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Possible Interpretations
</h2>
{possibleInterpretations.map((node) => (
<div
key={node.id}
className="w-full text-left rounded-lg border border-blue-100 bg-blue-50/40 px-5 py-4"
>
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
<span className="mt-2 block text-[10px] uppercase tracking-wider text-blue-400">Plausible interpretation</span>
</div>
))}
</div>
)}
</>
);
})()}
</div>
</div>
)}
{/* ── Workspace grid: persistent whenever a graph exists ─── */}
{hasGraph && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Current Understanding — independent row, full-width of left area (cols 1-2) */}
{propUnderstanding && hasCurrentSummaryCondition && postAnalyseStatus !== "success" && (
<div id="cu-scroll-target" className="lg:row-start-1 lg:col-span-full rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-7 pb-8 shadow-sm relative">
<CurrentUnderstandingCard currentSummary={graph?.currentSummary || result?.updatedSituationGraph?.currentSummary} plainLanguage={propUnderstanding} />
</div>
)}
{/* Left column: Investigation + Open Questions (rows 2-3, columns 1-2) */}
<div className="lg:row-start-2 lg:row-end-4 lg:col-start-1 lg:col-span-2 space-y-6">
{postAnalyseStatus !== "success" && (
<CurrentInvestigationCard selectedQuestion={result?.selectedQuestion} graph={graph} />
)}
{postAnalyseStatus !== "success" && (
<OpenQuestionsPanel
graph={graph}
selectedPresentationItemId={selectedPresentationItemId}
focusedPresentationItemId={focusedPresentationItemId}
hasFocusedContent={hasFocusedContent}
focused={getFocusedInvestigation()}
formulationStep={formulationStep}
formulateMsg={formulateMsg}
processingStep={processingStep}
deconstructMsg={deconstructMsg}
doneForNowIds={doneForNowIds}
cuSynthesisLoading={cuSynthesisLoading}
startFocused={startFocused}
handleDeconstructSubmit={handleDeconstructSubmit}
retryFormulation={retryFormulation}
setSelectedPresentationItemId={setSelectedPresentationItemId}
setFocusedPresentationItemId={setFocusedPresentationItemId}
setFocusedAnswer={setFocusedAnswer}
focusedAnswer={focusedAnswer}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions}
focusedInvestigations={focusedInvestigations}
setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen}
findings={findings}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
)}
{/* Terminal state — suppressed during initial reflection */}
{postAnalyseStatus !== "success" && status === "success" && !hasSelectedQuestion && showEvidenceLimit && (
<>
{genuineCompletion && (
<CompletionCard summary={resolveCurrentSummary(propUnderstanding || graph?.currentSummary || result?.updatedSituationGraph?.currentSummary)} />
)}
{!genuineCompletion ? (
<EvidenceLimitCard summary={resolveCurrentSummary(propUnderstanding || graph?.currentSummary || result?.updatedSituationGraph?.currentSummary)} />
) : null}
</>
)}
{investigationHistory.length > 0 && (
<InvestigationHistory turns={investigationHistory} />
)}
{/* ── Experiment 12: progress panel A / B / C toggle (temporary experimental UI) — de-emphasised by branch-as-context experiment RTO.25D */}
{hasGraph && (
<div className="hidden space-y-2">
<div className="flex items-center gap-2" role="radiogroup" aria-label="Progress panel variant">
<button
role="radio"
aria-checked={panelVariant === "a"}
onClick={() => setPanelVariant("a")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("b");
if (e.key === "ArrowLeft") setPanelVariant("c");
}}
className={`text-xs transition ${panelVariant === "a" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
A
</button>
<span className="text-gray-300">/</span>
<button
role="radio"
aria-checked={panelVariant === "b"}
onClick={() => setPanelVariant("b")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("c");
if (e.key === "ArrowLeft") setPanelVariant("a");
}}
className={`text-xs transition ${panelVariant === "b" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
B
</button>
<span className="text-gray-300">/</span>
<button
role="radio"
aria-checked={panelVariant === "c"}
onClick={() => setPanelVariant("c")}
onKeyDown={(e) => {
if (e.key === "ArrowRight") setPanelVariant("a");
if (e.key === "ArrowLeft") setPanelVariant("b");
}}
className={`text-xs transition ${panelVariant === "c" ? "font-medium text-gray-700 underline" : "text-gray-400 hover:text-gray-500"}`}
>
C (exp)
</button>
</div>
<div className="opacity-75">
{panelVariant === "a"
? <InvestigationSummaryPanel graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
: panelVariant === "b"
? <InvestigationSummaryPanelV2 graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
: <InvestigationSummaryPanelV3 graph={graph} selectedQuestion={selectedQ} result={result} />
}
</div>
</div>
)}
</div>
{/* Right lane: stable supporting reference (independent column) */}
{(scenario || graph?.centralStatement) && hasCurrentSummaryCondition && postAnalyseStatus !== "success" && (
<div className="lg:row-start-2 lg:row-end-4 lg:col-start-3 space-y-6">
{/* Situation — always here when condition met, independent of left column height */}
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement || scenario} />
{/* RTO.25B — temporarily hidden to reduce competing navigation while branch-experiment is active */}
<div className="hidden">
<InvestigationMap turnCount={investigationHistory.length} />
</div>
</div>
)}
{/* Possible Interpretations — persistent provisional hypothesis cards (spans full workspace width, below Investigation) */}
{(() => {
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
const interpretationNodes = (graph?.nodes || []).filter(
(n) =>
n.kind === "assumption" &&
n.status !== "resolved" &&
!resolvedIds.has(n.id),
);
return postAnalyseStatus !== "success" && interpretationNodes.length > 0 ? (
<div className="lg:row-start-4 lg:col-span-full space-y-3">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Possible Interpretations
</h2>
{interpretationNodes.map((node) => (
<div
key={node.id}
className="w-full text-left rounded-lg border border-blue-100 bg-blue-50/40 px-5 py-4"
>
<span className="block text-sm leading-relaxed text-gray-900">{node.label}</span>
{node.description && node.description !== node.label && (
<p className="mt-1.5 text-xs leading-snug text-gray-500">{node.description}</p>
)}
<span className="mt-2 block text-[10px] uppercase tracking-wider text-blue-400">Plausible interpretation</span>
</div>
))}
</div>
) : null;
})()}
</div>
)}
{/* ── Focused investigation overlay workspace ─────────── */}
{isFocusedWorkspaceOpen && (
<div
className="!mt-0 fixed inset-0 z-50 flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) setIsFocusedWorkspaceOpen(false); }}
>
{/* Dimmed background — prevents interaction with page behind overlay */}
<div className="absolute inset-0 bg-gray-900/40 backdrop-blur-[1px]" />
{/* Workspace container: constrained to viewport height, not pushed by content */}
<div
className="relative z-10 my-6 flex w-[94vw] max-h-[calc(100vh-3rem)] max-w-[1600px] flex-col overflow-hidden rounded-xl border border-gray-200/60 bg-white shadow-xl sm:w-[90vw] md:w-[88vw]"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Focused investigation workspace"
>
{/* Floating close control — persistent, top-right inside panel */}
<button
onClick={(e) => { e.stopPropagation(); setFocusedAnswer(""); setFocusedPresentationItemId(null); setIsFocusedWorkspaceOpen(false); }}
style={{ cursor: "pointer" }}
aria-label="Close workspace"
title="Close workspace"
className="absolute right-4 top-3 z-20 flex items-center gap-2 rounded-lg border border-gray-300 bg-white/90 px-4 py-2 text-sm font-medium text-gray-600 shadow-sm transition hover:bg-gray-50"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M1 1l12 12M13 1L1 13"/></svg>
Close workspace
</button>
{/* Scrollable workspace body */}
<div className="flex-1 overflow-y-auto px-4 pb-8 pt-[64px]">
{hasFocusedContent() || formulationStep === "active" ? (
<>
<FocusedInvestigationWorkspace
nodeId={focusedPresentationItemId}
focused={getFocusedInvestigation()}
formulationStep={formulationStep}
formulateMsg={formulateMsg}
processingStep={processingStep}
deconstructMsg={deconstructMsg}
focusedAnswer={focusedAnswer}
handleDeconstructSubmit={handleDeconstructSubmit}
retryFormulation={retryFormulation}
setFocusedAnswer={setFocusedAnswer}
setSelectedPresentationItemId={setSelectedPresentationItemId}
setFocusedPresentationItemId={setFocusedPresentationItemId}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen}
hasCompletedInvestigation={() => {
if (!focused) return false;
const q = focused.question;
return Boolean(q?.trim());
}}
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
findings={findings}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
{/* Workspace navigation — hidden during formulation/loading states */}
{formulationStep !== "active" && (
<FocusedWorkspaceNavigation
nodeId={focusedPresentationItemId}
doneForNow={() => {
if (processingStep === "active") return;
/* ── Immediate transition: resolve target node BEFORE async — cuSynthesisLoading also set ─── */
if (onImmediateGraphChange && focusedPresentationItemId) {
const currentGraph = result?.situationGraph;
if (currentGraph) {
const resolvedIds = new Set(currentGraph.resolvedNodeIds || []);
resolvedIds.add(focusedPresentationItemId);
const nextNodes = (currentGraph.nodes || []).map((n) =>
n.id === focusedPresentationItemId ? { ...n, status: "resolved" } : n,
);
onImmediateGraphChange({
...currentGraph,
nodes: nextNodes,
resolvedNodeIds: Array.from(resolvedIds),
});
}
}
onSummaryUpdate?.(focusedPresentationItemId);
setDoneForNowIds((prev) => [...prev, focusedPresentationItemId]);
setFocusedAnswer("");
setFocusedPresentationItemId(null);
/* ── v0.49 fix — close overlay after semantic action ─── */
setIsFocusedWorkspaceOpen(false);
}}
isDoneForNowActive={Boolean(getFocusedInvestigation()?.question?.trim())}
isProcessing={processingStep === "active"}
onImmediateGraphChange={onImmediateGraphChange}
/>
)}
</>
) : (
<div className="w-full max-w-[1400px] mx-auto">
<div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
<p className="text-base font-medium text-gray-500">Formulating your question</p>
<p className="mt-3 text-sm text-gray-400">{formulateMsg}</p>
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Developer details — full-width beneath workspace */}
{(status === "success" || status === "error") && graph && (
<DeveloperDetails
graph={graph}
selectedQuestion={selectedQ}
diagnostics={diagnostics}
newlySurfacedNodeIds={newlySurfacedNodeIds}
updateResult={updateStatus === "success" ? result : null}
/>
)}
</>
)}
{/* ── Errors (always visible above debug) ─────────── */}
{(status === "error" || updateStatus === "error") && (
<div className="space-y-3">
{status === "error" && result?.error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 whitespace-pre-wrap">
Error: {result.error}
</div>
)}
{updateStatus === "error" && !isProviderUnavailable && !isMalformedResponse && result?.updateError && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
Update error: {result.updateError.error || JSON.stringify(result.updateError)}
</div>
)}
</div>
)}
</div>
);
}
export { useLoadingStatus, INITIAL_MESSAGES, UPDATE_MESSAGES, LoadingOverlay, resolveCurrentSummary, isTechnicalSummary, ContinueLaterBanner };