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

391 lines
15 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";
// ── 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" }}
/>
);
}
// ── Situation card ────────────────────────────────────────────
function SituationCard({ centralStatement }) {
if (!centralStatement) return null;
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
Your situation
</h2>
<p className="text-base leading-relaxed text-gray-900">{centralStatement}</p>
</div>
);
}
// ── Current understanding card ────────────────────────────────
function CurrentUnderstanding({ currentSummary }) {
if (currentSummary) {
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">{currentSummary}</p>
</div>
);
}
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<p className="text-sm leading-relaxed text-gray-600">
We have started to separate what is known from what still needs checking.
</p>
</div>
);
}
// ── Current focus card ────────────────────────────────────────
function CurrentFocus({ graph }) {
if (!graph?.activeUnknownNodeId || !graph.nodes?.length) return null;
const activeNode = graph.nodes.find(
(n) => n.id === graph.activeUnknownNodeId
);
if (!activeNode) return null;
// Find the unknown label that maps to activeUnknownNodeId from selectedQuestion or nodes
const statusText =
activeNode.status === "resolved" ? "Answered" : "Under investigation";
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
What we are working out
</h2>
<p className="text-base font-medium text-gray-900">{activeNode.label}</p>
{activeNode.description && activeNode.description !== activeNode.label && (
<p className="mt-1 text-sm text-gray-600">Why it matters: {activeNode.description}</p>
)}
<span className="mt-2 inline-block rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-600">
{statusText}
</span>
</div>
);
}
// ── Next question card (prominent) ────────────────────────────
function NextQuestionCard({ selectedQuestion }) {
if (!selectedQuestion) return null;
const q = typeof selectedQuestion === "string" ? selectedQuestion : selectedQuestion.question;
if (!q) return null;
return (
<div className="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">
Next question
</h2>
<p className="text-xl font-semibold leading-snug text-gray-900">{q}</p>
</div>
);
}
// ── Reasoning progress card ────────────────────────────────────
function ReasoningProgress({ graph }) {
if (!graph?.nodes?.length) return null;
const unknowns = graph.nodes.filter((n) => n.kind === "unknown");
const remainingCount = unknowns.filter((u) => u.status !== "resolved").length;
return (
<div className="rounded-lg border border-gray-200 bg-white px-5 py-4">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
Reasoning progress
</h2>
{remainingCount > 0 ? (
<p className="mb-3 text-sm leading-relaxed text-gray-700">
We have identified {remainingCount} area{remainingCount === 1 ? "" : "s"} that still need investigation.
</p>
) : (
<p className="mb-3 text-sm leading-relaxed text-gray-700">
All areas under investigation are now complete.
</p>
)}
{graph.activeUnknownNodeId && (() => {
const activeNode = graph.nodes.find((n) => n.id === graph.activeUnknownNodeId);
if (!activeNode) return null;
return (
<>
<h3 className="mb-1 text-xs font-medium uppercase tracking-wide text-gray-400">
Current focus
</h3>
<p className="text-sm font-medium text-gray-900">{activeNode.label}</p>
{activeNode.description && activeNode.description !== activeNode.label && (
<p className="mt-1 text-xs text-gray-500">Why this matters: {activeNode.description}</p>
)}
</>
);
})()}
{!graph.activeUnknownNodeId && remainingCount === 0 && (
<p className="text-sm text-gray-500">There is no active area of investigation at the moment.</p>
)}
</div>
);
}
// ── No question state ─────────────────────────────────────────
function NoQuestionMessage({ noQuestionReason }) {
let message = "There is no next question at the moment.";
if (noQuestionReason) {
const reason = String(noQuestionReason);
if (reason.toLowerCase().includes("satisfied") || reason.toLowerCase().includes("complete")) {
message += " The situation has been fully investigated.";
} else if (reason.toLowerCase().includes("insufficient")) {
message += " We need more information to determine the next step.";
} else {
message += " " + reason;
}
}
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-4 text-center">
<p className="text-sm text-gray-600">{message}</p>
</div>
);
}
// ── 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">
<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">
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>
);
}
// ── 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>
);
}
// ── Main workspace component ──────────────────────────────────
export default function ReasoningWorkspace({
status,
updateStatus,
result,
answer,
setAnswer,
onAnswerSubmit,
}) {
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES,
status === "loading"
);
const { elapsed: updateElapsed, currentMessage: updateMsg } = useLoadingStatus(
UPDATE_MESSAGES,
updateStatus === "loading"
);
const canAnswer =
status === "success" &&
updateStatus === "idle" &&
Boolean(result?.situationGraph) &&
Boolean(result?.selectedQuestion);
const selectedQ = result?.selectedQuestion ?? null;
const graph = result?.situationGraph ?? null;
const diagnostics = result?.diagnostics ?? null;
const newlySurfacedNodeIds = result?.newlySurfacedNodeIds || [];
const noQuestionReason = diagnostics?.noQuestionReason ?? null;
return (
<div className="space-y-5">
{/* ── Loading overlays ─────────────────────────────── */}
<LoadingOverlay
isLoading={status === "loading"}
elapsed={startElapsed}
currentMessage={startMsg}
variant="initial"
/>
{updateStatus === "loading" && (
<div className="h-px bg-gray-100" />
)}
<LoadingOverlay
isLoading={updateStatus === "loading"}
elapsed={updateElapsed}
currentMessage={updateMsg}
variant="update"
/>
{/* ── User-facing workspace ────────────────────────── */}
{(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">
{noQuestionReason
? "Validation failed — no structured graph output was produced."
: "The analysis completed but did not produce a structured result."}
</div>
) : (
<>
{/* When analysis succeeded but there's no question to answer */}
{status === "success" && !canAnswer && graph && (
<NoQuestionMessage noQuestionReason={noQuestionReason} />
)}
{graph && <SituationCard centralStatement={graph.centralStatement} />}
{graph && <CurrentUnderstanding currentSummary={graph.currentSummary} />}
{graph && <CurrentFocus graph={graph} />}
{canAnswer && <NextQuestionCard selectedQuestion={selectedQ} />}
{graph && <ReasoningProgress graph={graph} />}
{/* ── Answer form ──────────────────────────────── */}
{canAnswer && (
<form onSubmit={onAnswerSubmit} 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 answer
</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">
{updateStatus === "loading" ? "Updating..." : "One update turn only in this prototype."}
</p>
<button
type="submit"
disabled={updateStatus === "loading" || !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"
>
{updateStatus === "loading" ? "Updating..." : "Update situation"}
</button>
</div>
</form>
)}
{/* ── 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" && 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 };