feat(experiment): checkpoint RTO case workspace lifecycle
This commit is contained in:
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user