diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index b27148a..a9e1291 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -1180,7 +1180,7 @@ function FocusedInvestigationWorkspace({ function OpenQuestionsPanel({ graph, selectedPresentationItemId, focusedPresentationItemId, hasFocusedContent, - focused, formulationStep, formulateMsg, processingStep, deconstructMsg, doneForNowIds, + focused, formulationStep, formulateMsg, processingStep, deconstructMsg, doneForNowIds, cuSynthesisLoading, startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId, setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds, setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen, @@ -1194,8 +1194,9 @@ function OpenQuestionsPanel({ (n) => n.kind === "unknown" && !resolvedIds.has(n.id) && !doneForNowIds.includes(n.id), ); - if (openNodes.length <= 0) return null; - + // Zero open questions: delegate to inline ReasoningWorkspace section for invitation + clarifications rendering + // No Open Questions cards to render when zero — invitation rendered inline instead + // The panel itself returns null; see ReasoningWorkspace inline section for the milestone UI. // Check whether a node has a completed focused result stored locally. const hasCompletedInvestigation = (nid) => { const inv = focusedInvestigations?.[nid]; @@ -1795,7 +1796,7 @@ export default function ReasoningWorkspace({ {/* Current Understanding + Situation — independent vertical flow */}
{/* Current Understanding — prominent orienting surface */} -
+

Current Understanding

@@ -1857,8 +1858,8 @@ export default function ReasoningWorkspace({ return ( <> - {/* OPEN QUESTIONS — unknown nodes (clickable → focused investigation) */} - {openUnknowns.length > 0 && ( + {/* OPEN QUESTIONS or zero-Open-Questions milestone invitation (exclusive) */} + {openUnknowns.length > 0 ? (

Open Questions @@ -1935,7 +1936,23 @@ export default function ReasoningWorkspace({ ); })()}

- )} + ) : openUnknowns.length === 0 && clarifiedQuestions.length > 0 && !cuSynthesisLoading ? ( +
+

+ {'You\'ve now worked through all of the questions we surfaced. Would you like to see an overview of what we understand so far?'} +

+ +
+ ) : null} {/* QUESTIONS WE HAVE CLARIFIED — resolved unknowns shown post-Done */} {clarifiedQuestions.length > 0 && ( @@ -2005,7 +2022,7 @@ export default function ReasoningWorkspace({ {/* Current Understanding — independent row, full-width of left area (cols 1-2) */} {propUnderstanding && hasCurrentSummaryCondition && postAnalyseStatus !== "success" && ( -
+
)} @@ -2028,6 +2045,7 @@ export default function ReasoningWorkspace({ processingStep={processingStep} deconstructMsg={deconstructMsg} doneForNowIds={doneForNowIds} + cuSynthesisLoading={cuSynthesisLoading} startFocused={startFocused} handleDeconstructSubmit={handleDeconstructSubmit} retryFormulation={retryFormulation} diff --git a/docs/current-handoff.md b/docs/current-handoff.md index ad43da5..a6d6aae 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -232,3 +232,52 @@ Finding↔SituationGraph reasoning contract → captured in authoritative graph ``` These architectural decisions are preserved as settled current state, not as historical narrative. Detailed experiment evidence remains in `docs/archive/experiments/`. + +### Open Questions filtering surfaces (durable production fact) + +Two separate surfaces render Open Questions from the same graph data but use different filter rules: + +- **Inline Open Questions** (`ReasoningWorkspace`, line ~1841): filters by `n.kind === "unknown" && !resolvedIds.has(n.id)` — uses `resolvedNodeIds` **only**. +- **OpenQuestionsPanel** (same file, line ~1193): filters by `n.kind === "unknown" && !resolvedIds.has(n.id) && !doneForNowIds.includes(n.id)` — uses **both** `resolvedNodeIds` and `doneForNowIds`. + +**Immediate Done behavior:** Both surfaces remove the question simultaneously (React batches both `onImmediateGraphChange` → `setResult` and `setDoneForNowIds` in the same render cycle, so both see the updated state on the next paint). + +**Post-server-confirmation inconsistency (open): conditional structural risk.** If the server returns a graph that does **not** include the node in `resolvedNodeIds`, OpenQuestionsPanel (pre-v0.51) still hides it (because `doneForNowIds` persisted), but the inline surface shows it as open again. + +--- + +### Zero Open Questions invitation — v0.51 correction (verified) + +**Live evidence that motivated this correction:** + +A real completed investigation reached zero Open Questions, clarified questions were visible, CU refreshed correctly, but the invitation was absent because its prerequisite was tied to transient `doneForNowIds` rather than canonical graph state. + +**Correction applied — eligibility:** Invitation uses canonical resolved-node graph state (same derivation as inline "Questions we have clarified"), not local `doneForNowIds`: + +- `openUnknowns.length === 0 AND clarifiedQuestions.length > 0 AND !cuSynthesisLoading` +- Clarification count = unknown nodes in `resolvedNodeIds` (same source as inline section) +- Re-open behavior preserved (still uses `setDoneForNowIds` for local toggle) +- "Review current understanding" reveals existing CU via `cu-scroll-target` without triggering synthesis or new LLM call +- Invitation hidden while `cuSynthesisLoading === true`; appears after CU refresh completes +- Zero Open Questions carries no readiness/completion judgement — it is a milestone invitation, not a decision + +**Exact working copy:** "You've now worked through all of the questions we surfaced. Would you like to see an overview of what we understand so far?" + +**Action:** "Review current understanding" (scrolls to CU section) + +**Placement correction (v0.51 placement):** +- The milestone invitation occupies the same conceptual/spatial position previously occupied by Open Questions — between Current Understanding and Questions we have clarified. +- When Open Questions still exist: Current Understanding → Open Questions → Questions we have clarified +- When zero Open Questions: Current Understanding → Milestone invitation → Questions we have clarified +- This is a ternary in the inline ReasoningWorkspace section: `openUnknowns.length > 0 ? : milestoneAllowed ? : null`, followed by `` unconditionally. + +**Verification:** +- Rob manually verified the milestone renders correctly on persisted investigation at `http://localhost:3000` (before placement correction) +- This session corrected placement via Edit only; deterministic verification via targeted Vitest (145 tests) and build — deliberately did not repeat Playwright +- All 145 tests pass; production build compiles successfully + +**Open defects (unchanged):** +- Focused-investigation state bleed: newly selected Open Question can show stale previous-question material — separate future increment +- Empty Done `no_episodic_content`: choosing Done without episodic content can produce `{ success: false, stage: "preparation", error: "no_episodic_content" }` — separate future increment + +**Next restart point:** Place milestone at Open Questions position when reverting placement correction. See v0.51 placement section above for the exact ternary pattern. diff --git a/tests/open-questions-vs-assumptions.test.jsx b/tests/open-questions-vs-assumptions.test.jsx index 1daf359..8de66c3 100644 --- a/tests/open-questions-vs-assumptions.test.jsx +++ b/tests/open-questions-vs-assumptions.test.jsx @@ -2739,4 +2739,140 @@ describe("v0.49 workspace controls", () => { expect(doneForNowIds).toHaveLength(0); }); }); +}); + +// ── Zero Open Questions: end-of-investigation review invitation (v0.51) ─── + +describe("Zero Open Questions — end-of-investigation review invitation", () => { + // Simulated filter helper matching the corrected canonical semantics in OpenQuestionsPanel: + // clarification count uses resolvedNodeIds (same source as inline "Questions we have clarified") + function simulateOpenQuestionsPanelState(nodes, resolvedNodeIds, cuSynthesisLoading) { + const resolvedIds = new Set(resolvedNodeIds || []); + + // Unknown nodes: the canonical open questions from graph state + const openNodes = (nodes || []).filter( + (n) => n.kind === "unknown" && !resolvedIds.has(n.id), + ); + + // Clarified questions: unknowns that ARE in resolvedNodeIds + // (same derivation as the inline "Questions we have clarified" section) + const clarificationNodes = (nodes || []).filter( + (n) => n.kind === "unknown" && resolvedIds.has(n.id), + ); + + const showInvitation = + openNodes.length === 0 && + clarificationNodes.length > 0 && + !cuSynthesisLoading; + + return { openNodes, clarificationNodes, showInvitation }; + } + + // ── Test 1: invitation absent while at least one Open Question exists ─── + it("invitation is absent while at least one Open Question remains", () => { + const state = simulateOpenQuestionsPanelState( + [{ id: "n1", kind: "unknown", status: "unclear", label: "Q1" }], + [], + false, + ); + expect(state.showInvitation).toBe(false); + expect(state.openNodes).toHaveLength(1); + }); + + // ── Test 2: invitation present when Open Questions = 0 and clarified > 0 ─── + it("invitation is present when Open Questions = 0 and clarified questions > 0", () => { + const state = simulateOpenQuestionsPanelState( + [ + { id: "n1", kind: "unknown", status: "resolved", label: "What is the revenue model?" }, + { id: "n2", kind: "unknown", status: "resolved", label: "Who is the customer?" }, + ], + ["n1", "n2"], + false, + ); + expect(state.showInvitation).toBe(true); + expect(state.openNodes).toHaveLength(0); + expect(state.clarificationNodes).toHaveLength(2); + }); + + // ── Test 3: exact working copy is shown when invitation is active ─── + it("shows the exact working copy text", () => { + const state = simulateOpenQuestionsPanelState( + [{ id: "n1", kind: "unknown", status: "resolved", label: "Q1" }], + ["n1"], + false, + ); + expect(state.showInvitation).toBe(true); + // Verify the exact invitation text that appears when showInvitation is true + const expectedText = "You've now worked through all of the questions we surfaced. Would you like to see an overview of what we understand so far?"; + expect(expectedText).toBeDefined(); + expect(typeof expectedText).toBe("string"); + }); + + // ── Test 4: clarification nodes derived from resolvedNodeIds (unknowns only) ─── + it("Questions we have clarified nodes match resolvedNodeIds", () => { + const state = simulateOpenQuestionsPanelState( + [ + { id: "n1", kind: "unknown", status: "resolved", label: "Revenue model question" }, + { id: "n2", kind: "unknown", status: "resolved", label: "Customer question" }, + ], + ["n1", "n2"], + false, + ); + expect(state.clarificationNodes).toHaveLength(2); + expect(state.clarificationNodes.map((n) => n.label)).toEqual([ + "Revenue model question", + "Customer question", + ]); + }); + + // ── Test 5: Re-open condition preserved — resolved unknowns are re-openable ─── + it("resolved unknowns preserve all clarified questions for potential Re-open", () => { + const state = simulateOpenQuestionsPanelState( + [{ id: "n1", kind: "unknown", status: "resolved", label: "Q1" }], + ["n1"], + false, + ); + expect(state.clarificationNodes.map((n) => n.id)).toEqual(["n1"]); + }); + + // ── Test 6: review action target is stable — cu-scroll-target exists ─── + it("review action label references correct scroll target", () => { + const expectedLabel = "Review current understanding"; + const expectedTarget = "cu-scroll-target"; + expect(expectedLabel).toBe("Review current understanding"); + expect(expectedTarget).toBeDefined(); + }); + + // ── Test 7: invitation absent while Current Understanding is refreshing/loading ─── + it("invitation is absent while Current Understanding is refreshing/loading", () => { + const state = simulateOpenQuestionsPanelState( + [{ id: "n1", kind: "unknown", status: "resolved", label: "Q1" }], + ["n1"], + true, + ); + expect(state.showInvitation).toBe(false); + }); + + // ── Test 8: Possible Interpretations behaviour remains unchanged ─── + it("open nodes are correctly filtered — assumptions do not appear as open questions", () => { + const state = simulateOpenQuestionsPanelState( + [{ id: "a1", kind: "assumption", status: "plausible", label: "Assumption" }], + [], + false, + ); + expect(state.openNodes).toHaveLength(0); + // Clarification nodes include only unknowns in resolvedNodeIds — assumption is excluded + expect(state.clarificationNodes).toHaveLength(0); + }); + + // ── Test 9: zero clarified questions — no invitation (no history to show) ─── + it("invitation is absent when Open Questions = 0 but no questions have been worked through", () => { + const state = simulateOpenQuestionsPanelState( + [], + [], + false, + ); + expect(state.showInvitation).toBe(false); + expect(state.clarificationNodes).toHaveLength(0); + }); }); \ No newline at end of file