|
| 1 | +# Offline Recovery Vs Session Gate Design |
| 2 | + |
| 3 | +> Status: Draft |
| 4 | +> Date: 2026-05-14 |
| 5 | +> Scope: `packages/web/src/hooks/use-activation.ts`, `packages/web/src/hooks/use-bootstrap.ts`, `packages/web/src/app/providers.tsx`, `packages/web/src/shells/shared/connection-status-banner.tsx`, related tests |
| 6 | +
|
| 7 | +## Goal |
| 8 | + |
| 9 | +Stop routing normal connection recovery failures to `/session-gate`. |
| 10 | + |
| 11 | +The app should only show `session-gate` when the current tab is explicitly displaced by another active tab. Ordinary websocket disconnects, reconnect backoff, and slow recovery should keep the user on the current workspace and use the existing top connection banner instead. |
| 12 | + |
| 13 | +## Problem |
| 14 | + |
| 15 | +The current implementation mixes two different failure classes into the same `activationStatus === "gated"` outcome: |
| 16 | + |
| 17 | +- true single-active displacement |
| 18 | +- ordinary reconnect or activation claim failure |
| 19 | + |
| 20 | +That creates two product problems: |
| 21 | + |
| 22 | +- users see a page that says another tab took over even when no other tab exists |
| 23 | +- the app abandons the current workspace UI during connection recovery, even though a reconnect banner already exists |
| 24 | + |
| 25 | +The result is both misleading and unnecessarily disruptive. |
| 26 | + |
| 27 | +## Decision |
| 28 | + |
| 29 | +Split activation and connection recovery into separate user-facing states. |
| 30 | + |
| 31 | +- `session-gate` is reserved for explicit displacement only |
| 32 | +- offline recovery stays in place on the current route |
| 33 | +- the top connection banner becomes the sole UI for reconnecting, disconnected, and slow-recovery states |
| 34 | + |
| 35 | +This keeps the meaning of each state narrow: |
| 36 | + |
| 37 | +- displacement means another tab actually replaced this one |
| 38 | +- offline recovery means this tab is still the intended active tab, but transport recovery is in progress or degraded |
| 39 | + |
| 40 | +## Current Behavior Summary |
| 41 | + |
| 42 | +Today the frontend navigates to `/session-gate` whenever `activationStatus === "gated"`. |
| 43 | + |
| 44 | +That `gated` state is currently reached from: |
| 45 | + |
| 46 | +- `activation.revoked` handling in `AppProviders` |
| 47 | +- `wsClient.connect()` failure inside `useActivation.claim()` |
| 48 | +- `activation.claim` command failure inside `useActivation.claim()` |
| 49 | + |
| 50 | +Only the first case is a real displacement signal. The latter two are transport or recovery failures and should not be represented as tab displacement. |
| 51 | + |
| 52 | +## Proposed State Model |
| 53 | + |
| 54 | +### Activation |
| 55 | + |
| 56 | +Activation should model whether this tab still owns the single-active lease. |
| 57 | + |
| 58 | +Required activation outcomes: |
| 59 | + |
| 60 | +- `active` |
| 61 | + - this tab owns the lease |
| 62 | +- `displaced` |
| 63 | + - the server explicitly revoked this tab because another tab took over |
| 64 | + |
| 65 | +`gated` should no longer be used as a catch-all for reconnect and claim failures. |
| 66 | + |
| 67 | +If the existing atom names are kept for a smaller patch, the implementation may continue storing `gated`, but only for true displacement. In that case, reconnect and claim failures must stop writing that value. |
| 68 | + |
| 69 | +### Connection Recovery |
| 70 | + |
| 71 | +Connection recovery should remain fully driven by websocket connection state: |
| 72 | + |
| 73 | +- `connecting` |
| 74 | +- `connected` |
| 75 | +- `reconnecting` |
| 76 | +- `disconnected` |
| 77 | +- `rejected` |
| 78 | + |
| 79 | +Normal websocket recovery should not mutate activation into a displaced-like state. |
| 80 | + |
| 81 | +## Routing Rules |
| 82 | + |
| 83 | +### When To Enter `/session-gate` |
| 84 | + |
| 85 | +Navigate to `/session-gate` only when the tab is explicitly displaced. |
| 86 | + |
| 87 | +Accepted triggers: |
| 88 | + |
| 89 | +- receipt of `activation.revoked` with displacement semantics |
| 90 | +- a websocket close reason that unambiguously means displacement, such as `single_active_displaced` |
| 91 | + |
| 92 | +### When To Stay On The Current Route |
| 93 | + |
| 94 | +Remain on the current page for: |
| 95 | + |
| 96 | +- temporary websocket disconnect |
| 97 | +- reconnect backoff |
| 98 | +- recovery probe failure |
| 99 | +- `activation.claim` failure caused by transport or request unavailability |
| 100 | +- long-running reconnect attempts |
| 101 | + |
| 102 | +The current workspace route, settings route, and other in-app routes should stay mounted so the user keeps context while recovery continues. |
| 103 | + |
| 104 | +## Banner Behavior |
| 105 | + |
| 106 | +The existing `ConnectionStatusBanner` remains the main recovery surface. |
| 107 | + |
| 108 | +### Primary Message |
| 109 | + |
| 110 | +When the app is recovering from a normal disconnect, show a top banner with the primary line: |
| 111 | + |
| 112 | +- `连接已断开,正在重新连接...` |
| 113 | + |
| 114 | +This replaces the current split phrasing where `reconnecting` shows one string and `disconnected` shows a harsher terminal message. During active automatic recovery, the banner should communicate progress, not final failure. |
| 115 | + |
| 116 | +### Slow Recovery Hint |
| 117 | + |
| 118 | +If recovery remains unresolved for roughly 25 seconds, add a second line beneath the primary message: |
| 119 | + |
| 120 | +- `连接恢复较慢,可能是网络问题。如果长时间没有恢复,可以刷新页面。` |
| 121 | + |
| 122 | +This hint should appear only after sustained failure, not immediately on the first reconnect attempt. |
| 123 | + |
| 124 | +### Reset Behavior |
| 125 | + |
| 126 | +The slow-recovery timer resets when: |
| 127 | + |
| 128 | +- connection returns to `connected` |
| 129 | +- an explicit displacement occurs and the app transitions to `session-gate` |
| 130 | + |
| 131 | +### Auto Retry |
| 132 | + |
| 133 | +Automatic reconnect attempts continue indefinitely under the existing reconnect strategy, subject to the current bounded backoff ceiling. |
| 134 | + |
| 135 | +The UI should not present a manual retry button in this phase. |
| 136 | + |
| 137 | +## Session Gate Behavior |
| 138 | + |
| 139 | +`SessionGatePage` remains a full-page displacement shell. |
| 140 | + |
| 141 | +Its meaning becomes narrower: |
| 142 | + |
| 143 | +- this tab was actively displaced by another tab |
| 144 | +- the current websocket was intentionally revoked because another holder took control |
| 145 | + |
| 146 | +This page should no longer be used for: |
| 147 | + |
| 148 | +- reconnect failure |
| 149 | +- offline network conditions |
| 150 | +- backend restart during recovery |
| 151 | +- temporary request metadata gaps |
| 152 | + |
| 153 | +## Error Semantics |
| 154 | + |
| 155 | +### Claim Failure |
| 156 | + |
| 157 | +If `activation.claim` fails during reconnect, treat it as a recovery error first, not a displacement event, unless the server explicitly reports displacement. |
| 158 | + |
| 159 | +Expected user-visible behavior: |
| 160 | + |
| 161 | +- stay on the current route |
| 162 | +- keep the connection banner visible |
| 163 | +- allow the reconnect loop to continue |
| 164 | + |
| 165 | +### Explicit Displacement |
| 166 | + |
| 167 | +If the server explicitly displaces the client: |
| 168 | + |
| 169 | +- set activation to the displacement state |
| 170 | +- clear projected workspace/session state as the app already does |
| 171 | +- disconnect the websocket intentionally |
| 172 | +- navigate to `/session-gate` |
| 173 | + |
| 174 | +## Implementation Strategy |
| 175 | + |
| 176 | +### Option Chosen |
| 177 | + |
| 178 | +Preferred implementation is a semantic split rather than a route-layer exception. |
| 179 | + |
| 180 | +That means: |
| 181 | + |
| 182 | +- remove reconnect and claim failures as causes of activation gating |
| 183 | +- keep `/session-gate` tied to explicit displacement signals only |
| 184 | +- extend the banner to support a slow-recovery secondary line |
| 185 | + |
| 186 | +### Rejected Alternative |
| 187 | + |
| 188 | +A smaller patch could keep the current state model and merely avoid routing to `/session-gate` when `activationReason` is `reconnect_failed` or `claim_failed`. |
| 189 | + |
| 190 | +That approach is not preferred because: |
| 191 | + |
| 192 | +- it preserves ambiguous activation semantics |
| 193 | +- future contributors can accidentally reintroduce the bug |
| 194 | +- the route logic becomes a policy exception table instead of reflecting clear state meaning |
| 195 | + |
| 196 | +## Testing Requirements |
| 197 | + |
| 198 | +Add or update tests for the following cases: |
| 199 | + |
| 200 | +1. reconnect failure does not navigate to `/session-gate` |
| 201 | +2. `activation.claim` failure does not navigate to `/session-gate` |
| 202 | +3. explicit `activation.revoked` still navigates to `/session-gate` |
| 203 | +4. the connection banner appears during reconnecting recovery |
| 204 | +5. the slow-recovery secondary hint appears only after the configured duration |
| 205 | +6. the slow-recovery hint resets after a successful reconnect |
| 206 | + |
| 207 | +## Risks |
| 208 | + |
| 209 | +### Risk: stale route remains visible too long |
| 210 | + |
| 211 | +Mitigation: |
| 212 | + |
| 213 | +- keep current projected-state reset behavior for explicit displacement only |
| 214 | +- use clear reconnect messaging in the banner during degraded transport states |
| 215 | + |
| 216 | +### Risk: transport failures that actually mask displacement remain on the workspace briefly |
| 217 | + |
| 218 | +Mitigation: |
| 219 | + |
| 220 | +- only explicit server displacement should trigger the displacement shell |
| 221 | +- once the server sends a real revoke event, the app still transitions immediately |
| 222 | + |
| 223 | +### Risk: banner messaging feels indefinite |
| 224 | + |
| 225 | +Mitigation: |
| 226 | + |
| 227 | +- add the 25-second slow-recovery hint |
| 228 | +- keep retry behavior automatic so the user is not forced into immediate action |
| 229 | + |
| 230 | +## Non-Goals |
| 231 | + |
| 232 | +This change does not: |
| 233 | + |
| 234 | +- redesign the visual style of `session-gate` |
| 235 | +- add a manual retry button |
| 236 | +- change websocket backoff math beyond existing limits |
| 237 | +- introduce a modal offline blocker |
| 238 | +- solve unrelated multi-tab fencing behavior |
| 239 | + |
| 240 | +## Verification |
| 241 | + |
| 242 | +After implementation, verify: |
| 243 | + |
| 244 | +1. placing the app idle long enough to trigger websocket recovery no longer routes to `/session-gate` |
| 245 | +2. normal reconnect flow keeps the user on `/workspace` |
| 246 | +3. a true displacement still shows `session-gate` |
| 247 | +4. the banner shows the primary reconnect line immediately |
| 248 | +5. the slow-recovery hint appears after the configured threshold and disappears after reconnect |
| 249 | + |
| 250 | +## Implementation Boundary |
| 251 | + |
| 252 | +Expected files to change: |
| 253 | + |
| 254 | +- `packages/web/src/hooks/use-activation.ts` |
| 255 | +- `packages/web/src/hooks/use-bootstrap.ts` |
| 256 | +- `packages/web/src/app/providers.tsx` |
| 257 | +- `packages/web/src/shells/shared/connection-status-banner.tsx` |
| 258 | +- relevant desktop/mobile shell tests |
| 259 | +- relevant provider lifecycle tests |
0 commit comments