From 043ba5f26446da398c19c01441b529b65a8e18f1 Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 2 Sep 2026 07:53:45 +0100 Subject: [PATCH] feat(confidence-engine): clarify understanding during Done refresh --- app/globals.css | 48 ++++++++++++++++ components/reasoning-workspace.jsx | 37 ++++++++++-- components/scenario-form.jsx | 29 +++++++--- docs/current-handoff.md | 14 +++++ tests/ui/scenario-form.test.jsx | 92 ++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 13 deletions(-) diff --git a/app/globals.css b/app/globals.css index dbb837d..02347c6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -24,6 +24,50 @@ animation-delay: 0.16s; } +/* ── CU skeleton overlay during synthesis refresh ─────────────── */ + +.cu-skeleton-overlay { + pointer-events: none; +} + +.cu-skeleton-lines { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-top: auto; +} + +.cu-skeleton-line { + height: 16px; + border-radius: 8px; + background-color: #e5e7eb; + position: relative; + overflow: hidden; +} + +/* Striped shimmer that travels left → right through each bar */ +.cu-skeleton-line::after { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 105deg, + transparent 0%, + transparent 8px, + rgba(255, 255, 255, 0.45) 8px, + rgba(255, 255, 255, 0.45) 16px, + transparent 16px, + transparent 24px + ); + animation: cuSkeletonShimmer 1.6s linear infinite; +} + +@keyframes cuSkeletonShimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + @media (prefers-reduced-motion: reduce) { [style*="animation:spin"] { animation: none !important; @@ -32,4 +76,8 @@ .investigation-card { animation: none; } + + .cu-skeleton-line::after { + animation: none !important; + } } diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index be25d22..b27148a 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -394,7 +394,7 @@ function FocusedQuestionBody({ // ── Persistent navigation controls (overlay-level, outside content grid) ── -function FocusedWorkspaceNavigation({ nodeId, doneForNow, isDoneForNowActive, isProcessing }) { +function FocusedWorkspaceNavigation({ nodeId, doneForNow, isDoneForNowActive, isProcessing, onImmediateGraphChange }) { const canDoneForNow = Boolean(isDoneForNowActive) && !isProcessing; return (
@@ -1303,6 +1303,7 @@ export default function ReasoningWorkspace({ scenario, status, updateStatus, + cuSynthesisLoading, currentUnderstanding: propUnderstanding, result, answer, @@ -1317,8 +1318,12 @@ export default function ReasoningWorkspace({ onUpdateFindingProposition, /* ── v0.49 — done-for-now promotion callback ───────── */ onSummaryUpdate, + /* ── immediate graph transition (Done acknowledged before async) ── */ + onImmediateGraphChange, /* ── canonical graph-replacement seam (future Re-open) ── */ onSituationGraphChange, + /* ── test init seam (no effect → immediate state) ───────── */ + initialPostAnalyseStatus, }) { const [investigationHistory, setInvestigationHistory] = useState([]); const turnCounter = useRef(0); @@ -1338,7 +1343,7 @@ export default function ReasoningWorkspace({ // ── RTO.29D — post-Analyse initial reflection surface ───────── const [initialReflectionActive, setInitialReflectionActive] = useState(false); - const [postAnalyseStatus, setPostAnalyseStatus] = useState(null); + const [postAnalyseStatus, setPostAnalyseStatus] = useState(initialPostAnalyseStatus ?? null); // Capture the current selected question at submit time (not from a stale ref) const capturePendingTurn = (selectedQuestion, answerText) => { @@ -1790,11 +1795,21 @@ export default function ReasoningWorkspace({ {/* Current Understanding + Situation — independent vertical flow */}
{/* Current Understanding — prominent orienting surface */} -
+

Current Understanding

{propUnderstanding}

+ {cuSynthesisLoading && ( +
+

Clarifying your current understanding…

+ + )}
{/* Situation panel during initial reflection */} @@ -1990,7 +2005,7 @@ export default function ReasoningWorkspace({ {/* Current Understanding — independent row, full-width of left area (cols 1-2) */} {propUnderstanding && hasCurrentSummaryCondition && postAnalyseStatus !== "success" && ( -
+
)} @@ -2211,7 +2226,18 @@ export default function ReasoningWorkspace({ nodeId={focusedPresentationItemId} doneForNow={() => { if (processingStep === "active") return; - /* ── v0.49 — promote eligible focused findings into Current Understanding ─── */ + /* ── Immediate transition: resolve target node BEFORE async — cuSynthesisLoading also set ─── */ + if (onImmediateGraphChange && focusedPresentationItemId) { + const currentGraph = result?.situationGraph; + if (currentGraph) { + const resolvedIds = new Set(currentGraph.resolvedNodeIds || []); + resolvedIds.add(focusedPresentationItemId); + onImmediateGraphChange({ + ...currentGraph, + resolvedNodeIds: Array.from(resolvedIds), + }); + } + } onSummaryUpdate?.(focusedPresentationItemId); setDoneForNowIds((prev) => [...prev, focusedPresentationItemId]); setFocusedAnswer(""); @@ -2221,6 +2247,7 @@ export default function ReasoningWorkspace({ }} isDoneForNowActive={Boolean(getFocusedInvestigation()?.question?.trim())} isProcessing={processingStep === "active"} + onImmediateGraphChange={onImmediateGraphChange} /> )} diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index 5e4375b..82751f7 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -281,6 +281,7 @@ export default function ScenarioForm() { const [updateResult, setUpdateResult] = useState(null); const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState(""); const [currentUnderstanding, setCurrentUnderstanding] = useState(null); + const [cuSynthesisLoading, setCuSynthesisLoading] = useState(false); const [mockScenario, setMockScenario] = useState(""); const [hideFacilitatorOnLanding, setHideFacilitatorOnLanding] = useState(false); @@ -357,16 +358,29 @@ export default function ScenarioForm() { * Authoritative graph reconsideration triggered by "Done for now" * activity boundary. Delegates to the exported executeEpisodeDone pipeline. */ - async function handleDoneForNowPromotion(targetNodeId) { + async function handleDoneForNowPromotion(targetNodeId, onImmediateGraphUpdate) { if (!targetNodeId || !findings?.length) return; // In-flight guard: exactly-once enforcement if (doneInProgressRef.current) return; doneInProgressRef.current = true; + /* ── Immediate client transition — before awaiting async work ── */ + const preDoneGraph = result?.situationGraph; + if (preDoneGraph && onImmediateGraphUpdate) { + const immediateResolvedIds = new Set(preDoneGraph.resolvedNodeIds || []); + immediateResolvedIds.add(targetNodeId); + const immediateGraph = { + ...preDoneGraph, + resolvedNodeIds: Array.from(immediateResolvedIds), + }; + onImmediateGraphUpdate(immediateGraph); + } + + setCuSynthesisLoading(true); try { const doneResult = await executeEpisodeDone({ - resultSituationGraph: result?.situationGraph, + resultSituationGraph: preDoneGraph, targetNodeId, focusedContributions: focusedContributions ?? [], findings, @@ -388,6 +402,7 @@ export default function ScenarioForm() { } finally { doneInProgressRef.current = false; + setCuSynthesisLoading(false); } } @@ -422,14 +437,9 @@ export default function ScenarioForm() { const currentGraph = result?.situationGraph; if (!currentGraph) return; - const completeNextFindings = normalizeFindings([ - ...(findings ?? []), - ...newFindingsDelta, - ]); - void synthesizeFromFindings(fetch, { situationGraph: currentGraph, - findings: completeNextFindings, + findings: normalizeFindings([...(findings ?? []), ...newFindingsDelta]), }).then((res) => { if (res.ok && res.data?.currentUnderstanding) { setCurrentUnderstanding(res.data.currentUnderstanding); @@ -794,6 +804,7 @@ export default function ScenarioForm() { scenario={scenario} status={status} updateStatus={updateStatus} + cuSynthesisLoading={cuSynthesisLoading} currentUnderstanding={currentUnderstanding} result={{ ...(result || {}), @@ -814,6 +825,8 @@ export default function ScenarioForm() { onUpdateFindingProposition={updateFindingProposition} /* ── v0.49 — done-for-now promotion seam ─────────── */ onSummaryUpdate={handleDoneForNowPromotion} + /* ── immediate graph transition (Done acknowledged before async) ── */ + onImmediateGraphChange={(nextGraph) => setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }))} /* ── canonical graph-replacement seam (future Re-open) ── */ onSituationGraphChange={(nextGraph) => setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }))} onRestart={() => { diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 742d427..ad43da5 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -144,6 +144,20 @@ coherent user-facing explanation A separate LLM call here remains appropriate because this operation serves presentation/coherence, **not** authoritative episode interpretation. Desired presentation direction: short, clear, scannable, plain language, minimal repetition. Do not redesign or tune the CU prompt now. +### Done-for-now interaction (current bounded contract) + +`Done for now` is user-owned and has immediate visible effect — the engine does not decide whether enough evidence has been gathered. + +On clicking **Done for now**: + +1. **Question parks immediately** under "Questions we have clarified" with `Clarified` status + Re-open button +2. **Focused investigation workspace closes** without waiting for async pipeline +3. **Current Understanding loading begins immediately** — skeleton overlay appears while the async pipeline runs +4. Skeleton spans: episode reconsideration → graph application → CU synthesis +5. **CU refresh completes the investigation checkpoint** — new CU replaces skeleton when ready + +The skeleton overlay uses strong paragraph-style bars with varied widths and a left→right shimmer, centred status message ("Clarifying your current understanding…"), and an opaque background that fully obscures old CU content until synthesis succeeds or fails. + ### Current Understanding refresh invariant Reconstruct Current Understanding when canonical meaning or the eligible evidence set changes. Do **not** reconstruct it merely because investigation/question status changes. diff --git a/tests/ui/scenario-form.test.jsx b/tests/ui/scenario-form.test.jsx index 2aff7cb..96df7bd 100644 --- a/tests/ui/scenario-form.test.jsx +++ b/tests/ui/scenario-form.test.jsx @@ -2147,4 +2147,96 @@ describe("ReasoningWorkspace UI", () => { expect(nextResult.selectedQuestion.question).toBe("What denominator is being used for the complaint rate?"); expect(nextResult.diagnostics.modelName).toBe("test"); }); + + // ── CU synthesis loading overlay (wired to visible PostAnalyse CU path) ───────────────────────────── + + // Uses renderToStaticMarkup + ReasoningWorkspace.initialPostAnalyseStatus test init prop + // to exercise Path A (initial-reflection CU with shimmer overlay) without effect lifecycle. + // initialPostAnalyseStatus="success" lets us render the same card that useEffect produces + // in production, proving cuSynthesisLoading reaches the actually visible CU card. + + function createCUOverlayProps(cuSynthesisLoading) { + return { + status: "success", + updateStatus: "idle", + initialPostAnalyseStatus: "success", + currentUnderstanding: "We have identified a key question to investigate further.", + cuSynthesisLoading, + result: makeWorkspaceResult({ + 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"], + }, + }), + answer: "", + setAnswer: vi.fn(), + onAnswerSubmit: vi.fn(), + }; + } + + it("does NOT show CU synthesis overlay when cuSynthesisLoading is false", () => { + const html = renderToStaticMarkup(); + + // The visible PostAnalyse CU card renders on Path A (postAnalyseStatus === "success") + expect(html).toContain("Current Understanding"); + // But cu-skeleton-overlay class MUST NOT be present + expect(html).not.toContain("cu-skeleton-overlay"); + }); + + it("shows CU skeleton overlay on the ACTUAL VISIBLE PostAnalyse CU card when cuSynthesisLoading is true", () => { + const html = renderToStaticMarkup(); + + // ── Prove the skeleton reaches Path A (the authoritative visible CU) ────────── + // initialPostAnalyseStatus="success" puts ReasoningWorkspace in the same state + // as after useEffect fires → Path A renders (initial-reflection CU). + // The skeleton overlay MUST appear there — this assertion would fail if cuSynthesisLoading + // were wired only to the old grid path (which is on Path B). + + // role="status" proves the overlay's accessible status container renders + expect(html).toContain("role=\"status\""); + // "Clarifying your current understanding…" message visible + expect(html).toContain("Clarifying your current understanding…"); + // The skeleton overlay class is present on Path A (not dead on Path B) + expect(html).toContain("cu-skeleton-overlay"); + // Exactly 7 skeleton line placeholders (strong skeleton requirement) + // cu-skeleton-lines parent class contains cu-skeleton-line as prefix substring, + // so count = 7 bars + 1 parent = 8 occurrences → split gives 9 parts + expect(html.split("cu-skeleton-line").length).toBe(9); + // Old CU text remains structurally available behind the overlay + expect(html).toContain("We have identified a key question to investigate further."); + }); + + it("proves skeleton is on Path A not the grid CU by verifying PostAnalyse CU header when cuSynthesisLoading=true", () => { + const html = renderToStaticMarkup(); + + // The PostAnalyse visible CU header and skeleton are present + expect(html).toContain("Current Understanding"); + expect(html).toContain("cu-skeleton-overlay"); + }); + + it("proves skeleton visible alongside clarified question — pending state does not block graph transition", () => { + // ── Pending-state assertion: cuSynthesisLoading=true AND resolved node coexist ─ + // The overlay (skeleton) must be visible WHILE the canonical graph already + // reflects the clarification. This proves the immediate move is NOT gated by + // async reconsideration completion. + const html = renderToStaticMarkup(); + + // Skeleton present → CU being regenerated + expect(html).toContain("cu-skeleton-overlay"); + // Target question already in Questions we have clarified (resolvedNodeIds) + expect(html).toContain("Questions we have clarified"); + // Question "Complaint rate denominator" moved into the resolved section + expect(html).toContain("Complaint rate denominator"); + }); + + it("proves skeleton loading surface blocks old CU — opaque gray background", () => { + const html = renderToStaticMarkup(); + + // The overlay uses a strong bg-gray-50 blocking surface (not transparent) + expect(html).toContain("bg-gray-50"); + }); }); \ No newline at end of file