feat(experiment): checkpoint one-turn focused investigation UI
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user