diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index ed82bad6..bb3f1878 100644
--- a/viewer/openworlds/screen-table.jsx
+++ b/viewer/openworlds/screen-table.jsx
@@ -518,6 +518,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
const appHealth = appStatus?.health || {};
const appFailureBucket = appReadiness.failure_bucket || appHealth.failure_bucket || "";
const appFailureDetail = appReadiness.failure_detail || appHealth.failure_detail || "";
+ const surfaceStatusBlocksPlay = surfaceStatus !== "ready";
+ const surfaceStatusBlockReason = surfaceStatus === "loading"
+ ? "The live session surface is still loading."
+ : `Session surface unavailable: ${surfaceStatus}`;
const appStatusBlocksPlay = Boolean(
appStatus &&
appReadiness.ready_for_play === false &&
@@ -528,6 +532,8 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
? "No Dungeon Master provider is connected."
: "The live move lane is not ready."
);
+ const livePlayBlocked = surfaceStatusBlocksPlay || appStatusBlocksPlay;
+ const livePlayBlockReason = surfaceStatusBlocksPlay ? surfaceStatusBlockReason : appStatusBlockReason;
const loadSurface = React.useCallback(async (isCancelled = () => false) => {
const params = new URLSearchParams();
@@ -690,8 +696,8 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
const postMove = async (move, label, actionId) => {
const enabledAction = actionId ? enabledActionById(actionId) : null;
- if (!move || !canAct || appStatusBlocksPlay || pendingActive || (actionId && !enabledAction)) {
- toast({ kind: "danger", title: "Action unavailable", body: pendingActive ? "The Dungeon Master is still narrating — one move at a time." : appStatusBlocksPlay ? appStatusBlockReason : readOnlyReason });
+ if (!move || !canAct || livePlayBlocked || pendingActive || (actionId && !enabledAction)) {
+ toast({ kind: "danger", title: "Action unavailable", body: pendingActive ? "The Dungeon Master is still narrating — one move at a time." : livePlayBlocked ? livePlayBlockReason : readOnlyReason });
return;
}
if (submittingRef.current) return; // already submitting this turn — drop the rapid double-fire
@@ -776,13 +782,13 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
// Route the click to the right handler so a stuck turn actually retries instead of no-op'ing.
const onDeclareClick = () => (pendingStuck ? retryStuck() : sendAction());
const declareNeedsDraft = !pendingStuck && !draftText;
- const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || declareNeedsDraft;
+ const declareDisabled = !composerAction?.available || pendingActive || livePlayBlocked || declareNeedsDraft;
const declareTitle = !composerAction?.available
? `${composerMode.label} is unavailable: ${composerAction?.disabled_reason || readOnlyReason}`
: pendingActive
? (pendingFirstBeat ? "The Dungeon Master is composing your opening scene." : "The Dungeon Master is still narrating.")
- : appStatusBlocksPlay
- ? appStatusBlockReason
+ : livePlayBlocked
+ ? livePlayBlockReason
: pendingStuck
? "Re-send your last action to the Dungeon Master, or type a new one first."
: declareNeedsDraft
@@ -792,8 +798,8 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
? `${composerMode.label} unavailable`
: pendingActive
? "Wait for the Dungeon Master before declaring"
- : appStatusBlocksPlay
- ? "Start or resume provider before declaring"
+ : livePlayBlocked
+ ? "Reconnect live session before declaring"
: pendingStuck
? "Try action again"
: declareNeedsDraft
@@ -1026,7 +1032,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
actionId={a.id}
selected={COMPOSER_MODE_BY_UI[a.ui] === composerModeId}
tone={a.available ? "" : "crimson"}
- disabled={!a.available || pendingActive || appStatusBlocksPlay}
+ disabled={!a.available || pendingActive || livePlayBlocked}
onClick={() => invokeAction(a)}
/>
))}
@@ -1047,7 +1053,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
hint={ACTION_HINTS[a.id]}
actionId={a.id}
tone={a.available ? "royal" : "crimson"}
- disabled={!a.available || pendingActive || appStatusBlocksPlay}
+ disabled={!a.available || pendingActive || livePlayBlocked}
onClick={() => invokeAction(a)}
/>
))}
@@ -1075,10 +1081,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
{/* #337: dice buttons explain themselves on hover — a newbie didn't know d20/d12/d8/d6 ask the DM for a check. */}
-
-
-
-
+
+
+
+
{/* #337: one-line hint under the action bar so a first-timer knows free-text + Declare is the core loop, distinct from the quick-action buttons. */}
@@ -1092,9 +1098,9 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onDeclareClick()}
- disabled={pendingActive || appStatusBlocksPlay}
+ disabled={pendingActive || livePlayBlocked}
title={composerMode.inputTitle || DECLARE_HINT}
- placeholder={pendingFirstBeat ? "The Dungeon Master is composing your opening scene…" : pendingActive ? "The Dungeon Master is narrating…" : pendingStuck ? "The DM seemed stuck — try again." : appStatusBlocksPlay ? "Start or resume a DM provider to play…" : (canAct ? composerMode.placeholder : `Read-only: ${readOnlyReason}`)}
+ placeholder={pendingFirstBeat ? "The Dungeon Master is composing your opening scene…" : pendingActive ? "The Dungeon Master is narrating…" : pendingStuck ? "The DM seemed stuck — try again." : livePlayBlocked ? "Reconnect the live session to play…" : (canAct ? composerMode.placeholder : `Read-only: ${readOnlyReason}`)}
style={{ ...inkInput, fontFamily: "var(--f-body)", fontSize: 16, opacity: pendingActive ? 0.6 : 1 }}
/>
{pendingFirstBeat ? "Composing…" : pendingActive ? "Narrating…" : pendingStuck ? "Try again" : "Declare"}
diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py
index 4cc44de5..f4324376 100644
--- a/viewer/tests/test_openworlds_static.py
+++ b/viewer/tests/test_openworlds_static.py
@@ -788,10 +788,13 @@ def test_openworlds_table_blocks_moves_when_app_status_play_lane_not_ready(self)
self.assertIn("appReadiness.ready_for_play === false", source)
self.assertIn("Start or resume a provider-backed session from Chronicles", source)
self.assertIn("data-worldos-status-scope=\"app-status\"", source)
- self.assertIn("appStatusBlocksPlay ? appStatusBlockReason : readOnlyReason", source)
- self.assertIn("disabled={!a.available || pendingActive || appStatusBlocksPlay}", source)
- self.assertIn("disabled={pendingActive || appStatusBlocksPlay}", source)
- self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || declareNeedsDraft", source)
+ self.assertIn("const surfaceStatusBlocksPlay = surfaceStatus !== \"ready\"", source)
+ self.assertIn("const livePlayBlocked = surfaceStatusBlocksPlay || appStatusBlocksPlay", source)
+ self.assertIn("livePlayBlocked ? livePlayBlockReason : readOnlyReason", source)
+ self.assertIn("disabled={!a.available || pendingActive || livePlayBlocked}", source)
+ self.assertIn("disabled={pendingActive || livePlayBlocked}", source)
+ self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || livePlayBlocked || declareNeedsDraft", source)
+ self.assertIn('"Reconnect live session before declaring"', source)
self.assertIn("disabled={declareDisabled}", source)
def test_openworlds_table_bounds_and_anchors_the_chronicle(self):
@@ -927,14 +930,15 @@ def test_openworlds_table_action_buttons_select_declare_mode(self):
self.assertIn("kind: composerMode.kind", source)
self.assertIn("composerMode.placeholder", source)
self.assertIn("const declareNeedsDraft = !pendingStuck && !draftText", source)
- self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || appStatusBlocksPlay || declareNeedsDraft", source)
+ self.assertIn("const declareDisabled = !composerAction?.available || pendingActive || livePlayBlocked || declareNeedsDraft", source)
self.assertIn('title={declareTitle}', source)
self.assertIn('ariaLabel={declareAriaLabel}', source)
self.assertIn('!composerAction?.available', source)
self.assertIn('pendingActive', source)
self.assertIn('appStatusBlocksPlay', source)
+ self.assertIn('livePlayBlocked', source)
self.assertIn('"Wait for the Dungeon Master before declaring"', source)
- self.assertIn('"Start or resume provider before declaring"', source)
+ self.assertIn('"Reconnect live session before declaring"', source)
def test_openworlds_table_immediate_actions_reset_stale_composer_mode(self):
# Fresh-player blocker: if Say was selected, clicking an immediate action like Continue