diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index c48eab0..a631010 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -5,6 +5,33 @@ 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" }, @@ -74,22 +101,20 @@ function SituationCard({ centralStatement }) { // ── Current understanding card ──────────────────────────────── function CurrentUnderstanding({ currentSummary }) { - if (currentSummary) { - return ( -
-

- What we've established -

-

{currentSummary}

-
- ); - } + const summary = resolveCurrentSummary(currentSummary); return (
-

- We have started to separate what is known from what still needs checking. -

+

+ What we've established +

+ {summary ? ( +

{summary}

+ ) : ( +

+ We have separated what is known from what still needs checking. +

+ )}
); } @@ -111,6 +136,22 @@ function CurrentInvestigationCard({ selectedQuestion }) { ); } +// ── 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; +} + // ── Investigation progress card ────────────────────────────── function InvestigationProgress({ graph, noQuestionReason: rwNoQuestionReason }) { if (!graph?.nodes?.length) return null; @@ -124,9 +165,11 @@ function InvestigationProgress({ graph, noQuestionReason: rwNoQuestionReason }) ? graph.nodes.find((n) => n.id === graph.activeUnknownNodeId) : null; + const isComplete = hasGenuineCompletion(graph); + return (
- {remainingCount > 0 && !rwNoQuestionReason ? ( + {remainingCount > 0 && !isComplete ? (

We are still building confidence about your situation.{" "} {remainingCount === 1 @@ -262,6 +305,13 @@ export default function ReasoningWorkspace({ const newlySurfacedNodeIds = result?.newlySurfacedNodeIds || []; const noQuestionReason = diagnostics?.noQuestionReason ?? null; + const remainingUnknowns = graph?.nodes?.filter( + (n) => n.kind === "unknown" && n.status !== "resolved" && !(graph.resolvedNodeIds || []).includes(n.id), + ); + + const genuineCompletion = hasGenuineCompletion(graph); + const unresolvedRemaining = !genuineCompletion && remainingUnknowns ? remainingUnknowns.length > 0 : false; + return (

{/* ── Loading overlays ─────────────────────────────── */} @@ -290,10 +340,17 @@ export default function ReasoningWorkspace({
) : ( <> - {/* When investigation has nothing further to ask */} - {status === "success" && !canAnswer && graph && ( + {/* Completion or holding state (only when there is no next question) */} + {status === "success" && !canAnswer && graph && genuineCompletion && ( )} + {status === "success" && !canAnswer && graph && unresolvedRemaining && ( +
+

There is no further question the engine can justify at the moment.

+

More evidence may be needed before a next step is clear.

+
+ )} + {graph && } {graph && } {canAnswer && } @@ -363,4 +420,4 @@ export default function ReasoningWorkspace({ ); } -export { useLoadingStatus, INITIAL_MESSAGES, UPDATE_MESSAGES, LoadingOverlay }; +export { useLoadingStatus, INITIAL_MESSAGES, UPDATE_MESSAGES, LoadingOverlay, resolveCurrentSummary, isTechnicalSummary }; diff --git a/tests/ui/scenario-form.test.jsx b/tests/ui/scenario-form.test.jsx index 01d9189..4275e86 100644 --- a/tests/ui/scenario-form.test.jsx +++ b/tests/ui/scenario-form.test.jsx @@ -9,6 +9,8 @@ import ReasoningWorkspace, { INITIAL_MESSAGES, UPDATE_MESSAGES, LoadingOverlay, + resolveCurrentSummary, + isTechnicalSummary, } from "@/components/reasoning-workspace.jsx"; import { ScenarioResultPanels, @@ -885,14 +887,14 @@ describe("ReasoningWorkspace UI", () => { expect(html).toContain("Complaints increased while production increased."); }); - it("shows what we've established section", () => { + it("shows what we've established section with plain-language summary", () => { const html = renderToStaticMarkup( { ); expect(html).toContain("What we've established"); - expect(html).toContain("Nodes: 2 observation, 1 unknown"); + expect(html).toContain("We have identified a key question to investigate further."); + }); + + it("filters technical summaries from the main view (not developer details)", () => { + const html = renderToStaticMarkup( + , + ); + + // Fallback text shown in main view (currentSummary filtered) + expect(html).toContain("We have separated what is known from what still needs checking."); + // Developer details preserves the full technical summary + expect(html).toContain("Developer details"); + }); + + it("keeps technical summary available in Developer details", () => { + const html = renderToStaticMarkup( + , + ); + + // Developer details is the mechanism for accessing full technical data + expect(html).toContain("Developer details"); }); it("prominently displays the current investigation", () => { @@ -1115,6 +1137,10 @@ describe("ReasoningWorkspace UI", () => { // After 16s elapsed, the second message should be active // (useEffect fires in real React; here we verify via hook export) expect(INITIAL_MESSAGES[1].min).toBe(10); + expect(UPDATE_MESSAGES[0].text).toBe("Considering your answer"); + expect(UPDATE_MESSAGES[1].text).toBe("Updating the situation"); + expect(UPDATE_MESSAGES[2].text).toBe("Checking what changed"); + expect(UPDATE_MESSAGES[3].text).toBe("Choosing the next question"); vi.useRealTimers(); }); @@ -1141,6 +1167,14 @@ describe("ReasoningWorkspace UI", () => { updateStatus="idle" result={makeWorkspaceResult({ selectedQuestion: null, + situationGraph: { + ...makeWorkspaceResult().situationGraph, + activeUnknownNodeId: null, + nodes: makeWorkspaceResult().situationGraph.nodes.map((n) => + n.id === "n-unknown" ? { ...n, status: "resolved", value: "the denominator is total units sold" } : n, + ), + resolvedNodeIds: ["n-unknown"], + }, diagnostics: { ...makeWorkspaceResult().diagnostics, noQuestionReason: "All unknowns resolved" }, })} answer="" @@ -1241,6 +1275,39 @@ describe("ReasoningWorkspace UI", () => { expect(html).not.toContain("Update situation"); }); + // ── Technical summary detection ─────────────────────── + + it("isTechnicalSummary detects node counts", () => { + expect(isTechnicalSummary("Nodes: 2 observation, 1 unknown")).toBe(true); + expect(isTechnicalSummary("Nodes: 3 observations total")).toBe(true); + }); + + it("isTechnicalSummary detects edge counts", () => { + expect(isTechnicalSummary("Edges: 5 total")).toBe(true); + expect(isTechnicalSummary("Edges: 2")).toBe(true); + }); + + it("isTechnicalSummary detects graph enum language", () => { + expect(isTechnicalSummary("1 unknown remaining")).toBe(true); + expect(isTechnicalSummary("Observation nodes identified")).toBe(true); + expect(isTechnicalSummary("Conclusion verified")).toBe(true); + }); + + it("isTechnicalSummary allows plain-language summaries", () => { + expect(isTechnicalSummary("We have separated what is known from what still needs checking.")).toBe(false); + expect(isTechnicalSummary("Updated summary")).toBe(false); + expect(isTechnicalSummary("")).toBe(false); + expect(isTechnicalSummary(null)).toBe(false); + expect(isTechnicalSummary(undefined)).toBe(false); + }); + + it("resolveCurrentSummary filters technical and passes plain", () => { + expect(resolveCurrentSummary("Nodes: 1 state, 3 observations")).toBe(null); + expect(resolveCurrentSummary("Updated summary")).toBe("Updated summary"); + expect(resolveCurrentSummary(null)).toBe(null); + expect(resolveCurrentSummary(undefined)).toBe(null); + }); + it("update result merges into workspace correctly", () => { const html = renderToStaticMarkup( { updateStatus="idle" result={makeWorkspaceResult({ selectedQuestion: null, + situationGraph: { + ...makeWorkspaceResult().situationGraph, + activeUnknownNodeId: null, + nodes: makeWorkspaceResult().situationGraph.nodes.map((n) => + n.id === "n-unknown" ? { ...n, status: "resolved", value: "1.9 complaints per 100 units" } : n, + ), + resolvedNodeIds: ["n-unknown"], + }, diagnostics: { ...makeWorkspaceResult().diagnostics, noQuestionReason: "All unknowns satisfied" }, })} answer="" @@ -1536,13 +1611,21 @@ describe("ReasoningWorkspace UI", () => { expect(html).not.toContain("There is no next question at the moment"); }); - it("investigation complete shows confidence-building fallback", () => { + it("no-question state with unresolved areas shows holding, not completion", () => { const html = renderToStaticMarkup( + n.id === "n-unknown" ? { ...n, status: "unknown", value: null } : n, + ), + resolvedNodeIds: [], + }, diagnostics: { ...makeWorkspaceResult().diagnostics, noQuestionReason: null }, })} answer="" @@ -1551,6 +1634,198 @@ describe("ReasoningWorkspace UI", () => { />, ); + expect(html).toContain("There is no further question the engine can justify at the moment"); + expect(html).not.toContain("We have established enough for now"); + expect(html).not.toContain("All areas under investigation are now complete"); + }); + + // ── Update loading tests ─────────────────────────────── + + it("update loading card appears immediately after answer submit", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Working through your situation"); + expect(html).toContain("Considering your answer"); + }); + + it("normal update button is hidden while loading", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("Update situation"); + expect(html).not.toContain("Your response"); + }); + + it("update status messages change with mocked timers", () => { + vi.useFakeTimers(); + + const baseProps = { + status: "success", + updateStatus: "loading", + result: makeWorkspaceResult(), + answer: "test", + setAnswer: vi.fn(), + onAnswerSubmit: vi.fn(), + }; + + let html = renderToStaticMarkup(); + expect(html).toContain("Considering your answer"); + + vi.advanceTimersByTime(10000); + vi.useRealTimers(); + + // We rely on the exported UPDATE_MESSAGES to verify message pool correctness + expect(UPDATE_MESSAGES[0].text).toBe("Considering your answer"); + expect(UPDATE_MESSAGES[1].text).toBe("Updating the situation"); + expect(UPDATE_MESSAGES[2].text).toBe("Checking what changed"); + expect(UPDATE_MESSAGES[3].text).toBe("Choosing the next question"); + + vi.useRealTimers(); + }); + + it("update prevents duplicate submission by removing controls while loading", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("Update situation"); + expect(html).toContain("Working through your situation"); + }); + + // ── Outcome state tests ──────────────────────────────── + + it("next-question state does not show completion wording", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Current investigation"); + expect(html).not.toContain("We have established enough for now"); + expect(html).not.toContain("All areas under investigation are now complete"); + }); + + it("unresolved-no-question state does not imply completion", () => { + const html = renderToStaticMarkup( + n.id !== "n-unknown" || n.kind === "observation", + ), + { + id: "n-unknown", + label: "Complaint rate denominator", + description: "Need the denominator for complaint rate", + kind: "unknown", + status: "unknown", + confidence: "medium", + }, + ], + resolvedNodeIds: [], + }, + })} + answer="" + setAnswer={vi.fn()} + onAnswerSubmit={vi.fn()} + />, + ); + + expect(html).not.toContain("All areas under investigation are now complete"); + expect(html).not.toContain("We have established enough for now"); + expect(html).toContain("There is no further question the engine can justify at the moment"); + }); + + it("genuine completion hides active investigation and remaining-area wording", () => { + const html = renderToStaticMarkup( + + n.id === "n-unknown" ? { ...n, status: "resolved", value: "total units" } : n, + ), + resolvedNodeIds: ["n-unknown"], + }, + })} + answer="" + setAnswer={vi.fn()} + onAnswerSubmit={vi.fn()} + />, + ); + + expect(html).not.toContain("Current investigation"); + expect(html).not.toContain("areas remain"); expect(html).toContain("We have established enough for now"); }); + + it("no contradictory completion and unresolved focus appear together", () => { + const html = renderToStaticMarkup( + , + ); + + const hasCompletion = html.includes("All areas under investigation are now complete") || html.includes("We have established enough for now"); + expect(hasCompletion).toBe(false); + }); + + it("Developer details remains collapsed and unchanged", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Developer details"); + }); }); \ No newline at end of file