From ed32d585bb81c3ac6a595c42c0ffeb9d580cac04 Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 3 Aug 2026 15:18:35 +0100 Subject: [PATCH 001/175] feat: add user-focused reasoning workspace --- app/globals.css | 5 + components/reasoning-workspace.jsx | 372 ++++++++++++++++++++++ components/scenario-form.jsx | 118 ++----- docs/v0.7-user-workspace-ux-first-pass.md | 129 ++++++++ tests/ui/scenario-form.test.jsx | 310 ++++++++++++++++++ 5 files changed, 835 insertions(+), 99 deletions(-) create mode 100644 components/reasoning-workspace.jsx create mode 100644 docs/v0.7-user-workspace-ux-first-pass.md diff --git a/app/globals.css b/app/globals.css index b5c61c9..6b97128 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,8 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx new file mode 100644 index 0000000..be30810 --- /dev/null +++ b/components/reasoning-workspace.jsx @@ -0,0 +1,372 @@ +"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 ( + + ); +} + +// ── Situation card ──────────────────────────────────────────── +function SituationCard({ centralStatement }) { + if (!centralStatement) return null; + return ( +
+

+ Your situation +

+

{centralStatement}

+
+ ); +} + +// ── Current understanding card ──────────────────────────────── +function CurrentUnderstanding({ currentSummary, graph }) { + if (!graph || !currentSummary) return null; + + const nodes = graph.nodes || []; + const unknowns = nodes.filter((n) => n.kind === "unknown"); + const resolvedCount = (graph.resolvedNodeIds || []).length; + const remainingUnknowns = unknowns.filter( + (u) => u.status !== "resolved" + ).length; + + return ( +
+

+ Current understanding +

+

{currentSummary}

+
+ ); +} + +// ── 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 ( +
+

+ What we are working out +

+

{activeNode.label}

+ {activeNode.description && activeNode.description !== activeNode.label && ( +

Why it matters: {activeNode.description}

+ )} + + {statusText} + +
+ ); +} + +// ── Next question card (prominent) ──────────────────────────── +function NextQuestionCard({ selectedQuestion }) { + if (!selectedQuestion) return null; + + const q = typeof selectedQuestion === "string" ? selectedQuestion : selectedQuestion.question; + if (!q) return null; + + return ( +
+

+ Next question +

+

{q}

+
+ ); +} + +// ── Progress summary ────────────────────────────────────────── +function ProgressSummary({ graph }) { + if (!graph?.nodes?.length) return null; + + const unknowns = graph.nodes.filter((n) => n.kind === "unknown"); + const resolvedCount = (graph.resolvedNodeIds || []).length; + const remainingUnknowns = unknowns.filter( + (u) => u.status !== "resolved" + ).length; + + return ( +
+ {resolvedCount > 0 && ( + + {resolvedCount} resolved + + )} + {remainingUnknowns > 0 && ( + + {remainingUnknowns} remaining + + )} +
+ ); +} + +// ── 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 ( +
+

{message}

+
+ ); +} + +// ── 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 ( +
+
+ + Working through your situation +
+

{statusText}

+

+ This has been running for {elapsed}s. + {variant === "initial" && elapsed > 30 && ( + This can take around a minute with the current local model. + )} +

+
+ ); +} + +// ── Developer details disclosure ────────────────────────────── +function DeveloperDetails({ graph, selectedQuestion, diagnostics, newlySurfacedNodeIds, updateResult }) { + return ( +
+ + Developer details + +
+ {graph && ( + + )} + {updateResult && ( + + )} + {diagnostics && } +
+
+ ); +} + +// ── 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 ( +
+ {/* ── Loading overlays ─────────────────────────────── */} + + {updateStatus === "loading" && ( +
+ )} + + + {/* ── User-facing workspace ────────────────────────── */} + {(status === "success" || status === "error") && !graph ? ( +
+ {noQuestionReason + ? "Validation failed — no structured graph output was produced." + : "The analysis completed but did not produce a structured result."} +
+ ) : ( + <> + {/* When analysis succeeded but there's no question to answer */} + {status === "success" && !canAnswer && graph && ( + + )} + {graph && } + {graph && } + {graph && } + {canAnswer && } + {graph && } + + {/* ── Answer form ──────────────────────────────── */} + {canAnswer && ( +
+
+ +