2232 lines
100 KiB
React
2232 lines
100 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";
|
|
|
|
// ── 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,
|
|
}) {
|
|
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 = [...(focusedContributions || [])].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 = [...(focusedContributions || [])].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 }) {
|
|
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,
|
|
startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId,
|
|
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
|
|
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
|
|
findings,
|
|
onUpdateFindingDisposition,
|
|
onUpdateFindingProposition,
|
|
}) {
|
|
const openNodes = (graph?.nodes || []).filter(
|
|
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
|
|
);
|
|
|
|
if (openNodes.length <= 0) return null;
|
|
|
|
// 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,
|
|
currentUnderstanding: propUnderstanding,
|
|
result,
|
|
answer,
|
|
setAnswer,
|
|
onAnswerSubmit,
|
|
lastSubmittedAnswer,
|
|
onRestart,
|
|
focusedContributions,
|
|
onFocusedContribution,
|
|
findings,
|
|
onUpdateFindingDisposition,
|
|
onUpdateFindingProposition,
|
|
/* ── v0.49 — done-for-now promotion callback ───────── */
|
|
onSummaryUpdate,
|
|
}) {
|
|
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(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 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 ${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>
|
|
</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: unresolved unknown nodes only (investigable)
|
|
const openUnknowns = (graph?.nodes || []).filter(
|
|
(n) =>
|
|
n.kind === "unknown" &&
|
|
n.status !== "resolved" &&
|
|
!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 — unknown nodes (clickable → focused investigation) */}
|
|
{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>
|
|
)}
|
|
|
|
{/* 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 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">
|
|
<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}
|
|
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;
|
|
/* ── v0.49 — promote eligible focused findings into Current Understanding ─── */
|
|
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"}
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<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 };
|