diff --git a/README.md b/README.md index d90b7666..51816a39 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ New create/join requests save a private, tab-scoped retry record before sending. Normal creation/join shows brief progress; recovery instructions stay open when attention is needed. Completed-action notices wait for **Continue** so their text -can be read at your pace. See [readable notices](docs/READABLE_NOTICES.md). +can be read at your pace. Seat recovery and handover confirmations also wait for +**Continue**; admin and seat-control requests show recovery instructions only +when needed. See [readable notices](docs/READABLE_NOTICES.md). An uncertain saved-kit recovery keeps its exact retry details in memory and prevents conflicting navigation. If that recovery has become obsolete, deliberate abandonment releases the controls after explaining that it cannot undo a completed recovery. Keep a valid saved kit before discarding retry details. diff --git a/app/page.tsx b/app/page.tsx index 53e9a5ea..5a4a5570 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { TableTalk } from '@/components/table-talk'; import { RemovedRoomNotice } from '@/components/removed-room-notice'; import { GameTable } from '@/components/game-table'; +import { SeatConfirmation, type SeatConfirmationKind } from '@/components/seat-confirmation'; import { RulesetControls } from '@/components/ruleset-controls'; import { SeatHandoverSetup, @@ -78,6 +79,7 @@ export default function Home() { const [name, setName] = useState(''); const [code, setCode] = useState(''); const [notice, setNotice] = useState(''); + const [seatConfirmation, setSeatConfirmation] = useState(null); const seatClaimUncertain = useRef(false); const [claimUncertain, setClaimUncertain] = useState(false); const pendingEntry = useRef(null); @@ -494,6 +496,7 @@ export default function Home() { setBotRecovery(false); setConnection(''); setGame(null); + setSeatConfirmation(null); setRemovedRoom(null); setRestoreFailed(false); setNotice(''); @@ -627,7 +630,7 @@ export default function Home() { setBusy(pending); } - function restoredSeat(view: GameView, claim = false) { + function restoredSeat(view: GameView, claim = false, confirmation?: SeatConfirmationKind) { if (removedRooms.current.has(view.code)) return false; if ( !claim && @@ -635,6 +638,7 @@ export default function Home() { ) return; if (claim) { + setSeatConfirmation(confirmation ?? null); setHandoverPending(true); setAiPermissionPending(true); activeRoom.current = view.code; @@ -701,7 +705,7 @@ export default function Home() { window.location.reload()} onRestored={(view) => { - if (restoredSeat(view, true)) setShowHandover(false); + if (restoredSeat(view, true, 'handover')) setShowHandover(false); }} /> @@ -709,6 +713,9 @@ export default function Home() { if (game) return ( <> + {seatConfirmation && !roomUnavailable &&
+ setSeatConfirmation(null)} /> +
} {roomUnavailable ?
void reconnect()} onExit={exitTable} exitDisabled={handoverPending || aiPermissionPending || claimUncertain} />
: restoredSeat(view, true)} + onRestored={(view) => restoredSeat(view, true, 'recovery')} /> )} @@ -1148,7 +1155,7 @@ export default function Home() { disabled={busy || needsReconcile || checkingConnection} onPending={controlPending} onUncertain={controlUncertain} - onRestored={(view) => restoredSeat(view, true)} + onRestored={(view) => restoredSeat(view, true, 'recovery')} /> - : pending ?
+
: initialRequestPending ? null : pending ?

Saved request for {code}: {describeAdminLobbyAction(pending.action, lobby)}.

Reason: {pending.reason}

The exact request stays in this tab across refresh. Retrying confirms an already completed change without applying it again, including after the game has started.

diff --git a/components/admin-room-archive.tsx b/components/admin-room-archive.tsx index cbe84356..a96ad3dd 100644 --- a/components/admin-room-archive.tsx +++ b/components/admin-room-archive.tsx @@ -23,6 +23,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate const [storageProblem, setStorageProblem] = useState(false); const [notice, setNotice] = useState(''); const [busy, setBusy] = useState(false); + const [initialRequestPending, setInitialRequestPending] = useState(false); const [loading, setLoading] = useState(true); const alive = useRef(false), inFlight = useRef(false); const title = useRef(null); @@ -69,7 +70,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate input = saved ?? newAdminArchiveRequest(room!, !room!.archived, reason); saveAdminArchiveRequest(window.sessionStorage, accountId, code, input); } catch (error) { setNotice(message(error)); return; } - inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.archived ? 'Archiving room…' : 'Unarchiving room…'); + inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.archived ? 'Archiving room…' : 'Unarchiving room…'); try { const result = adminArchiveConfirmation(await requestJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), @@ -96,7 +97,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate if (alive.current) setRoom(current); } catch { /* Preserve the original outcome and saved retry. */ } } - } finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); } + } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } } function discardUnreadableRecord() { @@ -121,7 +122,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate

No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.

-
: pending ?
+
: initialRequestPending ? null : pending ?

Saved request: {pending.archived ? 'Archive' : 'Unarchive'} room {code}.

Reason: {pending.reason}

The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again; the current archive status may differ after another administrator’s action.

After refresh or sign-in, choose All rooms in both the Directory and Archive filters to find the room and reopen these controls.

diff --git a/components/admin-room-closure.tsx b/components/admin-room-closure.tsx index bd09b318..d353ec9a 100644 --- a/components/admin-room-closure.tsx +++ b/components/admin-room-closure.tsx @@ -23,6 +23,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate const [storageProblem, setStorageProblem] = useState(false); const [notice, setNotice] = useState(''); const [busy, setBusy] = useState(false); + const [initialRequestPending, setInitialRequestPending] = useState(false); const [loading, setLoading] = useState(true); const alive = useRef(false), inFlight = useRef(false); const title = useRef(null); @@ -69,7 +70,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate input = saved ?? newAdminClosureRequest(room!, !room!.closed, reason); saveAdminClosureRequest(window.sessionStorage, accountId, code, input); } catch (error) { setNotice(message(error)); return; } - inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.closed ? 'Closing room…' : 'Reopening room…'); + inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.closed ? 'Closing room…' : 'Reopening room…'); try { const result = adminClosureConfirmation(await requestJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), @@ -96,7 +97,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate if (alive.current) setRoom(current); } catch { /* Preserve the original outcome and saved retry. */ } } - } finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); } + } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } } function discardUnreadableRecord() { @@ -121,7 +122,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate

No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a change already saved on the server.

-
: pending ?
+
: initialRequestPending ? null : pending ?

Saved request: {pending.closed ? 'Close' : 'Reopen'} room {code}.

Reason: {pending.reason}

The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.

After refreshing the page or signing in again, choose All rooms in both the Directory and Archive filters to find this room and reopen these controls.

diff --git a/components/admin-room-controls.tsx b/components/admin-room-controls.tsx index a1fea3f1..891089dd 100644 --- a/components/admin-room-controls.tsx +++ b/components/admin-room-controls.tsx @@ -34,6 +34,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat const [storageProblem, setStorageProblem] = useState(false); const [notice, setNotice] = useState(''); const [busy, setBusy] = useState(false); + const [initialRequestPending, setInitialRequestPending] = useState(false); const [loading, setLoading] = useState(true); const alive = useRef(false); const title = useRef(null); @@ -84,7 +85,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat input = saved ?? newAdminRoomRequest(control, paused, joinLocked, reason); saveAdminRoomRequest(window.sessionStorage, accountId, code, input); } catch (error) { setNotice(message(error)); return; } - inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving room settings…'); + inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving room settings…'); try { const result = await requestJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), @@ -116,7 +117,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat } catch { /* Preserve the original outcome and retry instructions. */ } } } - } finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); } + } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } } function discardUnreadableRecord() { @@ -141,7 +142,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat

No new changes can be sent until this tab’s unreadable retry record is resolved. Removing it does not cancel a change already saved on the server.

-
: pending ?
+
: initialRequestPending ? null : pending ?

Saved request: {pending.paused ? 'pause' : 'resume'} room {code}; {pending.joinLocked ? 'lock' : 'open'} new joins.

Reason: {pending.reason}

The exact request is kept in this tab across refresh. Retrying confirms an already completed operation without applying it again.

diff --git a/components/admin-room-creation.tsx b/components/admin-room-creation.tsx index 4dba9e63..f4612b24 100644 --- a/components/admin-room-creation.tsx +++ b/components/admin-room-creation.tsx @@ -28,6 +28,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB const [notice, setNotice] = useState(''); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); + const [initialRequestPending, setInitialRequestPending] = useState(false); const alive = useRef(false), inFlight = useRef(false); const heading = useRef(null); @@ -51,7 +52,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB input = saved ?? newAdminRoomCreationRequest({ name, faction, advanced, techTokens, strongholdCards, bots, reason }); saveAdminRoomCreation(window.sessionStorage, accountId, input); } catch (error) { setNotice(message(error)); return; } - inFlight.current = true; setBusy(true); onBusyChange(true); + inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setRecord({ kind: 'pending', input }); setNotice('Creating your room…'); try { const result = await requestJson('/api/admin/rooms', { @@ -82,7 +83,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB setNotice(requestMayHaveCompleted(error) ? `${message(error)} The room may already exist. Retry the saved request to confirm it.` : message(error)); if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); } - } finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); } + } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } } function clearRecord() { @@ -107,7 +108,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB

A room may already have been created. Check the directory before removing this unreadable record. Removing it cannot cancel creation and may lose this tab’s host-seat retry proof.

-
: record?.kind === 'pending' ?
+
: initialRequestPending ? null : record?.kind === 'pending' ?

Unconfirmed room: host {record.input.name} · {record.input.advanced ? 'Advanced preview' : 'Basic'} · {record.input.bots.length} AI players.

The exact request is kept privately in this tab across refresh. Keep the tab and its storage until the result is confirmed. Retrying finds the same room.

diff --git a/components/admin-room-removal.tsx b/components/admin-room-removal.tsx index f3f8bdaf..52db2ae3 100644 --- a/components/admin-room-removal.tsx +++ b/components/admin-room-removal.tsx @@ -23,6 +23,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate const [storageProblem, setStorageProblem] = useState(false); const [notice, setNotice] = useState(''); const [busy, setBusy] = useState(false); + const [initialRequestPending, setInitialRequestPending] = useState(false); const [loading, setLoading] = useState(true); const alive = useRef(false), inFlight = useRef(false); const title = useRef(null); @@ -69,7 +70,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate input = saved ?? newAdminRemovalRequest(room!, !room!.removed, reason); saveAdminRemovalRequest(window.sessionStorage, accountId, code, input); } catch (error) { setNotice(message(error)); return; } - inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.removed ? 'Removing room…' : 'Restoring room…'); + inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.removed ? 'Removing room…' : 'Restoring room…'); try { const result = adminRemovalConfirmation(await requestJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), @@ -96,7 +97,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate if (alive.current) setRoom(current); } catch { /* Preserve the original outcome and saved retry. */ } } - } finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); } + } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } } function discardUnreadableRecord() { @@ -121,7 +122,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate

No new changes can be sent until this tab’s unreadable retry record is resolved. Removing the record does not cancel a change already saved on the server.

-
: pending ?
+
: initialRequestPending ? null : pending ?

Saved request: {pending.removed ? 'Remove' : 'Restore'} room {code}.

Reason: {pending.reason}

The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.

After refreshing the page or signing in again, choose All rooms in both the Directory and Archive filters to find this room and reopen these controls.

diff --git a/components/seat-ai-delegation.tsx b/components/seat-ai-delegation.tsx index 532c5b23..9f320a40 100644 --- a/components/seat-ai-delegation.tsx +++ b/components/seat-ai-delegation.tsx @@ -35,6 +35,7 @@ export function SeatAiDelegation({ const [record, setRecord] = useState(null); const [storageProblem, setStorageProblem] = useState(false); const [busy, setBusy] = useState(false); + const [initialRequestPending, setInitialRequestPending] = useState(false); const [message, setMessage] = useState(''); const [abandon, setAbandon] = useState(false); const [delegateId, setDelegateId] = useState(''); @@ -86,6 +87,7 @@ export function SeatAiDelegation({ if (locked || pending.current || (closed && !record)) return; pending.current = true; setBusy(true); + setInitialRequestPending(!record); setExpanded(true); setAbandon(false); onPending(true); @@ -148,6 +150,7 @@ export function SeatAiDelegation({ ); } finally { pending.current = false; + setInitialRequestPending(false); setBusy(false); onPending(false); } @@ -175,7 +178,7 @@ export function SeatAiDelegation({ your own AI control, or recovering or transferring either seat invalidates unused permission.

- {record || storageProblem ? ( + {initialRequestPending ? Saving AI permission… : record || storageProblem ? (
void }) { + return
+

{kind === 'recovery' ? 'Seat recovered' : 'Seat handover complete'}

+ {kind === 'recovery' + ? 'Your seat is recovered in this browser. Previous browser sessions for this seat have been revoked.' + : 'You now control this seat and its saved progress. The previous owner’s access has been revoked. Use Protect your saved seat to create your own recovery kit.'} + +
; +} diff --git a/components/seat-handover.tsx b/components/seat-handover.tsx index e8e5b34f..3799874a 100644 --- a/components/seat-handover.tsx +++ b/components/seat-handover.tsx @@ -49,6 +49,7 @@ export function SeatHandoverSetup({ const [loaded, setLoaded] = useState(false); const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false); + const [initialRequestPending, setInitialRequestPending] = useState(false); const [replaceAcknowledged, setReplaceAcknowledged] = useState(false); const closed = !!game.roomControl?.closed; const own = game.players.find((player) => player.id === game.me); @@ -101,6 +102,7 @@ export function SeatHandoverSetup({ pending.current = true; setReplaceAcknowledged(false); setBusy(true); + setInitialRequestPending(!record?.operation); onPending(true); try { store({ kit, expiresAt: record?.expiresAt ?? null, operation }); @@ -151,6 +153,7 @@ export function SeatHandoverSetup({ ); } finally { pending.current = false; + setInitialRequestPending(false); setBusy(false); onPending(false); } @@ -172,7 +175,7 @@ export function SeatHandoverSetup({ share the kit with the intended recipient.

{closed &&

The room is closed. New handovers and cancellations are stopped; exact saved requests can still confirm a completed change.

} - {!record ? ( + {initialRequestPending ? Saving handover… : !record ? (