diff --git a/CONNECTIVITY.md b/CONNECTIVITY.md index 21390a236..f92f830cc 100644 --- a/CONNECTIVITY.md +++ b/CONNECTIVITY.md @@ -472,10 +472,10 @@ The hub does not create a session. It can only advise and relay: - **Peer degradation (no auto-cascade)**: When the peer becomes unreachable (delivery failures, 30-second silence, or hub disconnect) while the - session is off-chain, `peerLiveness` moves to `'degraded'` (yellow dot). - There is no automatic go-on-chain — the user must decide to escalate. - Only explicit terminal signals (user clicks "Go On-Chain" or receives a - FOAD) mark the peer as dead. + session is off-chain, `peerLiveness` moves to `'degraded'` (yellow banner + rail; tab stays a link). There is no automatic go-on-chain — the user must + decide to escalate. Only explicit terminal signals (user clicks "Go + On-Chain" or receives a FOAD) mark the peer as dead. - **Cascade warning dialogs**: Confirmation dialogs currently warn before disconnecting or switching hubs when a peer/session would be affected. @@ -485,41 +485,48 @@ The hub does not create a session. It can only advise and relay: ## UX: Connectivity Indicators -### Tab dots +### Tab pipe marks -Each tab in the tab bar has a small colored dot to the left of its label -text, indicating the connectivity health of the axis associated with that -tab. The dot is always present (gray when idle/irrelevant) so the tab bar -layout never shifts. +Wallet, Hub, and Game tabs show an uncolored link (connected) or broken-chain +(disconnected) emoji to the left of the label. History and Log have no pipe +mark. The existing upper-right notification dots indicate unread activity and +are unchanged. -Separately, the existing upper-right notification dots indicate unread -activity (new game events, etc.). These are unchanged and serve a different -purpose. +Pipe marks answer only “is this pipe up?” Session mode lives on the game +dashboard banner rail, not on the tabs. -### Per-tab color semantics +| Tab | Link | Broken chain | +|-----|------|----------------| +| Wallet | Connected. | Disconnected. The **Wallet** label is also red. | +| Hub | `hubLiveness === 'connected'` | Reconnecting, inactive, disconnected, or never connected | +| Game | Live session and peer is not `dead` | `sessionPhase` none/resolved, or `peerLiveness === 'dead'` | -| Tab | Green | Yellow | Red | Gray | -|-----|-------|--------|-----|------| -| Wallet | Connected | — | Disconnected | — | -| Hub | Connected | Reconnecting | Inactive (no heartbeat) | Not connected (null / disconnected) | -| Game | Peer connected (incl. clean shutdown) | On-chain, peer degraded, or peer unreachable during clean shutdown | Error, or peer dead outside clean shutdown | No session / resolved | -| History | — | — | — | Always gray | -| Log | — | — | — | Always gray | +Handshake with `peerLiveness === null` counts as connected. `degraded` pings +stay a link; that warning is banner-only. `sessionError` does not affect the +tab mark. -### Game tab dot priority +### Game dashboard banner rail -The Game tab dot checks conditions in this order: +The session dashboard has a full-height left-edge color rail: -1. `sessionPhase === 'none' || 'resolved'` → **gray** (no active session) -2. `sessionError` → **red** (genuine error — always wins) -3. Clean shutdown in progress (`ShuttingDown` / `ShutdownTransactionPending` / - `cleanShutdownStarted`): - - peer degraded (or unexpectedly dead) → **yellow** (unreachable) - - otherwise → **green** (cooperative close in flight; keepalives continue) -4. `peerLiveness === 'dead'` → **red** (terminal — go-on-chain or FOAD) -5. `sessionPhase === 'on-chain'` or `peerLiveness === 'degraded'` → **yellow** (resolving or stale peer) -6. `peerLiveness === 'connected'` → **green** (playing normally) -7. Otherwise → **gray** +| Tone | Color | When | +|------|-------|------| +| `idle` | Gray | No session / never set up | +| `playing` | Green | Setup, handshake, off-chain play, cooperative shutdown | +| `pings-bad` | Yellow | Same as playing, but `peerLiveness === 'degraded'` | +| `on-chain` | Red | Going on-chain, unrolling, or a resolved unroll that still has games | +| `ended` | Blue | Terminal dashboard still showing (clean resolve, failed, abandoned) | + +On-chain beats yellow. Failed/stale outcomes that are actually over stay +`ended`; the Channel label still names the outcome. Yellow also shows +“Peer pings look stuck.” + +### Game tab connectedness + +`selectGameTabConnected` is true unless: + +1. `sessionPhase === 'none' || 'resolved'`, or +2. `peerLiveness === 'dead'` Clean shutdown does **not** mark the peer dead on its own. Keepalives and the small allowlist of shutdown-related peer messages continue until local @@ -528,17 +535,14 @@ shutdown completes. Successful/terminal session exit does not send does arrive during pre-active matchmaking, it is honored as an abort: cancel the attempt (including any in-flight async session start), surface cancelled/error, and do not leave an orphan handshake. When the channel -reaches a terminal state the session exits and the dot goes gray. +reaches a terminal state the session exits and the game tab shows a broken +chain. -### Game tab error conditions (red dot) +### Session error conditions -The Game tab shows a red dot when `sessionError` is true, or when -`peerLiveness === 'dead'` outside a clean shutdown (go-on-chain or FOAD). `sessionError` is derived from: - `Failed` channel state — the channel encountered an unrecoverable error -- `ResolvedStale` channel state — the channel resolved but the outcome is - suspect (e.g., opponent exploited a timeout) - `ResolvedStale` channel state — the channel resolved but the outcome is suspect (e.g., opponent exploited a timeout) - `game-error` game terminal — a generic game-level error (`GameStatus` with @@ -549,7 +553,8 @@ The Game tab shows a red dot when `sessionError` is true, or when Normal settlements such as `accept_settlement`, `settled_cleanly`, `opponent_timed_out`, `we_accepted`, and `slashed_opponent` are **not** session -errors. +errors. These conditions do not change the tab pipe mark; terminal outcomes +use the `ended` banner rail. ### Settlement labels diff --git a/FRONTEND_ARCHITECTURE.md b/FRONTEND_ARCHITECTURE.md index d10c4f3cd..57c5f8c58 100644 --- a/FRONTEND_ARCHITECTURE.md +++ b/FRONTEND_ARCHITECTURE.md @@ -398,11 +398,11 @@ resumable-session marker, and tab/reset coordination keys, inside the same-origi trust model described above. The current and only legal envelope schema is `chia-gaming-session` version -`12`. Because the project is +`13`. Because the project is still alpha, every other version is deleted wholesale without decoding or -migration. A decoded v12 record must also satisfy the complete phase-owned +migration. A decoded v13 record must also satisfy the complete phase-owned envelope contract (keyed game membership, game-owned payload/type agreement, -terminal data, and frozen terminal coin list); malformed v12 records are +terminal data, and frozen terminal coin list); malformed v13 records are deleted rather than partially restored. The boot marker is retained after an incompatible or malformed resumable record is discarded so the failure remains visible at the Resume / Start Over boundary. The `version` field is kept as a @@ -426,7 +426,7 @@ are grouped under those phase-owned payloads: | Field | Type | Purpose | | ------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `version` | `bigint` | Save schema version; currently `12`. | +| `version` | `bigint` | Save schema version; currently `13`. | | `playerId` | `string` | Stable local hub/player identity for this browser state. | | `sessionId` | `string?` | Stable token linking the hub iframe and game-channel WebSocket. | | `alias` | `string?` | Local hub display alias preference. | @@ -550,7 +550,7 @@ independently, and the accepted entry is removed only after the hand is fully settled. Schema version 12 also makes `gameInstances` plus `lastDisplayedGameId` the only persisted game protocol presentation and stores the canonical `GameProtocolPresentation` discriminant. -Under the alpha no-migration policy, version 11 and all other incompatible +Under the alpha no-migration policy, version 12 and all other incompatible records are deleted rather than translated from aggregate current-game fields. #### Delivery-critical saves @@ -902,8 +902,9 @@ delivery, ack reception, and keepalive reception. Peer liveness is measured passively from relay traffic. The `PeerSession` object derives liveness indicators using a 5-second polling interval. These feed into -the **tab-dot connectivity indicators** — colored dots to the left of each tab -label showing connection health (green / yellow / red / gray). They are also +the **tab pipe marks** — uncolored link / broken-chain emojis to the left of +Wallet, Hub, and Game tab labels — and into the game dashboard **banner rail** +(session mode: idle / playing / pings-bad / on-chain / ended). They are also passed to `GameSession` for in-game display. Separately, Shell has a cascade rule: if the peer is marked lost while the session is still off-chain, it calls `goOnChain()` on the WASM cradle. @@ -931,12 +932,12 @@ Connected, keepalive timeout while WS is up → Inactive. **Peer indicator** (`PeerLiveness`) has four states: -| State | Meaning | Dot color | -| ----------- | ----------------------------------------------------------------------------------- | --------- | -| `connected` | Peer traffic received within the last 30 seconds | Green | -| `degraded` | Delivery failure reported by hub, or no peer traffic for 30+ seconds | Yellow | -| `dead` | Local go-on-chain or session rejection (FOAD) — terminal for this peer relationship | Red | -| `null` | No active peer session | Grey | +| State | Meaning | Tab mark | +| ----------- | ----------------------------------------------------------------------------------- | -------- | +| `connected` | Peer traffic received within the last 30 seconds | Link | +| `degraded` | Delivery failure reported by hub, or no peer traffic for 30+ seconds | Link (banner rail yellow) | +| `dead` | Local go-on-chain or session rejection (FOAD) — terminal for this peer relationship | Broken chain | +| `null` | No keepalive yet, or no active peer session | Link if a session is live (handshake); broken chain if none/resolved | `dead` is sticky: incoming messages from that peer are ignored. Only a new session start resets to `null`. @@ -1571,8 +1572,8 @@ not to limit concurrency. | `front-end/src/components/GameSession.tsx` | Game session UI: header, coin status, game area, overlays | | `front-end/src/hooks/useGameSession.ts` | Thin React boundary: controller/runtime setup, host subscription, typed dispatch, selector projection | | `front-end/src/lib/session/sessionMachine*.ts` | Root dispatcher plus cohesive channel, between-hand, proposal, durable-game, notification, command, effect, runtime, and persistence modules | -| `front-end/src/lib/session/persistence*.ts` | Canonical strict-v12 phase decoder plus primitive, between-hand/proposal, and phase-payload codecs; accepted records always produce a normalized `SessionModel` | -| `front-end/src/lib/session/sessionSnapshot.ts` | Canonical `SessionModel` → v12 presentation snapshot encoder | +| `front-end/src/lib/session/persistence*.ts` | Canonical strict-v13 phase decoder plus primitive, between-hand/proposal, and phase-payload codecs; accepted records always produce a normalized `SessionModel` | +| `front-end/src/lib/session/sessionSnapshot.ts` | Canonical `SessionModel` → v13 presentation snapshot encoder | | `front-end/src/lib/gameRegistry.ts` | Exhaustive pure feature registration and game-owned codec/terms/compose dispatch | | `front-end/src/lib/gameMountRegistry.tsx` | Exhaustive React live/frozen mount registration | | `front-end/src/features/calPoker/useCalpokerHand.ts` | Calpoker hook: five-step protocol, card parsing, move submission | @@ -1580,7 +1581,7 @@ not to limit concurrency. | `front-end/src/hooks/WasmStateInit.ts` | WASM initialization: load binary, deposit .hex files, create cradle | | `front-end/src/hooks/blobSingleton.ts` | Singleton management: `getOrCreateSessionController` / `destroySessionController`; restore path for session persistence | | `front-end/src/services/PeerSession.ts` | Per-session peer state: session ID, peer ID, liveness, message buffering/routing, send methods | -| `front-end/src/hooks/save.ts` | v12 cache/write and live/terminal lifecycle facade | +| `front-end/src/hooks/save.ts` | v13 cache/write and live/terminal lifecycle facade | | `front-end/src/hooks/saveCoordination.ts` | Resume markers, active-tab lease, and cross-tab persistence fencing | | `front-end/src/hooks/saveHardReset.ts` | Hard-reset and WalletConnect browser-storage cleanup | | `front-end/src/hooks/savePreferences.ts` | Local preference encoding and decoding | diff --git a/ON_CHAIN.md b/ON_CHAIN.md index 3fa186e13..3449f2428 100644 --- a/ON_CHAIN.md +++ b/ON_CHAIN.md @@ -64,10 +64,11 @@ unroll is extracted from the on-chain conditions. ### Step 2: Preempt or Wait -The player compares the on-chain unroll state number against their own latest -state to decide whether to preempt or wait for the timeout path (see -[Preemption](#preemption)). Both outcomes produce the same result: the unroll -coin is spent, creating game coins and reward coins. +`channel_coin_spent` classifies the parsed channel-coin spend (see +[Preemption](#preemption)). Timeout and preempt both spend the unroll coin, +creating game coins and reward coins. A spend we never signed (unknown puzzle +hash) or whose conditions do not match the signed historical record is an +error at parse time — it is not turned into a timeout. When the unroll coin spend is detected, a `ChannelStatus` notification with state `ResolvedUnrolled` (or `ResolvedStale` if the unroll was stale) is @@ -347,12 +348,12 @@ a player sees the channel coin being spent to an unroll coin, they compare the on-chain sequence number against their own latest state: -| On-chain SN vs ours | Action | Explanation | -| ------------------- | ----------------------- | ----------------------------------------------------------------------------------- | -| On-chain < ours, opposite parity | **Preempt** (immediate) | Spend the unroll coin immediately with our higher SN and more up-to-date conditions | -| On-chain < ours, same parity | **Wait for timeout** | The parity rule forbids our latest state from preempting this coin; use its compact historical timeout record | -| On-chain == ours | **Wait for timeout** | The unroll is at the state we expect; wait for it to resolve | -| On-chain > ours | **Error** | We've been hacked or something went very wrong | +| On-chain SN vs ours | Action | Explanation | +| ------------------- | ------ | ----------- | +| On-chain < ours, opposite parity, signed preemption source | **Preempt** (immediate) | Spend the unroll coin immediately with our higher SN and more up-to-date conditions | +| On-chain < ours, same parity (or no signed preemption source) | **Wait for timeout** | The parity rule forbids preemption; use the compact historical timeout record we signed | +| On-chain == ours | **Wait for timeout** | The unroll is at the state we expect; wait for it to resolve | +| Unknown puzzle hash or conditions-hash mismatch | **Error** | Never signed, or not the unroll we signed — classified when the channel coin spend is parsed | Preemption is **immediate** — no timelock. This is by design: the preempting diff --git a/OVERVIEW.md b/OVERVIEW.md index 446294ac2..d2be5b812 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -188,11 +188,15 @@ conditions. The map deliberately does not retain historical signatures or preemption conditions; preemption always uses a latest full record. When a channel coin spend is detected, the `CREATE_COIN` puzzle hashes in the on-chain conditions are matched against this map to identify which unroll -landed. An old opposite-parity state is preempted, while an old same-parity -state is resolved with its stored timeout conditions. Those old puzzle hashes -cannot be discarded: the opponent may publish any previously signed unroll, -so recognizing every historical hash is the minimum needed to choose the -correct timeout record safely. +landed. Classification happens then, in `channel_coin_spent`: an old +opposite-parity state is preempted, an old same-parity state we signed is +resolved with its stored timeout conditions, and a spend we never signed +(unknown puzzle hash) or whose conditions do not match the signed record is +an error. A state we have not reached cannot be in the map, so it fails the +same never-signed check. Those old puzzle hashes cannot be +discarded: the opponent may publish any previously signed unroll, so +recognizing every historical hash is how we tell a signed timeout record +from a spend we never signed. Browser session persistence stores the serialized game session as raw binary in IndexedDB. Compact historical unroll records are therefore part of the durable diff --git a/UX_NOTIFICATIONS.md b/UX_NOTIFICATIONS.md index 803ee2a3e..7d99749c2 100644 --- a/UX_NOTIFICATIONS.md +++ b/UX_NOTIFICATIONS.md @@ -201,10 +201,11 @@ advisory, coin identity and amount, both balances, game allocation, `havePotato`, `zeroPayout`, and optional on-chain progress context. During an unroll, `unrollInitiator` identifies whether we or the opponent caused the observed channel spend when that attribution is definitive; a locally queued -spend or cooperative-close setup alone leaves it unknown. `semanticPhase` refines the existing `GoingOnChain` / -`Unrolling` state as submitting or resolving the channel spend, preempting, -waiting for the relative timeout, submitting the timeout finish, or resolving -the unroll spend. These are display facts, not new lifecycle states. Banner text, the potato indicator, dashboard +spend or cooperative-close setup alone leaves it unknown. `semanticPhase` is +the situation within `GoingOnChain` / `Unrolling`: submitting or resolving a +channel spend, finding the landed unroll state, preempting, waiting for the +relative timeout, or spending the timeout finish. Actor is not encoded in the +phase. These are display facts, not new lifecycle states. Banner text, the potato indicator, dashboard actions, phase selection, persistence, and restore all project from that one snapshot instead of maintaining parallel channel-status shapes. During a cooperative terminal handoff, Rust sets @@ -226,12 +227,13 @@ Monotonicity applies across all three lenses: When a watched timeout spend becomes mature, `TransactionManager` is the sole component that queues its submission. Before the host drains the submission buffer, it updates the session's canonical status snapshot to -`submitting_timeout_finish`; the normal `ChannelStatus` notification then -persists and restores that fact. The UI never infers timeout maturity, submits +`finishing_spending` (with `unrollInitiator` naming who started the unroll); +the normal `ChannelStatus` notification then persists and restores that fact. The UI never infers timeout maturity, submits the transaction, or mutates a durable channel snapshot from a transient event. If a reorg changes or clears the watched coin's birthday, the manager re-arms -the relative timeout and restores the canonical phase to `waiting_timeout` -until the claim becomes mature again. +the relative timeout and restores the canonical waiting phase +(`finishing_waiting_timeout`) until the claim becomes mature +again. --- diff --git a/front-end/src/components/Shell.tsx b/front-end/src/components/Shell.tsx index ce518c932..4170d4b88 100644 --- a/front-end/src/components/Shell.tsx +++ b/front-end/src/components/Shell.tsx @@ -77,6 +77,7 @@ import { onFenced, offFenced, peekAlias, + releaseLeaseIfOwner, setAlias, } from '../hooks/save'; import { @@ -106,23 +107,23 @@ import { shouldReportHubBusyPresence, shouldSuppressPhaseReporting, shouldSwitchToHubOnResolved, + shouldWarnOnSessionUnload, transitionToFreshSession, } from '../lib/restoreLifecycle'; import { ABANDON_WAITING_STATES, isChannelAbandonable, - isCleanShutdownInProgress, PRE_ACTIVE_CHANNEL_STATES, selectGameDashboardView, - selectGameTabDotColor, + selectGameTabConnected, selectStatusBarBalances, sessionAmountsFromSave, sessionModelFromSave, DEFAULT_CHANNEL_TIMEOUT_BLOCKS, DEFAULT_UNROLL_TIMEOUT_BLOCKS, + type BannerTone, type GameDashboardActionKind, type GameDashboardViewModel, - type GameTabDotColor, type SessionModel, type StatusBarBalanceSegment, } from '../lib/session/model'; @@ -291,6 +292,17 @@ const TAB_DEFS: { id: TabId; label: string }[] = [ { id: 'log', label: 'Log' }, ]; +const TAB_PIPE_CONNECTED = '\u{1F517}'; +const TAB_PIPE_DISCONNECTED = '\u{26D3}\u{FE0F}\u{200D}\u{1F4A5}'; + +const BANNER_TONE_BAR: Record = { + idle: 'var(--color-canvas-text-subtle)', + playing: 'var(--color-success-solid)', + 'pings-bad': 'var(--color-warning-solid)', + 'on-chain': 'var(--color-alert-solid)', + ended: 'var(--color-info-solid)', +}; + const ABANDON_DELAY_MS = 120_000n; const GRACE_DELAY_MS = 10_000n; @@ -394,112 +406,121 @@ function GameDashboard({ if (expanded) refreshProtocolState(); }, [expanded, refreshProtocolState]); + const barColor = BANNER_TONE_BAR[view.bannerTone]; return ( -
-
-
- -
-
- - Channel: - - {view.channelStatusLabel} - - {view.havePotato && ( - - 🥔 - - )} - {view.channelDetail && ( - {view.channelDetail} - )} + - {view.lifecycleRows.length === 0 && - view.handStatusLabel !== 'Active' && - view.handStatusLabel !== 'No hand' && ( - - Hand: - - {view.handStatusLabel} - - {view.handDetail && {view.handDetail}} + +
+
+ + Channel: + + {view.channelStatusLabel} - )} - {view.lifecycleRows.map((row) => ( - - {row.label}: - {row.statusLabel} - {row.detail && {row.detail}} + {view.channelDetail && ( + {view.channelDetail} + )} + {view.havePotato && ( + + 🥔 + + )} + {view.bannerTone === 'pings-bad' && ( + Peer pings look stuck + )} - ))} -
- {balances && ( -
- {balances.map((seg) => ( - - {seg.label}: - - {formatBalanceValue(seg.value)} - {seg.value2 !== undefined ? ` / ${formatBalanceValue(seg.value2)}` : ''} + {view.lifecycleRows.length === 0 && + view.handStatusLabel !== 'Active' && + view.handStatusLabel !== 'No hand' && ( + + Hand: + + {view.handStatusLabel} + + {view.handDetail && ( + {view.handDetail} + )} + )} + {view.lifecycleRows.map((row) => ( + + {row.label}: + {row.statusLabel} + {row.detail && {row.detail}} ))}
- )} + {balances && ( +
+ {balances.map((seg) => ( + + {seg.label}: + + {formatBalanceValue(seg.value)} + {seg.value2 !== undefined ? ` / ${formatBalanceValue(seg.value2)}` : ''} + + + ))} +
+ )} +
+
+
+
-
- -
-
- {expanded && ( -
- {coins.length > 0 && ( -
- {coins.map((coin) => ( - - {coin.label}: - - {coin.id} + {expanded && ( +
+ {coins.length > 0 && ( +
+ {coins.map((coin) => ( + + {coin.label}: + + {coin.id} + - - ))} + ))} +
+ )} +
+ Protocol state +
- )} -
- Protocol state - +
+              {protocolText ?? 'No active channel.'}
+            
-
-            {protocolText ?? 'No active channel.'}
-          
-
- )} + )} +
); } @@ -752,8 +773,6 @@ const Shell = () => { shellDispatchRef.current({ type: 'setSessionError', value }); }, []); - const sessionError = shellState.sessionError; - const setRestoreStatus = useCallback((value: RestoreStatus) => { shellDispatchRef.current({ type: 'setRestoreStatus', value }); }, []); @@ -924,10 +943,18 @@ const Shell = () => { }; }, []); - // Close WebSocket connections on page unload/reload so the browser doesn't - // leave stale TCP sockets that block new connections in the reloaded page. + // Warn before closing a tab with a live session. Disconnect sockets only on + // actual leave (`pagehide`): `beforeunload` also runs if the user stays, and + // dropping the hub there would strand a session they chose to keep. useEffect(() => { - const cleanup = () => { + const onBeforeUnload = (event: BeforeUnloadEvent) => { + if (!shouldWarnOnSessionUnload(sessionPhaseRef.current)) return; + event.preventDefault(); + event.returnValue = ''; + }; + const cleanup = (event: PageTransitionEvent) => { + if (event.persisted) return; + releaseLeaseIfOwner(); hubConnRef.current?.disconnect(); // WalletConnect sessions are intentionally durable across reloads. // Calling disconnect() here sends a protocol-level session_delete. @@ -935,9 +962,11 @@ const Shell = () => { activeBlockchainRef.current?.disconnect().catch(() => {}); } }; - window.addEventListener('beforeunload', cleanup); + window.addEventListener('beforeunload', onBeforeUnload); + window.addEventListener('pagehide', cleanup); return () => { - window.removeEventListener('beforeunload', cleanup); + window.removeEventListener('beforeunload', onBeforeUnload); + window.removeEventListener('pagehide', cleanup); }; }, []); @@ -3418,6 +3447,7 @@ const Shell = () => { setupPending: shouldSynthesizeSetupPending(sessionPaneTransition, hasLiveSessionModel), cleanShutdownGraceActive, abandonEnabled, + peerLiveness, }); const statusBarBalances = selectStatusBarBalances(dashboardSessionModel); const sessionConsentOverlay = pendingAdvisory ? ( @@ -3520,46 +3550,29 @@ const Shell = () => { (tab.id === 'wallet' && walletAlert) || (tab.id === 'hub' && hubAlert)); - let dotColor: string | null = null; + let pipeConnected: boolean | null = null; switch (tab.id) { case 'wallet': - dotColor = walletConnected - ? 'var(--color-success-solid)' - : 'var(--color-alert-solid)'; + pipeConnected = walletConnected; break; case 'hub': - if (hubLiveness === 'connected') { - dotColor = 'var(--color-success-solid)'; - } else if (hubLiveness === 'reconnecting') { - dotColor = 'var(--color-warning-solid)'; - } else if (hubLiveness === 'inactive') { - dotColor = 'var(--color-alert-solid)'; - } else { - dotColor = 'var(--color-canvas-text-subtle)'; - } + pipeConnected = hubLiveness === 'connected'; break; - case 'game': { - const gameDot: GameTabDotColor = selectGameTabDotColor({ - sessionPhase, - sessionError, - peerLiveness, - cleanShutdownInProgress: isCleanShutdownInProgress(dashboardSessionModel), - }); - const gameDotCss: Record = { - green: 'var(--color-success-solid)', - yellow: 'var(--color-warning-solid)', - red: 'var(--color-alert-solid)', - gray: 'var(--color-canvas-text-subtle)', - }; - dotColor = gameDotCss[gameDot]; + case 'game': + pipeConnected = selectGameTabConnected({ sessionPhase, peerLiveness }); break; - } } + const walletDisconnected = tab.id === 'wallet' && !walletConnected; + const pipeLabel = + pipeConnected === null + ? tab.label + : `${tab.label}, ${pipeConnected ? 'connected' : 'disconnected'}`; return (