feat: allow earlier evidence to be revised

This commit is contained in:
2026-08-03 20:00:13 +01:00
parent 588a1cf0c2
commit 13b14fd01a
2 changed files with 250 additions and 43 deletions
+197 -37
View File
@@ -47,6 +47,13 @@ const UPDATE_MESSAGES = [
{ min: 45, text: "Choosing the next question" },
];
const REVISION_MESSAGES = [
{ min: 0, text: "Reading what changed" },
{ min: 10, text: "Rebuilding the investigation" },
{ min: 25, text: "Checking what this affects" },
{ min: 45, text: "Choosing the next justified question" },
];
function useLoadingStatus(messages, isLoading) {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(null);
@@ -76,6 +83,59 @@ function useLoadingStatus(messages, isLoading) {
return { elapsed, currentMessage };
}
// ── Revision warning banner ─────────────────────────────────
function RevisionWarningCard() {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-5 py-3 text-sm text-amber-900">
Changing this answer may alter the questions and conclusions that followed it.
</div>
);
}
// ── Revision inline editor ────────────────────────────────────
function RevisionEditor({ turn, onSave, onCancel }) {
const [draft, setDraft] = useState(turn.answer);
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-5 py-4 space-y-3">
<RevisionWarningCard />
<p className="text-sm font-medium text-amber-900">{turn.question}</p>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={3}
className="w-full rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm focus:border-amber-400 focus:outline-none"
/>
<div className="flex gap-3">
<button
type="button"
onClick={() => onSave(draft)}
disabled={!draft.trim()}
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-40"
>
Save revised answer
</button>
<button
type="button"
onClick={onCancel}
className="text-sm text-amber-700 underline hover:text-amber-900"
>
Cancel
</button>
</div>
</div>
);
}
// ── Stale turn summary (shown under "Earlier answers that may need reviewing") ───
function StaleTurnSummary({ turn, index }) {
return (
<div className="rounded-lg border border-red-100 bg-red-50/40 px-4 py-2 text-sm text-red-800">
<span className="font-medium">Earlier answer #{index + 1}:</span> {turn.question} "{turn.answer}" may no longer apply.
</div>
);
}
// ── Spinner component ───────────────────────────────────────
function ActivitySpinner() {
return (
@@ -198,40 +258,62 @@ function CurrentFocusCard({ graph }) {
}
// ── Investigation history card ────────────────────────────────
function InvestigationHistoryCard({ turn }) {
function InvestigationHistoryCard({ turn, onReview }) {
const hasReview = !onReview && turn.status === "current";
return (
<details className="rounded-lg border border-gray-100 bg-gray-50/60 px-4 py-3">
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-gray-400 hover:text-gray-600">
{new Date(turn.timestamp).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</summary>
<div className="mt-2 space-y-2 text-sm">
<p><strong>{turn.question}</strong></p>
<p className="text-gray-700">{turn.answer}</p>
{turn.engineResponse && (
<p className="italic text-gray-500">{turn.engineResponse}</p>
<div className="rounded-lg border border-gray-100 bg-gray-50/60 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs text-gray-400">
{new Date(turn.timestamp).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
<details className="mt-1">
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-gray-400 hover:text-gray-600">
{turn.question}
</summary>
<div className="mt-2 space-y-2 text-sm">
<p><strong>You told us:</strong> {turn.answer}</p>
{turn.engineResponse && (
<p className="italic text-gray-500">{turn.engineResponse}</p>
)}
</div>
</details>
</div>
{hasReview && (
<button
type="button"
onClick={onReview}
className="shrink-0 rounded border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
Review answer
</button>
)}
</div>
</details>
</div>
);
}
// ── Investigation history section ─────────────────────────────
function InvestigationHistory({ turns }) {
function InvestigationHistory({ turns, onReviewTurn }) {
if (!turns || turns.length === 0) return null;
const activeTurns = turns.filter((t) => t.status !== "superseded");
if (activeTurns.length === 0) return null;
return (
<div className="space-y-3">
<h2 className="text-xs font-bold uppercase tracking-wider text-gray-400">
Investigation history
</h2>
<div className="space-y-2">
{turns.map((turn, idx) => (
<InvestigationHistoryCard key={idx} turn={turn} />
{activeTurns.map((turn, idx) => (
<InvestigationHistoryCard key={turn.id ?? idx} turn={turn} onReview={turn.status === "current" ? () => onReviewTurn?.(idx) : undefined} />
))}
</div>
</div>
@@ -382,9 +464,18 @@ export default function ReasoningWorkspace({
setAnswer,
onAnswerSubmit,
lastSubmittedAnswer,
isRevisionLoading: propIsRevisionLoading,
}) {
const [investigationHistory, setInvestigationHistory] = useState([]);
// Inline revision editor state (local — no parent re-render until submit)
const [editingTurnIndex, setEditingTurnIndex] = useState(-1);
const [revisionDraft, setRevisionDraft] = useState("");
const [showRevisionEditor, setShowRevisionEditor] = useState(false);
// Track which turn was revised so we can mark stale turns after update succeeds
const revisedTurnIndexRef = useRef(-1);
// Capture the previous question before each new question is set
const prevQuestionRef = useRef(null);
const hasCapturedInitialQuestion = useRef(false);
@@ -396,22 +487,39 @@ export default function ReasoningWorkspace({
}
}, [result?.selectedQuestion]);
// Append completed turn to history after a successful update
// Append completed turn to history after a successful update (normal or revision)
useEffect(() => {
if (updateStatus === "success" && lastSubmittedAnswer) {
const q = prevQuestionRef.current;
setInvestigationHistory((prev) => [
...prev,
{
question: typeof q === "string" ? q : q?.question ?? "",
answer: lastSubmittedAnswer,
engineResponse: result?.summary || null,
timestamp: Date.now(),
},
]);
}
if (updateStatus !== "success" || !lastSubmittedAnswer) return;
const q = prevQuestionRef.current;
setInvestigationHistory((prev) => {
const newTurn = {
id: Date.now(),
question: typeof q === "string" ? q : q?.question ?? "",
answer: lastSubmittedAnswer,
engineResponse: result?.summary || null,
graphBeforeAnswer: result?.situationGraph ? JSON.parse(JSON.stringify(result.situationGraph)) : null,
selectedQuestionBeforeAnswer: q,
timestamp: Date.now(),
status: "current",
};
// If this was a revision, mark the target turn as superseded and all later turns as stale
const revIdx = revisedTurnIndexRef.current;
if (revIdx >= 0) {
return prev.map((t, i) => {
if (i === revIdx) return { ...t, status: "superseded" };
if (i > revIdx) return { ...t, status: t.status === "superseded" ? "superseded" : "stale" };
return t;
}).concat(newTurn);
}
return [...prev, newTurn];
});
revisedTurnIndexRef.current = -1;
}, [updateStatus, lastSubmittedAnswer]);
const isRevisionLoading = propIsRevisionLoading || false;
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES,
status === "loading"
@@ -428,6 +536,7 @@ export default function ReasoningWorkspace({
const canAnswer =
status === "success" &&
!isUpdating &&
!isRevisionLoading &&
Boolean(result?.situationGraph) &&
hasSelectedQuestion;
@@ -439,6 +548,36 @@ export default function ReasoningWorkspace({
const genuineCompletion = hasGenuineCompletion(graph);
// Filter turns into active and stale groups after revision
const activeTurns = investigationHistory.filter((t) => t.status !== "superseded");
const staleTurns = investigationHistory.filter((t) => t.status === "superseded");
// Open inline revision editor for a specific turn
function openRevisionEditor(idx) {
const turn = investigationHistory[idx];
if (!turn) return;
setEditingTurnIndex(idx);
setRevisionDraft(turn.answer);
setShowRevisionEditor(true);
}
function closeRevisionEditor() {
setEditingTurnIndex(-1);
setShowRevisionEditor(false);
setRevisionDraft("");
}
// Save revised answer — use graph checkpoint from the target turn,
// or fall back to the current situationGraph as a replay starting point.
function saveRevision(draft) {
if (!draft?.trim()) return;
closeRevisionEditor();
const checkpoint = investigationHistory[editingTurnIndex]?.graphBeforeAnswer ?? graph;
revisedTurnIndexRef.current = editingTurnIndex;
setAnswer(draft.trim());
onAnswerSubmit({ preventDefault: () => {} }, { graphCheckpoint: checkpoint, turnIndex: editingTurnIndex });
}
return (
<div className="space-y-5">
{/* ── Loading overlays ─────────────────────────────── */}
@@ -470,11 +609,32 @@ export default function ReasoningWorkspace({
{/* Post-update acknowledgement */}
{updateStatus === "success" && canAnswer && <UpdateAcknowledgement updateResult={result} />}
{/* Completion state */}
{status === "success" && !canAnswer && graph && genuineCompletion && (
{/* Revision editor inline */}
{showRevisionEditor && investigationHistory[editingTurnIndex] && (
<RevisionEditor
turn={investigationHistory[editingTurnIndex]}
onSave={(draft) => saveRevision(draft)}
onCancel={closeRevisionEditor}
/>
)}
{/* Stale turns summary — shown after revision */}
{staleTurns.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400">
Earlier answers that may need reviewing
</h3>
{staleTurns.map((turn, idx) => (
<StaleTurnSummary key={turn.id} turn={turn} index={idx} />
))}
</div>
)}
{/* Completion state — suppressed during any loading or revision */}
{status === "success" && !canAnswer && graph && genuineCompletion && !isRevisionLoading && (
<InvestigationCompleteMessage noQuestionReason={noQuestionReason} />
)}
{status === "success" && !canAnswer && graph && !genuineCompletion && updateStatus !== "success" && (
{status === "success" && !canAnswer && graph && !genuineCompletion && updateStatus !== "success" && !isRevisionLoading && (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-4 text-center">
<p className="text-sm text-gray-600">There is no further question the engine can justify at the moment.</p>
<p className="mt-1 text-xs text-gray-500">More evidence may be needed before a next step is clear.</p>
@@ -522,7 +682,7 @@ export default function ReasoningWorkspace({
)}
{/* ── Investigation history (below the answer form) ─ */}
<InvestigationHistory turns={investigationHistory} />
<InvestigationHistory turns={activeTurns} onReviewTurn={openRevisionEditor} />
{/* ── Developer details (collapsed by default) ─── */}
{(status === "success" || status === "error") && graph && (
@@ -556,4 +716,4 @@ export default function ReasoningWorkspace({
);
}
export { useLoadingStatus, INITIAL_MESSAGES, UPDATE_MESSAGES, LoadingOverlay, resolveCurrentSummary, isTechnicalSummary };
export { useLoadingStatus, INITIAL_MESSAGES, UPDATE_MESSAGES, REVISION_MESSAGES, LoadingOverlay, resolveCurrentSummary, isTechnicalSummary };
+53 -6
View File
@@ -100,6 +100,16 @@ export function ScenarioResultPanels({ status, result }) {
);
}
// ── Spinner for revision loading overlay ─────────────────────
function RevisionSpinner() {
return (
<span
className="inline-block h-4 w-4 border-[2px] border-gray-300 border-t-amber-600 rounded-full"
style={{ animation: "spin 1s linear infinite" }}
/>
);
}
// ── Message pools ───────────────────────────────────────────
const INITIAL_MESSAGES = [
{ min: 0, text: "Reading your situation" },
@@ -115,6 +125,13 @@ const UPDATE_MESSAGES = [
{ min: 45, text: "Choosing the next question" },
];
const REVISION_MESSAGES = [
{ min: 0, text: "Reading what changed" },
{ min: 10, text: "Rebuilding the investigation" },
{ min: 25, text: "Checking what this affects" },
{ min: 45, text: "Choosing the next justified question" },
];
function useLoadingStatus(messages, isLoading) {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(null);
@@ -189,8 +206,12 @@ export default function ScenarioForm() {
const [updateError, setUpdateError] = useState(null);
const [updateResult, setUpdateResult] = useState(null);
const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState("");
const [isRevisionLoading, setIsRevisionLoading] = useState(false);
const [revisionTurnIndex, setRevisionTurnIndex] = useState(-1);
const textareaRef = useRef(null);
// Loading state tracking for normal update vs revision
const isAnyUpdateLoading = updateStatus === "loading" || isRevisionLoading;
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES,
status === "loading"
@@ -198,7 +219,12 @@ export default function ScenarioForm() {
const { elapsed: updateElapsed, currentMessage: updateMsg } = useLoadingStatus(
UPDATE_MESSAGES,
updateStatus === "loading"
updateStatus === "loading" && !isRevisionLoading
);
const { elapsed: revisionElapsed, currentMessage: revisionMsg } = useLoadingStatus(
REVISION_MESSAGES,
isRevisionLoading
);
const handleSubmit = async (e) => {
@@ -231,7 +257,7 @@ export default function ScenarioForm() {
}
};
const handleUpdate = async (e) => {
const handleUpdate = async (e, options = {}) => {
e.preventDefault();
// Guard empty answer before showing loading state
@@ -241,19 +267,25 @@ export default function ScenarioForm() {
return;
}
const isRevision = !!options.graphCheckpoint;
const turnIndex = options.turnIndex ?? revisionTurnIndex;
setUpdateStatus("loading");
setIsRevisionLoading(isRevision);
if (isRevision) setRevisionTurnIndex(turnIndex);
setUpdateError(null);
setLastSubmittedAnswer(answer.trim());
const submission = await submitAnswerForUpdateCase(fetch, {
situationGraph: result?.situationGraph,
previousQuestion: result?.selectedQuestion,
situationGraph: options.graphCheckpoint ?? result?.situationGraph,
previousQuestion: isRevision ? undefined : result?.selectedQuestion,
answer,
});
if (submission.skipped) {
setUpdateStatus("error");
setUpdateError(submission.data);
setIsRevisionLoading(false);
return;
}
@@ -261,10 +293,11 @@ export default function ScenarioForm() {
const outcome = submission.data;
if (submission.ok && outcome.success) {
if (isRevision) setIsRevisionLoading(false);
setUpdateStatus("success");
setUpdateResult({
...outcome,
previousSituationGraph: result?.situationGraph ?? null,
previousSituationGraph: options.graphCheckpoint ?? result?.situationGraph ?? null,
});
setResult((current) => ({
...current,
@@ -280,10 +313,12 @@ export default function ScenarioForm() {
setAnswer("");
} else {
setUpdateStatus("error");
setIsRevisionLoading(false);
setUpdateError(outcome);
}
} catch (err) {
setUpdateStatus("error");
setIsRevisionLoading(false);
setUpdateError({ error: err.message || "Network request failed" });
}
};
@@ -316,7 +351,7 @@ export default function ScenarioForm() {
</form>
{/* ── Initial analysis loading card ─────────────── */}
{status === "loading" && (
{status === "loading" && !isRevisionLoading && (
<LoadingOverlay
isLoading={true}
elapsed={startElapsed}
@@ -325,6 +360,17 @@ export default function ScenarioForm() {
/>
)}
{/* ── Revision loading card ─────────────────────── */}
{isRevisionLoading && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-5 py-6" role="status" aria-busy="true">
<div className="flex items-center gap-3">
<RevisionSpinner />
<span className="text-base font-medium text-amber-900">Considering your revised answer</span>
</div>
<p className="mt-2 text-sm text-amber-700">{revisionMsg}</p>
</div>
)}
{/* ── Main result workspace ─────────────────────── */}
{(status === "success" || status === "error" || updateStatus === "success") && (
<ReasoningWorkspace
@@ -342,6 +388,7 @@ export default function ScenarioForm() {
setAnswer={setAnswer}
onAnswerSubmit={handleUpdate}
lastSubmittedAnswer={lastSubmittedAnswer}
isRevisionLoading={isRevisionLoading}
/>
)}