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
This commit is contained in:
@@ -840,6 +840,8 @@ export default function ReasoningWorkspace({
|
|||||||
onAnswerSubmit,
|
onAnswerSubmit,
|
||||||
lastSubmittedAnswer,
|
lastSubmittedAnswer,
|
||||||
onRestart,
|
onRestart,
|
||||||
|
focusedContributions,
|
||||||
|
onFocusedContribution,
|
||||||
}) {
|
}) {
|
||||||
const [investigationHistory, setInvestigationHistory] = useState([]);
|
const [investigationHistory, setInvestigationHistory] = useState([]);
|
||||||
const turnCounter = useRef(0);
|
const turnCounter = useRef(0);
|
||||||
@@ -869,9 +871,10 @@ export default function ReasoningWorkspace({
|
|||||||
selectedQuestion: result.selectedQuestion,
|
selectedQuestion: result.selectedQuestion,
|
||||||
summary: result.summary || propUnderstanding,
|
summary: result.summary || propUnderstanding,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
focusedContributions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [updateStatus, result]);
|
}, [updateStatus, result, focusedContributions]);
|
||||||
|
|
||||||
// Capture the current selected question at submit time (not from a stale ref)
|
// Capture the current selected question at submit time (not from a stale ref)
|
||||||
const capturePendingTurn = (selectedQuestion, answerText) => {
|
const capturePendingTurn = (selectedQuestion, answerText) => {
|
||||||
@@ -1049,6 +1052,20 @@ export default function ReasoningWorkspace({
|
|||||||
|
|
||||||
if (!data.success) throw new Error(data.error || "Deconstruction failed");
|
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");
|
setProcessingStep("idle");
|
||||||
setFocusedInvestigations((prev) => ({
|
setFocusedInvestigations((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -251,6 +251,16 @@ export default function ScenarioForm() {
|
|||||||
const [currentUnderstanding, setCurrentUnderstanding] = useState(null);
|
const [currentUnderstanding, setCurrentUnderstanding] = useState(null);
|
||||||
const [mockScenario, setMockScenario] = useState("");
|
const [mockScenario, setMockScenario] = useState("");
|
||||||
const [hideFacilitatorOnLanding, setHideFacilitatorOnLanding] = useState(false);
|
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);
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
/* ── Valid investigation predicate ─────────────────────── */
|
/* ── Valid investigation predicate ─────────────────────── */
|
||||||
@@ -269,6 +279,7 @@ export default function ScenarioForm() {
|
|||||||
setScenario(saved.scenario || "");
|
setScenario(saved.scenario || "");
|
||||||
setResult(hasGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
|
setResult(hasGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
|
||||||
setCurrentUnderstanding(saved.summary || null);
|
setCurrentUnderstanding(saved.summary || null);
|
||||||
|
setFocusedContributions(saved.focusedContributions || []);
|
||||||
|
|
||||||
// Partial sessions (present but no graph) must NOT suppress the
|
// Partial sessions (present but no graph) must NOT suppress the
|
||||||
// scenario-entry form. Only promote to success when there is actual
|
// scenario-entry form. Only promote to success when there is actual
|
||||||
@@ -353,7 +364,7 @@ export default function ScenarioForm() {
|
|||||||
setCurrentUnderstanding(data.summary ?? null);
|
setCurrentUnderstanding(data.summary ?? null);
|
||||||
const normalised = normaliseStartResult(data);
|
const normalised = normaliseStartResult(data);
|
||||||
setResult(normalised);
|
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 {
|
} else {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setCurrentUnderstanding(data.summary ?? null);
|
setCurrentUnderstanding(data.summary ?? null);
|
||||||
@@ -419,7 +430,7 @@ export default function ScenarioForm() {
|
|||||||
}));
|
}));
|
||||||
setAnswer("");
|
setAnswer("");
|
||||||
// Persist after successful update turn
|
// 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 {
|
} else {
|
||||||
setUpdateStatus("error");
|
setUpdateStatus("error");
|
||||||
setUpdateError(outcome);
|
setUpdateError(outcome);
|
||||||
@@ -575,6 +586,8 @@ export default function ScenarioForm() {
|
|||||||
setAnswer={setAnswer}
|
setAnswer={setAnswer}
|
||||||
onAnswerSubmit={handleUpdate}
|
onAnswerSubmit={handleUpdate}
|
||||||
lastSubmittedAnswer={lastSubmittedAnswer}
|
lastSubmittedAnswer={lastSubmittedAnswer}
|
||||||
|
focusedContributions={focusedContributions}
|
||||||
|
onFocusedContribution={appendFocusedContribution}
|
||||||
onRestart={() => {
|
onRestart={() => {
|
||||||
clearSession();
|
clearSession();
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
@@ -585,6 +598,7 @@ export default function ScenarioForm() {
|
|||||||
setLastSubmittedAnswer("");
|
setLastSubmittedAnswer("");
|
||||||
setCurrentUnderstanding(null);
|
setCurrentUnderstanding(null);
|
||||||
setUpdateError(null);
|
setUpdateError(null);
|
||||||
|
setFocusedContributions([]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -594,7 +608,7 @@ export default function ScenarioForm() {
|
|||||||
|
|
||||||
{/* ── Continue later banner when session was restored ── */}
|
{/* ── Continue later banner when session was restored ── */}
|
||||||
{status === "success" && result?.updatedAt && (
|
{status === "success" && result?.updatedAt && (
|
||||||
<ContinueLaterBanner onRestart={() => { clearSession(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); }} />
|
<ContinueLaterBanner onRestart={() => { clearSession(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reset button after successful analysis */}
|
{/* Reset button after successful analysis */}
|
||||||
@@ -612,6 +626,7 @@ export default function ScenarioForm() {
|
|||||||
setLastSubmittedAnswer("");
|
setLastSubmittedAnswer("");
|
||||||
setCurrentUnderstanding(null);
|
setCurrentUnderstanding(null);
|
||||||
setUpdateError(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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user