Remote Chat Failover Needs a Tab-Stop Origin Banner, Not a Skeleton Overlay A developer documented a debugging retrospective on remote chat failover, arguing that reusing a single `status: "streaming"` state for both on-device and remote inference hides origin changes from keyboard and screen-reader users. The write-up proposes a typed `Origin` union and a multi-phase chat state model so that failover to a remote server surfaces an explicit consent step and live-region announcement instead of a skeleton overlay. The developer frames the issue as a state-modeling defect rather than an ARIA problem, noting that adding more live regions without an origin enum obscures the one message that matters. I was halfway through a keyboard-only pass on a streaming chat when the local path died. A skeleton overlay covered the composer, my focus jumped, and tokens started arriving from somewhere else. Did the interface tell me those tokens had left the browser? It did not, and that silence is the real defect. Sighted teammates might notice a faint retry pulse and keep going. Keyboard and screen-reader users only felt a trap, then a voice that never named the new host. This write-up is a debugging retrospective, from that frozen caret to a typed origin state you can fail on purpose. I built a small chat shell that retries when a local inference call rejects. The retry helper reused status: "streaming" , so the UI never distinguished on-device work from a remote server. That collapse is how a privacy change becomes a loading animation, and it is an easy mistake when failover feels like infrastructure rather than UX. Here is the interaction I treated as the failing transition. If your table has one streaming row, you will hide origin changes from assistive tech. That is the pitfall, not the spinner’s CSS. | State | Origin | Focus | Live region | Composer | |---|---|---|---|---| | idle | local | textarea | silent | enabled | | streaming-local | local | textarea | Answer streaming on this device | disabled, cancel available | | needs-consent | remote proposed | primary consent control | Local path failed. Remote send needs confirmation | disabled | | streaming-remote | remote | textarea after confirm | Answer streaming from a remote server | disabled, cancel available | | error | last known | retry, then composer | assertive error, once | enabled | | cancelled | last known | composer | Request cancelled | enabled | Why does a skeleton feel honest while still lying? Because waiting is visible, and destination is not. I started with the keyboard, not the network panel, because the complaint was focus loss. Tab after the overlay appeared cycled through chrome around the chat, then died against aria-hidden on the main landmark. The overlay had been marked aria-busy="true" on app , which is a blunt instrument and a familiar false friend. Was the fetch still using the local URL? DevTools said no. The retry wrapper swapped the endpoint after a single 503, then streamed as if nothing privacy-related had happened. The visual skeleton was honest about waiting and dishonest about where the bytes went. Root cause, stacked in three mistakes I now look for first: isStreaming , represented local work, remote work, and the gap between them. tabIndex={-1} plus an effect that called overlayRef.current?.focus . None of those is an ARIA trivia problem. They are state-modeling problems that accessibility tools merely made visible. If you “fix” them by adding more live regions without an origin enum, you will drown the one sentence that mattered. I now keep a three-column log while reproducing AI chat failures. It is slower than jumping into CSS, and it stops me from treating a privacy change like a spinner ticket. If column two changes host while column three stays frozen, you have a silent origin bug. If column one and the accessibility tree disagree about focus, you have a trap. Write the log before you add another aria- attribute, because attributes cannot recover a missing phase. I also freeze the retry in a reducer so I can replay it without waiting on a real model. The sketch below does that with a local failure, then an optional remote path. Think of origin like a shipping label on a package you already started wrapping: the packing motion can look identical, but the destination is what people must hear. A typed union forces every view to answer a rude question: where is this request going? Automatic failover is convenient for developers and hostile for people who cannot see the URL bar change. type Origin = "local" | "remote"; type ChatPhase = | { status: "idle" } | { status: "streaming"; origin: Origin } | { status: "needs-consent"; to: Origin; prompt: string } | { status: "error"; message: string; origin: Origin } | { status: "cancelled"; origin: Origin }; The needs-consent phase is the missing beat. Ask before the first remote token, keep that question in the tab order, then restore focus. I am not using a focus-trapped modal here on purpose: the last time I shipped a blocking dialog for a different chat prompt, testers lost the transcript. This banner stays in document flow. php stateDiagram-v2 -- idle idle -- streamingLocal: submit streamingLocal -- idle: done streamingLocal -- cancelled: stop streamingLocal -- needsConsent: local 503 needsConsent -- streamingRemote: confirm needsConsent -- cancelled: decline streamingRemote -- idle: done streamingRemote -- cancelled: stop cancelled -- idle: type again The following React sketch is a labeled demo, not a production privacy platform. It keeps the origin banner in tab order and returns focus to the composer after confirm or cancel. Screen-reader copy is short on purpose so the live region does not flood. I needed a remote target that did not require a private GPU story in this article. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I pointed the failover branch at MonkeyCode’s free model access and free server option so the remote origin was real enough to test announcements, without standing up my own inference box. js import { useEffect, useReducer, useRef } from "react"; type Origin = "local" | "remote"; type Phase = | { status: "idle" } | { status: "streaming"; origin: Origin } | { status: "needs-consent"; prompt: string } | { status: "error"; message: string } | { status: "cancelled" }; type State = { phase: Phase; draft: string; answer: string; log: string; }; type Action = | { type: "type"; value: string } | { type: "submit" } | { type: "local-fail"; prompt: string } | { type: "consent-yes" } | { type: "consent-no" } | { type: "token"; chunk: string } | { type: "done" } | { type: "cancel" }; function reducer state: State, action: Action : State { switch action.type { case "type": return { ...state, draft: action.value }; case "submit": if state.phase.status === "streaming" return state; return { ...state, answer: "", phase: { status: "streaming", origin: "local" }, log: "Answer streaming on this device.", }; case "local-fail": return { ...state, phase: { status: "needs-consent", prompt: action.prompt }, log: "Local path failed. Confirm before sending to a remote server.", }; case "consent-yes": return { ...state, phase: { status: "streaming", origin: "remote" }, log: "Answer streaming from a remote server.", }; case "consent-no": return { ...state, phase: { status: "cancelled" }, log: "Remote send cancelled. Composer is ready.", }; case "token": return { ...state, answer: state.answer + action.chunk }; case "done": return { ...state, phase: { status: "idle" }, log: "Answer complete." }; case "cancel": return { ...state, phase: { status: "cancelled" }, log: "Request cancelled.", }; default: return state; } } export function OriginAwareChat { const state, dispatch = useReducer reducer, { phase: { status: "idle" }, draft: "", answer: "", log: "Chat idle on this device.", } ; const composerRef = useRef