fix(confidence-engine): preserve completed-narrative when selecting follow-up + reverse prior-contribs display

Two presentation fixes (no reasoning-engine changes):

A. Follow-up answer continuity — selectFollowUpQuestion clears focused.answer,
   which previously caused the completed-narrative framing ('Previously answered'
   and 'Your response') to disappear mid-investigation. The guard now treats
   a non-null result as sufficient evidence of a completed-context state, so the
   user's verbatim answer and derived findings remain visible while a follow-up is
   being formulated.

B. Prior-contributions chronology — display order in 'Previous learning' panels
   has been reversed at the presentation boundary (newest → oldest). This means
   users see the most recently learned evidence first, without modifying data-order
   anywhere else. Applies to both PriorContributionsSummary and
   SecondaryPreviousLearning.
This commit is contained in:
2026-08-29 19:04:06 +01:00
parent 890a18c5a7
commit 17c6048047
2 changed files with 180 additions and 5 deletions
+14 -3
View File
@@ -145,6 +145,9 @@ function FocusedQuestionBody({
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
const hasResult = Boolean(focused?.result);
const hasAnswer = Boolean(focused?.answer);
// A non-null result means we are still in a completed-context state even after the user selects a follow-up (which clears answer).
// Without this guard, selecting a follow-up question would erase "Previously answered" + "Your response".
const hasCompletedContext = processingStep !== "active" && Boolean(focused?.result);
// ── Local correction state (FQB-owned, not propagated upward) ─
const [editingFindingId, setEditingFindingId] = useState(null);
@@ -175,7 +178,7 @@ function FocusedQuestionBody({
{isFocused && hasContent && (
<div className="mt-4 space-y-4">
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
{hasAnswer && focused?.question?.trim() ? (
{(hasAnswer || hasCompletedContext) && focused?.question?.trim() ? (
<div className="space-y-3">
{/* Previously answered question */}
<div>
@@ -511,12 +514,16 @@ function PriorContributionsSummary({ nodeId, contributions, findings }) {
const priorContribs = threadContribs.slice(0, -1);
if (!priorContribs.length) return null;
// Presentation-reversal: render newest prior turn first so user sees what was learned most recently at the top.
// This is a presentation-only decision; chronological order in data is preserved elsewhere.
const reversedPrior = [...priorContribs].reverse();
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
<h4 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Previous learning
</h4>
{priorContribs.map((c, idx) => (
{reversedPrior.map((c, idx) => (
<details key={c.id || idx} className="mb-2 border-b border-gray-200/40 last:border-0 pb-2 last:pb-0" open={idx === 0}>
<summary className="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 select-none py-1">
Turn {c.sequence || idx + 1} contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
@@ -564,12 +571,16 @@ function SecondaryPreviousLearning({ nodeId, contributions, findings }) {
const priorContribs = threadContribs.slice(0, -1);
if (!priorContribs.length) return null;
// Presentation-reversal: render newest prior turn first so user sees what was learned most recently at the top.
// This is a presentation-only decision; chronological order in data is preserved elsewhere.
const reversedPrior = [...priorContribs].reverse();
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
<h4 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Previous learning
</h4>
{priorContribs.map((c, idx) => (
{reversedPrior.map((c, idx) => (
<details key={c.id || idx} className="mb-2 border-b border-gray-200/40 last:border-0 pb-2 last:pb-0" open={idx === 0}>
<summary className="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 select-none py-1">
Turn {c.sequence || idx + 1} contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
+166 -2
View File
@@ -1604,9 +1604,173 @@ describe("Narrow textarea rendering condition — v0.49 completed-result repair"
});
});
// ── v0.49 — completed-result provenance narrative ─────────────
// ── v0.50: IN-PLACE FOLLOW-UP CONTINUATION (defect: follow-up selection clears hasAnswer, causing "Question" header to reappear)
describe("v0.49 Case A — completed turn provenance narrative", () => {
// Simulated FQB condition for determining whether to render completed-narrative framing
function shouldRenderCompletedFraming(focused) {
const hasAnswer = Boolean(focused?.answer);
// OLD (buggy): only checks answer
return hasAnswer && Boolean(focused?.question?.trim());
}
// FIXED condition: also treats a non-null result as "completed context exists"
function shouldRenderCompletedFraming_FIXED(focused) {
const hasAnswer = Boolean(focused?.answer);
const hasCompletedContext = Boolean(focused?.result);
return (hasAnswer || hasCompletedContext) && Boolean(focused?.question?.trim());
}
// ── Simulated reverse rendering for Previous Learning chronology ────────
function renderPriorContribs(chronological) {
// Mirrors PriorContributionsSummary / SecondaryPreviousLearning: slice(0, -1) preserves order
const prior = chronological.slice(0, -1);
return prior.map((c, idx) => ({
label: `Turn ${c.sequence || idx + 1}`,
sequence: c.sequence,
id: c.id,
}));
}
function renderPriorContribs_REVERSED(chronological) {
// Reversed at presentation boundary only
const prior = chronological.slice(0, -1);
return [...prior].reverse().map((c, idx) => ({
label: `Turn ${c.sequence || idx + 1}`,
sequence: c.sequence,
id: c.id,
}));
}
describe("v0.50 Case A — selected follow-up continues in place with completed context", () => {
it("OLD: selecting follow-up (answer=null, result exists) does NOT render completed framing → proves defect", () => {
const beforeSelect = {
question: "Turn 2 question",
answer: "Turn 2 answer", // non-null = hasAnswer = true
status: "formulated",
result: { observations: ["Finding A"], uncertainties: [], possibleFollowUpQuestions: ["Should we explore X?"] },
};
expect(shouldRenderCompletedFraming(beforeSelect)).toBe(true);
const afterSelect = {
...beforeSelect,
question: "Should we explore X?", // follow-up question
answer: null, // setFollowUpQuestion clears answer
status: "formulated",
result: beforeSelect.result, // result stays (causal context)
};
// BUG: after selecting a follow-up, completed framing is lost
expect(shouldRenderCompletedFraming(afterSelect)).toBe(false);
});
it("FIXED: after follow-up selection with result, completed framing IS preserved → proves repair", () => {
const afterSelect = {
question: "Should we explore X?",
answer: null,
status: "formulated",
result: { observations: ["Finding A"], uncertainties: [], possibleFollowUpQuestions: ["Another option?"] },
};
expect(shouldRenderCompletedFraming_FIXED(afterSelect)).toBe(true);
});
});
// ── Simulated reverse rendering for Previous Learning ──────────────
function renderPriorContribs(contributions) {
// PriorContributionsSummary and SecondaryPreviousLearning use: slice(0, -1)
const prior = contributions.slice(0, -1);
return prior.map((c, idx) => ({
label: `Turn ${c.sequence || idx + 1}`,
sequence: c.sequence,
id: c.id,
}));
}
function renderPriorContribs_REVERSED(contributions) {
const prior = contributions.slice(0, -1);
// Reverse at presentation boundary only
return [...prior].reverse().map((c, idx) => ({
label: `Turn ${c.sequence || idx + 1}`,
sequence: c.sequence,
id: c.id,
}));
}
describe("v0.50 Prior contribs display order — reversed at presentation boundary", () => {
const threeContributions = [
{ id: "contrib-turn-1", sequence: 1, targetNodeId: "node-A", observations: ["Turn 1 finding"] },
{ id: "contrib-turn-2", sequence: 2, targetNodeId: "node-A", observations: ["Turn 2 finding"] },
{ id: "contrib-turn-3", sequence: 3, targetNodeId: "node-A", observations: ["Turn 3 finding"] }, // latest = excluded as current result
];
it("chronological order: slice(0,-1) preserves input sequence [1, 2]", () => {
const ordered = renderPriorContribs(threeContributions);
expect(ordered.map((c) => c.sequence)).toEqual([1, 2]);
});
it("REVERSED display order: newest prior turn first [2, 1]", () => {
const reversed = renderPriorContribs_REVERSED(threeContributions);
expect(reversed.map((c) => c.sequence)).toEqual([2, 1]);
});
it("reversal puts Turn 2 (newest prior) at top of the list for the user", () => {
const reversed = renderPriorContribs_REVERSED(threeContributions);
expect(reversed[0].label).toBe("Turn 2");
expect(reversed[1].label).toBe("Turn 1");
});
it("data order is unchanged — only the presentation layer reverses", () => {
const ordered = renderPriorContribs(threeContributions);
const reversed = renderPriorContribs_REVERSED(threeContributions);
// The data order (ordered) is unaffected by reversing
expect(ordered.map((c) => c.sequence)).toEqual([1, 2]);
// Reversal produces the opposite visual order for the user
expect(reversed.map((c) => c.sequence)).toEqual([2, 1]);
// Both refer to the same underlying contributions (same IDs)
const orderedIds = new Set(ordered.map((c) => c.id));
const reversedIds = new Set(reversed.map((c) => c.id));
expect(orderedIds).toEqual(reversedIds);
});
it("two-turn thread: reversal still works", () => {
const twoContributions = [
{ id: "contrib-turn-1", sequence: 1, targetNodeId: "node-B" },
{ id: "contrib-turn-2", sequence: 2, targetNodeId: "node-B" }, // excluded as current
];
const reversed = renderPriorContribs_REVERSED(twoContributions);
expect(reversed).toHaveLength(1);
expect(reversed[0].sequence).toBe(1);
});
it("single prior turn: reversal is no-op (one element)", () => {
const single = [
{ id: "contrib-turn-1", sequence: 1, targetNodeId: "node-C" },
{ id: "contrib-turn-2", sequence: 2, targetNodeId: "node-C" }, // excluded as current
];
const reversed = renderPriorContribs_REVERSED(single);
expect(reversed).toHaveLength(1);
expect(reversed[0].sequence).toBe(1);
});
it("reversal with non-sequential sequences preserves correct chronological reverse", () => {
const irregular = [
{ id: "contrib-A", sequence: 5, targetNodeId: "node-D" },
{ id: "contrib-B", sequence: 10, targetNodeId: "node-D" },
{ id: "contrib-C", sequence: 15, targetNodeId: "node-D" }, // excluded as current
];
const reversed = renderPriorContribs_REVERSED(irregular);
expect(reversed.map((c) => c.sequence)).toEqual([10, 5]);
});
});
// ── v0.50 INVESTIGATING cue on normal Open Questions buttons (actual render path) ───
describe("v0.49 Case A — completed turn provenance narrative", () => {
it("completed turn renders distinct user response and derived findings sections", () => {
const focused = {
question: "Completed question",