feat(confidence-engine): add focused investigation overlay workspace

This commit is contained in:
2026-08-26 19:22:54 +01:00
parent 990b51aecf
commit abeb3fcb03
2 changed files with 1271 additions and 85 deletions
+306 -85
View File
@@ -169,90 +169,106 @@ function FocusedQuestionBody({
setFollowUpQuestion,
focusedContributions,
}) {
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
const hasResult = Boolean(focused?.result);
return (
<>
{isFocused && (() => {
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
{isFocused && hasContent && (
<div className="mt-4 space-y-4">
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
{focused?.question?.trim() ? (
<div>
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Question</h3>
<p className="text-base font-medium leading-relaxed text-gray-900">{focused.question}</p>
</div>
) : formulationStep === "active" ? (
<p className="text-sm text-blue-600/70">{formulateMsg}</p>
) : null}
if (!hasContent) return null;
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && (
<div>
<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?" />
<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>
)}
return (
<div className="mt-4 space-y-4">
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
{focused?.question?.trim() ? (
<div>
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Question</h3>
<p className="text-base font-medium leading-relaxed text-gray-900">{focused.question}</p>
{processingStep === "active" && <p className="text-sm text-blue-600/70">{deconstructMsg}</p>}
{focused?.result && (
<>
{/* Prior accumulated learning (prior turns, current turn excluded — shown above) */}
<PriorContributionsSummary nodeId={nodeId} contributions={focusedContributions || []} />
<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-1">{(focused.result.observations || []).map((o, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{o}</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">
{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>
)}
</div>
) : formulationStep === "active" ? (
<p className="text-sm text-blue-600/70">{formulateMsg}</p>
) : null}
<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>
</>
)}
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && (
<div>
<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?" />
<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>
)}
{processingStep === "active" && <p className="text-sm text-blue-600/70">{deconstructMsg}</p>}
{focused?.result && (
<>
{/* Prior accumulated learning (prior turns, current turn excluded — shown above) */}
<PriorContributionsSummary nodeId={nodeId} contributions={focusedContributions || []} />
<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-1">{(focused.result.observations || []).map((o, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{o}</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">
{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>
)}
</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>
</>
)}
{focused?.error && processingStep !== "active" && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">We were unable to process your request right now. Please try again later.<button onClick={(e) => { e.stopPropagation(); retryFormulation(); }} className="ml-2 font-medium underline">Retry</button></div>
)}
</div>
<div className="mt-4 mb-3 flex items-center justify-between gap-4">
<button onClick={(e) => { e.stopPropagation(); setFocusedPresentationItemId(null); }} style={{ cursor: "pointer" }} className="text-sm text-gray-400 underline hover:text-gray-600 transition whitespace-nowrap">Back to open questions</button>
<button onClick={(e) => { e.stopPropagation(); setFocusedPresentationItemId(null); setDoneForNowIds((prev) => [...prev, nodeId]); }} style={{ cursor: "pointer" }} className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 transition whitespace-nowrap">Done for now</button>
</div>
{focused?.error && processingStep !== "active" && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">We were unable to process your request right now. Please try again later.<button onClick={(e) => { e.stopPropagation(); retryFormulation(); }} className="ml-2 font-medium underline">Retry</button></div>
)}
</div>
);
})()}
</div>
)}
</>
);
}
// ── Persistent navigation controls (overlay-level, outside content grid) ──
function FocusedWorkspaceNavigation({ nodeId, doneForNow, onBackToOpenQuestions, isDoneForNowActive }) {
const canDoneForNow = Boolean(isDoneForNowActive);
return (
<div className="mt-6 flex items-center justify-between gap-4 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
onClick={(e) => { e.stopPropagation(); doneForNow?.(); }}
style={{ cursor: canDoneForNow ? "pointer" : "not-allowed" }}
className={`rounded-lg border px-4 py-2 text-sm font-medium transition whitespace-nowrap ${canDoneForNow ? 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50' : 'border-gray-100 bg-gray-50 text-gray-300'}`}
>
Done for now
</button>
</div>
);
}
// ── Current understanding card ────────────────────────────────
// Evidence-limit text that must not appear inside Current understanding
@@ -463,6 +479,54 @@ function PriorContributionsSummary({ nodeId, contributions }) {
);
}
// ── Standalone previous learning block (for two-column secondary placement) ───
function SecondaryPreviousLearning({ nodeId, contributions }) {
const threadContribs = contributions.filter((c) => c.targetNodeId === nodeId);
if (!threadContribs.length) return null;
// Exclude the most recent contribution — it is already shown as the current result above.
const priorContribs = threadContribs.slice(0, -1);
if (!priorContribs.length) return null;
return (
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
<h4 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
Previous learning
</h4>
{priorContribs.map((c, idx) => (
<details key={c.id || idx} className="mb-2 border-b border-gray-200/40 last:border-0 pb-2 last:pb-0" open={idx === 0}>
<summary className="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 select-none py-1">
Turn {c.sequence || idx + 1} contribution ({c.observations?.length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
</summary>
<div className="pt-2 space-y-3">
{c.observations?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{c.observations.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null}
{c.uncertainties?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h5>
<ul className="list-disc pl-5 space-y-0.5">
{c.uncertainties.map((u, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{u}</li>
))}
</ul>
</div>
) : null}
</div>
</details>
))}
</div>
);
}
// ── Thread contributions badge (standalone — used outside focused body) ───
function ThreadContributionsBadge({ nodeId, contributions }) {
@@ -938,6 +1002,65 @@ function getErrorType(errorStr, stage, hasGraph) {
return null;
}
// ── Focused investigation overlay workspace container ──────────
function FocusedInvestigationWorkspace({
nodeId,
focused,
formulationStep,
formulateMsg,
processingStep,
deconstructMsg,
focusedAnswer,
handleDeconstructSubmit,
retryFormulation,
setFocusedAnswer,
setSelectedPresentationItemId,
setFocusedPresentationItemId,
setDoneForNowIds,
setFollowUpQuestion,
setIsFocusedWorkspaceOpen,
hasCompletedInvestigation,
focusedContributions,
}) {
const hasResult = Boolean(focused?.result);
return (
<div className="mx-auto w-full max-w-[1600px]">
{/* Two-column responsive grid: primary active work + secondary context */}
<div className={`grid gap-5 xl:grid-cols-[minmax(0,1.7fr)_minmax(280px,0.8fr)]`}>
{/* ── Primary column: active investigation — min-w-0 prevents flex growth in single-column mode ── */}
<div className="min-w-0 overflow-hidden">
<FocusedQuestionBody
nodeId={nodeId}
isFocused={true}
hasCompletedInvestigation={hasCompletedInvestigation}
focused={focused}
formulationStep={formulationStep}
formulateMsg={formulateMsg}
processingStep={processingStep}
deconstructMsg={deconstructMsg}
focusedAnswer={focusedAnswer}
handleDeconstructSubmit={handleDeconstructSubmit}
retryFormulation={retryFormulation}
setFocusedAnswer={setFocusedAnswer}
setSelectedPresentationItemId={setSelectedPresentationItemId}
setFocusedPresentationItemId={setFocusedPresentationItemId}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={hasResult ? [] : (focusedContributions || [])}
/>
</div>
{/* ── Secondary context: Previous Learning — visible on all breakpoints, placed in grid column on wide / flows below primary on narrow ── */}
{hasResult && (
<SecondaryPreviousLearning nodeId={nodeId} contributions={focusedContributions || []} />
)}
</div>
</div>
);
}
// ── Open questions panel (production, non-experiment mode) ────────
function OpenQuestionsPanel({
@@ -945,7 +1068,7 @@ function OpenQuestionsPanel({
focused, formulationStep, formulateMsg, processingStep, deconstructMsg, doneForNowIds,
startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId,
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
setFollowUpQuestion, focusedContributions, focusedInvestigations,
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
}) {
const openNodes = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
@@ -959,17 +1082,14 @@ function OpenQuestionsPanel({
return Boolean(inv && inv.status === "formulated" && inv.result && typeof inv.question === "string" && inv.question.trim());
};
// When a completed result exists and this node is already selected, reopen it.
function handleNodeClick(node) {
if (!focused?.question?.trim() || (focused && !hasFocusedContent())) {
if (hasCompletedInvestigation(node.id) && selectedPresentationItemId === node.id) {
setFocusedPresentationItemId(node.id);
return;
}
setSelectedPresentationItemId(
selectedPresentationItemId === node.id ? null : node.id,
);
if (hasCompletedInvestigation(node.id) && selectedPresentationItemId === node.id) {
setFocusedPresentationItemId(node.id);
return;
}
setSelectedPresentationItemId(
selectedPresentationItemId === node.id ? null : node.id,
);
}
return (
@@ -1081,6 +1201,9 @@ export default function ReasoningWorkspace({
const [focusedPresentationItemId, setFocusedPresentationItemId] = useState(null);
const [doneForNowIds, setDoneForNowIds] = useState([]);
// ── Focused investigation overlay workspace ───────────────
const [isFocusedWorkspaceOpen, setIsFocusedWorkspaceOpen] = useState(false);
// ── RTO.29D — post-Analyse initial reflection surface ─────────
const [initialReflectionActive, setInitialReflectionActive] = useState(false);
const [postAnalyseStatus, setPostAnalyseStatus] = useState(null);
@@ -1247,6 +1370,9 @@ export default function ReasoningWorkspace({
setFocusedPresentationItemId(target);
setFocusedAnswer("");
// Always open the focused workspace overlay
setIsFocusedWorkspaceOpen(true);
if (priorContribs.length > 0) {
// Reopen path: resume from accumulated contribution history.
// Do NOT call doFormulate — the user's prior investigation direction
@@ -1418,6 +1544,14 @@ export default function ReasoningWorkspace({
// ── End RTO.13B ──────────────────────────────────────────────
// ── Body scroll lock while overlay is open ────────────────
useEffect(() => {
if (isFocusedWorkspaceOpen) {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}
}, [isFocusedWorkspaceOpen]);
const canAnswer =
status === "success" &&
!isUpdating &&
@@ -1585,6 +1719,7 @@ export default function ReasoningWorkspace({
function handleNodeClick(n) {
if (hasCompleted && selectedPresentationItemId === n.id) {
setFocusedPresentationItemId(n.id);
setIsFocusedWorkspaceOpen(true);
return;
}
startFocused(n.id);
@@ -1606,8 +1741,8 @@ export default function ReasoningWorkspace({
);
})}
{/* Focused content — rendered inline in the initial reflection surface */}
{(() => {
{/* Focused content — rendered inline in the initial reflection surface (suppressed when overlay open) */}
{!isFocusedWorkspaceOpen && (() => {
const activeForFocus = openUnknowns.find((n) => focusedPresentationItemId === n.id);
if (!activeForFocus) return null;
@@ -1711,6 +1846,7 @@ export default function ReasoningWorkspace({
setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions}
focusedInvestigations={focusedInvestigations}
setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen}
/>
)}
@@ -1830,6 +1966,91 @@ export default function ReasoningWorkspace({
</div>
)}
{/* ── Focused investigation overlay workspace ─────────── */}
{isFocusedWorkspaceOpen && (
<div
className="!mt-0 fixed inset-0 z-50 flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) setIsFocusedWorkspaceOpen(false); }}
>
{/* Dimmed background — prevents interaction with page behind overlay */}
<div className="absolute inset-0 bg-gray-900/40 backdrop-blur-[1px]" />
{/* Workspace container: constrained to viewport height, not pushed by content */}
<div
className="relative z-10 my-6 flex w-[94vw] max-h-[calc(100vh-3rem)] max-w-[1600px] flex-col overflow-hidden rounded-xl border border-gray-200/60 bg-white shadow-xl sm:w-[90vw] md:w-[88vw]"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Focused investigation workspace"
>
{/* Floating close control — persistent, top-right inside panel */}
<button
onClick={(e) => { e.stopPropagation(); setFocusedAnswer(""); setFocusedPresentationItemId(null); setIsFocusedWorkspaceOpen(false); }}
style={{ cursor: "pointer" }}
aria-label="Close investigation"
title="Close investigation"
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>
Close investigation
</button>
{/* Scrollable workspace body */}
<div className="flex-1 overflow-y-auto px-4 pb-8 pt-[64px]">
{hasFocusedContent() || formulationStep === "active" ? (
<>
<FocusedInvestigationWorkspace
nodeId={focusedPresentationItemId}
focused={getFocusedInvestigation()}
formulationStep={formulationStep}
formulateMsg={formulateMsg}
processingStep={processingStep}
deconstructMsg={deconstructMsg}
focusedAnswer={focusedAnswer}
handleDeconstructSubmit={handleDeconstructSubmit}
retryFormulation={retryFormulation}
setFocusedAnswer={setFocusedAnswer}
setSelectedPresentationItemId={setSelectedPresentationItemId}
setFocusedPresentationItemId={setFocusedPresentationItemId}
setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion}
setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen}
hasCompletedInvestigation={() => {
if (!focused) return false;
const q = focused.question;
return Boolean(q?.trim());
}}
focusedContributions={focusedContributions}
/>
{/* Workspace navigation — hidden during formulation/loading states */}
{formulationStep !== "active" && (
<FocusedWorkspaceNavigation
nodeId={focusedPresentationItemId}
doneForNow={() => {
setDoneForNowIds((prev) => [...prev, focusedPresentationItemId]);
setFocusedAnswer("");
setFocusedPresentationItemId(null);
}}
isDoneForNowActive={Boolean(getFocusedInvestigation()?.question?.trim())}
onBackToOpenQuestions={() => {
setFocusedAnswer("");
setFocusedPresentationItemId(null);
}}
/>
)}
</>
) : (
<div className="w-full max-w-[1400px] mx-auto">
<div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
<p className="text-base font-medium text-gray-500">Formulating your question</p>
<p className="mt-3 text-sm text-gray-400">{formulateMsg}</p>
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Developer details — full-width beneath workspace */}
{(status === "success" || status === "error") && graph && (
<DeveloperDetails