Files
confidence-engine/components/reasoning-workspace.jsx
T
robbond 68be2344c6 fix(rto): persist focused contributions immediately after deconstruct success
The successful focused deconstruct calls onFocusedContribution which
updates parent state, but never persisted the new collection to
sessionStorage. This meant an immediate reload would lose the
contribution.

Fix: add a useEffect in ReasoningWorkspace that watches the
focusedContributions prop for changes and saves via the existing
saveSession mechanism. A ref guard prevents double-save alongside the
existing updateStatus-success effect.
2026-08-21 18:33:20 +01:00

1411 lines
58 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>
);
}
// ── Session persistence hook (Phase 3) ────────────────────────
function useSessionPersistence() {
const [sessionReady, setSessionReady] = useState(false);
const sessionKey = "confidence-engine-session";
function saveSession(state) {
if (typeof sessionStorage === "undefined") return;
try {
sessionStorage.setItem(sessionKey, JSON.stringify(state));
} catch (_) { /* quota or disabled — ignore silently */ }
}
function loadSession() {
if (typeof sessionStorage === "undefined") return null;
try {
const raw = sessionStorage.getItem(sessionKey);
return raw ? JSON.parse(raw) : null;
} catch (_) {
return null;
}
}
function clearSession() {
if (typeof sessionStorage === "undefined") return;
try { sessionStorage.removeItem(sessionKey); } catch (_) {}
}
return { saveSession, loadSession, clearSession, sessionReady: true };
}
// ── 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>
);
}
// ── 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 border-gray-100/80 bg-transparent px-6 pt-5 pb-6">
<h2 className="mb-3 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Understanding
</h2>
<p className="text-sm leading-relaxed text-gray-600">{summary}</p>
</div>
);
}
// ── Plain-language understanding card (from pipeline summary) ──
function PlainLanguageCard({ summary }) {
if (!summary) return null;
return (
<div className="rounded-lg border border-gray-100/80 bg-transparent px-6 pt-5 pb-6">
<h2 className="mb-3 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Understanding
</h2>
<p className="text-sm leading-relaxed text-gray-600">{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;
}
// ── 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,
}) {
const openNodes = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
);
if (openNodes.length <= 1) return null;
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={() => {
if (!focused?.question?.trim() || (focused && !hasFocusedContent())) {
setSelectedPresentationItemId(
selectedPresentationItemId === node.id ? null : node.id,
);
}
}}
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>
)}
{isFocused && (() => {
const focusedNode = graph?.nodes.find((n) => n.id === node.id);
return (
<div className="mt-4 space-y-4">
{hasFocusedContent() && (
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
{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" && (
<div>
<label htmlFor={`rw-answer-${node.id}`} className="mb-2 block text-sm font-medium text-gray-700">Your response</label>
<textarea id={`rw-answer-${node.id}`} 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(focusedPresentationItemId, 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" && <p className="text-sm text-blue-600/70">{deconstructMsg}</p>}
{focused?.result && (
<>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">What we learned</h3><ul className="list-disc pl-5 space-y-1">{focused.result.observations.map((o, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{o}</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">{focused.result.uncertainties.map((u, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{u}</li>))}</ul></div>
{focused.result.assumptions?.length > 0 && <div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Assumptions in this response</h3><ul className="list-disc pl-5 space-y-1">{focused.result.assumptions.map((a, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{a}</li>))}</ul></div>}
{focused.result.relationships?.length > 0 && <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">{focused.result.relationships.map((r, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{r.from} {r.to} ({r.type})</li>))}</ul></div>}
{focused.result.possibleFollowUpQuestions?.length > 0 && <div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Questions this raises</h3><ul className="list-disc pl-5 space-y-1">{focused.result.possibleFollowUpQuestions.map((q, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{q}</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 className="mt-4 mb-3 flex items-center justify-between gap-4">
<button onClick={(e) => { e.stopPropagation(); setFocusedPresentationItemId(null); }} style={{ cursor: "pointer" }} className="text-sm text-gray-400 underline hover:text-gray-600 transition whitespace-nowrap">Back to open questions</button>
<button onClick={(e) => { e.stopPropagation(); setFocusedPresentationItemId(null); setDoneForNowIds((prev) => [...prev, node.id]); }} 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>
</div>
);
})()}
</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">
<p className="text-sm text-gray-500 leading-snug">{node.label}</p>
<button onClick={() => setDoneForNowIds(doneForNowIds.filter(id => id !== node.id))} style={{ cursor: "pointer" }} className="mt-2 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 default function ReasoningWorkspace({
scenario,
status,
updateStatus,
currentUnderstanding: propUnderstanding,
result,
answer,
setAnswer,
onAnswerSubmit,
lastSubmittedAnswer,
onRestart,
focusedContributions,
onFocusedContribution,
}) {
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([]);
// ── RTO.29D — post-Analyse initial reflection surface ─────────
const [initialReflectionActive, setInitialReflectionActive] = useState(false);
const [postAnalyseStatus, setPostAnalyseStatus] = useState(null);
const { saveSession, loadSession } = useSessionPersistence();
// Persist workspace state on every successful update (Phase 3)
useEffect(() => {
if (updateStatus === "success" && result?.situationGraph) {
saveSession({
scenario,
situationGraph: result.situationGraph,
selectedQuestion: result.selectedQuestion,
summary: result.summary || propUnderstanding,
updatedAt: new Date().toISOString(),
focusedContributions,
});
}
}, [updateStatus, result, focusedContributions]);
// 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("");
// ── Persist focused contributions immediately after deconstruct success ────
const lastPersistedContribCount = useRef(0);
useEffect(() => {
if (!graph) return;
if (focusedContributions.length === lastPersistedContribCount.current) return;
lastPersistedContribCount.current = focusedContributions.length;
saveSession({
scenario,
situationGraph: graph,
selectedQuestion: result?.selectedQuestion,
summary: result?.summary || propUnderstanding,
updatedAt: new Date().toISOString(),
focusedContributions,
});
}, [focusedContributions]);
function getFocusedInvestigation() {
if (!focusedPresentationItemId) return null;
return focusedInvestigations[focusedPresentationItemId] || null;
}
function focusItem(nodeId) {
setSelectedPresentationItemId(nodeId);
setFocusedPresentationItemId(nodeId);
}
const focused = getFocusedInvestigation();
// ── RTO.13B — workflow handlers ──────────────────────────────
function startFocused(nodeId) {
const target = nodeId || focusedPresentationItemId;
if (!target) return;
setFocusedPresentationItemId(target);
setFocusedAnswer("");
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");
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");
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,
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,
});
setProcessingStep("idle");
setFocusedInvestigations((prev) => ({
...prev,
[targetNodeId]: { ...prev[targetNodeId], result: data, answer: answerText, error: null },
}));
} catch (err) {
setProcessingStep("idle");
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 hasFocusedContent() {
if (!focused) return false;
const q = focused.question;
return Boolean(q?.trim()) || formulationStep === "active" || processingStep === "active" || focused.error;
}
// ── End RTO.13B ──────────────────────────────────────────────
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(() => {
const entering = status === "success" && hasGraph && propUnderstanding && !hasSelectedQuestion;
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 does anything meaningful
useEffect(() => {
if (!initialReflectionActive) return;
if (selectedPresentationItemId || focusedPresentationItemId || investigationHistory.length > 0 || formulationStep !== "idle" || processingStep !== "idle") {
setInitialReflectionActive(false);
setPostAnalyseStatus(null);
}
}, [selectedPresentationItemId, focusedPresentationItemId, investigationHistory, formulationStep, processingStep]);
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">
{/* What I understood */}
<div className="rounded-lg border border-blue-200/60 bg-blue-50/40 px-6 pt-5 pb-6">
<h2 className="mb-3 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
What I understood
</h2>
<p className="text-sm leading-relaxed text-gray-700">{propUnderstanding}</p>
</div>
{/* Initial proposed findings */}
<div className="space-y-3" data-testid="initial-proposed-findings">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Initial proposed findings
</h2>
{(() => {
const resolvedIds = new Set(graph?.resolvedNodeIds || []);
return (
graph?.nodes?.map((node) =>
node.kind === "unknown" && node.status !== "resolved" && !resolvedIds.has(node.id) ? (
<button
key={node.id}
onClick={() => startFocused(node.id)}
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 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>
)}
</button>
) : null,
) || []
);
})()}
</div>
</div>
)}
{/* ── Workspace grid: persistent whenever a graph exists ─── */}
{hasGraph && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* ── Left lane: active conversation & notebook ───────── */}
<div className={`space-y-6 ${hasGraph ? 'lg:col-span-2' : 'lg:col-span-full'}`}>
{/* Current investigation (prominent hero section) */}
{postAnalyseStatus !== "success" && (
<CurrentInvestigationCard selectedQuestion={result?.selectedQuestion} graph={graph} />
)}
{/* ── Open questions (case workspace) — no ranking bias ── */}
{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}
/>
)}
{/* Terminal state — suppressed during initial reflection */}
{postAnalyseStatus !== "success" && status === "success" && !hasSelectedQuestion && (
<>
{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} />
)}
{/* Supporting context within conversation lane */}
{hasCurrentSummaryCondition && (
<>
{postAnalyseStatus !== "success" && (
<CurrentUnderstandingCard currentSummary={graph?.currentSummary || result?.updatedSituationGraph?.currentSummary} plainLanguage={propUnderstanding || null} />
)}
{/* ── 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 ───────── */}
{hasCurrentSummaryCondition && (
<div className="space-y-6 lg:col-span-1">
{/* RTO.26B — situation card from scenario text when no graph */}
{!graph && scenario && (
<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>
)}
{graph && postAnalyseStatus !== "success" && <OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />}
{/* RTO.25B — temporarily hidden to reduce competing navigation while branch-experiment is active */}
<div className="hidden">
<InvestigationMap turnCount={investigationHistory.length} />
</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 };