The response form now appears immediately after the active question so the user can read and answer without scrolling. Everything else becomes supporting context beneath the interaction area.
560 lines
21 KiB
React
560 lines
21 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";
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
function resolveCurrentSummary(currentSummary) {
|
|
if (isTechnicalSummary(currentSummary)) {
|
|
return null;
|
|
}
|
|
return currentSummary || null;
|
|
}
|
|
|
|
// ── 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() {
|
|
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">You have provided enough information. The situation has been fully investigated.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Evidence exhaustion card (terminal state) ─────────────────
|
|
function EvidenceExhaustedCard({ noQuestionReason }) {
|
|
let message = "There is not yet enough evidence to guide the next step.";
|
|
if (noQuestionReason) {
|
|
const reason = String(noQuestionReason);
|
|
if (reason.toLowerCase().includes("insufficient")) {
|
|
message = reason;
|
|
}
|
|
}
|
|
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">Further investigation needed</h2>
|
|
<p className="text-base text-gray-700">{message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Current understanding card ────────────────────────────────
|
|
function CurrentUnderstandingCard({ currentSummary }) {
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ── Investigation history card (readable notebook style) ──────
|
|
function InvestigationHistoryCard({ turn }) {
|
|
const isCollapsed = turn._collapsed;
|
|
|
|
return (
|
|
<details
|
|
className="rounded-lg border border-gray-100 bg-gray-50/60"
|
|
key={turn.id}
|
|
open={!isCollapsed}
|
|
>
|
|
<summary className="cursor-pointer px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">
|
|
{isCollapsed ? "Q: " : "✓ "}
|
|
{turn.question.length > 60 && !isCollapsed
|
|
? turn.question.slice(0, 60) + "…"
|
|
: turn.question}
|
|
{!isCollapsed && (
|
|
<span className="ml-2 text-xs font-normal text-gray-400">— answered</span>
|
|
)}
|
|
</summary>
|
|
|
|
<div className="px-4 pb-3 pt-1 space-y-2">
|
|
<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">
|
|
<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} turn={{ ...turn, _collapsed: turn.id !== latestId }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Original situation (collapsed by default) ─────────────────
|
|
function OriginalSituation({ scenario, centralStatement }) {
|
|
if (!scenario) return null;
|
|
|
|
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">
|
|
Original situation
|
|
</summary>
|
|
<div className="border-t border-gray-200 px-5 pb-4 pt-3 space-y-3">
|
|
{centralStatement && (
|
|
<p className="text-sm text-gray-600 italic">{centralStatement}</p>
|
|
)}
|
|
<p className="whitespace-pre-wrap text-sm text-gray-800">{scenario}</p>
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
// ── Update acknowledgement ────────────────────────────────────
|
|
function UpdateAcknowledgement({ updateResult }) {
|
|
if (!updateResult) return null;
|
|
|
|
const summary = updateResult.summary;
|
|
const hasResolvedNodes = updateResult.resolvedUnknownNodeIds?.length > 0;
|
|
const hasAffectedNodes = updateResult.affectedNodeIds?.length > 0;
|
|
const graph = updateResult.updatedSituationGraph;
|
|
|
|
function getNodeText(nodeId) {
|
|
if (!graph?.nodes) return String(nodeId);
|
|
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
if (node) {
|
|
const parts = [node.label];
|
|
if (node.status !== "resolved") {
|
|
parts.push(node.status);
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
return String(nodeId);
|
|
}
|
|
|
|
let changedText;
|
|
if (hasResolvedNodes) {
|
|
const items = [];
|
|
for (const id of updateResult.resolvedUnknownNodeIds.slice(0, 3)) {
|
|
items.push(getNodeText(id));
|
|
}
|
|
if (updateResult.resolvedUnknownNodeIds.length > 3) {
|
|
items.push(`and ${updateResult.resolvedUnknownNodeIds.length - 3} more resolved`);
|
|
}
|
|
changedText = items.join(". ") + ".";
|
|
} else if (hasAffectedNodes) {
|
|
const items = [];
|
|
for (const id of updateResult.affectedNodeIds.slice(0, 3)) {
|
|
items.push(getNodeText(id));
|
|
}
|
|
if (updateResult.affectedNodeIds.length > 3) {
|
|
items.push(`and ${updateResult.affectedNodeIds.length - 3} more affected`);
|
|
}
|
|
changedText = items.join(". ") + ".";
|
|
} else if (updateResult.changesApplied) {
|
|
const ca = updateResult.changesApplied;
|
|
const parts = [];
|
|
if (ca.addedNodeCount) parts.push(`${ca.addedNodeCount} node(s) added`);
|
|
if (ca.updatedNodeCount) parts.push(`${ca.updatedNodeCount} node(s) updated`);
|
|
if (ca.addedEdgeCount) parts.push(`${ca.addedEdgeCount} edge(s) added`);
|
|
if (ca.removedEdgeCount) parts.push(`${ca.removedEdgeCount} edge(s) removed`);
|
|
changedText = parts.length > 0 ? parts.join(", ") : null;
|
|
}
|
|
|
|
const displayMessage = summary || changedText || "Your answer has been added to the investigation.";
|
|
|
|
return (
|
|
<div className="rounded-lg border border-blue-100 bg-blue-50/80 px-5 py-3 text-sm text-blue-900">
|
|
{displayMessage}
|
|
</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">
|
|
<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 ──────────────────────────────────
|
|
export default function ReasoningWorkspace({
|
|
scenario,
|
|
status,
|
|
updateStatus,
|
|
result,
|
|
answer,
|
|
setAnswer,
|
|
onAnswerSubmit,
|
|
lastSubmittedAnswer,
|
|
}) {
|
|
const [investigationHistory, setInvestigationHistory] = useState([]);
|
|
const turnCounter = useRef(0);
|
|
const pendingTurnRef = useRef(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 { 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;
|
|
const graph = result?.situationGraph ?? null;
|
|
const diagnostics = result?.diagnostics ?? null;
|
|
const newlySurfacedNodeIds = result?.newlySurfacedNodeIds || [];
|
|
const noQuestionReason = diagnostics?.noQuestionReason ?? null;
|
|
|
|
const genuineCompletion = hasGenuineCompletion(graph);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* ── Loading overlays ─────────────────────────────── */}
|
|
{status === "loading" && (
|
|
<LoadingOverlay
|
|
elapsed={startElapsed}
|
|
currentMessage={startMsg}
|
|
variant="initial"
|
|
/>
|
|
)}
|
|
{updateStatus === "loading" && (
|
|
<LoadingOverlay
|
|
elapsed={updateElapsed}
|
|
currentMessage={updateMsg}
|
|
variant="update"
|
|
/>
|
|
)}
|
|
|
|
{/* ── 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">
|
|
{noQuestionReason
|
|
? "Validation failed — no structured graph output was produced."
|
|
: "The analysis completed but did not produce a structured result."}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* ── 1. Current investigation (or terminal state) ─ */}
|
|
{canAnswer && <CurrentInvestigationCard selectedQuestion={selectedQ} graph={graph} />}
|
|
|
|
{/* Terminal states occupy the hero position when no question */}
|
|
{status === "success" && !canAnswer && graph && genuineCompletion && (
|
|
<CompletionCard />
|
|
)}
|
|
{status === "success" && !canAnswer && graph && !genuineCompletion && updateStatus !== "success" && (
|
|
<EvidenceExhaustedCard noQuestionReason={noQuestionReason} />
|
|
)}
|
|
|
|
{/* ── 2. Post-update acknowledgement ─────────────── */}
|
|
{updateStatus === "success" && canAnswer && <UpdateAcknowledgement updateResult={result} />}
|
|
|
|
{/* ── 3. Response form ───────────────────────────── */}
|
|
{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">
|
|
{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>
|
|
)}
|
|
|
|
{/* ── 4. Current understanding ───────────────────── */}
|
|
{canAnswer && graph && <CurrentUnderstandingCard currentSummary={graph.currentSummary} />}
|
|
|
|
{/* ── 5. Investigation history ───────────────────── */}
|
|
<InvestigationHistory turns={investigationHistory} />
|
|
|
|
{/* ── 6. Original situation (collapsed by default) ─ */}
|
|
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
|
|
|
|
{/* ── 7. Developer details (collapsed by default) ── */}
|
|
{(status === "success" || status === "error") && graph && (
|
|
<DeveloperDetails
|
|
graph={graph}
|
|
selectedQuestion={selectedQ}
|
|
diagnostics={diagnostics}
|
|
newlySurfacedNodeIds={newlySurfacedNodeIds}
|
|
updateResult={updateStatus === "success" ? result : null}
|
|
/>
|
|
)}
|
|
|
|
{/* ── Terminal state with understanding (when no active question) ─ */}
|
|
{status === "success" && !canAnswer && graph && genuineCompletion && <CurrentUnderstandingCard currentSummary={graph.currentSummary} />}
|
|
{status === "success" && !canAnswer && graph && !genuineCompletion && updateStatus !== "success" && <CurrentUnderstandingCard currentSummary={graph.currentSummary} />}
|
|
</>
|
|
)}
|
|
|
|
{/* ── 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, resolveCurrentSummary, isTechnicalSummary };
|