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"; errorType === "provider-unavailable" || errorType === "provider-error";
const isMalformedResponse = errorType === "malformed-response"; 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( const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES, INITIAL_MESSAGES,
status === "loading" status === "loading"
@@ -609,9 +614,156 @@ export default function ReasoningWorkspace({
updateStatus === "loading" 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 isUpdating = updateStatus === "loading";
const hasSelectedQuestion = Boolean(result?.selectedQuestion); 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 = const canAnswer =
status === "success" && status === "success" &&
!isUpdating && !isUpdating &&
@@ -709,8 +861,8 @@ export default function ReasoningWorkspace({
{node.label} {node.label}
</p> </p>
{/* Invitation / returned state — one block for both, since isSelected!==isFocused covers both */} {/* Invitation — shows when selected but no content yet */}
{isSelected && !isFocused && ( {isSelected && !hasFocusedContent() && (
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
<p className="text-sm leading-relaxed text-gray-500"> <p className="text-sm leading-relaxed text-gray-500">
We have not explored this yet. Do you want to work through it? We have not explored this yet. Do you want to work through it?
@@ -718,8 +870,7 @@ export default function ReasoningWorkspace({
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedPresentationItemId(node.id); startFocused(node.id);
setFocusedPresentationItemId(node.id);
}} }}
style={{ cursor: "pointer" }} 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" 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); const focusedNode = graph?.nodes.find((n) => n.id === node.id);
return ( return (
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{/* Real Engine-backed content — only for matching question */} {/* Real focused investigation content */}
{hasReasoningSupport ? ( {hasFocusedContent() && (() => {
<> return null;
<div className="text-xs text-gray-400"> })()}
Chosen investigation: {" "} {hasFocusedContent() && (
{focusedNode?.label} <div className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
</div> {/* Formulated question */}
<CurrentInvestigationCard selectedQuestion={selectedQ} graph={graph} /> {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 && ( {/* Answer textarea (hidden while processing) */}
<UpdateAcknowledgement updateResult={result} /> {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 */} {/* Deconstruction loading */}
{!isUpdating && canAnswer && ( {processingStep === "active" && (
<form onSubmit={handleUpdateCaptureAndSubmit} className="space-y-4 rounded-lg border border-gray-200 bg-white p-5"> <p className="text-sm text-blue-600/70">{deconstructMsg}</p>
)}
{/* Focused result — structured response */}
{focused?.result && (
<>
<div> <div>
<label htmlFor={`rw-answer-${node.id}`} className="mb-2 block text-sm font-medium text-gray-700"> <h3 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-300">
Response What we learned
</label> </h3>
<textarea <ul className="list-disc pl-5 space-y-1">
id={`rw-answer-${node.id}`} {focused.result.observations.map((o, i) => (
value={answer} <li key={i} className="text-sm leading-relaxed text-gray-700">{o}</li>
onChange={(e) => setAnswer(e.target.value)} ))}
rows={4} </ul>
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?"
/>
</div> </div>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-400"> <div>
One update turn only in this prototype. <h3 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-300">
</p> Still unclear
<button </h3>
type="submit" <ul className="list-disc pl-5 space-y-1">
disabled={!answer.trim()} {focused.result.uncertainties.map((u, i) => (
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" <li key={i} className="text-sm leading-relaxed text-gray-700">{u}</li>
> ))}
Update </ul>
</button>
</div> </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> </div>
)} )}