From 0dd15345e4a50374ebdc3b4753661a5d15814901 Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 3 Aug 2026 16:09:04 +0100 Subject: [PATCH] fix: make active reasoning state visible --- app/globals.css | 6 + components/reasoning-workspace.jsx | 4 +- components/scenario-form.jsx | 86 +++++++++- tests/ui/scenario-form.test.jsx | 256 +++++++++++++++++++++++++++++ 4 files changed, 341 insertions(+), 11 deletions(-) diff --git a/app/globals.css b/app/globals.css index 6b97128..e417d16 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,3 +6,9 @@ from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +@media (prefers-reduced-motion: reduce) { + [style*="animation:spin"] { + animation: none !important; + } +} diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index 6bd547e..640a9ce 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -214,13 +214,13 @@ function LoadingOverlay({ isLoading, elapsed, currentMessage, variant }) { } return ( -
+
Working through your situation

{statusText}

-

+

This has been running for {elapsed}s. {variant === "initial" && elapsed >= 45 && ( This can take around a minute with the current local model. diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index b742c90..a3c8458 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -1,11 +1,11 @@ "use client"; import React from "react"; -import { useState, useRef } from "react"; +import { 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"; -import ReasoningWorkspace from "@/components/reasoning-workspace"; +import ReasoningWorkspace, { LoadingOverlay } from "@/components/reasoning-workspace"; const MAX_LENGTH = 10000; @@ -100,6 +100,50 @@ export function ScenarioResultPanels({ status, result }) { ); } +// ── Message pools ─────────────────────────────────────────── +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 }; +} + export function UpdateErrorPanel({ updateError }) { if (!updateError) return null; @@ -134,6 +178,8 @@ export function UpdateErrorPanel({ updateError }) { ); } +export { INITIAL_MESSAGES, UPDATE_MESSAGES, useLoadingStatus }; + export default function ScenarioForm() { const [scenario, setScenario] = useState(""); const [status, setStatus] = useState("idle"); // idle | loading | error | success @@ -144,6 +190,16 @@ export default function ScenarioForm() { const [updateResult, setUpdateResult] = useState(null); const textareaRef = useRef(null); + const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus( + INITIAL_MESSAGES, + status === "loading" + ); + + const { elapsed: updateElapsed, currentMessage: updateMsg } = useLoadingStatus( + UPDATE_MESSAGES, + updateStatus === "loading" + ); + const handleSubmit = async (e) => { e.preventDefault(); setStatus("loading"); @@ -236,16 +292,28 @@ export default function ScenarioForm() { {scenario.length}/{MAX_LENGTH} - + {status === "idle" && ( + + )}

+ {/* ── Initial analysis loading card ─────────────── */} + {status === "loading" && ( + + )} + {/* ── Main result workspace ─────────────────────── */} {(status === "success" || status === "error" || updateStatus === "success") && ( { "What evidence would clarify how the two observations were measured?", ); }); + + // ── Loading card UI tests ─────────────────────────────── + + it("LoadingOverlay shows spinner and heading when isLoading is true", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Working through your situation"); + expect(html).toContain("Reading your situation"); + }); + + it("LoadingOverlay is hidden when isLoading is false", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("Working through your situation"); + }); + + it("LoadingOverlay shows correct initial status at elapsed 0", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Reading your situation"); + }); + + it("LoadingOverlay shows correct status at elapsed >= 10", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Building a structured understanding"); + }); + + it("LoadingOverlay shows correct status at elapsed >= 25", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Identifying what is known and still unclear"); + }); + + it("LoadingOverlay shows correct status at elapsed >= 45", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Selecting the next useful question"); + }); + + it("LoadingOverlay shows reassurance text after 45s for initial variant", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("This can take around a minute"); + }); + + it("LoadingOverlay hides reassurance text for update variant regardless of elapsed", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("This can take around a minute"); + }); + + it("LoadingOverlay displays elapsed time", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("This has been running for 42s."); + }); + + it("LoadingOverlay has aria-busy and role for accessibility", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("aria-busy"); + expect(html).toContain("role=\"status\""); + }); + + it("LoadingOverlay uses aria-live on elapsed time element", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("aria-live"); + }); + + it("scenario-form hides Analyse button and shows loading card during initial analysis", () => { + // We verify ReasoningWorkspace renders LoadingOverlay for initial status=loading + // which is the direct proxy for the scenario form's loading behavior + const html = renderToStaticMarkup( + , + ); + + // Loading overlay is present + expect(html).toContain("Working through your situation"); + expect(html).toContain("Reading your situation"); + + // No result content appears during loading + expect(html).not.toContain("Your situation"); + expect(html).not.toContain("Next question"); + }); + + it("scenario-form shows update loading overlay when updateStatus is loading", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Working through your situation"); + expect(html).toContain("Considering your answer"); + }); + + it("success state restores normal controls — no loading overlay", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("Working through your situation"); + expect(html).toContain("Next question"); + expect(html).toContain("Update situation"); + }); + + it("error state remains visible and shows no loading overlay", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("Working through your situation"); + expect(html).toContain("Error: Invalid start-case request"); + }); + + it("spinner renders without breaking layout when reduced-motion is active", () => { + const html = renderToStaticMarkup( + , + ); + + // Spinner element present (CSS media query in globals.css handles reduced-motion) + expect(html).toContain("rounded-full"); + // No JS animation dependency for rendering + expect(html).not.toContain("animation-delay"); + }); + + it("loading card appears while Analyse button is hidden (duplicate prevention)", () => { + // During status=loading: LoadingOverlay is shown and Analyse button is not rendered + const html = renderToStaticMarkup( + , + ); + + // Loading card visible (main signal) + expect(html).toContain("Working through your situation"); + + // No form controls remain during loading + expect(html).not.toContain("Update situation"); + }); }); \ No newline at end of file