Files
confidence-engine/components/reasoning-workspace.jsx
T

770 lines
28 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";
// ── 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 bg-blue-50 px-5 py-4 text-center">
<p className="text-sm text-blue-800">
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 border-green-300 bg-green-50 p-6">
<h2 className="mb-2 text-sm font-bold uppercase tracking-wide text-green-700">
Current investigation
</h2>
<p className="text-xl font-semibold leading-snug text-gray-900">{q}</p>
{whyMattersText && (
<div className="mt-4 space-y-1">
<h3 className="text-xs font-bold uppercase tracking-wide text-green-800">Why this matters</h3>
<p className="text-sm text-gray-700">{whyMattersText}</p>
</div>
)}
</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>
);
}
// ── 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-200 bg-white p-5">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">
Current understanding
</h2>
<p className="text-sm leading-relaxed text-gray-700">{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-200 bg-white p-5">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">
Current understanding
</h2>
<p className="text-sm leading-relaxed text-gray-700">{summary}</p>
</div>
);
}
// ── Investigation history card (readable notebook style) ──────
function InvestigationHistoryCard({ turn }) {
const isCollapsed = turn._collapsed;
const isAnswered = Boolean(turn.answer?.trim());
const displayedQuestion =
isCollapsed && turn.question.length > 60
? `${turn.question.slice(0, 60)}…`
: turn.question;
return (
<details
className="rounded-lg border border-gray-100 bg-gray-50/60"
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-3 pt-1">
<p>
<strong>Your answer</strong>
</p>
<p className="text-gray-700">{turn.answer}</p>
{turn.acknowledgement && (
<>
<hr className="border-gray-200" />
<p>
<strong>What changed</strong>
</p>
<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-xs font-bold uppercase tracking-wider text-gray-400">
Investigation 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 bg-gray-50/70 px-5 py-4">
<h2 className="mb-2 text-xs font-bold uppercase tracking-widest text-gray-400">
Original situation
</h2>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-700">
{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-lg border border-blue-200 bg-blue-50/60 px-4 py-2 text-xs text-blue-800">
{summary}
</div>
</div>
);
}
// ── Developer details disclosure ──────────────────────────────
function DeveloperDetails({ graph, selectedQuestion, diagnostics, newlySurfacedNodeIds, updateResult }) {
return (
<details className="rounded-lg border border-gray-200 bg-gray-50">
<summary className="cursor-pointer px-5 py-3 text-sm font-medium text-gray-600 hover:text-gray-800">
Developer details
</summary>
<div className="border-t border-gray-200 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-gray-200 bg-blue-50 px-5 py-6" 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-900">Working through your situation</span>
</div>
<p className="mt-2 text-sm text-blue-700">{statusText}</p>
<p className="mt-1 text-xs text-blue-500" 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;
}
export default function ReasoningWorkspace({
scenario,
status,
updateStatus,
currentUnderstanding: propUnderstanding,
result,
answer,
setAnswer,
onAnswerSubmit,
lastSubmittedAnswer,
onRestart,
}) {
const [investigationHistory, setInvestigationHistory] = useState([]);
const turnCounter = useRef(0);
const pendingTurnRef = useRef(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(),
});
}
}, [updateStatus, result]);
// 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 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";
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES,
status === "loading"
);
const { elapsed: updateElapsed, currentMessage: updateMsg } = useLoadingStatus(
UPDATE_MESSAGES,
updateStatus === "loading"
);
const isUpdating = updateStatus === "loading";
const hasSelectedQuestion = Boolean(result?.selectedQuestion);
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;
return (
<div className="space-y-6" data-testid="reasoning-workspace">
{/* ── Loading overlays ─────────────────────────────── */}
{status === "loading" && (
<LoadingOverlay
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>
) : (
<>
{/* ── Investigation summary card ─────────────── */}
<InvestigationSummaryPanel graph={graph} selectedQuestion={selectedQ} result={result} updateStatus={updateStatus} />
{/* ── Active investigation context (always visible while question exists) ─ */}
{hasSelectedQuestion && (
<>
<CurrentInvestigationCard selectedQuestion={selectedQ} graph={graph} />
</>
)}
{/* Post-update acknowledgement — only after success, never during loading */}
{updateStatus === "success" && !isUpdating && <UpdateAcknowledgement updateResult={result} />}
{/* ── Response area (replaced by loading / error during reasoning) ─ */}
{!isUpdating && canAnswer ? (
<form onSubmit={handleUpdateCaptureAndSubmit} className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
<div>
<label htmlFor="rw-answer" className="mb-2 block text-sm font-medium text-gray-700">
Your response
</label>
<textarea
id="rw-answer"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
rows={4}
disabled={updateStatus === "loading"}
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="Enter the answer to the selected question..."
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-400">
One update turn only in this prototype.
</p>
<button
type="submit"
disabled={!answer.trim()}
className="rounded-lg bg-blue-700 px-5 py-2 text-sm font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
Update situation
</button>
</div>
</form>
) : !isUpdating ? null : (
/* Local update loading card — replaces the response panel during reasoning */
<LoadingOverlay
elapsed={updateElapsed}
currentMessage={updateMsg}
variant="update"
/>
)}
{/* ── Terminal state: outcome card — hidden during update loading ─ */}
{!isUpdating && status === "success" && !hasSelectedQuestion && graph && genuineCompletion && (
<CompletionCard summary={resolveCurrentSummary(propUnderstanding || graph?.currentSummary || result?.updatedSituationGraph?.currentSummary)} />
)}
{!isUpdating && status === "success" && !hasSelectedQuestion && graph && !genuineCompletion && (
<EvidenceLimitCard summary={resolveCurrentSummary(propUnderstanding || graph?.currentSummary || result?.updatedSituationGraph?.currentSummary)} />
)}
{/* ── Current understanding (active investigation only) ─ */}
{hasCurrentSummaryCondition && !hasSelectedQuestion === false && (
<CurrentUnderstandingCard currentSummary={graph?.currentSummary || result?.updatedSituationGraph?.currentSummary} plainLanguage={propUnderstanding || null} />
)}
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
{investigationHistory.length > 0 && <InvestigationHistory turns={investigationHistory} />}
{/* ── Developer details (collapsed by default) ── */}
{(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 };