feat(confidence-engine): place zero-Open-Questions milestone at Open Questions position

Move the milestone invitation from after Clarified Questions to occupy
the same spatial position as Open Questions — between Current Understanding
and Questions we have clarified. Uses ternary: openUnknowns > 0 ? OpenQuestionsUI : milestoneAllowed ? MilestoneInvitation : null, followed by ClarifiedQuestionsUI unconditionally. No duplication of clarified cards or Re-open controls.
This commit is contained in:
2026-09-02 12:02:55 +01:00
parent 043ba5f264
commit a061428711
3 changed files with 211 additions and 8 deletions
+26 -8
View File
@@ -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 */}
<div className="flex gap-3 items-start flex-wrap">
{/* Current Understanding — prominent orienting surface */}
<div className={`rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-7 pb-8 shadow-sm flex-1 min-w-0 relative ${hasGraph ? 'lg:max-w-2xl' : ''}`}>
<div id="cu-scroll-target" className={`rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-7 pb-8 shadow-sm flex-1 min-w-0 relative ${hasGraph ? 'lg:max-w-2xl' : ''}`}>
<h2 className="mb-4 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/60">
Current Understanding
</h2>
@@ -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 ? (
<div className="space-y-3">
<h2 className="text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Open Questions
@@ -1935,7 +1936,23 @@ export default function ReasoningWorkspace({
);
})()}
</div>
)}
) : openUnknowns.length === 0 && clarifiedQuestions.length > 0 && !cuSynthesisLoading ? (
<div className="rounded-xl border-[2.5px] border-teal-300/60 bg-gradient-to-b from-teal-50/40 to-white px-7 pt-5 pb-6">
<p className="text-sm leading-relaxed text-gray-700 mb-4">
{'You\'ve now worked through all of the questions we surfaced. Would you like to see an overview of what we understand so far?'}
</p>
<button
onClick={() => {
const el = document.getElementById("cu-scroll-target");
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}}
style={{ cursor: "pointer" }}
className="rounded-lg border border-teal-600 bg-white px-4 py-2 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
>
Review current understanding
</button>
</div>
) : 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" && (
<div className="lg:row-start-1 lg:col-span-full rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-7 pb-8 shadow-sm relative">
<div id="cu-scroll-target" className="lg:row-start-1 lg:col-span-full rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-7 pb-8 shadow-sm relative">
<CurrentUnderstandingCard currentSummary={graph?.currentSummary || result?.updatedSituationGraph?.currentSummary} plainLanguage={propUnderstanding} />
</div>
)}
@@ -2028,6 +2045,7 @@ export default function ReasoningWorkspace({
processingStep={processingStep}
deconstructMsg={deconstructMsg}
doneForNowIds={doneForNowIds}
cuSynthesisLoading={cuSynthesisLoading}
startFocused={startFocused}
handleDeconstructSubmit={handleDeconstructSubmit}
retryFormulation={retryFormulation}
+49
View File
@@ -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 ? <OpenQuestionsUI> : milestoneAllowed ? <MilestoneInvitation> : null`, followed by `<ClarifiedQuestionsUI />` 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.
@@ -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);
});
});