diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index dcc44e17..98680565 100644
--- a/viewer/openworlds/screen-table.jsx
+++ b/viewer/openworlds/screen-table.jsx
@@ -215,6 +215,13 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
const recordPlayerEcho = session.recordPlayerEcho;
const pendingActive = Boolean(pending && !pending.stuck);
const pendingStuck = Boolean(pending && pending.stuck);
+ // #344: remember the move that is currently in flight so the "Try again" recovery (shown when a
+ // turn goes `stuck`) can actually RE-POST it. The first submit clears the input box (setInput("")),
+ // so by the time the bar re-opens stuck the box is empty — without the original move stored, the
+ // old "Try again" path (sendAction → input.trim() → empty → early-return) was a silent no-op. We
+ // keep the canonical move object + its label here, set on every postMove, so a retry resends the
+ // exact stalled turn (free-text, roll, or structured action alike).
+ const lastMoveRef = React.useRef(null);
// #342: surface the recovery exactly once when a turn goes `stuck` so the player knows the bar
// re-opened on purpose (the DM stalled) rather than silently.
@@ -241,6 +248,9 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
: move;
const rawLabel = label || cleanMove.text || cleanMove.name || "declares an action";
const text = window.neutralizeMarkup(String(rawLabel)) || "declares an action";
+ // #344: capture the (already-neutralized) move + label + actionId so a later "Try again"
+ // recovery can re-POST this exact turn if the DM stalls on it.
+ lastMoveRef.current = { move: cleanMove, label: text, actionId };
try {
const response = await fetch(writeLane.endpoint || "/move", {
method: "POST",
@@ -272,6 +282,33 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
setInput("");
};
+ // #344: the recovery action for a `stuck` turn. The old "Try again" reused sendAction, which reads
+ // the (now-empty) input box and early-returned — a dead button. This handler resends the stalled
+ // turn: if the player typed something new into the re-opened box we send THAT (a rephrase, the
+ // toast's other option); otherwise we re-POST the exact move that stalled. Either way the bar
+ // re-arms via postMove → armPending, so the turn goes back to "narrating" and the recovery clears.
+ const retryStuck = async () => {
+ const typed = input.trim();
+ if (typed) {
+ // Player rephrased — send the new text as a fresh `do` (this path already worked).
+ await sendAction();
+ return;
+ }
+ const last = lastMoveRef.current;
+ if (!last || !last.move) {
+ // Nothing captured to retry (shouldn't happen for a stuck turn) — nudge the player to type.
+ toast({ kind: "danger", title: "Nothing to retry", body: "Type your action again, then press Try again." });
+ inputRef.current?.focus();
+ return;
+ }
+ // Re-POST the stalled move verbatim. postMove re-arms pending + the recovery/backstop timers.
+ await postMove(last.move, last.label, last.actionId);
+ };
+
+ // #344: the Declare button doubles as the stuck-recovery "Try again" button (same slot, relabeled).
+ // Route the click to the right handler so a stuck turn actually retries instead of no-op'ing.
+ const onDeclareClick = () => (pendingStuck ? retryStuck() : sendAction());
+
const requestRoll = (sides = 20) => {
const action = actionById("check");
if (!action?.available) {
@@ -406,13 +443,13 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && sendAction()}
+ onKeyDown={(e) => e.key === "Enter" && onDeclareClick()}
disabled={pendingActive}
title={DECLARE_HINT}
placeholder={pendingActive ? "The Dungeon Master is narrating…" : pendingStuck ? "The DM seemed stuck — try again." : (canAct ? "Describe what your hero does..." : `Read-only: ${readOnlyReason}`)}
style={{ ...inkInput, fontFamily: "var(--f-body)", fontSize: 16, opacity: pendingActive ? 0.6 : 1 }}
/>
- {pendingActive ? "Narrating…" : pendingStuck ? "Try again" : "Declare"}
+ {pendingActive ? "Narrating…" : pendingStuck ? "Try again" : "Declare"}