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";
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user