diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx
index 6551fa9..e240e9c 100644
--- a/components/reasoning-workspace.jsx
+++ b/components/reasoning-workspace.jsx
@@ -169,6 +169,7 @@ function FocusedQuestionBody({
setFollowUpQuestion,
focusedContributions,
currentFindings,
+ onUpdateFindingDisposition,
}) {
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
const hasResult = Boolean(focused?.result);
@@ -202,7 +203,22 @@ function FocusedQuestionBody({
{/* Prior accumulated learning (prior turns, current turn excluded — shown above) */}
-
What this tells us
{(currentFindings?.length ? currentFindings : (focused.result.observations || [])).map((item, i) => (- {currentFindings?.length ? item.proposition : item}
))}
+ 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;
+ return (
+ -
+ {currentFindings?.length ? item.proposition : item}
+ {isFinding && onUpdateFindingDisposition && (
+ disposition === "not_relevant" ? (
+
+ ) : (
+
+ )
+ )}
+
+ );
+ })}
Still unclear
{(focused.result.uncertainties || []).map((u, i) => (- {u}
))}
Questions this raises
{(focused.result.possibleFollowUpQuestions || []).length > 0 ? (
@@ -1024,6 +1040,7 @@ function FocusedInvestigationWorkspace({
hasCompletedInvestigation,
focusedContributions,
currentFindings,
+ onUpdateFindingDisposition,
}) {
const hasResult = Boolean(focused?.result);
@@ -1052,6 +1069,7 @@ function FocusedInvestigationWorkspace({
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={hasResult ? [] : (focusedContributions || [])}
currentFindings={currentFindings || []}
+ onUpdateFindingDisposition={onUpdateFindingDisposition}
/>
@@ -1072,6 +1090,7 @@ function OpenQuestionsPanel({
startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId,
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
+ onUpdateFindingDisposition,
}) {
const openNodes = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
@@ -1149,6 +1168,7 @@ function OpenQuestionsPanel({
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions}
+ onUpdateFindingDisposition={onUpdateFindingDisposition}
/>
{/* Thread contributions for this node */}
@@ -1191,6 +1211,7 @@ export default function ReasoningWorkspace({
focusedContributions,
onFocusedContribution,
findings,
+ onUpdateFindingDisposition,
}) {
const [investigationHistory, setInvestigationHistory] = useState([]);
const turnCounter = useRef(0);
@@ -1794,6 +1815,7 @@ export default function ReasoningWorkspace({
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
+ onUpdateFindingDisposition={onUpdateFindingDisposition}
/>
);
})()}
@@ -2043,6 +2065,7 @@ export default function ReasoningWorkspace({
}}
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
+ onUpdateFindingDisposition={onUpdateFindingDisposition}
/>
{/* Workspace navigation — hidden during formulation/loading states */}
{formulationStep !== "active" && (
diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx
index 608f74a..00d8a94 100644
--- a/components/scenario-form.jsx
+++ b/components/scenario-form.jsx
@@ -270,6 +270,12 @@ export default function ScenarioForm() {
});
}
+ function updateFindingDisposition(findingId, newDisposition) {
+ setFindings((prev) =>
+ prev.map((f) => (f.id === findingId ? { ...f, userDisposition: newDisposition } : f)),
+ );
+ }
+
function appendFocusedContribution(contribution) {
// Derive a single stored contribution object and use it for BOTH
// contribution storage AND Finding derivation so the same identity
@@ -623,6 +629,7 @@ export default function ScenarioForm() {
focusedContributions={focusedContributions}
onFocusedContribution={appendFocusedContribution}
findings={findings}
+ onUpdateFindingDisposition={updateFindingDisposition}
onRestart={() => {
clearSession();
setStatus("idle");
diff --git a/tests/v03-reasoning.test.js b/tests/v03-reasoning.test.js
index de40c6f..62fd897 100644
--- a/tests/v03-reasoning.test.js
+++ b/tests/v03-reasoning.test.js
@@ -596,3 +596,219 @@ describe("diagnostics prompt version", () => {
expect(PROMPT_VERSIONS).toContain("v0.2");
});
});
+
+// ── Finding disposition toggle seam ────────────────────────────────
+
+describe("Finding disposition toggle — state machine", () => {
+ it("default finding has null userDisposition (visible, clickable 'not relevant')", async () => {
+ const testFinding = {
+ id: "find-disp-001",
+ proposition: "Team is overloaded",
+ userDisposition: null,
+ };
+ expect(testFinding.userDisposition).toBeNull();
+ });
+
+ it("toggling to not_relevant shows 'restore' button instead of 'not relevant'", async () => {
+ const testFinding = {
+ id: "find-disp-002",
+ proposition: "Scope too narrow",
+ userDisposition: "not_relevant",
+ };
+ expect(testFinding.userDisposition).toBe("not_relevant");
+ });
+
+ it("restore sets disposition back to null", async () => {
+ const testFinding = {
+ id: "find-disp-003",
+ proposition: "Timeline is tight",
+ userDisposition: "not_relevant",
+ };
+ const restored = { ...testFinding, userDisposition: null };
+ expect(restored.userDisposition).toBeNull();
+ });
+
+ it("no crash when currentFindings is empty array", async () => {
+ const currentFindings = [];
+ expect(() => {
+ currentFindings.forEach((item) => {
+ const isFinding = typeof item === "object" && item !== null && "id" in item;
+ const disposition = isFinding ? item.userDisposition : null;
+ expect(disposition).not.toBe(undefined);
+ });
+ }).not.toThrow();
+ });
+
+ it("no crash when currentFindings contains primitive strings (observations path)", async () => {
+ const observations = ["Factor A confirmed", "Timing unknown"];
+ expect(() => {
+ observations.forEach((item) => {
+ const isFinding = typeof item === "object" && item !== null && "id" in item;
+ const disposition = isFinding ? item.userDisposition : null;
+ expect(isFinding).toBe(false);
+ expect(disposition).toBeNull();
+ });
+ }).not.toThrow();
+ });
+
+ it("finding with 'not_relevant' disposition excluded from confirmed observations", async () => {
+ const findings = [
+ { id: "f1", proposition: "Valid finding", userDisposition: null },
+ { id: "f2", proposition: "Not relevant", userDisposition: "not_relevant" },
+ ];
+ const approved = findings.filter((f) => f.userDisposition !== "not_relevant");
+ expect(approved).toHaveLength(1);
+ expect(approved[0].id).toBe("f1");
+ });
+
+ it("finding with null disposition INCLUDED in confirmed observations", async () => {
+ const findings = [
+ { id: "f3", proposition: "Valid finding", userDisposition: null },
+ ];
+ const approved = findings.filter((f) => f.userDisposition !== "not_relevant");
+ expect(approved).toHaveLength(1);
+ expect(approved[0].id).toBe("f3");
+ });
+
+ it("all findings with not_relevant filtered out — mixed batch", async () => {
+ const findings = [
+ { id: "f1", proposition: "P1", userDisposition: null },
+ { id: "f2", proposition: "P2", userDisposition: "not_relevant" },
+ { id: "f3", proposition: "P3", userDisposition: null },
+ { id: "f4", proposition: "P4", userDisposition: "not_relevant" },
+ ];
+ const approved = findings.filter((f) => f.userDisposition !== "not_relevant");
+ expect(approved).toHaveLength(2);
+ expect(approved.map((f) => f.id)).toEqual(["f1", "f3"]);
+ });
+
+ it("toggle click handler structure — stopPropagation prevents overlay close", async () => {
+ let stopped = false;
+ const mockEvent = {
+ stopPropagation: () => { stopped = true; },
+ };
+
+ // Simulate the inline onClick handler pattern used in FocusedQuestionBody
+ const onClick = (e, id, disposition) => {
+ e.stopPropagation();
+ // onUpdateFindingDisposition(id, disposition);
+ };
+
+ onClick(mockEvent, "f1", "not_relevant");
+ expect(stopped).toBe(true);
+ });
+});
+
+// ── Disposition prop chain verification ────────────────────────
+
+describe("Disposition prop chain — ScenarioForm → ReasoningWorkspace → FocusedQuestionBody", () => {
+ it("ScenarioForm exposes updateFindingDisposition callback with correct arity", async () => {
+ const testFindings = [{ id: "f1", proposition: "P", userDisposition: null }];
+
+ const updateFindingDisposition = (findingId, newDisposition) => {
+ return testFindings.map((f) =>
+ f.id === findingId ? { ...f, userDisposition: newDisposition } : f,
+ );
+ };
+
+ const updated = updateFindingDisposition("f1", "not_relevant");
+ expect(updated[0].userDisposition).toBe("not_relevant");
+ });
+
+ it("ReasoningWorkspace receives onUpdateFindingDisposition and passes to FocusedInvestigationWorkspace", async () => {
+ // Verify the prop chain exists in source code
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
+ const content = await fs.readFile(path, "utf-8");
+
+ expect(content).toContain("onUpdateFindingDisposition");
+ // ReasoningWorkspace accepts it as prop
+ expect(content).toContain("export default function ReasoningWorkspace");
+ });
+
+ it("FocusedQuestionBody receives onUpdateFindingDisposition via all three call sites", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
+ const content = await fs.readFile(path, "utf-8");
+
+ // Count occurrences of onUpdateFindingDisposition in FocusedQuestionBody props
+ const focusedQuestionBodyCalls = content.match(//g) || [];
+ expect(focusedQuestionBodyCalls.length).toBeGreaterThanOrEqual(3);
+
+ for (const call of focusedQuestionBodyCalls) {
+ expect(call).toContain("onUpdateFindingDisposition");
+ }
+ });
+
+ it("FocusedInvestigationWorkspace also receives onUpdateFindingDisposition", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
+ const content = await fs.readFile(path, "utf-8");
+
+ // Find the FocusedInvestigationWorkspace call site
+ const fiwsCall = content.match(//g);
+ expect(fiwsCall).not.toBeNull();
+ expect(fiwsCall[0]).toContain("onUpdateFindingDisposition");
+ });
+
+ it("ScenarioForm updateFindingDisposition updates findings state immutably", async () => {
+ let state = [
+ { id: "f1", proposition: "P1", userDisposition: null },
+ { id: "f2", proposition: "P2", userDisposition: null },
+ ];
+
+ const updateFindingDisposition = (findingId, newDisposition) => {
+ state = state.map((f) =>
+ f.id === findingId ? { ...f, userDisposition: newDisposition } : f,
+ );
+ };
+
+ updateFindingDisposition("f1", "not_relevant");
+
+ expect(state[0].userDisposition).toBe("not_relevant");
+ expect(state[1].userDisposition).toBeNull(); // untouched
+ });
+
+ it("restore operation sets disposition back to null — state preserved", async () => {
+ let state = [
+ { id: "f1", proposition: "P1", userDisposition: "not_relevant" },
+ ];
+
+ const updateFindingDisposition = (findingId, newDisposition) => {
+ state = state.map((f) =>
+ f.id === findingId ? { ...f, userDisposition: newDisposition } : f,
+ );
+ };
+
+ updateFindingDisposition("f1", null);
+
+ expect(state[0].userDisposition).toBeNull();
+ expect(state[0].proposition).toBe("P1"); // proposition unchanged
+ });
+});
+
+// ── applyFindingsToSummary respects userDisposition ────────────
+
+describe("applyFindingsToSummary — disposition-aware", () => {
+ it("not_relevant findings do NOT appear in summary text", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "graph", "finding-helpers.js");
+ const content = await fs.readFile(path, "utf-8");
+
+ // Verify the function handles not_relevant disposition
+ expect(content).toContain("not_relevant");
+ expect(content).toContain("f.evaluation");
+ });
+
+ it("null disposition findings ARE included in summary text", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "graph", "finding-helpers.js");
+ const content = await fs.readFile(path, "utf-8");
+
+ // default case: null disposition → considered only (included)
+ expect(content).toContain("default");
+ });
+
+ it("not_quite findings appear as partial matches in summary", async () => {
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "graph", "finding-helpers.js");
+ const content = await fs.readFile(path, "utf-8");
+
+ expect(content).toContain("not_quite");
+ expect(content).toContain("notQuiteTexts");
+ });
+});