fix(confidence-engine): preserve follow-up context while processing
This commit is contained in:
+150
-115
@@ -147,14 +147,28 @@ function FocusedQuestionBody({
|
||||
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);
|
||||
// Completed context: result (primary) OR prior contributions (fallback during processing/error).
|
||||
// Processing and error are transient states — they must NOT collapse completed context.
|
||||
const hasCompletedContext = Boolean(focused?.result) || (() => {
|
||||
const pc = [...(focusedContributions || [])].reverse().find((c) => c?.question && c?.answer);
|
||||
return !!pc;
|
||||
})();
|
||||
|
||||
// ── Source of completed context: latest canonical Contribution when follow-up is active ──
|
||||
// After setFollowUpQuestion() mutates focused.question/answer, derive from the
|
||||
// latest completed Contribution so the narrative remains correct.
|
||||
const hasActiveFollowUp = hasCompletedContext && !hasAnswer
|
||||
&& (focused.result?.possibleFollowUpQuestions || []).some((q) => q === focused?.question);
|
||||
const latestCompletedContrib = [...(focusedContributions || [])].reverse().find((c) => c?.question && c?.answer);
|
||||
// Active follow-up detection: primary via result (when result exists), fallback via priorContribs (error state may have null result).
|
||||
const priorContribs = [...(focusedContributions || [])].reverse();
|
||||
// Follow-ups from result are primary; priorContribs is fallback when result is null.
|
||||
const followUpsFromResult = focused?.result?.possibleFollowUpQuestions || [];
|
||||
const hasActiveFollowUpFromResult =
|
||||
!hasAnswer && followUpsFromResult.length > 0 && followUpsFromResult.some((q) => q === focused?.question);
|
||||
const hasActiveFollowUpFromPrior = priorContribs.length > 0
|
||||
? priorContribs.find((c) => (c.possibleFollowUpQuestions || []).length > 0)?.possibleFollowUpQuestions?.includes(focused?.question) ?? false
|
||||
: false;
|
||||
// Active follow-up requires either: a matched follow-up in result, OR priorContribs with a valid possibleFollowUp.
|
||||
const hasActiveFollowUp = (hasActiveFollowUpFromResult || hasActiveFollowUpFromPrior);
|
||||
const latestCompletedContrib = priorContribs.find((c) => c?.question && c?.answer);
|
||||
|
||||
const displayedCompletedQuestion = hasActiveFollowUp
|
||||
? (latestCompletedContrib?.question ?? focused?.question)
|
||||
@@ -222,120 +236,141 @@ function FocusedQuestionBody({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{processingStep === "active" && <p className="text-sm text-blue-600/70">{deconstructMsg}</p>}
|
||||
{processingStep === "active" && (
|
||||
<p className="flex items-center gap-2 text-sm text-blue-600/70">
|
||||
<svg className="h-4 w-4 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
|
||||
<span className="sr-only">Processing:</span>
|
||||
{deconstructMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{focused?.result && (
|
||||
{(hasActiveFollowUp || hasCompletedContext) && (
|
||||
<>
|
||||
{/* Prior accumulated learning removed from left pane — SecondaryPreviousLearning on the right owns historical Previous Learning exclusively */}
|
||||
{/* PriorContributionsSummary was causing duplication in the two-column focused workspace */}
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h3><ul className="list-disc pl-5 space-y-2">{(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;
|
||||
const isEditing = isFinding && editingFindingId === item.id;
|
||||
if (!isFinding) {
|
||||
return (
|
||||
<li key={i} className="text-sm leading-relaxed text-gray-700">{item}</li>
|
||||
);
|
||||
}
|
||||
if (isEditing) {
|
||||
return (
|
||||
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={2}
|
||||
data-testid="proposition-editor"
|
||||
className="flex-1 rounded border border-blue-300 bg-blue-50/40 px-2 py-1 text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-300"
|
||||
/>
|
||||
<div className="flex gap-1 shrink-0 mt-[2px]">
|
||||
<button onClick={(e) => { e.stopPropagation(); saveEditing(); }} data-testid="proposition-save" className="text-[10px] font-medium text-blue-600 underline shrink-0 hover:text-blue-700">Save</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); cancelEditing(); }} data-testid="proposition-cancel" className="text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-gray-500">Cancel</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
|
||||
<span className="flex-1">{item.proposition}</span>
|
||||
{onUpdateFindingProposition && (
|
||||
<button onClick={(e) => { e.stopPropagation(); startEditing(item.id, item.proposition); }} data-testid={`not-quite-${item.id}`} className="mt-[2px] text-[10px] font-medium text-amber-500 underline shrink-0 hover:text-amber-600">Not quite</button>
|
||||
)}
|
||||
{isFinding && onUpdateFindingDisposition && (
|
||||
disposition === "not_relevant" ? (
|
||||
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, null); }} data-testid={`restore-${item.id}`} className="mt-[2px] text-[10px] font-medium text-teal-600 underline shrink-0 hover:text-teal-700" title="Restore to understanding">restore</button>
|
||||
) : (
|
||||
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, "not_relevant"); }} data-testid={`not-relevant-${item.id}`} className="mt-[2px] text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-red-500" title="Remove from understanding">not relevant</button>
|
||||
)
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}</ul></div>
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.uncertainties || []).map((u, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{u}</li>))}</ul></div>
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Questions this raises</h3>
|
||||
{(focused.result.possibleFollowUpQuestions || []).length > 0 ? (
|
||||
<div className="space-y-1 mt-1">
|
||||
{hasActiveFollowUp
|
||||
? focused.result.possibleFollowUpQuestions.filter((q) => q !== focused.question).map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => { e.stopPropagation(); setFollowUpQuestion(q); }}
|
||||
className="w-full text-left rounded-lg border border-blue-200/60 bg-blue-50/40 px-3 py-2.5 text-sm leading-relaxed text-gray-800 transition hover:border-blue-300 hover:bg-blue-100/60 cursor-pointer"
|
||||
data-testid="follow-up-question"
|
||||
>
|
||||
{q}
|
||||
{" → pick this question"}
|
||||
</button>
|
||||
))
|
||||
: focused.result.possibleFollowUpQuestions.map((q, i) => {
|
||||
const isCurrentQuestion = q === focused?.question;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => { if (!isCurrentQuestion) { e.stopPropagation(); setFollowUpQuestion(q); } }}
|
||||
style={{ cursor: isCurrentQuestion ? "default" : "pointer" }}
|
||||
className={`w-full text-left rounded-lg border px-3 py-2.5 text-sm leading-relaxed transition ${
|
||||
isCurrentQuestion
|
||||
? "border-gray-200 bg-gray-100/60 text-gray-400 cursor-default"
|
||||
: "border-blue-200/60 bg-blue-50/40 text-gray-800 hover:border-blue-300 hover:bg-blue-100/60"
|
||||
}`}
|
||||
data-testid="follow-up-question"
|
||||
>
|
||||
{q}
|
||||
{isCurrentQuestion ? " (current question)" : " → pick this question"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">None yet</p>
|
||||
)}
|
||||
{/* Derived sections fallback to priorContribs data during processing/error when result is null */}
|
||||
{(() => {
|
||||
const effectiveObservations = currentFindings?.length ? currentFindings :
|
||||
(focused?.result?.observations ?? priorContribs.find((c) => c?.observations)?.observations);
|
||||
const effectiveUncertainties = focused?.result?.uncertainties ?? priorContribs.find((c) => c?.uncertainties)?.uncertainties;
|
||||
const effectiveFollowUps = focused?.result?.possibleFollowUpQuestions || priorContribs.find((c) => c?.possibleFollowUpQuestions)?.possibleFollowUpQuestions;
|
||||
const effectiveAssumptions = focused?.result?.assumptions || priorContribs.find((c) => c?.assumptions)?.assumptions;
|
||||
const effectiveRelationships = focused?.result?.relationships || priorContribs.find((c) => c?.relationships)?.relationships;
|
||||
|
||||
{/* In-place answer textarea for the active follow-up — renders only when a candidate is selected */}
|
||||
{hasActiveFollowUp ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-sm font-medium text-gray-900">{focused.question}</p>
|
||||
<textarea
|
||||
id={`rw-answer-fu-${nodeId}`}
|
||||
value={focusedAnswer}
|
||||
onChange={(e) => setFocusedAnswer(e.target.value)}
|
||||
rows={4}
|
||||
data-testid="follow-up-textarea"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="What do you know about this?"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }}
|
||||
disabled={!focusedAnswer.trim() || processingStep === "active"}
|
||||
style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }}
|
||||
className="rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50"
|
||||
>
|
||||
Submit response
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.assumptions || []).map((a, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{a}</li>))}</ul></div>
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Connections</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.relationships || []).map((r, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{r.from} → {r.to} ({r.type})</li>))}</ul></div>
|
||||
return (
|
||||
<>
|
||||
{/* Prior accumulated learning removed from left pane — SecondaryPreviousLearning on the right owns historical Previous Learning exclusively */}
|
||||
{/* PriorContributionsSummary was causing duplication in the two-column focused workspace */}
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h3><ul className="list-disc pl-5 space-y-2">{(effectiveObservations || []).map((item, i) => {
|
||||
const isFinding = typeof item === "object" && item !== null && "id" in item;
|
||||
const disposition = isFinding ? item.userDisposition : null;
|
||||
const isEditing = isFinding && editingFindingId === item.id;
|
||||
if (!isFinding) {
|
||||
return (
|
||||
<li key={i} className="text-sm leading-relaxed text-gray-700">{item}</li>
|
||||
);
|
||||
}
|
||||
if (isEditing) {
|
||||
return (
|
||||
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={2}
|
||||
data-testid="proposition-editor"
|
||||
className="flex-1 rounded border border-blue-300 bg-blue-50/40 px-2 py-1 text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-300"
|
||||
/>
|
||||
<div className="flex gap-1 shrink-0 mt-[2px]">
|
||||
<button onClick={(e) => { e.stopPropagation(); saveEditing(); }} data-testid="proposition-save" className="text-[10px] font-medium text-blue-600 underline shrink-0 hover:text-blue-700">Save</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); cancelEditing(); }} data-testid="proposition-cancel" className="text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-gray-500">Cancel</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
|
||||
<span className="flex-1">{item.proposition}</span>
|
||||
{onUpdateFindingProposition && (
|
||||
<button onClick={(e) => { e.stopPropagation(); startEditing(item.id, item.proposition); }} data-testid={`not-quite-${item.id}`} className="mt-[2px] text-[10px] font-medium text-amber-500 underline shrink-0 hover:text-amber-600">Not quite</button>
|
||||
)}
|
||||
{isFinding && onUpdateFindingDisposition && (
|
||||
disposition === "not_relevant" ? (
|
||||
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, null); }} data-testid={`restore-${item.id}`} className="mt-[2px] text-[10px] font-medium text-teal-600 underline shrink-0 hover:text-teal-700" title="Restore to understanding">restore</button>
|
||||
) : (
|
||||
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, "not_relevant"); }} data-testid={`not-relevant-${item.id}`} className="mt-[2px] text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-red-500" title="Remove from understanding">not relevant</button>
|
||||
)
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}</ul></div>
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h3><ul className="list-disc pl-5 space-y-1">{(effectiveUncertainties || []).map((u, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{u}</li>))}</ul></div>
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Questions this raises</h3>
|
||||
{(effectiveFollowUps || []).length > 0 ? (
|
||||
<div className="space-y-1 mt-1">
|
||||
{hasActiveFollowUp
|
||||
? effectiveFollowUps.filter((q) => q !== focused.question).map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => { e.stopPropagation(); setFollowUpQuestion(q); }}
|
||||
className="w-full text-left rounded-lg border border-blue-200/60 bg-blue-50/40 px-3 py-2.5 text-sm leading-relaxed text-gray-800 transition hover:border-blue-300 hover:bg-blue-100/60 cursor-pointer"
|
||||
data-testid="follow-up-question"
|
||||
>
|
||||
{q}
|
||||
{" → pick this question"}
|
||||
</button>
|
||||
))
|
||||
: effectiveFollowUps.map((q, i) => {
|
||||
const isCurrentQuestion = q === focused?.question;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => { if (!isCurrentQuestion) { e.stopPropagation(); setFollowUpQuestion(q); } }}
|
||||
style={{ cursor: isCurrentQuestion ? "default" : "pointer" }}
|
||||
className={`w-full text-left rounded-lg border px-3 py-2.5 text-sm leading-relaxed transition ${
|
||||
isCurrentQuestion
|
||||
? "border-gray-200 bg-gray-100/60 text-gray-400 cursor-default"
|
||||
: "border-blue-200/60 bg-blue-50/40 text-gray-800 hover:border-blue-300 hover:bg-blue-100/60"
|
||||
}`}
|
||||
data-testid="follow-up-question"
|
||||
>
|
||||
{q}
|
||||
{isCurrentQuestion ? " (current question)" : " → pick this question"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">None yet</p>
|
||||
)}
|
||||
|
||||
{/* In-place answer textarea for the active follow-up */}
|
||||
{hasActiveFollowUp ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-sm font-medium text-gray-900">{focused.question}</p>
|
||||
<textarea
|
||||
id={`rw-answer-fu-${nodeId}`}
|
||||
value={focusedAnswer}
|
||||
onChange={(e) => setFocusedAnswer(e.target.value)}
|
||||
rows={4}
|
||||
data-testid="follow-up-textarea"
|
||||
disabled={processingStep === "active"}
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="What do you know about this?"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }}
|
||||
disabled={!focusedAnswer.trim() || processingStep === "active"}
|
||||
style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }}
|
||||
className="rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50"
|
||||
>
|
||||
Submit response
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h3><ul className="list-disc pl-5 space-y-1">{(effectiveAssumptions || []).map((a, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{a}</li>))}</ul></div>
|
||||
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Connections</h3><ul className="list-disc pl-5 space-y-1">{(effectiveRelationships || []).map((r, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{r.from} → {r.to} ({r.type})</li>))}</ul></div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user