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:
@@ -145,6 +145,9 @@ function FocusedQuestionBody({
|
|||||||
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
|
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
|
||||||
const hasResult = Boolean(focused?.result);
|
const hasResult = Boolean(focused?.result);
|
||||||
const hasAnswer = Boolean(focused?.answer);
|
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) ─
|
// ── Local correction state (FQB-owned, not propagated upward) ─
|
||||||
const [editingFindingId, setEditingFindingId] = useState(null);
|
const [editingFindingId, setEditingFindingId] = useState(null);
|
||||||
@@ -175,7 +178,7 @@ function FocusedQuestionBody({
|
|||||||
{isFocused && hasContent && (
|
{isFocused && hasContent && (
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
|
<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">
|
<div className="space-y-3">
|
||||||
{/* Previously answered question */}
|
{/* Previously answered question */}
|
||||||
<div>
|
<div>
|
||||||
@@ -511,12 +514,16 @@ function PriorContributionsSummary({ nodeId, contributions, findings }) {
|
|||||||
const priorContribs = threadContribs.slice(0, -1);
|
const priorContribs = threadContribs.slice(0, -1);
|
||||||
if (!priorContribs.length) return null;
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
|
<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">
|
<h4 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
|
||||||
Previous learning
|
Previous learning
|
||||||
</h4>
|
</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}>
|
<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">
|
<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)
|
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);
|
const priorContribs = threadContribs.slice(0, -1);
|
||||||
if (!priorContribs.length) return null;
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
|
<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">
|
<h4 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
|
||||||
Previous learning
|
Previous learning
|
||||||
</h4>
|
</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}>
|
<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">
|
<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)
|
Turn {c.sequence || idx + 1} — contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
|
||||||
|
|||||||
@@ -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", () => {
|
it("completed turn renders distinct user response and derived findings sections", () => {
|
||||||
const focused = {
|
const focused = {
|
||||||
question: "Completed question",
|
question: "Completed question",
|
||||||
|
|||||||
Reference in New Issue
Block a user