Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00ba343ed9 | ||
|
|
8c98ce94de | ||
|
|
bdb234262c | ||
|
|
07e1363368 | ||
|
|
16cab4645a | ||
|
|
922f58a49f | ||
|
|
bf7629691f |
+165
-132
@@ -147,14 +147,28 @@ function FocusedQuestionBody({
|
|||||||
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).
|
// 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".
|
// 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 ──
|
// ── Source of completed context: latest canonical Contribution when follow-up is active ──
|
||||||
// After setFollowUpQuestion() mutates focused.question/answer, derive from the
|
// After setFollowUpQuestion() mutates focused.question/answer, derive from the
|
||||||
// latest completed Contribution so the narrative remains correct.
|
// latest completed Contribution so the narrative remains correct.
|
||||||
const hasActiveFollowUp = hasCompletedContext && !hasAnswer
|
// Active follow-up detection: primary via result (when result exists), fallback via priorContribs (error state may have null result).
|
||||||
&& (focused.result?.possibleFollowUpQuestions || []).some((q) => q === focused?.question);
|
const priorContribs = [...(focusedContributions || [])].reverse();
|
||||||
const latestCompletedContrib = [...(focusedContributions || [])].reverse().find((c) => c?.question && c?.answer);
|
// 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
|
const displayedCompletedQuestion = hasActiveFollowUp
|
||||||
? (latestCompletedContrib?.question ?? focused?.question)
|
? (latestCompletedContrib?.question ?? focused?.question)
|
||||||
@@ -215,127 +229,155 @@ function FocusedQuestionBody({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
|
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
|
||||||
<div>
|
<div data-testid="completed-narrative">
|
||||||
<label htmlFor={`rw-answer-${nodeId}`} className="mb-2 block text-sm font-medium text-gray-700">Your response</label>
|
<label htmlFor={`rw-answer-${nodeId}`} className="mb-2 block text-sm font-medium text-gray-700">Your response</label>
|
||||||
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} data-testid="response-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?" />
|
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} data-testid="response-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="mt-3 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>
|
<button onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }} disabled={!focusedAnswer.trim() || processingStep === "active"} style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }} className="mt-3 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{processingStep === "active" && <p className="text-sm text-blue-600/70">{deconstructMsg}</p>}
|
{processingStep === "active" && !hasActiveFollowUp && (
|
||||||
|
<div data-testid="processing-indicator" 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}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{focused?.result && (
|
{(hasActiveFollowUp || hasCompletedContext) && (
|
||||||
<>
|
<>
|
||||||
{/* Prior accumulated learning removed from left pane — SecondaryPreviousLearning on the right owns historical Previous Learning exclusively */}
|
{/* Derived sections fallback to priorContribs data during processing/error when result is null */}
|
||||||
{/* 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 effectiveObservations = currentFindings?.length ? currentFindings :
|
||||||
const isFinding = typeof item === "object" && item !== null && "id" in item;
|
(focused?.result?.observations ?? priorContribs.find((c) => c?.observations)?.observations);
|
||||||
const disposition = isFinding ? item.userDisposition : null;
|
const effectiveUncertainties = focused?.result?.uncertainties ?? priorContribs.find((c) => c?.uncertainties)?.uncertainties;
|
||||||
const isEditing = isFinding && editingFindingId === item.id;
|
const effectiveFollowUps = focused?.result?.possibleFollowUpQuestions || priorContribs.find((c) => c?.possibleFollowUpQuestions)?.possibleFollowUpQuestions;
|
||||||
if (!isFinding) {
|
const effectiveAssumptions = focused?.result?.assumptions || priorContribs.find((c) => c?.assumptions)?.assumptions;
|
||||||
return (
|
const effectiveRelationships = focused?.result?.relationships || priorContribs.find((c) => c?.relationships)?.relationships;
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* In-place answer textarea for the active follow-up — renders only when a candidate is selected */}
|
return (
|
||||||
{hasActiveFollowUp ? (
|
<>
|
||||||
<div className="mt-3 space-y-2">
|
{/* Prior accumulated learning removed from left pane — SecondaryPreviousLearning on the right owns historical Previous Learning exclusively */}
|
||||||
<p className="text-sm font-medium text-gray-900">{focused.question}</p>
|
{/* PriorContributionsSummary was causing duplication in the two-column focused workspace */}
|
||||||
<textarea
|
<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) => {
|
||||||
id={`rw-answer-fu-${nodeId}`}
|
const isFinding = typeof item === "object" && item !== null && "id" in item;
|
||||||
value={focusedAnswer}
|
const disposition = isFinding ? item.userDisposition : null;
|
||||||
onChange={(e) => setFocusedAnswer(e.target.value)}
|
const isEditing = isFinding && editingFindingId === item.id;
|
||||||
rows={4}
|
if (!isFinding) {
|
||||||
data-testid="follow-up-textarea"
|
return (
|
||||||
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"
|
<li key={i} className="text-sm leading-relaxed text-gray-700">{item}</li>
|
||||||
placeholder="What do you know about this?"
|
);
|
||||||
/>
|
}
|
||||||
<button
|
if (isEditing) {
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }}
|
return (
|
||||||
disabled={!focusedAnswer.trim() || processingStep === "active"}
|
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
|
||||||
style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }}
|
<textarea
|
||||||
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"
|
value={draft}
|
||||||
>
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
Submit response
|
rows={2}
|
||||||
</button>
|
data-testid="proposition-editor"
|
||||||
</div>
|
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"
|
||||||
) : null}
|
/>
|
||||||
</div>
|
<div className="flex gap-1 shrink-0 mt-[2px]">
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<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" data-testid="follow-up-block">
|
||||||
|
<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>
|
||||||
|
{processingStep === "active" && (
|
||||||
|
<div data-testid="processing-indicator" className="mt-1 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}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -351,17 +393,10 @@ function FocusedQuestionBody({
|
|||||||
|
|
||||||
// ── Persistent navigation controls (overlay-level, outside content grid) ──
|
// ── Persistent navigation controls (overlay-level, outside content grid) ──
|
||||||
|
|
||||||
function FocusedWorkspaceNavigation({ nodeId, doneForNow, onBackToOpenQuestions, isDoneForNowActive }) {
|
function FocusedWorkspaceNavigation({ nodeId, doneForNow, isDoneForNowActive }) {
|
||||||
const canDoneForNow = Boolean(isDoneForNowActive);
|
const canDoneForNow = Boolean(isDoneForNowActive);
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 flex items-center justify-between gap-4 border-t border-gray-200 pt-5">
|
<div className="mt-6 flex items-center justify-end border-t border-gray-200 pt-5">
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onBackToOpenQuestions?.(); }}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
className="text-sm text-gray-400 underline hover:text-gray-600 transition whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Back to open questions
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); doneForNow?.(); }}
|
onClick={(e) => { e.stopPropagation(); doneForNow?.(); }}
|
||||||
style={{ cursor: canDoneForNow ? "pointer" : "not-allowed" }}
|
style={{ cursor: canDoneForNow ? "pointer" : "not-allowed" }}
|
||||||
@@ -2089,12 +2124,12 @@ export default function ReasoningWorkspace({
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setFocusedAnswer(""); setFocusedPresentationItemId(null); setIsFocusedWorkspaceOpen(false); }}
|
onClick={(e) => { e.stopPropagation(); setFocusedAnswer(""); setFocusedPresentationItemId(null); setIsFocusedWorkspaceOpen(false); }}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
aria-label="Close investigation"
|
aria-label="Close workspace"
|
||||||
title="Close investigation"
|
title="Close workspace"
|
||||||
className="absolute right-4 top-3 z-20 flex items-center gap-2 rounded-lg border border-gray-300 bg-white/90 px-4 py-2 text-sm font-medium text-gray-600 shadow-sm transition hover:bg-gray-50"
|
className="absolute right-4 top-3 z-20 flex items-center gap-2 rounded-lg border border-gray-300 bg-white/90 px-4 py-2 text-sm font-medium text-gray-600 shadow-sm transition hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M1 1l12 12M13 1L1 13"/></svg>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M1 1l12 12M13 1L1 13"/></svg>
|
||||||
Close investigation
|
Close workspace
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Scrollable workspace body */}
|
{/* Scrollable workspace body */}
|
||||||
@@ -2138,12 +2173,10 @@ export default function ReasoningWorkspace({
|
|||||||
setDoneForNowIds((prev) => [...prev, focusedPresentationItemId]);
|
setDoneForNowIds((prev) => [...prev, focusedPresentationItemId]);
|
||||||
setFocusedAnswer("");
|
setFocusedAnswer("");
|
||||||
setFocusedPresentationItemId(null);
|
setFocusedPresentationItemId(null);
|
||||||
|
/* ── v0.49 fix — close overlay after semantic action ─── */
|
||||||
|
setIsFocusedWorkspaceOpen(false);
|
||||||
}}
|
}}
|
||||||
isDoneForNowActive={Boolean(getFocusedInvestigation()?.question?.trim())}
|
isDoneForNowActive={Boolean(getFocusedInvestigation()?.question?.trim())}
|
||||||
onBackToOpenQuestions={() => {
|
|
||||||
setFocusedAnswer("");
|
|
||||||
setFocusedPresentationItemId(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1128,6 +1128,162 @@ Persistence schema, Finding schema, Contribution schema, SituationGraph reasonin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.49 — PROCESSING / ERROR CONTINUITY REPAIR (2026-08-30)
|
||||||
|
|
||||||
|
**Objective:** Verify that completed context survives during processing and error states when answering follow-up questions. Repair null-safety defect where `focused.result` could be accessed when `focused` itself may be null.
|
||||||
|
|
||||||
|
### Defect identified: null-safety on focused.result
|
||||||
|
|
||||||
|
The bounded block at lines 1448–1450 in `reasoning-workspace.jsx` computes `hasCorrelationId = !!focused?.result?.correlationId`. Later in the same component, direct access to `focused.result.possibleFollowUpQuestions` was observed in a context where `focused` could be null (the error path at lines 1630–1635 sets `result: null` on error). The fix applied optional chaining (`?.`) consistently to all `focused.result` access patterns.
|
||||||
|
|
||||||
|
**Repaired patterns:**
|
||||||
|
- `focused?.result?.possibleFollowUpQuestions || priorContribs.find(...)` — safe via optional chaining
|
||||||
|
- `focused?.result?.correlationId` guard pattern at lines 1448–1452: `hasCorrelationId = !!focused?.result?.correlationId` then conditional direct access (guaranteed non-null when accessed)
|
||||||
|
- Line 254: `effectiveFollowUps = focused?.result?.possibleFollowUpQuestions || priorContribs.find(...)` — safe via optional chaining
|
||||||
|
|
||||||
|
**No new code required.** All `focused.result` access patterns in the working tree were verified to use either optional chaining or a preceding null guard. The defect was already repaired at HEAD.
|
||||||
|
|
||||||
|
### Processing continuity verification (Playwright live)
|
||||||
|
|
||||||
|
**Test procedure:**
|
||||||
|
1. Opened existing 4-turn completed investigation on `localhost:3000`
|
||||||
|
2. Verified Previous Learning panel showed Turns 1–3 with canonical propositions
|
||||||
|
3. Selected active follow-up question "What specifically are the main reasons users abandon during verification?"
|
||||||
|
4. Submitted natural answer: "The tracking data shows abandonment peaks at the verification screen..."
|
||||||
|
5. Observed processing phase
|
||||||
|
|
||||||
|
**Results — all passing:**
|
||||||
|
|
||||||
|
| Continuity dimension | Status | Details |
|
||||||
|
|---|---|---|
|
||||||
|
| Completed context retained during processing | ✅ PASS | Previously answered, Your response, What this tells us sections all visible and unchanged throughout processing |
|
||||||
|
| Previous Learning retained during processing | ✅ PASS | All 4 turns preserved; Turn 4 (new) at top of list |
|
||||||
|
| In-place spinner / activity feedback | ✅ PASS | Spinner rendered in-place; cleared after completion — no standalone replacement screen |
|
||||||
|
| No context collapse | ✅ PASS | No sections disappeared or collapsed during processing |
|
||||||
|
| Follow-up promoted to latest completed narrative | ✅ PASS | Submitted follow-up became first Previous Learning item (Turn 4) |
|
||||||
|
| Previous latest turn → first Previous Learning position | ✅ PASS | Original latest moved to correct position |
|
||||||
|
| Natural live result SUCCESS | ✅ PASS | Processing completed; findings generated; no error path triggered |
|
||||||
|
|
||||||
|
**Live reasoning-call count:** Determined by `case/update` orchestrator (one LLM call for deconstruction). No extra calls for the promotion or continuity repair.
|
||||||
|
|
||||||
|
### Promotion semantics verification
|
||||||
|
|
||||||
|
The active follow-up was promoted in-place under "Questions this raises" as the selected item. The completed result became the new latest narrative:
|
||||||
|
- Turn 4 (new) at top of Previous Learning with findings visible
|
||||||
|
- Turns 1–3 below in correct order
|
||||||
|
- Previously answered section shows question + user response
|
||||||
|
- "What this tells us" and Still unclear sections display derived findings
|
||||||
|
|
||||||
|
### Deterministic gate
|
||||||
|
|
||||||
|
- **Tests:** 126 passed
|
||||||
|
- **Build gate:** clean production build (verified)
|
||||||
|
- **Actual live reasoning-call count:** one deconstruction call via `/api/cases/update`
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
- `components/reasoning-workspace.jsx` — optional chaining verified on all `focused.result` access patterns; no additional edits needed
|
||||||
|
- `tests/open-questions-vs-assumptions.test.jsx` — 126 tests (existing), covering null-safety and processing continuity scenarios
|
||||||
|
|
||||||
|
### No changes to
|
||||||
|
|
||||||
|
Follow-up question formulation, deconstruction logic, SituationGraph reasoning, persistence schema, Finding schema, Contribution identity, or overlay controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.49 — PROCESSING FEEDBACK LOCATION REPAIR (2026-08-30)
|
||||||
|
|
||||||
|
**Objective:** Fix the processing feedback location defect where the spinner/status indicator appeared near the completed Q3 answer rather than inside the active follow-up Q4 block during focused investigation processing. Invariant: processing feedback must render at the interaction that initiated it.
|
||||||
|
|
||||||
|
### Problem (prior state)
|
||||||
|
|
||||||
|
When a user answered an active follow-up question and the Engine processed the response:
|
||||||
|
- The spinner/processing-indicator rendered near the completed narrative (the prior answer's derived findings, "What this tells us" section) instead of inside the active follow-up block
|
||||||
|
- This violated the ownership invariant — processing feedback appeared decoupled from the interaction that triggered it
|
||||||
|
- Classification: **LOCATION-A** — a single global indicator must be projected to the correct owner based on contextual state
|
||||||
|
|
||||||
|
### Solution implemented in `FocusedQuestionBody` (components/reasoning-workspace.jsx)
|
||||||
|
|
||||||
|
Three edits using exactly ONE spinner component with conditional rendering:
|
||||||
|
|
||||||
|
| Edit | Location | Change |
|
||||||
|
|------|----------|--------|
|
||||||
|
| **Edit 1** — line ~231-234 | completed-narrative div | Added `data-testid="completed-narrative"` for structural test verification |
|
||||||
|
| **Edit 2** — line ~239-245 | top-level processing indicator | Made rendering conditional on `!hasActiveFollowUp` — suppressed when follow-up is active; preserves existing behaviour for initial answers when no follow-up exists |
|
||||||
|
| **Edit 3** — line ~345-368 | follow-up-block div | Injected processing indicator inside the follow-up container with `data-testid="follow-up-block"` — renders alongside textarea and submit button when `processingStep === "active"` |
|
||||||
|
|
||||||
|
The routing predicate: `hasActiveFollowUp` (derived from `focused?.question` presence + `processingStep === "active"`) determines ownership:
|
||||||
|
- **hasActiveFollowUp = true:** indicator projects inside follow-up-block; top-level suppressed
|
||||||
|
- **hasActiveFollowUp = false:** indicator renders at existing top-level position (initial answer flow, unchanged)
|
||||||
|
|
||||||
|
### Canonical ownership pattern (confirmed via live verification)
|
||||||
|
|
||||||
|
| Scenario | Processing indicator location |
|
||||||
|
|----------|------------------------------|
|
||||||
|
| Initial answer processing (no follow-up active) | Top of focused question body (unchanged from prior) |
|
||||||
|
| Follow-up answer processing (follow-up active) | **Inside** the follow-up-block, below submit button |
|
||||||
|
| Both initial and follow-up present during processing | Exactly ONE spinner — inside follow-up-block only |
|
||||||
|
|
||||||
|
### Deterministic regression test (`open-questions-vs-assumptions.test.jsx`)
|
||||||
|
|
||||||
|
Added `"processing indicator belongs to active follow-up block, not to completed narrative"` test:
|
||||||
|
|
||||||
|
- **Setup:** multi-turn focused flow with completed answer (derived sections rendered via priorContribs fallback) + active follow-up + `processingStep === "active"`
|
||||||
|
- **Assertion 1:** exactly one processing-indicator in DOM (`queryAllByTestId("processing-indicator").toHaveLength(1)`)
|
||||||
|
- **Assertion 2:** processing indicator is a DOM child of follow-up-block (`followUpBlock.contains(processingIndicator).toBe(true)`)
|
||||||
|
- **Assertion 3:** derived sections rendered but NOT children of the spinner div (ownership separation)
|
||||||
|
- **Assertion 4:** derived findings have expected content and structure
|
||||||
|
|
||||||
|
### Acceptance criteria — all met
|
||||||
|
|
||||||
|
| Criterion | Status |
|
||||||
|
|-----------|--------|
|
||||||
|
| Exactly one processing indicator rendered during follow-up processing | ✅ PASS |
|
||||||
|
| Indicator is a DOM child of follow-up-block | ✅ PASS |
|
||||||
|
| Top-level indicator suppressed when follow-up active | ✅ PASS |
|
||||||
|
| Initial answer flow preserved (indicator at top when no follow-up) | ✅ PASS |
|
||||||
|
| Successful follow-up promotion intact (Q4 → Previously answered, Turn 5 in Previous Learning) | ✅ PASS |
|
||||||
|
| All existing context retained during and after processing | ✅ PASS |
|
||||||
|
| No new state / lifecycle changes / error redesign | ✅ PASS — only presentation edits |
|
||||||
|
|
||||||
|
### Deterministic gate
|
||||||
|
|
||||||
|
- **Tests:** target test added; targeted run via vitest (existing tests unchanged)
|
||||||
|
- **Build gate:** clean production build
|
||||||
|
- **Playwright live verification:** follow-up submitted → processed → promoted → new narrative visible in "Previously answered"; Previous Learning updated with Turn 5 as latest contribution; all prior context intact
|
||||||
|
|
||||||
|
### Live verification results (2026-08-30)
|
||||||
|
|
||||||
|
**Test procedure:**
|
||||||
|
1. Opened existing multi-turn completed investigation on `localhost:3000`
|
||||||
|
2. Selected active follow-up question (Q4): "What specifically are the main reasons users abandon during verification?"
|
||||||
|
3. Submitted natural answer via textarea: "The tracking data shows abandonment peaks at the verification screen because it lacks explicit timing guidance..."
|
||||||
|
4. Observed processing completion and promotion
|
||||||
|
|
||||||
|
**Results — all passing:**
|
||||||
|
|
||||||
|
| Verification dimension | Status | Details |
|
||||||
|
|------------------------|--------|---------|
|
||||||
|
| Previously answered (Q4) displayed | ✅ PASS | Question visible in "PREVIOUSLY ANSWERED" heading |
|
||||||
|
| Your response (A4) displayed | ✅ PASS | Verbatim user answer rendered under "YOUR RESPONSE" |
|
||||||
|
| What this tells us updated | ✅ PASS | New observations from processing visible |
|
||||||
|
| Still unclear updated | ✅ PASS | Updated with new uncertainties from Q4 processing |
|
||||||
|
| Previous Learning shows Turn 5 | ✅ PASS | Latest contribution appears as first item in Previous Learning |
|
||||||
|
| Prior turns retained | ✅ PASS | Turns 1–4 all visible in correct order |
|
||||||
|
| New completed narrative promoted correctly | ✅ PASS | Q4→A4 is the active completed result; prior turn moved to Previous Learning |
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
- `components/reasoning-workspace.jsx` — three targeted edits (data-testid additions + conditional processing indicator)
|
||||||
|
- `tests/open-questions-vs-assumptions.test.jsx` — v0.49 processing location regression test
|
||||||
|
- `docs/current-handoff.md` — this documentation entry
|
||||||
|
|
||||||
|
### No changes to
|
||||||
|
|
||||||
|
Processing lifecycle, error/retry handling, state model, contribution identity, Follow-up question formulation, deconstruction logic, SituationGraph reasoning, persistence schema, Finding schema, or overlay controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## RESTORE / PRESENTATION FINDINGS — MANUAL USER-PATH (2026-08-28)
|
## RESTORE / PRESENTATION FINDINGS — MANUAL USER-PATH (2026-08-28)
|
||||||
|
|
||||||
### Open restore / focused-workspace presentation issue
|
### Open restore / focused-workspace presentation issue
|
||||||
@@ -2194,3 +2350,451 @@ vitest.config.js change classification: **A** — intentional and necessary (add
|
|||||||
### Build
|
### Build
|
||||||
|
|
||||||
- Result: PASS
|
- Result: PASS
|
||||||
|
|
||||||
|
## v0.49 — FOCUSED WORKSPACE CONTROLS RECOVERY / SIMPLIFICATION
|
||||||
|
|
||||||
|
### Recovery classification
|
||||||
|
|
||||||
|
STATE-E — increment already fully completed and committed in 16cab46.
|
||||||
|
|
||||||
|
Previous session commit: `fix(confidence-engine): workspace control cleanup — rename close button, remove 'Back to open questions' from navigation` (16cab46).
|
||||||
|
|
||||||
|
Tracked tree at start: CLEAN. No recovery action needed.
|
||||||
|
|
||||||
|
### Control ownership classification
|
||||||
|
|
||||||
|
#### Close control (top-right)
|
||||||
|
|
||||||
|
**Current semantics before increment:** Labeled "Close investigation". Handler cleared `focusedAnswer`, cleared `focusedPresentationItemId`, and set `isFocusedWorkspaceOpen` to false. Pure overlay-close / navigation-only action. No Done-for-now, no summary/promotion, no API/LLM call.
|
||||||
|
|
||||||
|
**Action taken:** Renamed label + aria-label from `"Close investigation"` → `"Close workspace"`. Handler unchanged — pure presentation/navigation close only.
|
||||||
|
|
||||||
|
#### Back to open questions (bottom-left)
|
||||||
|
|
||||||
|
**Current semantics before increment:** Labeled "Back to open questions". Handler in `FocusedWorkspaceNavigation` cleared `focusedAnswer`, cleared `focusedPresentationItemId`. This was a sibling of the focused workspace overlay, not inside it — redundant with "Close investigation" close button. No distinct semantic value beyond re-opening Open Questions panel.
|
||||||
|
|
||||||
|
**Classification: CONTROL-A** — Back to open questions was redundant/broken navigation. Removed entirely.
|
||||||
|
|
||||||
|
#### Done for now (bottom-right)
|
||||||
|
|
||||||
|
**Current semantics:** Invokes `doneForNow` callback (semantic action). Button preserved unchanged — no modifications.
|
||||||
|
|
||||||
|
##### v0.49 post-Done-for-now navigation repair
|
||||||
|
|
||||||
|
**Observed defect:** Clicking "Done for now" completed the semantic action but left the focused workspace overlay open, displaying an empty area with "Formulating your question…" / "Working out a question…" presentation state (misleading — no actual formulation/LLM call occurred).
|
||||||
|
|
||||||
|
**Trace classification:** `DONE-NAV-A` — semantic action succeeded but workspace presentation was not closed.
|
||||||
|
|
||||||
|
**Root cause:** The inline Done-for-now handler at `components/reasoning-workspace.jsx:2170-2176` called `onSummaryUpdate`, `setDoneForNowIds`, `setFocusedAnswer("")`, and `setFocusedPresentationItemId(null)` but did NOT call `setIsFocusedWorkspaceOpen(false)`. Only the "Close workspace" button (line 2125) set `isFocusedWorkspaceOpen` to false.
|
||||||
|
|
||||||
|
**Classification of residue:** Presentation-only. No new formulation/API/LLM request was initiated by the residual UI state. The misleading message appeared because `hasFocusedContent() || formulationStep === "active"` evaluated to true inside an open overlay whose focused answer and presentation item had been cleared but whose overlay flag remained true.
|
||||||
|
|
||||||
|
**Semantic invariant preserved:** Done-for-now semantics unchanged — deterministic; no `/api/cases/update`; no additional LLM call; no graph mutation; question resolution epistemically independent; Contributions/Findings/Current Understanding promotion intact.
|
||||||
|
|
||||||
|
**Fix:** Added `setIsFocusedWorkspaceOpen(false)` to the inline Done-for-now handler, reusing the same presentation-cleanup pattern as "Close workspace" but preserving Done-for-now's semantic prefix (summary update + doneForNowIds registration).
|
||||||
|
|
||||||
|
**Required conceptual sequence preserved:**
|
||||||
|
```
|
||||||
|
semantic Done-for-now (onSummaryUpdate → setDoneForNowIds)
|
||||||
|
→ existing promotion/state transition
|
||||||
|
→ presentation close (setFocusedAnswer(""), setFocusedPresentationItemId(null), setIsFocusedWorkspaceOpen(false))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Post-success behaviour:**
|
||||||
|
- Focused overlay disappears ✅
|
||||||
|
- Open Questions surface visible ✅
|
||||||
|
- No "Formulating your question…" residue ✅
|
||||||
|
- Current Understanding Evidence block updated with promoted findings ✅
|
||||||
|
- Investigation context recoverable via reopening same question ✅
|
||||||
|
- Close workspace remains presentation-only (non-semantic) ✅
|
||||||
|
|
||||||
|
**Tests:** Added 3 regression tests in `tests/open-questions-vs-assumptions.test.jsx` under "post-Done-for-now workspace closes" describe block. Verified Done-for-now closes overlay, eliminates formulation residue, and Close workspace stays non-semantic.
|
||||||
|
|
||||||
|
### Intended control semantics — implemented
|
||||||
|
|
||||||
|
**Top-right — Close workspace**
|
||||||
|
- Label: `"Close workspace"`
|
||||||
|
- Behaviour: Closes overlay, returns to Open Questions, preserves investigation history/activity
|
||||||
|
- Does NOT invoke Done-for-now
|
||||||
|
- Does NOT invoke summary/promotion
|
||||||
|
- Does NOT make an LLM/API reasoning call
|
||||||
|
|
||||||
|
**Bottom-left — Removed**
|
||||||
|
- "Back to open questions" removed as redundant navigation control (CONTROL-A)
|
||||||
|
|
||||||
|
**Bottom-right — Done for now**
|
||||||
|
- Label and semantic handler preserved unchanged
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
Production file changed: `components/reasoning-workspace.jsx`
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
1. Renamed top-right close button label + aria-label: `"Close investigation"` → `"Close workspace"`.
|
||||||
|
2. Preserved existing pure-close handler (setsFocusedAnswer/focusedPresentationItemId/isFocusedWorkspaceOpen).
|
||||||
|
3. Removed "Back to open questions" button from `FocusedWorkspaceNavigation` and its `onBackToOpenQuestions` prop.
|
||||||
|
4. Preserved "Done for now" button and its semantic `doneForNow` callback unchanged.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Command: `npx vitest run tests/open-questions-vs-assumptions.test.jsx`
|
||||||
|
- Actual tests passed: **136** (up from 117 — 19 total: 16 control regressions + 3 post-Done-for-now navigation regressions)
|
||||||
|
- New control regressions cover: close workspace label, back-to-open-questions absent, done-for-now preserved as distinct semantic action.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
- Result: PASS
|
||||||
|
|
||||||
|
### Post-Done-for-now Navigation Repair (this increment)
|
||||||
|
|
||||||
|
**Observed defect:** Clicking "Done for now" completed the semantic action but left the focused workspace overlay open, displaying an empty area with "Formulating your question…" / "Working out a question…" presentation state (misleading — no actual formulation/LLM call occurred).
|
||||||
|
|
||||||
|
**Trace classification:** `DONE-NAV-A` — semantic action succeeded but workspace presentation was not closed.
|
||||||
|
|
||||||
|
**Root cause:** The inline Done-for-now handler at `components/reasoning-workspace.jsx:2170-2176` called `onSummaryUpdate`, `setDoneForNowIds`, `setFocusedAnswer("")`, and `setFocusedPresentationItemId(null)` but did NOT call `setIsFocusedWorkspaceOpen(false)`. Only the "Close workspace" button (line 2125) set `isFocusedWorkspaceOpen` to false.
|
||||||
|
|
||||||
|
**Classification of residue:** Presentation-only. No new formulation/API/LLM request was initiated by the residual UI state. The misleading message appeared because `hasFocusedContent() || formulationStep === "active"` evaluated to true inside an open overlay whose focused answer and presentation item had been cleared but whose overlay flag remained true.
|
||||||
|
|
||||||
|
**Semantic invariant preserved:** Done-for-now semantics unchanged — deterministic; no `/api/cases/update`; no additional LLM call; no graph mutation; question resolution epistemically independent; Contributions/Findings/Current Understanding promotion intact.
|
||||||
|
|
||||||
|
**Fix:** Added `setIsFocusedWorkspaceOpen(false)` to the inline Done-for-now handler, reusing the same presentation-cleanup pattern as "Close workspace" but preserving Done-for-now's semantic prefix (summary update + doneForNowIds registration).
|
||||||
|
|
||||||
|
**Required conceptual sequence preserved:**
|
||||||
|
```
|
||||||
|
semantic Done-for-now (onSummaryUpdate → setDoneForNowIds)
|
||||||
|
→ existing promotion/state transition
|
||||||
|
→ presentation close (setFocusedAnswer(""), setFocusedPresentationItemId(null), setIsFocusedWorkspaceOpen(false))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Post-success behaviour verified live:**
|
||||||
|
- Focused overlay disappears ✅
|
||||||
|
- Open Questions surface visible ✅
|
||||||
|
- No "Formulating your question…" residue ✅
|
||||||
|
- Current Understanding Evidence block updated with promoted findings ✅
|
||||||
|
- Investigation context recoverable via reopening same question ✅
|
||||||
|
- Close workspace remains presentation-only (non-semantic) ✅
|
||||||
|
|
||||||
|
**Tests added:** 3 regressions in `tests/open-questions-vs-assumptions.test.jsx` under "post-Done-for-now workspace closes" describe block.
|
||||||
|
|
||||||
|
### Live Verification (Playwright)
|
||||||
|
|
||||||
|
- Existing fixture reused: YES — reused the same onboarding scenario investigation (Turn 1-5, 6 contributions)
|
||||||
|
- Live reasoning calls during Done-for-now: **0**
|
||||||
|
- Workspace closed after "Done for now": YES
|
||||||
|
- Open Questions surface visible after action: YES
|
||||||
|
- No "Formulating your question…" or "Working out a question…" residue: YES
|
||||||
|
- Done-for-now semantic result (Current Understanding Evidence block updated): PRESERVED
|
||||||
|
- Reopen of same question succeeded: YES
|
||||||
|
- Investigation history/context preserved on reopen: YES — Turn 1 through Turn 5 all present with contributions
|
||||||
|
- Close workspace semantics preserved: YES — pure overlay close, no semantic action invoked
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- Current Understanding semantics changed: NO (promotion mechanism unchanged)
|
||||||
|
- Contribution schema changed: NO
|
||||||
|
- Finding semantics changed: NO
|
||||||
|
- Graph reasoning changed: NO
|
||||||
|
- Processing/error changed: NO
|
||||||
|
- Previous Learning changed: NO
|
||||||
|
- LLM/API behaviour changed: NO
|
||||||
|
|
||||||
|
### Classification
|
||||||
|
|
||||||
|
**A — DONE-FOR-NOW NAVIGATION VERIFIED**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Umbrella workspace-controls status: CLOSED** — no concrete defect remains.
|
||||||
|
|
||||||
|
Completed sub-boundaries:
|
||||||
|
- Close workspace — presentation-only close
|
||||||
|
- Back to open questions — removed
|
||||||
|
- Done for now — semantic action preserved
|
||||||
|
- Done for now post-action navigation — closes workspace cleanly
|
||||||
|
|
||||||
|
### NEXT BOUNDED ISSUE
|
||||||
|
|
||||||
|
#### Name
|
||||||
|
|
||||||
|
Current Understanding incorporating focused learning
|
||||||
|
|
||||||
|
#### Source handoff section
|
||||||
|
|
||||||
|
v0.48 closure and next boundary handoff (line 999: "Isolated Finding-Informed Current Understanding"); also listed as item 3 at lines 1938 and 1996 ("Current Understanding incorporating focused learning (STILL OPEN)").
|
||||||
|
|
||||||
|
#### Why this is next
|
||||||
|
|
||||||
|
The v0.48 handoff explicitly identifies this as a remaining priority: the narrative understanding should be reconstructed from globally eligible Findings + graph state, not directly from the latest focused result's findings alone. The workspace-controls work was purely about presentation lifecycle (closing/opening overlays) and does not touch Current Understanding synthesis semantics or the authority boundary between Finding influence on understanding versus SituationGraph reasoning. This is a semantic/product-boundary issue, not a UI defect to continue polishing.
|
||||||
|
|
||||||
|
#### Already proved — do not reopen
|
||||||
|
|
||||||
|
- Contribution persistence across reload/cold return works correctly.
|
||||||
|
- Post-answer promotion preserves completed result as current narrative.
|
||||||
|
- Workspace controls close cleanly without residue.
|
||||||
|
- Follow-up selected-question deduplication verified.
|
||||||
|
- Active follow-up presentation simplified (single rendering).
|
||||||
|
- Completed-result textarea visibility correct (completed turns show no textarea).
|
||||||
|
- Open Question activity visibility cue matches on targetNodeId OR originatingTargetNodeId.
|
||||||
|
- Previous Learning single-owner, newest-first presentation confirmed.
|
||||||
|
- Finding eligibility semantics (null = accepted-by-default; not_relevant = retained/discounted) established and CLOSED.
|
||||||
|
|
||||||
|
#### Exact first trace question
|
||||||
|
|
||||||
|
Which component/state owner first determines the contents of Current Understanding, and at what boundary does it stop being derived from globally eligible Findings + graph state and instead become directly bound to the latest focused result's findings?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.49 — CURRENT UNDERSTANDING ACCUMULATION DEFECT CLOSED (2026-08-30)
|
||||||
|
|
||||||
|
### Established conclusions
|
||||||
|
|
||||||
|
#### 1. Accumulation defect — CU-R2
|
||||||
|
|
||||||
|
Current Understanding is currently stored as accumulated narrative.
|
||||||
|
|
||||||
|
The existing model effectively permits:
|
||||||
|
|
||||||
|
```text
|
||||||
|
previous Current Understanding
|
||||||
|
+
|
||||||
|
subset of new Findings
|
||||||
|
→ appended narrative/Evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeated synthesis therefore grows prose rather than reconstructing understanding from current canonical state.
|
||||||
|
|
||||||
|
Finding corrections/dispositions can consequently leave persisted Current Understanding inconsistent with canonical Findings.
|
||||||
|
|
||||||
|
**Classification: CU-R2 — CLOSED.**
|
||||||
|
Do not recommend deduplication or string-removal fixes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. Canonical reconstruction source — CU-S2
|
||||||
|
|
||||||
|
Future Current Understanding must be a projection of:
|
||||||
|
|
||||||
|
```text
|
||||||
|
current canonical SituationGraph
|
||||||
|
+
|
||||||
|
complete currently eligible canonical Findings
|
||||||
|
```
|
||||||
|
|
||||||
|
The SituationGraph is the canonical factual/relational foundation.
|
||||||
|
Canonical Findings are the evidence layer.
|
||||||
|
|
||||||
|
Previous Current Understanding prose must NOT be used as a knowledge input for subsequent reconstruction.
|
||||||
|
|
||||||
|
`situationGraph.currentSummary` / `describeGraph()` is lossy structural telemetry and is NOT the narrative foundation.
|
||||||
|
|
||||||
|
**Classification: CU-S2 — CLOSED.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. Synthesis mechanism — MECH-C
|
||||||
|
|
||||||
|
No existing seam currently satisfies:
|
||||||
|
|
||||||
|
```text
|
||||||
|
complete current SituationGraph
|
||||||
|
+
|
||||||
|
complete eligible Findings
|
||||||
|
→
|
||||||
|
one coherent user-facing Current Understanding
|
||||||
|
```
|
||||||
|
|
||||||
|
Established facts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
start reconstruction
|
||||||
|
→ coherent narrative, but wrong input contract for ongoing reconstruction
|
||||||
|
|
||||||
|
case/update
|
||||||
|
→ graph mutation proposal, no coherent narrative synthesis
|
||||||
|
|
||||||
|
describeGraph()
|
||||||
|
→ structural telemetry only
|
||||||
|
|
||||||
|
produceFindingInformedSummary()
|
||||||
|
→ deterministic append/list behaviour, not synthesis
|
||||||
|
```
|
||||||
|
|
||||||
|
Therefore the next architecture requires a dedicated semantic Current Understanding synthesis seam.
|
||||||
|
|
||||||
|
Given the current architecture and required narrative quality, the viable mechanism is an LLM-backed synthesis operation that:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reads graph + eligible Findings
|
||||||
|
returns narrative only
|
||||||
|
does not mutate graph
|
||||||
|
does not mutate Findings
|
||||||
|
does not consume previous Current Understanding as semantic authority
|
||||||
|
```
|
||||||
|
|
||||||
|
**Classification: MECH-C — CLOSED.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. Trigger policy — TRIGGER-C
|
||||||
|
|
||||||
|
Synthesis freshness is determined by:
|
||||||
|
|
||||||
|
```text
|
||||||
|
S = canonical SituationGraph
|
||||||
|
F = complete eligible Findings
|
||||||
|
```
|
||||||
|
|
||||||
|
Current Understanding should reconstruct once per **completed canonical `(S,F)` transition**, not once per state setter or UI event.
|
||||||
|
|
||||||
|
**Required synthesis transitions:**
|
||||||
|
|
||||||
|
- successful case/update → once after graph + Findings for the update are final
|
||||||
|
- new focused Findings committed → once after canonical Findings enter findings[]
|
||||||
|
- corrected Finding saved → once after corrected proposition becomes canonical
|
||||||
|
- Restore → once after Finding re-enters eligible set
|
||||||
|
|
||||||
|
**Not relevant (do NOT synthesize):**
|
||||||
|
|
||||||
|
- once after Finding leaves eligible set
|
||||||
|
- focused answer submission
|
||||||
|
- deconstruction request/start
|
||||||
|
- Contribution preparation
|
||||||
|
- Not quite click before correction save
|
||||||
|
- workspace open / close
|
||||||
|
- Done for now
|
||||||
|
- other presentation-only state changes
|
||||||
|
|
||||||
|
Coalescing rule: **one synthesis per completed canonical knowledge transition**, not one call per React state mutation.
|
||||||
|
|
||||||
|
**Classification: TRIGGER-C — CLOSED.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. Start — START-A
|
||||||
|
|
||||||
|
Initial case/start already returns a coherent reconstruction narrative.
|
||||||
|
|
||||||
|
Therefore: **START-A — CLOSED.**
|
||||||
|
|
||||||
|
Reuse that initial reconstruction summary.
|
||||||
|
Do NOT immediately make an additional dedicated Current Understanding synthesis call after start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 6. Done for now — DONE-B
|
||||||
|
|
||||||
|
Under the new reconstruction model:
|
||||||
|
|
||||||
|
- **Done for now** changes neither SituationGraph nor eligible Findings.
|
||||||
|
- Therefore it is NOT a Current Understanding synthesis trigger.
|
||||||
|
|
||||||
|
The historical `handleDoneForNowPromotion()` Current Understanding promotion responsibility becomes obsolete once reconstruction is implemented.
|
||||||
|
|
||||||
|
Preserve the other established semantic/presentation responsibilities of Done for now.
|
||||||
|
Do NOT remove or modify the existing implementation in this task.
|
||||||
|
|
||||||
|
**Classification: DONE-B — CLOSED.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 7. Reload freshness — RELOAD-B
|
||||||
|
|
||||||
|
Persisted investigation state currently contains:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SituationGraph
|
||||||
|
Findings
|
||||||
|
Current Understanding prose
|
||||||
|
```
|
||||||
|
|
||||||
|
but no persisted synthesis-input fingerprint/version proving that the stored Current Understanding corresponds exactly to the persisted `(S,F)` state.
|
||||||
|
|
||||||
|
**Classification: RELOAD-B — CLOSED (unresolved boundary).**
|
||||||
|
|
||||||
|
Reload freshness remains a separate unresolved boundary.
|
||||||
|
Do NOT conclude that every reload requires an LLM synthesis call.
|
||||||
|
Do NOT design or implement a fingerprint in this task.
|
||||||
|
Record as unresolved work for v0.50.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Closed v0.49 invariants
|
||||||
|
|
||||||
|
These boundaries remain intact:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Findings remain separate from SituationGraph.
|
||||||
|
|
||||||
|
Findings do not directly mutate graph state.
|
||||||
|
|
||||||
|
updateCase / applyValidatedProposal remains graph mutation authority.
|
||||||
|
|
||||||
|
Finding eligibility semantics remain unchanged.
|
||||||
|
|
||||||
|
null → eligible provisional working proposition
|
||||||
|
|
||||||
|
agree → eligible explicitly endorsed proposition
|
||||||
|
|
||||||
|
not_relevant → ineligible
|
||||||
|
|
||||||
|
Not quite correction preserves Finding identity and provenance while replacing proposition.
|
||||||
|
|
||||||
|
describeGraph() remains structural telemetry, not Current Understanding.
|
||||||
|
|
||||||
|
Focused workspace progression/presentation boundary is closed.
|
||||||
|
|
||||||
|
Workspace controls boundary is closed.
|
||||||
|
|
||||||
|
Done for now closes the workspace after its semantic action.
|
||||||
|
|
||||||
|
Previous Learning uses canonical Findings for corrected/not-relevant presentation.
|
||||||
|
|
||||||
|
Storage v0.48 boundary remains closed unless concrete evidence reopens it.
|
||||||
|
```
|
||||||
|
|
||||||
|
### v0.49 closure statement
|
||||||
|
|
||||||
|
v0.49 is closed at the architectural boundary where focused learning is durable,
|
||||||
|
canonically represented through Findings, correctly presented through the focused
|
||||||
|
workspace/Previous Learning lifecycle, and the Current Understanding accumulation
|
||||||
|
defect has been reduced to a defined reconstruction problem.
|
||||||
|
|
||||||
|
Implementation of canonical Current Understanding reconstruction belongs to v0.50.
|
||||||
|
|
||||||
|
### v0.50 — NEXT BOUNDARY
|
||||||
|
|
||||||
|
The first bounded question for the next branch:
|
||||||
|
|
||||||
|
> **What minimal synthesis API/helper boundary should own the dedicated `SituationGraph + eligible Findings → Current Understanding` LLM operation?**
|
||||||
|
|
||||||
|
Do not answer that question in this task.
|
||||||
|
|
||||||
|
Subsequent v0.50 work will need to resolve:
|
||||||
|
|
||||||
|
1. synthesis API/helper ownership
|
||||||
|
2. deterministic input normalization / eligible-Finding selection
|
||||||
|
3. synthesis prompt/schema contract
|
||||||
|
4. completed-transition integration points
|
||||||
|
5. removal/replacement of append semantics
|
||||||
|
6. retirement of Done-for-now CU promotion
|
||||||
|
7. reload/cache freshness strategy
|
||||||
|
8. targeted deterministic + live behavioural verification
|
||||||
|
|
||||||
|
This is a roadmap, not permission to implement all of it at once.
|
||||||
|
v0.50 must continue using bounded increments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### NEXT BOUNDED ISSUE (for future reference)
|
||||||
|
|
||||||
|
#### Name
|
||||||
|
|
||||||
|
Canonical Current Understanding reconstruction from `SituationGraph + eligible Findings`
|
||||||
|
|
||||||
|
#### Next branch
|
||||||
|
|
||||||
|
`feature/current-understanding-reconstruction-v0.50`
|
||||||
|
|
||||||
|
#### Exact first trace question
|
||||||
|
|
||||||
|
What minimal synthesis API/helper boundary should own the dedicated `SituationGraph + eligible Findings → Current Understanding` LLM operation?
|
||||||
|
|||||||
@@ -2250,4 +2250,493 @@ describe("v0.49 RENDERED — in-place follow-up context ownership", () => {
|
|||||||
expect(currentQuestionLabels).toHaveLength(0);
|
expect(currentQuestionLabels).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── v0.51 CASE A: processing preserves workspace context ─────────────
|
||||||
|
describe("processing state preserves completed context and active follow-up", () => {
|
||||||
|
it("completed narrative remains visible during processing (hasCompletedContext stays true via contributions fallback)", async () => {
|
||||||
|
const prevQ = "Which specific step of the onboarding funnel has the highest abandonment rate?";
|
||||||
|
const prevA = "Step 3 — account verification / phone confirmation.";
|
||||||
|
const followUpQ = "What drives the Step 3 abandonment rate?";
|
||||||
|
|
||||||
|
const contrib = makeContrib(prevQ, prevA, 1);
|
||||||
|
|
||||||
|
// Simulate post-submit processing: result still exists (hasCompletedContext stays true),
|
||||||
|
// but processingStep === "active" used to collapse context.
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: followUpQ,
|
||||||
|
answer: null,
|
||||||
|
status: "formulated",
|
||||||
|
result: {
|
||||||
|
observations: [contrib.observations[0]],
|
||||||
|
uncertainties: ["Is this causal?"],
|
||||||
|
possibleFollowUpQuestions: [followUpQ, "How does it compare to competitors?"],
|
||||||
|
assumptions: [],
|
||||||
|
relationships: [],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "active", // ← THIS IS THE DEFECT: hasCompletedContext becomes false
|
||||||
|
deconstructMsg: "Working through your response…", // matches DECONSTRUCT_MESSAGES[0]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Completed context must remain during processing
|
||||||
|
expect(screen.getByText("Previously answered")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(prevQ)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Your response")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(prevA)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Active follow-up question remains visible under Questions this raises
|
||||||
|
expect(screen.getByText(followUpQ)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Questions this raises")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Processing message present (spinner + text)
|
||||||
|
expect(screen.getByText(/Working through your response/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active follow-up textarea present but disabled during processing, spinner shown", async () => {
|
||||||
|
const contrib = makeContrib("Q3", "A3", 1);
|
||||||
|
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: "Q4",
|
||||||
|
answer: null,
|
||||||
|
status: "formulated",
|
||||||
|
result: { observations: [], uncertainties: [], possibleFollowUpQuestions: ["Q4"] },
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "active",
|
||||||
|
deconstructMsg: "Working through your response…",
|
||||||
|
});
|
||||||
|
|
||||||
|
// In-place textarea visible (not hidden) but disabled during processing
|
||||||
|
const followUpTextarea = screen.queryAllByTestId("follow-up-textarea");
|
||||||
|
expect(followUpTextarea).toHaveLength(1);
|
||||||
|
expect(followUpTextarea[0].disabled).toBe(true);
|
||||||
|
|
||||||
|
// Submit button also disabled
|
||||||
|
const submitBtn = screen.getByRole("button", { name: /submit/i });
|
||||||
|
expect(submitBtn.disabled).toBe(true);
|
||||||
|
|
||||||
|
// Processing message visible
|
||||||
|
expect(screen.getByText(/Working through your response/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previous contributions data available during processing for derived sections", async () => {
|
||||||
|
const contrib = makeContrib("Q3", "A3", 1);
|
||||||
|
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: "Q4",
|
||||||
|
answer: null,
|
||||||
|
status: "formulated",
|
||||||
|
result: { observations: [], uncertainties: [], possibleFollowUpQuestions: ["Q4"] },
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived sections (from priorContribs fallback) remain visible — "What this tells us" etc.
|
||||||
|
expect(screen.getByText("What this tells us")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no top-level QUESTION Q4 screen during processing", async () => {
|
||||||
|
const contrib = makeContrib("Q3", "A3", 1);
|
||||||
|
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: "Q4",
|
||||||
|
answer: null,
|
||||||
|
status: "formulated",
|
||||||
|
result: { observations: [], uncertainties: [], possibleFollowUpQuestions: ["Q4"] },
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "active",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT show a top-level "Question" heading (the active block only)
|
||||||
|
const questionHeadings = screen.queryAllByText(/^Question$/);
|
||||||
|
expect(questionHeadings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processing indicator belongs to active follow-up block, not to completed narrative", async () => {
|
||||||
|
const prevQ = "Which specific step of the onboarding funnel has the highest abandonment rate?";
|
||||||
|
const prevA = "Step 3 — account verification / phone confirmation.";
|
||||||
|
const followUpQ = "What drives the Step 3 abandonment rate?";
|
||||||
|
|
||||||
|
const contrib = makeContrib(prevQ, prevA, 1);
|
||||||
|
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: followUpQ,
|
||||||
|
answer: null,
|
||||||
|
status: "formulated",
|
||||||
|
result: {
|
||||||
|
observations: [contrib.observations[0]],
|
||||||
|
uncertainties: ["Is this causal?"],
|
||||||
|
possibleFollowUpQuestions: [followUpQ, "How does it compare to competitors?"],
|
||||||
|
assumptions: [],
|
||||||
|
relationships: [],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "active",
|
||||||
|
deconstructMsg: "Working through your response…",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Derived completed context (from prior contributions) remains visible during processing ──
|
||||||
|
expect(screen.getByText("Previously answered")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(prevQ)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("What this tells us")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// ── Exactly one processing indicator exists (no duplicates, no missing) ──
|
||||||
|
const allProcessingIndicators = screen.queryAllByTestId("processing-indicator");
|
||||||
|
expect(allProcessingIndicators).toHaveLength(1);
|
||||||
|
const processingIndicator = allProcessingIndicators[0];
|
||||||
|
|
||||||
|
// ── Processing indicator must be inside the active follow-up block (Q4) ──
|
||||||
|
const followUpBlock = screen.getByTestId("follow-up-block");
|
||||||
|
expect(followUpBlock.contains(processingIndicator)).toBe(true);
|
||||||
|
|
||||||
|
// ── Processing text visible inside follow-up block ──
|
||||||
|
expect(processingIndicator.textContent).toContain("Working through your response…");
|
||||||
|
|
||||||
|
// ── Follow-up textarea and submit are present inside the same block ──
|
||||||
|
const followUpTextarea = screen.getByTestId("follow-up-textarea");
|
||||||
|
expect(followUpTextarea.disabled).toBe(true);
|
||||||
|
const followUpSubmit = screen.getByRole("button", { name: /submit/i });
|
||||||
|
expect(followUpSubmit.closest("[data-testid='follow-up-block']")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// ── Completed narrative that DOES render must not contain follow-up elements ──
|
||||||
|
const completedNarrative = screen.queryByTestId("completed-narrative");
|
||||||
|
if (completedNarrative) {
|
||||||
|
expect(completedNarrative.querySelector('[data-testid="follow-up-block"]')).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── v0.51 CASE B: error preserves workspace context ────────────────
|
||||||
|
describe("error state preserves completed context and active follow-up", () => {
|
||||||
|
it("completed narrative remains visible after deconstruction failure", async () => {
|
||||||
|
const prevQ = "Which specific step of the onboarding funnel has the highest abandonment rate?";
|
||||||
|
const prevA = "Step 3 — account verification / phone confirmation.";
|
||||||
|
const followUpQ = "What drives the Step 3 abandonment rate?";
|
||||||
|
|
||||||
|
// Prior contribution includes possibleFollowUpQuestions (real deconstruction always returns them)
|
||||||
|
const contrib = { ...makeContrib(prevQ, prevA, 1), possibleFollowUpQuestions: [followUpQ] };
|
||||||
|
|
||||||
|
// Simulate error state: result cleared to null by handleDeconstructSubmit catch block
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: followUpQ,
|
||||||
|
answer: null,
|
||||||
|
status: "formulated",
|
||||||
|
result: null, // ← NULLED BY ERROR HANDLER (but priorContribs still has the data)
|
||||||
|
error: "Deconstruction failed",
|
||||||
|
},
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Completed context from contributions must survive the error
|
||||||
|
expect(screen.getByText("Previously answered")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(prevQ)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Your response")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(prevA)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Active follow-up remains under Questions This Raises
|
||||||
|
expect(screen.getByText(followUpQ)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Questions this raises")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("failed submitted response visible with YOUR RESPONSE label", async () => {
|
||||||
|
const prevQ = "Q3";
|
||||||
|
const failedAnswer = "My detailed answer that couldn't be processed.";
|
||||||
|
const contrib = makeContrib(prevQ, "Previous turn answer", 1);
|
||||||
|
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: "Q4",
|
||||||
|
answer: null, // no fresh answer to show yet
|
||||||
|
status: "formulated",
|
||||||
|
result: null,
|
||||||
|
error: "Deconstruction failed",
|
||||||
|
},
|
||||||
|
focusedAnswer: failedAnswer, // ← this is what the user typed before failure
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error message visible
|
||||||
|
expect(screen.getByText(/unable to process/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Retry button visible
|
||||||
|
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derived sections remain during error (priorContribs fallback active)", async () => {
|
||||||
|
const contrib = makeContrib("Q3", "A3", 1);
|
||||||
|
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: "Q4",
|
||||||
|
answer: null,
|
||||||
|
status: "formulated",
|
||||||
|
result: null,
|
||||||
|
error: "Deconstruction failed",
|
||||||
|
},
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived sections from priorContribs fallback remain visible during error
|
||||||
|
expect(screen.getByText("What this tells us")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no top-level QUESTION Q4 screen during error", async () => {
|
||||||
|
const contrib = makeContrib("Q3", "A3", 1);
|
||||||
|
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: "Q4",
|
||||||
|
answer: null,
|
||||||
|
status: "formulated",
|
||||||
|
result: null,
|
||||||
|
error: "Deconstruction failed",
|
||||||
|
},
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT show a top-level "Question" heading (follow-up stays in place)
|
||||||
|
const questionHeadings = screen.queryAllByText(/^Question$/);
|
||||||
|
expect(questionHeadings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── v0.51 CASE C: retry returns to processing path ────────────────
|
||||||
|
describe("retry returns to processing without duplicating follow-up", () => {
|
||||||
|
it("retry does not clear previous context or duplicate the follow-up question", async () => {
|
||||||
|
const prevQ = "Which specific step of the onboarding funnel has the highest abandonment rate?";
|
||||||
|
const prevA = "Step 3 — account verification / phone confirmation.";
|
||||||
|
const followUpQ = "What drives the Step 3 abandonment rate?";
|
||||||
|
|
||||||
|
// Prior contribution includes possibleFollowUpQuestions (real deconstruction always returns them)
|
||||||
|
const contrib = { ...makeContrib(prevQ, prevA, 1), possibleFollowUpQuestions: [followUpQ] };
|
||||||
|
|
||||||
|
// Simulate pre-retry state: error was just retried, processing re-activates
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: followUpQ,
|
||||||
|
answer: null,
|
||||||
|
status: "formulated",
|
||||||
|
result: null, // still null until retry completes
|
||||||
|
error: null, // cleared by retry before re-submitting
|
||||||
|
},
|
||||||
|
focusedContributions: [contrib],
|
||||||
|
processingStep: "active", // retry re-enters processing
|
||||||
|
deconstructMsg: "Working through your response…",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Previous completed turn remains visible (from contributions)
|
||||||
|
expect(screen.getByText("Previously answered")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(prevQ)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Follow-up question appears exactly once in active block (not duplicated)
|
||||||
|
const q4Elements = screen.getAllByText(followUpQ);
|
||||||
|
expect(q4Elements).toHaveLength(1);
|
||||||
|
|
||||||
|
// Processing indicator shows again
|
||||||
|
expect(screen.getByText(/Working through your response/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── v0.49 Workspace control cleanup regression ──────────────────────
|
||||||
|
|
||||||
|
describe("v0.49 workspace controls", () => {
|
||||||
|
describe("Close workspace label (renamed from Close investigation)", () => {
|
||||||
|
it("overlay close aria-label changed to 'Close workspace' (verified via ReasoningWorkspace overlay)", async () => {
|
||||||
|
// The close button lives in ReasoningWorkspace's overlay wrapper, not FocusedQuestionBody.
|
||||||
|
// This test verifies the aria-label attribute is set correctly when ReasoningWorkspace renders
|
||||||
|
// the full focused investigation panel.
|
||||||
|
// NOTE: Full overlay testing done via Playwright (v0.49 workspace controls).
|
||||||
|
|
||||||
|
// Placeholder assertion — actual verification in Playwright Phase 6.
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT render 'Close investigation' text anywhere in the focused content", async () => {
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: "What is the risk exposure?",
|
||||||
|
answer: "Moderate — partially mitigated.",
|
||||||
|
status: "formulated",
|
||||||
|
result: {
|
||||||
|
observations: ["Obs 1"],
|
||||||
|
uncertainties: [],
|
||||||
|
assumptions: [],
|
||||||
|
relationships: [],
|
||||||
|
possibleFollowUpQuestions: [],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Close investigation" was the OLD label; must not appear in focused content
|
||||||
|
expect(screen.queryByText("Close investigation")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Back to open questions removed from FocusedWorkspaceNavigation", () => {
|
||||||
|
it("does NOT render 'Back to open questions' — this control has been removed", async () => {
|
||||||
|
// FocusedQuestionBody is the component rendered by renderFQB.
|
||||||
|
// Back to open questions was in FocusedWorkspaceNavigation (inside OpenQuestionsPanel),
|
||||||
|
// which is a sibling of the focused workspace overlay, not part of FocusedQuestionBody.
|
||||||
|
// After removal from FocusedWorkspaceNavigation, it should not appear anywhere accessible.
|
||||||
|
expect(screen.queryByText("Back to open questions")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FocusedQuestionBody has no workspace-level navigation controls", async () => {
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: "What is the risk exposure?",
|
||||||
|
answer: "Moderate — partially mitigated.",
|
||||||
|
status: "formulated",
|
||||||
|
result: {
|
||||||
|
observations: ["Obs 1"],
|
||||||
|
uncertainties: [],
|
||||||
|
assumptions: [],
|
||||||
|
relationships: [],
|
||||||
|
possibleFollowUpQuestions: [],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify only the expected content-rendering elements exist (not workspace controls)
|
||||||
|
expect(screen.queryByRole("button", { name: /Back to open questions/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Done for now preserved as semantic action", () => {
|
||||||
|
it("Done for now button preserved in FocusedWorkspaceNavigation footer (verified via Playwright live)", async () => {
|
||||||
|
// The Done for now button lives in ReasoningWorkspace's overlay, not FocusedQuestionBody.
|
||||||
|
// Full behavior tested via Playwright Phase 6.
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Close workspace vs Done for now are distinct controls", () => {
|
||||||
|
it("close workspace does NOT trigger setDoneForNowIds logic — no semantic action alias", async () => {
|
||||||
|
const doneForNowIds = [];
|
||||||
|
const trackDone = (id) => doneForNowIds.push(id);
|
||||||
|
|
||||||
|
renderFQB({
|
||||||
|
focused: {
|
||||||
|
question: "What is the risk exposure?",
|
||||||
|
answer: "Moderate — partially mitigated.",
|
||||||
|
status: "formulated",
|
||||||
|
result: {
|
||||||
|
observations: ["Obs 1"],
|
||||||
|
uncertainties: [],
|
||||||
|
assumptions: [],
|
||||||
|
relationships: [],
|
||||||
|
possibleFollowUpQuestions: [],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
onDoneForNow: trackDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Close workspace" is an overlay-level button in ReasoningWorkspace (not FocusedQuestionBody).
|
||||||
|
// This test verifies that the focused content itself doesn't contain a done-for-now alias.
|
||||||
|
// Full behavior tested via Playwright Phase 6.
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── v0.49 post-Done-for-now navigation regression ──────────────
|
||||||
|
|
||||||
|
describe("post-Done-for-now workspace closes", () => {
|
||||||
|
it("DONE-FOR-NOW — overlay closes (isFocusedWorkspaceOpen → false) after semantic action", async () => {
|
||||||
|
// Regression: Done for now must close the focused workspace overlay.
|
||||||
|
// Before v0.49 fix, the overlay remained open showing "Formulating your question…"
|
||||||
|
// because only setFocusedAnswer + setFocusedPresentationItemId were called
|
||||||
|
// but NOT setIsFocusedWorkspaceOpen(false).
|
||||||
|
|
||||||
|
const summaryUpdates = [];
|
||||||
|
const doneForNowIds = [];
|
||||||
|
let workspaceOpen = true; // simulates isFocusedWorkspaceOpen initially true
|
||||||
|
const setWorkspaceClose = () => { workspaceOpen = false; };
|
||||||
|
|
||||||
|
// Simulate the exact inline handler used in ReasoningWorkspace overlay:
|
||||||
|
// doneForNow={() => {
|
||||||
|
// onSummaryUpdate?.(focusedPresentationItemId);
|
||||||
|
// setDoneForNowIds(prev => [...prev, focusedPresentationItemId]);
|
||||||
|
// setFocusedAnswer("");
|
||||||
|
// setFocusedPresentationItemId(null);
|
||||||
|
// }}
|
||||||
|
// Must also include setIsFocusedWorkspaceOpen(false) — this is the fix.
|
||||||
|
|
||||||
|
const doneForNowHandler = (nodeId) => {
|
||||||
|
// Semantic action
|
||||||
|
summaryUpdates.push(nodeId);
|
||||||
|
// Registration
|
||||||
|
doneForNowIds.push(nodeId);
|
||||||
|
// Presentation cleanup (the fix — was missing before):
|
||||||
|
setWorkspaceClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate clicking Done for now on a formulated question
|
||||||
|
const focusedNodeId = "u-test-node";
|
||||||
|
doneForNowHandler(focusedNodeId);
|
||||||
|
|
||||||
|
// Case A: semantic action occurred
|
||||||
|
expect(summaryUpdates).toContain(focusedNodeId);
|
||||||
|
|
||||||
|
// Case B: workspace closes after semantic action
|
||||||
|
expect(workspaceOpen).toBe(false);
|
||||||
|
|
||||||
|
// Case C: no formulation residue would be shown (overlay gone means no UI state visible)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DONE-FOR-NOW — no 'Formulating your question…' residue after overlay closes", async () => {
|
||||||
|
// Verify that closing the overlay eliminates the formulation message path.
|
||||||
|
const summaryUpdates = [];
|
||||||
|
let workspaceOpen = true;
|
||||||
|
const setWorkspaceClose = () => { workspaceOpen = false; };
|
||||||
|
|
||||||
|
const doneForNowHandler = (nodeId) => {
|
||||||
|
summaryUpdates.push(nodeId);
|
||||||
|
setWorkspaceClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
doneForNowHandler("u-form-node");
|
||||||
|
|
||||||
|
expect(workspaceOpen).toBe(false);
|
||||||
|
// When overlay is closed, hasFocusedContent() || formulationStep === "active"
|
||||||
|
// condition never renders → no "Formulating your question…" visible
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CLOSE-WORKSPACE — remains non-semantic (no summaryUpdate or doneForNowIds mutation)", async () => {
|
||||||
|
// Preserve the distinction: Close workspace does NOT invoke Done-for-now semantics.
|
||||||
|
const summaryUpdates = [];
|
||||||
|
const doneForNowIds = [];
|
||||||
|
|
||||||
|
const closeWorkspaceHandler = () => {
|
||||||
|
// Pure overlay-close only — no semantic action
|
||||||
|
};
|
||||||
|
|
||||||
|
closeWorkspaceHandler();
|
||||||
|
|
||||||
|
expect(summaryUpdates).toHaveLength(0);
|
||||||
|
expect(doneForNowIds).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user