feat(experiment): checkpoint one-turn focused investigation UI

This commit is contained in:
2026-08-19 07:07:10 +01:00
parent 7add85d8d2
commit dafc020f66
+284 -48
View File
@@ -599,6 +599,11 @@ export default function ReasoningWorkspace({
errorType === "provider-unavailable" || errorType === "provider-error";
const isMalformedResponse = errorType === "malformed-response";
// ── RTO.13B — focused investigation localized state ───────────
const [formulationStep, setFormulationStep] = useState("idle");
const [processingStep, setProcessingStep] = useState("idle");
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES,
status === "loading"
@@ -609,9 +614,156 @@ export default function ReasoningWorkspace({
updateStatus === "loading"
);
// ── RTO.13B — focused investigation loading hooks ─────────────
const FORMULATING_MESSAGES = [
{ min: 0, text: "Working out a question…" },
{ min: 15, text: "Still working on that question" },
{ min: 30, text: "Formulating the right question for this" },
{ min: 45, text: "A moment longer — this can take around a minute" },
];
const DECONSTRUCT_MESSAGES = [
{ min: 0, text: "Working through your response…" },
{ min: 15, text: "Still working on that response" },
{ min: 30, text: "A moment longer — this can take around a minute" },
];
const { elapsed: formulateElapsed, currentMessage: formulateMsg } = useLoadingStatus(
FORMULATING_MESSAGES,
formulationStep === "active",
);
const { elapsed: deconstructElapsed, currentMessage: deconstructMsg } = useLoadingStatus(
DECONSTRUCT_MESSAGES,
processingStep === "active",
);
const isUpdating = updateStatus === "loading";
const hasSelectedQuestion = Boolean(result?.selectedQuestion);
// ── RTO.13B — focused investigation localized state (keyed by node ID) ──
const [focusedInvestigations, setFocusedInvestigations] = useState({});
const [focusedAnswer, setFocusedAnswer] = useState("");
function getFocusedInvestigation() {
if (!focusedPresentationItemId) return null;
return focusedInvestigations[focusedPresentationItemId] || null;
}
function focusItem(nodeId) {
setSelectedPresentationItemId(nodeId);
setFocusedPresentationItemId(nodeId);
}
const focused = getFocusedInvestigation();
// ── RTO.13B — workflow handlers ──────────────────────────────
function startFocused(nodeId) {
const target = nodeId || focusedPresentationItemId;
if (!target) return;
setFocusedPresentationItemId(target);
setFocusedAnswer("");
setFormulationStep("active");
setFocusedInvestigations((prev) => ({
...prev,
[target]: { status: "formulating", question: "", answer: null, result: null, error: null },
}));
doFormulate(target);
}
async function doFormulate(targetNodeId) {
const nodeId = targetNodeId || focusedPresentationItemId;
if (!nodeId || !hasGraph) return;
try {
const url = "/api/focused-investigation/formulate";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ situationGraph: graph, targetNodeId: nodeId }),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || "Formulation failed");
setFocusedInvestigations((prev) => ({
...prev,
[nodeId]: { ...prev[nodeId], question: data.question, status: "formulated", error: null },
}));
setFormulationStep("idle");
} catch (err) {
setFocusedInvestigations((prev) => ({
...prev,
[focusedPresentationItemId]: { ...prev[focusedPresentationItemId], question: "", error: err.message || "Formulation failed" },
}));
}
}
async function handleDeconstructSubmit(targetNodeId, answerText) {
if (!hasGraph) return;
const targetNode = graph?.nodes?.find((n) => n.id === targetNodeId);
const centralStmt = graph?.centralStatement || scenario || "";
setProcessingStep("active");
try {
const url = "/api/focused-investigation/deconstruct";
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: targetNodeId,
targetLabel: targetNode?.label || "",
targetDescription: targetNode?.description || "",
centralStatement: centralStmt,
question: focused.question,
answer: answerText,
}),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || "Deconstruction failed");
setProcessingStep("idle");
setFocusedInvestigations((prev) => ({
...prev,
[targetNodeId]: { ...prev[targetNodeId], result: data, answer: answerText, error: null },
}));
} catch (err) {
setProcessingStep("idle");
setFocusedInvestigations((prev) => ({
...prev,
[targetNodeId]: { ...prev[targetNodeId], result: null, error: err.message || "Deconstruction failed" },
}));
}
}
async function submitFocusedAnswer() {
if (!focused?.question?.trim() || !hasGraph) return;
const targetNodeId = focusedPresentationItemId;
await handleDeconstructSubmit(targetNodeId, focusedAnswer);
}
function retryFormulation() {
const target = focusedPresentationItemId;
if (!target) return;
setFocusedInvestigations((prev) => ({
...prev,
[target]: { ...prev[target], question: "", error: null },
}));
doFormulate(target);
}
function hasFocusedContent() {
if (!focused) return false;
const q = focused.question;
return Boolean(q?.trim()) || formulationStep === "active" || processingStep === "active" || focused.error;
}
// ── End RTO.13B ──────────────────────────────────────────────
const canAnswer =
status === "success" &&
!isUpdating &&
@@ -709,8 +861,8 @@ export default function ReasoningWorkspace({
{node.label}
</p>
{/* Invitation / returned state — one block for both, since isSelected!==isFocused covers both */}
{isSelected && !isFocused && (
{/* Invitation — shows when selected but no content yet */}
{isSelected && !hasFocusedContent() && (
<div className="mt-3 space-y-3">
<p className="text-sm leading-relaxed text-gray-500">
We have not explored this yet. Do you want to work through it?
@@ -718,8 +870,7 @@ export default function ReasoningWorkspace({
<button
onClick={(e) => {
e.stopPropagation();
setSelectedPresentationItemId(node.id);
setFocusedPresentationItemId(node.id);
startFocused(node.id);
}}
style={{ cursor: "pointer" }}
className="rounded-lg border border-blue-600 bg-white px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 transition"
@@ -734,57 +885,142 @@ export default function ReasoningWorkspace({
const focusedNode = graph?.nodes.find((n) => n.id === node.id);
return (
<div className="mt-4 space-y-4">
{/* Real Engine-backed content — only for matching question */}
{hasReasoningSupport ? (
<>
<div className="text-xs text-gray-400">
Chosen investigation: {" "}
{focusedNode?.label}
</div>
<CurrentInvestigationCard selectedQuestion={selectedQ} graph={graph} />
{/* Real focused investigation content */}
{hasFocusedContent() && (() => {
return null;
})()}
{hasFocusedContent() && (
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
{/* Formulated question */}
{focused?.question?.trim() ? (
<div>
<h3 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-300">
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}
{updateStatus === "success" && !isUpdating && (
<UpdateAcknowledgement updateResult={result} />
{/* Answer textarea (hidden while processing) */}
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && (
<div>
<label htmlFor={`rw-answer-${node.id}`} className="mb-2 block text-sm font-medium text-gray-700">
Your response
</label>
<textarea
id={`rw-answer-${node.id}`}
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();
const targetNodeId = focusedPresentationItemId;
handleDeconstructSubmit(targetNodeId, 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>
)}
{/* Answer form — only when focused item matches Engine question */}
{!isUpdating && canAnswer && (
<form onSubmit={handleUpdateCaptureAndSubmit} className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
{/* Deconstruction loading */}
{processingStep === "active" && (
<p className="text-sm text-blue-600/70">{deconstructMsg}</p>
)}
{/* Focused result — structured response */}
{focused?.result && (
<>
<div>
<label htmlFor={`rw-answer-${node.id}`} className="mb-2 block text-sm font-medium text-gray-700">
Response
</label>
<textarea
id={`rw-answer-${node.id}`}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
rows={4}
disabled={updateStatus === "loading"}
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?"
/>
<h3 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-300">
What we learned
</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 className="flex items-center justify-between">
<p className="text-xs text-gray-400">
One update turn only in this prototype.
</p>
<button
type="submit"
disabled={!answer.trim()}
className="rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
Update
</button>
<div>
<h3 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-300">
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>
</form>
{focused.result.assumptions && focused.result.assumptions.length > 0 && (
<div>
<h3 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-300">
Assumptions in this response
</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>
)}
{focused.result.relationships && focused.result.relationships.length > 0 && (
<div>
<h3 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-300">
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.result.possibleFollowUpQuestions && focused.result.possibleFollowUpQuestions.length > 0 && (
<div>
<h3 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-300">
Questions this raises
</h3>
<ul className="list-disc pl-5 space-y-1">
{focused.result.possibleFollowUpQuestions.map((q, i) => (
<li key={i} className="text-sm leading-relaxed text-gray-700">{q}</li>
))}
</ul>
</div>
)}
</>
)}
{/* Formulation or deconstruction failure */}
{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 className="rounded-lg border border-gray-200/60 bg-gray-100/50 px-8 py-6 text-center">
<p className="text-sm leading-relaxed text-gray-500">
We have not explored this yet.
</p>
</div>
)}