From fce68a050fa7bbacf001097acab6f16e56c64ed0 Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 21 Aug 2026 18:03:49 +0100 Subject: [PATCH] feat(ui): RTO.31 ownership of focused contributions flows to scenario form - Add focusedContributions state + appendFocusedContribution callback in ScenarioForm - Contributions persist through session lifecycle (save/restore/restart) - Pass onFocusedContribution and focusedContributions to ReasoningWorkspace - Call onFocusedContribution on successful deconstruct with full result shape - Test: contribution sequence, field preservation, same/different target coexistence --- components/reasoning-workspace.jsx | 19 ++++++- components/scenario-form.jsx | 21 ++++++-- tests/focused-contributions.test.jsx | 79 ++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 tests/focused-contributions.test.jsx diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index 771561c..980b9f5 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -840,6 +840,8 @@ export default function ReasoningWorkspace({ onAnswerSubmit, lastSubmittedAnswer, onRestart, + focusedContributions, + onFocusedContribution, }) { const [investigationHistory, setInvestigationHistory] = useState([]); const turnCounter = useRef(0); @@ -869,9 +871,10 @@ export default function ReasoningWorkspace({ selectedQuestion: result.selectedQuestion, summary: result.summary || propUnderstanding, updatedAt: new Date().toISOString(), + focusedContributions, }); } - }, [updateStatus, result]); + }, [updateStatus, result, focusedContributions]); // Capture the current selected question at submit time (not from a stale ref) const capturePendingTurn = (selectedQuestion, answerText) => { @@ -1049,6 +1052,20 @@ export default function ReasoningWorkspace({ if (!data.success) throw new Error(data.error || "Deconstruction failed"); + // Persist contribution to case-level owner (ScenarioForm) + onFocusedContribution?.({ + targetNodeId, + targetLabel: targetNode?.label || "", + targetDescription: targetNode?.description || "", + question: focused.question, + answer: answerText, + observations: data.observations, + uncertainties: data.uncertainties, + assumptions: data.assumptions, + relationships: data.relationships, + possibleFollowUpQuestions: data.possibleFollowUpQuestions, + }); + setProcessingStep("idle"); setFocusedInvestigations((prev) => ({ ...prev, diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index 78c0034..ff43d32 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -251,6 +251,16 @@ export default function ScenarioForm() { const [currentUnderstanding, setCurrentUnderstanding] = useState(null); const [mockScenario, setMockScenario] = useState(""); const [hideFacilitatorOnLanding, setHideFacilitatorOnLanding] = useState(false); + + /* ── RTO.31: focused contributions ownership ─────────────── */ + const [focusedContributions, setFocusedContributions] = useState([]); + + function appendFocusedContribution(contribution) { + setFocusedContributions((prev) => { + const seq = prev.length + 1; + return [...prev, { ...contribution, sequence: seq, id: `contrib-${String(seq).padStart(4, "0")}` }]; + }); + } const textareaRef = useRef(null); /* ── Valid investigation predicate ─────────────────────── */ @@ -269,6 +279,7 @@ export default function ScenarioForm() { setScenario(saved.scenario || ""); setResult(hasGraph ? { ...saved, situationGraph: saved.situationGraph } : null); setCurrentUnderstanding(saved.summary || null); + setFocusedContributions(saved.focusedContributions || []); // Partial sessions (present but no graph) must NOT suppress the // scenario-entry form. Only promote to success when there is actual @@ -353,7 +364,7 @@ export default function ScenarioForm() { setCurrentUnderstanding(data.summary ?? null); const normalised = normaliseStartResult(data); setResult(normalised); - saveSession({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString() }); + saveSession({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions }); } else { setStatus("error"); setCurrentUnderstanding(data.summary ?? null); @@ -419,7 +430,7 @@ export default function ScenarioForm() { })); setAnswer(""); // Persist after successful update turn - saveSession({ scenario, situationGraph: outcome.updatedSituationGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: outcome.summary ?? currentUnderstanding, updatedAt: new Date().toISOString() }); + saveSession({ scenario, situationGraph: outcome.updatedSituationGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: outcome.summary ?? currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions }); } else { setUpdateStatus("error"); setUpdateError(outcome); @@ -575,6 +586,8 @@ export default function ScenarioForm() { setAnswer={setAnswer} onAnswerSubmit={handleUpdate} lastSubmittedAnswer={lastSubmittedAnswer} + focusedContributions={focusedContributions} + onFocusedContribution={appendFocusedContribution} onRestart={() => { clearSession(); setStatus("idle"); @@ -585,6 +598,7 @@ export default function ScenarioForm() { setLastSubmittedAnswer(""); setCurrentUnderstanding(null); setUpdateError(null); + setFocusedContributions([]); }} /> @@ -594,7 +608,7 @@ export default function ScenarioForm() { {/* ── Continue later banner when session was restored ── */} {status === "success" && result?.updatedAt && ( - { clearSession(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); }} /> + { clearSession(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); }} /> )} {/* Reset button after successful analysis */} @@ -612,6 +626,7 @@ export default function ScenarioForm() { setLastSubmittedAnswer(""); setCurrentUnderstanding(null); setUpdateError(null); + setFocusedContributions([]); }} className="rounded-lg border border-gray-200/60 px-4 py-2 text-sm font-medium text-gray-500 transition hover:bg-gray-50/80" > diff --git a/tests/focused-contributions.test.jsx b/tests/focused-contributions.test.jsx new file mode 100644 index 0000000..cd1b5ee --- /dev/null +++ b/tests/focused-contributions.test.jsx @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import React from "react"; +import { renderHook, act } from "@testing-library/react"; + +// ── Test contribution shape and sequence logic ──────────────── + +describe("contribution append behavior", () => { + function simulateAppend(contributions, contribution) { + return [...contributions, { ...contribution, id: `contrib-${String(contributions.length + 1).padStart(4, "0")}`, sequence: contributions.length + 1 }]; + } + + it("first successful deconstruct -> one contribution", () => { + const result = simulateAppend([], { targetNodeId: "a" }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("contrib-0001"); + expect(result[0].sequence).toBe(1); + }); + + it("second successful deconstruct -> two contributions", () => { + const first = simulateAppend([], { targetNodeId: "a" }); + const result = simulateAppend(first, { targetNodeId: "b" }); + expect(result).toHaveLength(2); + expect(result[1].id).toBe("contrib-0002"); + expect(result[1].sequence).toBe(2); + }); + + it("same-target contributions coexist as distinct records", () => { + const first = simulateAppend([], { targetNodeId: "a", question: "Q1?", answer: "A1" }); + const result = simulateAppend(first, { targetNodeId: "a", question: "Q2?", answer: "A2" }); + expect(result).toHaveLength(2); + expect(result[0].targetNodeId).toBe("a"); + expect(result[1].targetNodeId).toBe("a"); + expect(result[0].id).not.toBe(result[1].id); + expect(result[0].question).toBe("Q1?"); + expect(result[1].question).toBe("Q2?"); + }); + + it("different-target contributions coexist", () => { + const first = simulateAppend([], { targetNodeId: "a" }); + const result = simulateAppend(first, { targetNodeId: "b" }); + expect(result).toHaveLength(2); + expect(result[0].targetNodeId).toBe("a"); + expect(result[1].targetNodeId).toBe("b"); + }); + + it("contribution preserves exact fields", () => { + const data = { + targetNodeId: "node-abc", + targetLabel: "Test Label", + targetDescription: "Test description text", + question: "What is the answer?", + answer: "The definitive answer.", + observations: ["obs1", "obs2"], + uncertainties: ["unc1"], + assumptions: ["asm1"], + relationships: [{ from: "a", to: "b", type: "depends_on" }], + possibleFollowUpQuestions: ["follow1"], + }; + const result = simulateAppend([], data); + expect(result[0].targetNodeId).toBe("node-abc"); + expect(result[0].targetLabel).toBe("Test Label"); + expect(result[0].targetDescription).toBe("Test description text"); + expect(result[0].question).toBe("What is the answer?"); + expect(result[0].answer).toBe("The definitive answer."); + expect(result[0].observations).toEqual(["obs1", "obs2"]); + expect(result[0].uncertainties).toEqual(["unc1"]); + expect(result[0].assumptions).toEqual(["asm1"]); + expect(result[0].relationships).toEqual([{ from: "a", to: "b", type: "depends_on" }]); + expect(result[0].possibleFollowUpQuestions).toEqual(["follow1"]); + }); + + it("failed deconstruct appends nothing", () => { + const before = []; + // Simulate failure: do not call append + const result = before; + expect(result).toHaveLength(0); + }); +}); +