feat: add user-focused reasoning workspace

This commit is contained in:
2026-08-03 15:18:35 +01:00
parent 0fe11b93a0
commit ed32d585bb
5 changed files with 835 additions and 99 deletions
+5
View File
@@ -1,3 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
+372
View File
@@ -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 };
+19 -99
View File
@@ -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..."
/>
</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>
{/* ── 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}
/>
)}
{/* Empty state */}
+129
View File
@@ -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):
- 010s: "Reading your situation"
- 1025s: "Building a structured understanding"
- 2545s: "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:
- 010s: "Considering your answer"
- 1025s: "Updating the situation"
- 2545s: "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.*
+310
View File
@@ -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,
@@ -838,4 +843,309 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("New active unknown");
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?",
);
});
});