diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx
index e240e9c..15d0b20 100644
--- a/components/reasoning-workspace.jsx
+++ b/components/reasoning-workspace.jsx
@@ -170,10 +170,35 @@ function FocusedQuestionBody({
focusedContributions,
currentFindings,
onUpdateFindingDisposition,
+ onUpdateFindingProposition,
}) {
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
const hasResult = Boolean(focused?.result);
+ // ── Local correction state (FQB-owned, not propagated upward) ─
+ const [editingFindingId, setEditingFindingId] = useState(null);
+ const [draft, setDraft] = useState("");
+
+ function startEditing(id, proposition) {
+ setEditingFindingId(id);
+ setDraft(proposition ?? "");
+ }
+
+ function cancelEditing() {
+ setEditingFindingId(null);
+ setDraft("");
+ }
+
+ function saveEditing() {
+ const trimmed = (draft ?? "").trim();
+ if (!trimmed || !editingFindingId) {
+ cancelEditing();
+ return;
+ }
+ onUpdateFindingProposition?.(editingFindingId, trimmed);
+ cancelEditing();
+ }
+
return (
<>
{isFocused && hasContent && (
@@ -206,14 +231,40 @@ function FocusedQuestionBody({
What this tells us
{(currentFindings?.length ? currentFindings : (focused.result.observations || [])).map((item, i) => {
const isFinding = typeof item === "object" && item !== null && "id" in item;
const disposition = isFinding ? item.userDisposition : null;
+ const isEditing = isFinding && editingFindingId === item.id;
+ if (!isFinding) {
+ return (
+ - {item}
+ );
+ }
+ if (isEditing) {
+ return (
+ -
+
+ );
+ }
return (
-
- {currentFindings?.length ? item.proposition : item}
+ {item.proposition}
+ {onUpdateFindingProposition && (
+
+ )}
{isFinding && onUpdateFindingDisposition && (
disposition === "not_relevant" ? (
-
+
) : (
-
+
)
)}
@@ -1041,6 +1092,7 @@ function FocusedInvestigationWorkspace({
focusedContributions,
currentFindings,
onUpdateFindingDisposition,
+ onUpdateFindingProposition,
}) {
const hasResult = Boolean(focused?.result);
@@ -1070,6 +1122,7 @@ function FocusedInvestigationWorkspace({
focusedContributions={hasResult ? [] : (focusedContributions || [])}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
+ onUpdateFindingProposition={onUpdateFindingProposition}
/>
@@ -1091,6 +1144,7 @@ function OpenQuestionsPanel({
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
onUpdateFindingDisposition,
+ onUpdateFindingProposition,
}) {
const openNodes = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
@@ -1169,6 +1223,7 @@ function OpenQuestionsPanel({
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions}
onUpdateFindingDisposition={onUpdateFindingDisposition}
+ onUpdateFindingProposition={onUpdateFindingProposition}
/>
{/* Thread contributions for this node */}
@@ -1212,6 +1267,7 @@ export default function ReasoningWorkspace({
onFocusedContribution,
findings,
onUpdateFindingDisposition,
+ onUpdateFindingProposition,
}) {
const [investigationHistory, setInvestigationHistory] = useState([]);
const turnCounter = useRef(0);
@@ -1816,6 +1872,7 @@ export default function ReasoningWorkspace({
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
+ onUpdateFindingProposition={onUpdateFindingProposition}
/>
);
})()}
@@ -1891,6 +1948,7 @@ export default function ReasoningWorkspace({
focusedContributions={focusedContributions}
focusedInvestigations={focusedInvestigations}
setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen}
+ onUpdateFindingProposition={onUpdateFindingProposition}
/>
)}
@@ -2066,6 +2124,7 @@ export default function ReasoningWorkspace({
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
+ onUpdateFindingProposition={onUpdateFindingProposition}
/>
{/* Workspace navigation — hidden during formulation/loading states */}
{formulationStep !== "active" && (
diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx
index 00d8a94..379a7f1 100644
--- a/components/scenario-form.jsx
+++ b/components/scenario-form.jsx
@@ -276,6 +276,16 @@ export default function ScenarioForm() {
);
}
+ function updateFindingProposition(findingId, newProposition) {
+ setFindings((prev) =>
+ prev.map((f) =>
+ f.id === findingId
+ ? { ...f, proposition: newProposition, userDisposition: null }
+ : f,
+ ),
+ );
+ }
+
function appendFocusedContribution(contribution) {
// Derive a single stored contribution object and use it for BOTH
// contribution storage AND Finding derivation so the same identity
@@ -630,6 +640,7 @@ export default function ScenarioForm() {
onFocusedContribution={appendFocusedContribution}
findings={findings}
onUpdateFindingDisposition={updateFindingDisposition}
+ onUpdateFindingProposition={updateFindingProposition}
onRestart={() => {
clearSession();
setStatus("idle");
diff --git a/tests/v03-reasoning.test.js b/tests/v03-reasoning.test.js
index 62fd897..42825e4 100644
--- a/tests/v03-reasoning.test.js
+++ b/tests/v03-reasoning.test.js
@@ -812,3 +812,222 @@ describe("applyFindingsToSummary — disposition-aware", () => {
expect(content).toContain("notQuiteTexts");
});
});
+
+// ── updateFindingProposition mutation seam (ScenarioForm) ───
+
+describe("updateFindingProposition — ScenarioForm mutation seam", () => {
+ function simulateUpdateFindingProposition(findings, findingId, newProposition) {
+ return findings.map((f) =>
+ f.id === findingId
+ ? { ...f, proposition: newProposition, userDisposition: null }
+ : f,
+ );
+ }
+
+ const baseFindings = [
+ {
+ id: "f1",
+ proposition: "Original text A",
+ userDisposition: null,
+ sourceObservation: "obs-001",
+ contributionId: "contrib-0001",
+ originatingTargetNodeId: "node-A",
+ createdAt: "2024-01-01T00:00:00Z",
+ status: "confirmed",
+ },
+ {
+ id: "f2",
+ proposition: "Original text B",
+ userDisposition: null,
+ sourceObservation: "obs-002",
+ contributionId: "contrib-0001",
+ originatingTargetNodeId: "node-B",
+ createdAt: "2024-01-01T00:01:00Z",
+ status: "confirmed",
+ },
+ ];
+
+ it("target finding.id is unchanged", () => {
+ const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
+ expect(result[0].id).toBe("f1");
+ });
+
+ it("target proposition changes to new value", () => {
+ const result = simulateUpdateFindingProposition(baseFindings, "f1", "Corrected text A");
+ expect(result[0].proposition).toBe("Corrected text A");
+ });
+
+ it("target userDisposition resets to null", () => {
+ const findingsWithNull = baseFindings.map((f) => f.id === "f2" ? { ...f, userDisposition: "not_relevant" } : f);
+ const result = simulateUpdateFindingProposition(findingsWithNull, "f2", "Corrected text B");
+ expect(result[1].userDisposition).toBeNull();
+ });
+
+ it("sourceObservation remains unchanged on target", () => {
+ const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
+ expect(result[0].sourceObservation).toBe("obs-001");
+ });
+
+ it("contributionId remains unchanged on target", () => {
+ const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
+ expect(result[0].contributionId).toBe("contrib-0001");
+ });
+
+ it("finding.id remains unchanged on target (reconfirmed)", () => {
+ const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
+ expect(result[0].id).toBe("f1");
+ });
+
+ it("another Finding (non-target) remains the exact existing object", () => {
+ const before = baseFindings.find((f) => f.id === "f2");
+ const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
+ const after = result.find((f) => f.id === "f2");
+ expect(after).toBe(before); // same object reference
+ });
+
+ it("no Finding is added or removed — count unchanged", () => {
+ const beforeCount = baseFindings.length;
+ const result = simulateUpdateFindingProposition(baseFindings, "f1", "New text A");
+ expect(result.length).toBe(beforeCount);
+ });
+});
+
+// ── onUpdateFindingProposition prop chain verification ───────────
+
+describe("onUpdateFindingProposition — prop chain verification", () => {
+ it("ScenarioForm exposes updateFindingProposition callback with correct arity", async () => {
+ const testFindings = [{ id: "f1", proposition: "P", userDisposition: null }];
+
+ const updateFindingProposition = (findingId, newProposition) => {
+ return testFindings.map((f) =>
+ f.id === findingId ? { ...f, proposition: newProposition, userDisposition: null } : f,
+ );
+ };
+
+ const updated = updateFindingProposition("f1", "Corrected");
+ expect(updated[0].proposition).toBe("Corrected");
+ expect(updated[0].userDisposition).toBeNull();
+ });
+
+ it("ReasoningWorkspace accepts onUpdateFindingProposition as prop", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
+ const content = await fs.readFile(path, "utf-8");
+
+ expect(content).toContain("onUpdateFindingProposition");
+ });
+
+ it("ReasoningWorkspace passes onUpdateFindingProposition to FocusedInvestigationWorkspace", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
+ const content = await fs.readFile(path, "utf-8");
+
+ const fiwsCall = content.match(//g);
+ expect(fiwsCall).not.toBeNull();
+ expect(fiwsCall[0]).toContain("onUpdateFindingProposition");
+ });
+
+ it("ScenarioForm passes onUpdateFindingProposition to ReasoningWorkspace", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "scenario-form.jsx");
+ const content = await fs.readFile(path, "utf-8");
+ expect(content).toContain("onUpdateFindingProposition={updateFindingProposition}");
+ });
+
+ it("no correction state introduced in ReasoningWorkspace — only FQB owns editingFindingId + draft", async () => {
+ const rwPath = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
+ const rwContent = await fs.readFile(rwPath, "utf-8");
+
+ // ReasoningWorkspace should NOT declare editingFindingId or draft as its own state
+ // (they are only declared inside FocusedQuestionBody)
+ const reasonFuncMatch = rwContent.match(/export default function ReasoningWorkspace\([\s\S]*?return \(/);
+ expect(reasonFuncMatch).not.toBeNull();
+ const funcBody = reasonFuncMatch[0];
+ expect(funcBody).not.toMatch(/editingFindingId/);
+ expect(funcBody).not.toMatch(/setEditingFindingId/);
+ });
+
+ it("Not quite invokes correction for exact Finding.id", async () => {
+ // Simulate: user clicks "Not quite" → startEditing(id, proposition)
+ let state = { editingFindingId: null, draft: "" };
+ const startEditing = (id, proposition) => {
+ state.editingFindingId = id;
+ state.draft = proposition ?? "";
+ };
+
+ startEditing("f1", "Original text A");
+
+ expect(state.editingFindingId).toBe("f1");
+ expect(state.draft).toBe("Original text A");
+ });
+
+ it("cancel does not invoke canonical mutation", async () => {
+ let mutations = [];
+ const originalMutation = (id, val) => { mutations.push({ id, val }); };
+
+ let state = { editingFindingId: "f1", draft: "changed" };
+
+ // Simulate cancel — clears state without calling mutation
+ state.editingFindingId = null;
+ state.draft = "";
+
+ expect(mutations.length).toBe(0);
+ expect(state.editingFindingId).toBeNull();
+ expect(state.draft).toBe("");
+ });
+
+ it("valid save invokes proposition mutation with exact Finding.id + trimmed text", () => {
+ let mutations = [];
+ const mockMutation = (id, val) => { mutations.push({ id, val }); };
+
+ let state = { editingFindingId: "f1", draft: " corrected text " };
+
+ const saveEditing = () => {
+ const trimmed = (state.draft ?? "").trim();
+ if (!trimmed || !state.editingFindingId) { return; }
+ mockMutation(state.editingFindingId, trimmed);
+ state.editingFindingId = null;
+ state.draft = "";
+ };
+
+ saveEditing();
+
+ expect(mutations).toEqual([{ id: "f1", val: "corrected text" }]);
+ expect(state.editingFindingId).toBeNull();
+ });
+
+ it("whitespace-only save does not invoke mutation", () => {
+ let mutations = [];
+ const mockMutation = (id, val) => { mutations.push({ id, val }); };
+
+ let state = { editingFindingId: "f1", draft: " \n\t " };
+
+ const saveEditing = () => {
+ const trimmed = (state.draft ?? "").trim();
+ if (!trimmed || !state.editingFindingId) { return; }
+ mockMutation(state.editingFindingId, trimmed);
+ state.editingFindingId = null;
+ state.draft = "";
+ };
+
+ saveEditing();
+
+ expect(mutations.length).toBe(0);
+ expect(state.editingFindingId).not.toBeNull(); // state preserved when no-op
+ });
+
+ it("Not quite and Not relevant coexist on same Finding", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
+ const content = await fs.readFile(path, "utf-8");
+
+ // Both buttons should appear in the same rendering block for findings
+ const findingLiBlock = content.match(//g);
+ expect(findingLiBlock).not.toBeNull();
+ expect(findingLiBlock[0]).toContain("Not quite");
+ expect(findingLiBlock[0]).toContain("not relevant");
+ });
+
+ it("restore button still present after Not quite introduced", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
+ const content = await fs.readFile(path, "utf-8");
+
+ expect(content).toContain(">restore<");
+ });
+});