fix(webapp): stop swallowing errors, and keep the recording page honest when the connection drops (#377) #529

Merged
claude-bot merged 11 commits from fix/377-silent-errors into main 2026-09-06 07:40:33 +00:00
Contributor

Closes #377. The last cross-cutting item of the v4.3.0 build order (#514), run after every lane settled. Frontend only, ten commits.

Every site in the audit now shows a visible signal on failure. Site 3 (the proposal fetches on CampaignDetail and CampaignStoryline) needed nothing; #375 had already fixed both with a shared "Couldn't load suggested wiki updates." state, verified on main.

One small reusable InlineError (components/InlineError.jsx: message, optional retry, optional boxed look) carries eleven of them; CampaignPlanning's private copy, which was the prototype, is folded into it.

The recording page gets a state machine. fetchRecordingLive rejecting used to set {active: false}, which is the same value the server sends when nothing is recording, so a dropped connection mid-session redrew the page as "No active recording", idle, with a Start button, while the bot was still capturing. live now only ever holds something the server actually said, a separate connection state (connecting | ok | lost) says whether we are still hearing from it, and a failed read raises "Connection lost, retrying… Showing the last reading, from Xs ago." over the last known state instead of replacing it. Any successful read or SSE event clears it; a page that has never had an answer says "Unknown" rather than picking the value that invites a second recording of the same evening. The SSE onerror does not itself mark the connection lost (it re-reads first), so a stream that merely cycles does not flicker a banner. The players' table view gets the same indicator for its polling.

Retry affordances: the vote grid re-sends the exact vote that failed; the shelf's position save re-reads the card's current position, since by the time anyone clicks Try again the card has usually moved again. CampaignPlanning's prefetch error is rendered with what it actually means (name suggestions were not pre-generated; the tools still work). Title suggestions, the cold-open panel, and the session page's attendance-proposal and loot reads each show a quiet inline line with retry. "Check for link suggestions" now says "No link suggestions found" (or that the rest were dismissed); the suggestions are computed locally, so this was empty-result feedback rather than an error. A real failure to load the game-system schema says so in the stat block, distinguished from a 404 that legitimately means "this system has no schema".

Two notes where the audit and the code differed: the vote grid never "silently reverted" a cell (it is not optimistic, so a rejected vote produced no DOM change at all), and ColdOpenPanel is still mounted only from the GM's shelf.

vitest 696 passed (59 files, +29), eslint clean apart from the pre-existing CampaignDetail warning, vite build green. No backend change, no migration.

🤖 Generated with Claude Code

Closes #377. The last cross-cutting item of the v4.3.0 build order (#514), run after every lane settled. Frontend only, ten commits. Every site in the audit now shows a visible signal on failure. Site 3 (the proposal fetches on `CampaignDetail` and `CampaignStoryline`) needed nothing; #375 had already fixed both with a shared "Couldn't load suggested wiki updates." state, verified on main. One small reusable `InlineError` (`components/InlineError.jsx`: message, optional retry, optional boxed look) carries eleven of them; `CampaignPlanning`'s private copy, which was the prototype, is folded into it. **The recording page gets a state machine.** `fetchRecordingLive` rejecting used to set `{active: false}`, which is *the same value* the server sends when nothing is recording, so a dropped connection mid-session redrew the page as "No active recording", idle, with a Start button, while the bot was still capturing. `live` now only ever holds something the server actually said, a separate `connection` state (`connecting | ok | lost`) says whether we are still hearing from it, and a failed read raises "Connection lost, retrying… Showing the last reading, from Xs ago." over the last known state instead of replacing it. Any successful read or SSE event clears it; a page that has never had an answer says "Unknown" rather than picking the value that invites a second recording of the same evening. The SSE `onerror` does not itself mark the connection lost (it re-reads first), so a stream that merely cycles does not flicker a banner. The players' table view gets the same indicator for its polling. Retry affordances: the vote grid re-sends the exact vote that failed; the shelf's position save re-reads the card's *current* position, since by the time anyone clicks Try again the card has usually moved again. `CampaignPlanning`'s prefetch error is rendered with what it actually means (name suggestions were not pre-generated; the tools still work). Title suggestions, the cold-open panel, and the session page's attendance-proposal and loot reads each show a quiet inline line with retry. "Check for link suggestions" now says "No link suggestions found" (or that the rest were dismissed); the suggestions are computed locally, so this was empty-result feedback rather than an error. A real failure to load the game-system schema says so in the stat block, distinguished from a 404 that legitimately means "this system has no schema". Two notes where the audit and the code differed: the vote grid never "silently reverted" a cell (it is not optimistic, so a rejected vote produced no DOM change at all), and `ColdOpenPanel` is still mounted only from the GM's shelf. vitest 696 passed (59 files, +29), eslint clean apart from the pre-existing `CampaignDetail` warning, `vite build` green. No backend change, no migration. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Twelve places in the app treated a failed request as nothing at all: a
console.error, a `.catch(() => {})`, a panel that simply never appeared.
The fix at each of them is the same shape — a short red line beside the
control that failed, plus a button to try again where repeating the
action is the obvious next move — so it is one component rather than
twelve near-identical divs.

Both looks it offers were already in the tree: plain red text for a
failure inside a panel (the wiki lane's #395 errors) and `boxed` for one
that owns the top of a page. It renders nothing for a null message, so
callers hand it their error state without guarding first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The availability grid never renders a vote optimistically — the cell is
redrawn from the server's answer — so when `submitVote` or `deleteVote`
rejected, the only trace was a `console.error` and a cell that stayed
exactly as it was. A vote the backend refused (voting closed, the slot
gone, the session no longer in vote mode) was therefore indistinguishable
from a click that had missed the button, and the player's usual response
is to click it again, which fails again, silently.

A failed vote now says so under the grid, naming the slot it was for and
quoting the backend's reason, with a "Try again" that re-sends the same
intent rather than re-deriving it from state the failure never changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Dropping a card after a drag or a resize wrote its geometry back with
`.catch(() => {})`. The card is moved locally before the request goes
out, so a failed save looked exactly like a successful one — the board
was right, and stayed right, until the next reload replayed the server's
copy and put every card back where it had been. A GM arranging a board
between scenes had no way to know the arrangement was being thrown away.

The save now reports a failure above the board, names the card, and
offers to send it again. The retry re-reads that card's position off the
live board rather than replaying the one that failed, because by the
time anyone clicks it the card has usually been nudged again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Opening Prep fires a background job that pre-generates name suggestions
for all thirteen categories, and the page kept three states for it:
idle, queued and error. Only "queued" was ever rendered. The commonest
way for that call to fail is a 503 — no LLM endpoint configured — which
also means every generative tool on the page is about to fail, and the
GM was told none of it: the header simply stayed blank, exactly as it
does before the call returns.

The error now renders above the tool panel, with what the backend said
and what it means for the page: everything still works, the first thing
that asks the model is just slower. This page's private `InlineError`
became the shared one at the same time, since it was the prototype for
it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both of these are deliberately quiet components that render nothing when
there is nothing to say, and both used that as the answer to a failed
request as well.

Title suggestions on the new-session form emptied the list on any error,
which for a player removed the component outright and for a GM left a
lone "Regenerate" — the same screen a campaign that has never generated
any titles gets. It now says it could not read them, and offers to look
again.

The shelf's cold open swallowed its fetch "by design", so "there is no
last session to recap" and "we could not ask about the last session"
were the same blank strip. A failure now gets one line in the strip and
a retry, and no dismiss button, since there is nothing there to get out
of the way of. It goes as soon as a read succeeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The attendance auto-fill banner and the "Loot & XP this session" panel
are both conditional on having something to show, and both caught their
read with a comment saying the panel was best-effort. That makes a
failed request indistinguishable from a true empty: a GM saw a recording
that recognised nobody, and a session where nothing was awarded — two
statements about the evening that were never checked.

Each now reports in its own area, with a retry: the attendance note
inside the Attendance section (which renders on exactly the conditions
the proposals read runs under), and the loot note in the Loot & XP panel,
which now appears when the read failed as well as when it returned
something.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The live panel had two states where the world has three. A failed
`fetchRecordingLive` — the fallback the page falls back to every time the
event stream drops — set `{ active: false }`, which is the same value the
server sends when nothing is being recorded. So a GM whose wifi blinked
during a session watched the page redraw itself as "No active recording",
"Idle", no speakers, and a Start Recording button, while the bot went on
capturing in Discord. The obvious response to that screen starts a second
recording of the same evening.

`live` now only ever holds something the server actually said, and a new
`connection` state says whether we are still hearing from it. A failed
read leaves the last known state exactly where it was and raises
"Connection lost, retrying…" over it, with how long ago that state was
true, so a two-second blip reads differently from a page that has been
frozen for ten minutes. Any successful read or stream event clears it.
Where nothing has ever been read the page says "Unknown" rather than
picking the answer that is dangerous to be wrong about.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The table view polls for reveals every eight seconds and dropped any
failure after the first load on the floor. That is the right call for the
list itself — swapping a working table for a red error mid-scene is
worse than a stale one — but it left the page with no way to distinguish
"your GM has not revealed anything for a while" from "this tab stopped
being able to ask half an hour ago". Players keep this open on a second
screen for exactly the moments when nobody is looking at it closely.

The last good list still stands; a failing poll now puts "Connection
lost, retrying…" in the header, and the next success clears it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two ways an article editor went quiet.

"Check for link suggestions" computes its answer locally, and its
commonest successful answer is an empty list — at which point it rendered
no change whatsoever, which is indistinguishable from a button wired to
nothing. It now says "No link suggestions found", or, when every
suggestion has already been dismissed, says that instead. Editing the
body clears the answer, because it is no longer about what is in the box.

The infobox's stat block falls back to raw field keys — "armor_class",
"hp_max" — when the campaign's game system has no stat schema, which is
correct for the generic system and looks like a bug for everyone else. A
schema read that actually failed took the same path with nothing to
explain it, so the fallback now says so where the stats render. A 404 is
still silent: "this system has no schema" is an answer, not a failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs(changelog): record the silent-failure fixes (#377)
Some checks failed
CI / Docker image build (pull_request) Successful in 38s
CI / Bot/backend version sync (pull_request) Successful in 41s
CI / Backend lint (ruff) (pull_request) Successful in 42s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 54s
CI / Frontend tests, audit, and build (pull_request) Failing after 1m42s
CI / Bot tests and audit (pull_request) Successful in 1m50s
CI / Backend migration, tests, and audit (pull_request) Successful in 7m49s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
b459f3ef18
One entry for the whole of #377, leading with the recording page,
because that is the one a GM meets in the middle of a session and the
only one where the wrong reading invites a second recording of the
same evening.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-06 07:05:54 +00:00
test(frontend): give the draft-poll tests room for a loaded runner (#395)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 48s
CI / Docker image build (pull_request) Successful in 42s
CI / Bot/backend version sync (pull_request) Successful in 48s
CI / Backend lint (ruff) (pull_request) Successful in 54s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m55s
CI / Bot tests and audit (pull_request) Successful in 2m24s
CI / Backend migration, tests, and audit (pull_request) Successful in 7m6s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 17m49s
d520c21f81
The give-up check runs on the poll's real 2 s interval tick; under CI load
that tick arrived after the 6 s wait and failed an unrelated PR. The waits
are now 20 s with a 30 s test budget, still without faking the interval.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude-bot deleted branch fix/377-silent-errors 2026-09-06 07:40:33 +00:00
Sign in to join this conversation.
No description provided.