feat(experiment): checkpoint RTO case workspace lifecycle

This commit is contained in:
2026-08-18 14:59:52 +01:00
parent 4a34dcc361
commit db5016c138
+180 -36
View File
@@ -527,6 +527,14 @@ export default function ReasoningWorkspace({
const pendingTurnRef = useRef(null); const pendingTurnRef = useRef(null);
// ── Experiment 12: toggle between progress panel versions (temporary experimental UI) ── // ── Experiment 12: toggle between progress panel versions (temporary experimental UI) ──
const [panelVariant, setPanelVariant] = useState("c"); const [panelVariant, setPanelVariant] = useState("c");
// ── RTO.01 — presentation-only selection for open investigation items ──
const [selectedPresentationItemId, setSelectedPresentationItemId] = useState(null);
// ── RTO.02 — case / question workspace lifecycle (presentation only) ──
const [focusedPresentationItemId, setFocusedPresentationItemId] = useState(null);
const [doneForNowIds, setDoneForNowIds] = useState([]);
const { saveSession, loadSession } = useSessionPersistence(); const { saveSession, loadSession } = useSessionPersistence();
// Persist workspace state on every successful update (Phase 3) // Persist workspace state on every successful update (Phase 3)
@@ -663,49 +671,185 @@ export default function ReasoningWorkspace({
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* ── Left lane: active conversation & notebook ───────── */} {/* ── Left lane: active conversation & notebook ───────── */}
<div className="space-y-6 lg:col-span-2"> <div className="space-y-6 lg:col-span-2">
{/* Active question — only when there is one */} {/* ── Open questions (case workspace) — no ranking bias ── */}
{hasSelectedQuestion && ( {(() => {
<> const openNodes = graph.nodes.filter(
<CurrentInvestigationCard selectedQuestion={selectedQ} graph={graph} /> (n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id)
);
if (openNodes.length <= 1) return null;
{updateStatus === "success" && !isUpdating && ( return (
<UpdateAcknowledgement updateResult={result} /> <div className="space-y-4">
)} <h2 className="text-[11px] font-medium tracking-widest uppercase text-gray-300">
</> Open questions
)} </h2>
<div className="space-y-1">
{openNodes.map((node) => {
const isSelected = selectedPresentationItemId === node.id;
{/* Answer form — only when a question is active and not loading */} return (
{!isUpdating && canAnswer && ( <div key={node.id}>
<form onSubmit={handleUpdateCaptureAndSubmit} className="space-y-4 rounded-lg border border-gray-200 bg-white p-5"> <div
<div> onClick={() => {
<label htmlFor="rw-answer" className="mb-2 block text-sm font-medium text-gray-700"> if (result?.selectedQuestion?.nodeId !== node.id) {
Response setSelectedPresentationItemId(
</label> selectedPresentationItemId === node.id
<textarea ? null
id="rw-answer" : node.id,
value={answer} );
onChange={(e) => setAnswer(e.target.value)} }
rows={4} }}
disabled={updateStatus === "loading"} style={{ cursor: "pointer" }}
data-testid="response-textarea" className={`w-full text-left rounded-lg px-4 py-3 transition ${
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" isSelected
placeholder="What do you know about this?" ? "border border-gray-300 bg-gray-50/60"
/> : "hover:bg-gray-50"
}`}
>
<p className={
isSelected
? "text-sm font-medium text-gray-900 leading-snug"
: "text-sm text-gray-700 leading-snug"
}>
{node.label}
</p>
</div>
{isSelected && (
<div className="px-4 pb-4 pt-1 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?
</p>
<button
onClick={() => {
setSelectedPresentationItemId(null);
setFocusedPresentationItemId(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"
>
Work through this
</button>
</div>
)}
</div>
);
})}
</div>
{/* Done for now — separate visual area */}
{doneForNowIds.length > 0 && (
<div className="pt-3 border-t border-gray-200">
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-300">
Done for now
</h3>
<div className="space-y-1">
{graph.nodes.filter(
(n) => doneForNowIds.includes(n.id)
).map((node) => (
<div key={node.id} className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3">
<p className="text-sm text-gray-500 leading-snug">{node.label}</p>
<button
onClick={() => setDoneForNowIds(doneForNowIds.filter(id => id !== node.id))}
style={{ cursor: "pointer" }}
className="mt-2 rounded border border-gray-300 px-3 py-1 text-xs font-medium text-gray-500 hover:bg-white transition"
>
Reopen
</button>
</div>
))}
</div>
</div>
)}
</div> </div>
<div className="flex items-center justify-between"> );
<p className="text-xs text-gray-400"> })()}
One update turn only in this prototype.
</p> {/* ── Focused question workspace ── */}
{focusedPresentationItemId !== null && (() => {
const focusedNode = graph?.nodes.find(
(n) => n.id === focusedPresentationItemId,
);
const hasReasoningSupport = result?.selectedQuestion?.nodeId === focusedPresentationItemId;
return (
<div className="space-y-5">
<button <button
type="submit" onClick={() => setFocusedPresentationItemId(null)}
disabled={!answer.trim()} style={{ cursor: "pointer" }}
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" className="text-sm text-gray-400 underline hover:text-gray-600 transition"
> >
Update Back to open questions
</button>
{hasReasoningSupport && (
<div className="space-y-3">
<div className="text-xs text-gray-400">
Chosen investigation: {" "}
{focusedNode?.label}
</div>
<CurrentInvestigationCard selectedQuestion={selectedQ} graph={graph} />
</div>
)}
{!hasReasoningSupport && (
<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>
)}
{updateStatus === "success" && !isUpdating && (
<UpdateAcknowledgement updateResult={result} />
)}
{/* Answer form — only when focused item matches Engine question */}
{!isUpdating && hasReasoningSupport && canAnswer && (
<form onSubmit={handleUpdateCaptureAndSubmit} className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
<div>
<label htmlFor="rw-answer" className="mb-2 block text-sm font-medium text-gray-700">
Response
</label>
<textarea
id="rw-answer"
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?"
/>
</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>
</form>
)}
<button
onClick={() => {
setFocusedPresentationItemId(null);
setDoneForNowIds((prev) => [...prev, focusedPresentationItemId]);
}}
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"
>
Done for now
</button> </button>
</div> </div>
</form> );
)} })()}
{/* Terminal state */} {/* Terminal state */}
{status === "success" && !hasSelectedQuestion && ( {status === "success" && !hasSelectedQuestion && (