feat: allow earlier evidence to be revised
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user