Keep every retried attempt instead of deleting it

Retry used to delete the last AI action and generate a replacement, so the
discarded narration was simply gone. The row survives now: each attempt is
appended to actions.variants with variant_index naming the live one, and a
ChatGPT-style pager under the message browses them.

Action.text still mirrors the active variant, so the context builder, memory
bank, summarizer and export needed no changes. A variant carries only what
differs between attempts -- the text, the reasoning, and the state it
produced -- never the assembled prompt, which is identical across attempts of
one turn and is the bulk of context_snapshot.

Only the last message can be switched, restoring the script/world state that
attempt produced; earlier turns were written as a continuation of whatever is
active there, so theirs are read-only previews.

Three things that would otherwise bite:

- generate_turn now wraps _generate_turn and watches for a save sentinel. If
  the generator ends without it (provider error, empty reply, script stop,
  client hangup) it re-applies the previous variant -- otherwise a failed
  retry leaves rolled-back stats under un-rolled-back text.
- Retry reuses the turn's own index rather than next_index, or the clock the
  world-state cooldowns run on advances on a re-run of the same turn.
- Editing a message rewrites the active variant too, or paging away and back
  silently reverts the edit.

Migrations 34/35 verified as an upgrade against a populated database, not
just a fresh schema. test_state_revert's retry test asserted the old delete
behaviour and was rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
This commit is contained in:
parththakkar106
2026-08-02 20:44:06 +05:30
co-authored by Claude Opus 5
parent be6dbb0f24
commit 906ba423d8
9 changed files with 734 additions and 22 deletions
+8
View File
@@ -90,6 +90,14 @@ export const api = {
request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }),
deleteAction: (advId, actionId) =>
request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }),
// Retry history. The adventure payload carries only the counts, so the
// attempts themselves are fetched when the reader actually pages through.
listVariants: (advId, actionId) =>
request(`/adventures/${advId}/actions/${actionId}/variants`),
selectVariant: (advId, actionId, index) =>
request(`/adventures/${advId}/actions/${actionId}/variant`, {
method: 'POST', body: JSON.stringify({ index }),
}),
sendAction: (advId, payload, handlers, signal) =>
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
+37
View File
@@ -392,6 +392,43 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
}
.story .action-tools button:hover { color: var(--text); }
/* Retry-history pager under an AI beat. Stays quiet until hovered — it's a
footnote on the message, not part of the prose. */
.variant-pager {
display: flex;
align-items: center;
gap: 4px;
margin-top: 6px;
font-family: var(--font-ui);
opacity: 0.45;
transition: opacity 0.15s;
}
.story .action:hover .variant-pager,
.variant-pager:focus-within { opacity: 1; }
.variant-pager button {
padding: 0 7px;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-dim);
background: transparent;
border: 1px solid transparent;
}
.variant-pager button:hover:not(:disabled) { color: var(--accent-bright); border-color: var(--border); }
.variant-pager button:disabled { opacity: 0.35; cursor: default; }
.variant-count {
font-size: 0.74rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
min-width: 30px;
text-align: center;
}
.variant-note {
margin-left: 4px;
font-size: 0.72rem;
font-style: italic;
color: var(--accent-dim);
}
.action-edit { margin-bottom: 14px; }
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
several paragraphs, and the old fixed 110px turned that into a keyhole.
+82 -3
View File
@@ -920,6 +920,59 @@ function WorldStateDrawer({ advId, refreshKey }) {
)
}
// ChatGPT-style ‹ 2/3 › pager under an AI message that has been retried.
//
// The last message can actually be switched (the server restores the stats that
// attempt produced); earlier ones are read-only, because the turns after them
// were written as a continuation of whatever is active now. Browsing an
// earlier one is a local preview, so `onPreview` hands the text up to the story
// renderer rather than the pager drawing it.
function VariantPager({ advId, action, isLast, busy, previewIndex, onPreview, onSwitched, onError }) {
const [variants, setVariants] = useState(null)
const [loading, setLoading] = useState(false)
const count = action.variant_count
const current = previewIndex ?? action.variant_index
async function go(delta) {
const next = current + delta
if (next < 0 || next >= count || loading || busy) return
setLoading(true)
try {
if (isLast) {
onPreview(null)
onSwitched(await api.selectVariant(advId, action.id, next))
} else {
// Fetched once per message, then cached — paging back and forth
// shouldn't re-hit the server.
const list = variants || await api.listVariants(advId, action.id)
if (!variants) setVariants(list)
onPreview(next === action.variant_index
? null
: { actionId: action.id, index: next, text: list[next].text, reasoning: list[next].reasoning })
}
} catch (err) {
onError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="variant-pager">
<button type="button" onClick={() => go(-1)} disabled={current === 0 || busy || loading}
title="Previous attempt">‹</button>
<span className="variant-count">{current + 1}/{count}</span>
<button type="button" onClick={() => go(1)} disabled={current === count - 1 || busy || loading}
title="Next attempt">›</button>
{previewIndex !== null && (
<span className="variant-note">
earlier attempt — the story continued from {action.variant_index + 1}
</span>
)}
</div>
)
}
// Compact chips shown under an AI message summarizing what state changed.
function StateChangeChips({ changes }) {
if (!changes?.length) return null
@@ -1118,6 +1171,9 @@ export default function Play() {
// (currently "Update from scenario"), which no action count would reflect.
const [stateKey, setStateKey] = useState(0)
const [inspectActionId, setInspectActionId] = useState(null)
// Read-only browsing of an earlier attempt at a past turn (see VariantPager).
// One at a time; null when every message is showing its active version.
const [preview, setPreview] = useState(null)
const storyEndRef = useRef(null)
const abortRef = useRef(null)
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
@@ -1243,6 +1299,7 @@ export default function Play() {
}
function retry() {
setPreview(null)
setActions((prev) =>
prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev)
runTurn(async (signal) => {
@@ -1259,6 +1316,7 @@ export default function Play() {
async function undo() {
setToast(null)
setPreview(null)
try {
setActions(await api.undo(id))
} catch (err) {
@@ -1348,6 +1406,9 @@ export default function Play() {
// Drop cap goes on the first narrated beat only. `firstNarrationId`
// is derived once above rather than per row.
const opening = action.id === firstNarrationId
// Non-null while the reader is browsing an older attempt of this
// message without making it active (earlier turns only).
const previewing = preview?.actionId === action.id ? preview : null
return editing?.id === action.id ? (
<div key={action.id} className="action-edit">
@@ -1365,9 +1426,27 @@ export default function Play() {
<Fragment key={action.id}>
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
<div className={`action ${isPlayer ? 'player' : ''}${opening ? ' opening' : ''}`}>
<ReasoningBlock text={action.reasoning} />
{renderEmphasis(action.text)}
{action.type === 'ai' && <StateChangeChips changes={action.world_changes} />}
<ReasoningBlock text={previewing ? previewing.reasoning : action.reasoning} />
{renderEmphasis(previewing ? previewing.text : action.text)}
{/* The chips describe the *active* attempt's state changes,
which a previewed one didn't make — so they're hidden
rather than shown against the wrong text. */}
{action.type === 'ai' && !previewing && (
<StateChangeChips changes={action.world_changes} />
)}
{action.type === 'ai' && action.variant_count > 1 && (
<VariantPager
advId={id}
action={action}
isLast={i === actions.length - 1}
busy={busy}
previewIndex={previewing ? previewing.index : null}
onPreview={setPreview}
onSwitched={(updated) =>
setActions((prev) => prev.map((a) => (a.id === updated.id ? updated : a)))}
onError={(message) => setToast({ text: message, isError: true })}
/>
)}
{!busy && (
<span className="action-tools">
{action.type === 'ai' && (