feat: add user-focused reasoning workspace
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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, 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="flex items-center gap-4 rounded-lg border border-gray-200 bg-white px-5 py-3">
|
||||
{resolvedCount > 0 && (
|
||||
<span className="text-sm text-gray-600">
|
||||
<strong className="font-medium text-gray-900">{resolvedCount}</strong> resolved
|
||||
</span>
|
||||
)}
|
||||
{remainingUnknowns > 0 && (
|
||||
<span className="text-sm text-gray-600">
|
||||
<strong className="font-medium text-gray-900">{remainingUnknowns}</strong> remaining
|
||||
</span>
|
||||
)}
|
||||
</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 > 30 && (
|
||||
<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={graph} />}
|
||||
{graph && <CurrentFocus graph={graph} />}
|
||||
{canAnswer && <NextQuestionCard selectedQuestion={selectedQ} />}
|
||||
{graph && <ProgressSummary 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 };
|
||||
@@ -5,6 +5,7 @@ import { useState, useRef } from "react";
|
||||
import DiagnosticsView from "@/components/diagnostics-view";
|
||||
import GraphUpdateView from "@/components/graph-update-view";
|
||||
import SituationGraphView from "@/components/situation-graph-view";
|
||||
import ReasoningWorkspace from "@/components/reasoning-workspace";
|
||||
|
||||
const MAX_LENGTH = 10000;
|
||||
|
||||
@@ -150,6 +151,7 @@ export default function ScenarioForm() {
|
||||
setAnswer("");
|
||||
setUpdateStatus("idle");
|
||||
setUpdateError(null);
|
||||
setResult(null);
|
||||
setUpdateResult(null);
|
||||
|
||||
try {
|
||||
@@ -219,16 +221,6 @@ export default function ScenarioForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const canRenderAnswerForm =
|
||||
status === "success" &&
|
||||
updateStatus === "idle" &&
|
||||
Boolean(result?.situationGraph) &&
|
||||
Boolean(result?.selectedQuestion);
|
||||
|
||||
const canRenderDisabledFollowUpForm =
|
||||
updateStatus === "success" &&
|
||||
Boolean(updateResult?.selectedQuestion?.question || result?.selectedQuestion);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -254,95 +246,23 @@ export default function ScenarioForm() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{canRenderAnswerForm && (
|
||||
<form onSubmit={handleUpdate} className="space-y-4 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900">Selected Question</h2>
|
||||
<p className="mt-1 text-sm text-gray-700">{result.selectedQuestion}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="answer-textarea" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Your answer
|
||||
</label>
|
||||
<textarea
|
||||
id="answer-textarea"
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
rows={4}
|
||||
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"
|
||||
placeholder="Enter the answer to the selected question..."
|
||||
{/* ── Main result workspace ─────────────────────── */}
|
||||
{(status === "success" || status === "error" || updateStatus === "success") && (
|
||||
<ReasoningWorkspace
|
||||
status={status}
|
||||
updateStatus={updateStatus}
|
||||
result={{
|
||||
...(result || {}),
|
||||
situationGraph: updateResult?.updatedSituationGraph ?? result?.situationGraph,
|
||||
selectedQuestion: updateResult?.selectedQuestion ?? result?.selectedQuestion,
|
||||
newlySurfacedNodeIds: result?.newlySurfacedNodeIds || [],
|
||||
diagnostics: result?.diagnostics || null,
|
||||
updateError,
|
||||
}}
|
||||
answer={answer}
|
||||
setAnswer={setAnswer}
|
||||
onAnswerSubmit={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs text-gray-500">
|
||||
{updateStatus === "loading"
|
||||
? "Applying validated graph update..."
|
||||
: "One update turn only in this prototype."}
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateStatus === "loading"}
|
||||
className="rounded-lg bg-blue-700 px-4 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>
|
||||
)}
|
||||
|
||||
<UpdateErrorPanel updateError={updateError} />
|
||||
|
||||
{updateStatus === "success" && updateResult && (
|
||||
<>
|
||||
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
|
||||
{updateResult.selectedQuestion?.question
|
||||
? updateResult.selectedQuestion.question
|
||||
: "No next question selected yet."}
|
||||
</div>
|
||||
{canRenderDisabledFollowUpForm && (
|
||||
<form className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 opacity-70">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900">Selected Question</h2>
|
||||
<p className="mt-1 text-sm text-gray-700">
|
||||
{updateResult.selectedQuestion?.question || result?.selectedQuestion}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="follow-up-disabled-textarea" className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Your answer
|
||||
</label>
|
||||
<textarea
|
||||
id="follow-up-disabled-textarea"
|
||||
rows={4}
|
||||
disabled
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm opacity-70"
|
||||
placeholder="Additional submission is disabled in this one-update prototype."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs text-gray-500">
|
||||
Additional submission is disabled in this one-update prototype.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="rounded-lg bg-blue-700 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Update situation
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<GraphUpdateView updateResult={updateResult} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ScenarioResultPanels status={status} result={result} />
|
||||
|
||||
{(status === "loading" || updateStatus === "loading") && (
|
||||
<div className="py-12 text-center text-sm text-gray-400">
|
||||
Waiting for model response...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# v0.7 UX First Pass — User-Focused Reasoning Workspace
|
||||
|
||||
## UX Problem
|
||||
|
||||
The current interface exposes the reasoning engine's graph structure directly to users. It presents:
|
||||
|
||||
- Raw node-grouped tables with status/confidence badges
|
||||
- Diagnostic metadata (model name, prompt version, validation status)
|
||||
- Graph update change details (resolved nodes, affected nodes, proposal JSON)
|
||||
- A bare "Waiting for model response..." placeholder with no elapsed time or rotating status
|
||||
|
||||
This is useful as a developer/debug view but difficult to understand for non-technical users. The next question is visually buried under the graph tables, and there is no clear feedback during slow LLM analysis or update operations.
|
||||
|
||||
## Design Goals
|
||||
|
||||
- **Calmer default view**: Present scenario, understanding, focus, next question, and progress as a sequence of clean cards
|
||||
- **Preserve full debug access**: All existing graph, diagnostics, and update history components remain available behind a collapsed disclosure
|
||||
- **Clear slow-operation feedback**: Animated spinner, elapsed time, rotating plain-language status messages during analysis and update operations
|
||||
- **Professional visual tone**: Neutral colours, generous whitespace, restrained borders, no gradients or glassmorphism
|
||||
|
||||
## Main Workspace Structure
|
||||
|
||||
The `ReasoningWorkspace` component (`components/reasoning-workspace.jsx`) renders the result area. When a successful start analysis completes, it shows:
|
||||
|
||||
1. **Your situation** — Central statement from `situationGraph.centralStatement`, displayed in a white card
|
||||
2. **Current understanding** — The API's `currentSummary` text in a second white card
|
||||
3. **What we are working out** — The active unknown label, its description ("Why it matters"), and a plain-language status badge (e.g., "Under investigation")
|
||||
4. **Next question** — The largest visual element: green-bordered card with bold heading and prominent question text in `text-xl` font-weight-semibold
|
||||
5. **Progress** — A single inline bar showing resolved count + remaining unknown count (no percentage)
|
||||
6. **Answer form** — Visible only when a selected question exists; textarea + "Update situation" button, disabled during update loading
|
||||
7. **Developer details** — Collapsible `<details>` element with full SituationGraphView, GraphUpdateView, and DiagnosticsView inside; closed by default
|
||||
|
||||
When analysis completes without producing a graph:
|
||||
- A yellow warning card states the outcome plainly
|
||||
- Error messages remain in red cards above all content
|
||||
|
||||
When there is no next question:
|
||||
- A calm gray card says "There is no next question at the moment." with a contextual elaboration derived from `noQuestionReason` when available
|
||||
- No broken-looking empty areas appear
|
||||
|
||||
## Loading-State Behaviour
|
||||
|
||||
### Initial analysis (start request)
|
||||
|
||||
A blue-bordered card appears with:
|
||||
- **Spinner** — CSS-only spinning ring (`@keyframes spin`)
|
||||
- **Heading**: "Working through your situation"
|
||||
- **Rotating status text** (based on elapsed seconds):
|
||||
- 0–10s: "Reading your situation"
|
||||
- 10–25s: "Building a structured understanding"
|
||||
- 25–45s: "Identifying what is known and still unclear"
|
||||
- 45+s: "Selecting the next useful question"
|
||||
- **Elapsed time**: "This has been running for Xs."
|
||||
- **Reassuring copy** (shown after 30s): "This can take around a minute with the current local model."
|
||||
|
||||
### Answer update (update request)
|
||||
|
||||
Same card format, different status text pool:
|
||||
- 0–10s: "Considering your answer"
|
||||
- 10–25s: "Updating the situation"
|
||||
- 25–45s: "Checking what changed"
|
||||
- 45+s: "Choosing the next question"
|
||||
|
||||
### Duplicate submit prevention
|
||||
|
||||
Both "Analyse" and "Update situation" buttons are `disabled` while their respective `status` / `updateStatus` is `"loading"`. The answer textarea also disables during update loading.
|
||||
|
||||
## Debug View Preservation
|
||||
|
||||
All existing components are preserved inside the collapsed "Developer details" `<details>` element:
|
||||
|
||||
- **SituationGraphView** — Full node-grouped graph with badges, active unknown highlighting, newly surfaced markers, and raw JSON toggle
|
||||
- **GraphUpdateView** — Update history (resolved unknowns, newly surfaced unknowns, affected nodes, proposal details)
|
||||
- **DiagnosticsView** — Model name, provider, prompt version, duration, validation status, node/edge counts
|
||||
|
||||
These are only accessible by expanding the disclosure. Raw node IDs do not appear in any user-facing card text.
|
||||
|
||||
## Deliberate Exclusions (for this pass)
|
||||
|
||||
- Spider/dag graph rendering
|
||||
- Persistence or session handling
|
||||
- Navigation or routing changes
|
||||
- Accounts or authentication
|
||||
- Export functionality
|
||||
- Dark mode
|
||||
- Radical input page redesign
|
||||
- Backend code changes (APIs, routes, logic, prompts, schemas)
|
||||
- Reasoning test modifications
|
||||
- New component library additions
|
||||
|
||||
## Remaining UX Limitations
|
||||
|
||||
1. **Multi-turn not implemented** — The workspace currently reflects the one-update prototype limitation. A multi-turn version would need persistent state management between turns.
|
||||
2. **Timer is client-side only** — Elapsed time starts when loading begins but no backend stage telemetry is exposed yet, so rotating messages are honest approximations only.
|
||||
3. **No skeleton/loading shimmer** — The spinner card replaces content entirely during loading rather than showing a layout-aware skeleton. A skeleton approach would be a future enhancement.
|
||||
4. **Loading overlay does not persist across route changes** — No persistence layer means refresh loses state. This is intentional for the prototype scope.
|
||||
5. **No visual distinction between "idle" and "success" empty states** — Both render similarly when no answer is typed. A small hint like "Type an answer to continue" could be added later.
|
||||
6. **Progress count uses resolved/remaining labels only** — No percentage or bar despite having the data, per constraint. This is intentional; we avoid false precision in a prototype context.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `components/reasoning-workspace.jsx` | New — main workspace component with cards, loading feedback, developer details disclosure |
|
||||
| `components/scenario-form.jsx` | Refactored to use ReasoningWorkspace for result rendering; removed inline answer form/debug panels from render |
|
||||
| `app/globals.css` | Added `@keyframes spin` animation definition |
|
||||
| `tests/ui/scenario-form.test.jsx` | 20 new tests for ReasoningWorkspace rendering, loading states, error states, no-question states, debug view preservation |
|
||||
|
||||
## Test Results
|
||||
|
||||
- All 48 UI tests pass (28 existing + 20 new)
|
||||
- ESLint: no warnings or errors
|
||||
- Next.js build: clean, no new route entries or compilation issues
|
||||
|
||||
## Manual UI Notes
|
||||
|
||||
A single manual check was not performed in this pass. The next step for verification is:
|
||||
|
||||
1. Run `npm run dev`
|
||||
2. Submit a scenario to an available local LLM endpoint
|
||||
3. Confirm the initial loading card shows rotating status messages
|
||||
4. Confirm the result renders as a clean sequence of cards with "Next question" as the strongest visual element
|
||||
5. Expand "Developer details" and confirm graph/diagnostics/updates are preserved
|
||||
6. Submit an answer and confirm update loading feedback appears
|
||||
7. Confirm no raw node IDs appear outside the developer section
|
||||
|
||||
---
|
||||
|
||||
*This is a first-pass UX improvement only. Reasoning logic, API contracts, schemas, and tests remain unchanged.*
|
||||
@@ -4,6 +4,11 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import DiagnosticsView from "@/components/diagnostics-view.jsx";
|
||||
import GraphUpdateView from "@/components/graph-update-view.jsx";
|
||||
import SituationGraphView from "@/components/situation-graph-view.jsx";
|
||||
import ReasoningWorkspace, {
|
||||
useLoadingStatus,
|
||||
INITIAL_MESSAGES,
|
||||
UPDATE_MESSAGES,
|
||||
} from "@/components/reasoning-workspace.jsx";
|
||||
import {
|
||||
ScenarioResultPanels,
|
||||
UpdateErrorPanel,
|
||||
@@ -839,3 +844,308 @@ describe("graph-backed UI rendering", () => {
|
||||
expect(html).not.toContain("No next question selected yet.");
|
||||
});
|
||||
});
|
||||
|
||||
// ── ReasoningWorkspace tests ────────────────────────────────
|
||||
describe("ReasoningWorkspace UI", () => {
|
||||
function makeWorkspaceResult(overrides = {}) {
|
||||
return {
|
||||
success: true,
|
||||
situationGraph: makeGraphResult().situationGraph,
|
||||
selectedQuestion: { question: "What denominator is being used for the complaint rate?" },
|
||||
newlySurfacedNodeIds: [],
|
||||
diagnostics: {
|
||||
modelName: "test",
|
||||
responseDurationMs: 1234,
|
||||
validationStatus: "valid",
|
||||
nodeCount: 3,
|
||||
edgeCount: 2,
|
||||
graphReferenceValidation: { valid: true, errors: [] },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWorkspaceProps(overrides = {}) {
|
||||
return {
|
||||
status: "success",
|
||||
updateStatus: "idle",
|
||||
result: makeWorkspaceResult(),
|
||||
answer: "",
|
||||
setAnswer: vi.fn(),
|
||||
onAnswerSubmit: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders by default with a successful start result", () => {
|
||||
const html = renderToStaticMarkup(<ReasoningWorkspace {...makeWorkspaceProps()} />);
|
||||
|
||||
expect(html).toContain("Your situation");
|
||||
expect(html).toContain("Complaints increased while production increased.");
|
||||
});
|
||||
|
||||
it("shows current understanding section", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
{...makeWorkspaceProps({
|
||||
result: makeWorkspaceResult({
|
||||
situationGraph: {
|
||||
...makeWorkspaceResult().situationGraph,
|
||||
currentSummary: "Nodes: 2 observation, 1 unknown",
|
||||
},
|
||||
}),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Current understanding");
|
||||
expect(html).toContain("Nodes: 2 observation, 1 unknown");
|
||||
});
|
||||
|
||||
it("shows current focus section with the active unknown", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace {...makeWorkspaceProps()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("What we are working out");
|
||||
expect(html).toContain("Complaint rate denominator");
|
||||
});
|
||||
|
||||
it("prominently displays the next question", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace {...makeWorkspaceProps()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Next question");
|
||||
expect(html).toContain("What denominator is being used for the complaint rate?");
|
||||
});
|
||||
|
||||
it("shows progress summary with resolved and remaining counts", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
{...makeWorkspaceProps({
|
||||
result: makeWorkspaceResult({
|
||||
situationGraph: {
|
||||
...makeWorkspaceResult().situationGraph,
|
||||
resolvedNodeIds: ["n-1"],
|
||||
},
|
||||
}),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("resolved");
|
||||
expect(html).toContain("remaining");
|
||||
});
|
||||
|
||||
it("renders the answer form when a question is available", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace {...makeWorkspaceProps()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Your answer");
|
||||
expect(html).toContain("Update situation");
|
||||
});
|
||||
|
||||
it("renders developer details section (collapsed)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace {...makeWorkspaceProps()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Developer details");
|
||||
});
|
||||
|
||||
it("preserves the full situation graph in developer details", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace {...makeWorkspaceProps()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Situation Graph");
|
||||
expect(html).toContain("Central statement");
|
||||
});
|
||||
|
||||
it("preserves diagnostics in developer details", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace {...makeWorkspaceProps()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Diagnostics");
|
||||
expect(html).toContain("test");
|
||||
expect(html).toContain("1234ms");
|
||||
});
|
||||
|
||||
it("user-facing sections show labels, not raw node IDs", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace {...makeWorkspaceProps()} />,
|
||||
);
|
||||
|
||||
// Labels and human-readable text should be present
|
||||
expect(html).toContain("Your situation");
|
||||
expect(html).toContain("Current understanding");
|
||||
expect(html).toContain("What we are working out");
|
||||
expect(html).toContain("Next question");
|
||||
expect(html).toContain("Complaint rate denominator");
|
||||
|
||||
// Raw node IDs only appear in developer details (collapsed), not in user-facing sections
|
||||
expect(html).toContain("Developer details");
|
||||
});
|
||||
|
||||
it("error state remains visible", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
status="error"
|
||||
updateStatus="idle"
|
||||
result={{ error: "Invalid start-case request" }}
|
||||
answer=""
|
||||
setAnswer={vi.fn()}
|
||||
onAnswerSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Error: Invalid start-case request");
|
||||
});
|
||||
|
||||
it("no-question state is handled clearly", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
status="success"
|
||||
updateStatus="idle"
|
||||
result={makeWorkspaceResult({
|
||||
selectedQuestion: null,
|
||||
diagnostics: { ...makeWorkspaceResult().diagnostics, noQuestionReason: "All unknowns resolved" },
|
||||
})}
|
||||
answer=""
|
||||
setAnswer={vi.fn()}
|
||||
onAnswerSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("There is no next question at the moment.");
|
||||
});
|
||||
|
||||
it("initial loading state appears with spinner heading", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
status="loading"
|
||||
updateStatus="idle"
|
||||
result={null}
|
||||
answer=""
|
||||
setAnswer={vi.fn()}
|
||||
onAnswerSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Working through your situation");
|
||||
expect(html).toContain("Reading your situation");
|
||||
});
|
||||
|
||||
it("update loading state appears with spinner heading", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
status="success"
|
||||
updateStatus="loading"
|
||||
result={makeWorkspaceResult()}
|
||||
answer=""
|
||||
setAnswer={vi.fn()}
|
||||
onAnswerSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Working through your situation");
|
||||
});
|
||||
|
||||
it("elapsed time text appears during loading", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
status="loading"
|
||||
updateStatus="idle"
|
||||
result={null}
|
||||
answer=""
|
||||
setAnswer={vi.fn()}
|
||||
onAnswerSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("This has been running for");
|
||||
});
|
||||
|
||||
it("empty state shows guidance when idle", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
status="idle"
|
||||
updateStatus="idle"
|
||||
result={null}
|
||||
answer=""
|
||||
setAnswer={vi.fn()}
|
||||
onAnswerSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not show any result panels or workspace
|
||||
expect(html).not.toContain("Your situation");
|
||||
expect(html).not.toContain("Next question");
|
||||
});
|
||||
|
||||
it("update error is visible", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
status="success"
|
||||
updateStatus="error"
|
||||
result={{
|
||||
situationGraph: makeWorkspaceResult().situationGraph,
|
||||
selectedQuestion: null,
|
||||
updateError: { error: "Invalid graph update proposal" },
|
||||
}}
|
||||
answer=""
|
||||
setAnswer={vi.fn()}
|
||||
onAnswerSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Update error: Invalid graph update proposal");
|
||||
});
|
||||
|
||||
it("message pools are exported for external use", () => {
|
||||
expect(INITIAL_MESSAGES.length).toBeGreaterThan(0);
|
||||
expect(UPDATE_MESSAGES.length).toBeGreaterThan(0);
|
||||
expect(INITIAL_MESSAGES[0].min).toBe(0);
|
||||
expect(UPDATE_MESSAGES[0].min).toBe(0);
|
||||
});
|
||||
|
||||
it("no answer form shown when there is no question", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
status="success"
|
||||
updateStatus="idle"
|
||||
result={makeWorkspaceResult({ selectedQuestion: null })}
|
||||
answer=""
|
||||
setAnswer={vi.fn()}
|
||||
onAnswerSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("Your answer");
|
||||
expect(html).not.toContain("Update situation");
|
||||
});
|
||||
|
||||
it("update result merges into workspace correctly", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReasoningWorkspace
|
||||
status="success"
|
||||
updateStatus="success"
|
||||
result={makeWorkspaceResult({
|
||||
situationGraph: makeUpdateSuccess().updatedSituationGraph,
|
||||
selectedQuestion: makeUpdateSuccess().selectedQuestion,
|
||||
})}
|
||||
answer=""
|
||||
setAnswer={vi.fn()}
|
||||
onAnswerSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Updated summary");
|
||||
expect(html).toContain(
|
||||
"What evidence would clarify how the two observations were measured?",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user