Device-offline banner, pre-handshake loading skeleton, and a full-screen terminal-error screen on top of 03-RichContent.
open ResilienceSwiftUI.xcodeproj # from this folder
# Cmd+R on an iPhone simulatorSet your connector token in ResilienceApp.swift (currently "YOUR_CONNECTOR_TOKEN").
- Track device connectivity with
NWPathMonitorindependently of the SDK's socket state - Stack a red "offline" banner above the SDK's yellow "reconnecting" banner
- Gate a pulsing loading skeleton on
!session.isReady && session.messages.isEmpty - Replace the entire chat surface with a full-screen retry screen when
session.failureReasonis non-nil - Recover via
session.client.resume()from the terminal screen
The SDK invariants behind each pattern are in the root README's Integration guide; this example shows them as one concrete view.
Each subsection leads with the SDK call (one or a few lines — the actual API), then shows how it's wired into a view.
Track the OS network path separately from the SDK's socket and render a red bar above the yellow reconnect banner:
The SDK call:
session.connection // .idle / .connecting / .open / .closing / .closed / .reconnecting / .failed — fires on every transitionisOnline is your own state, sourced from NWPathMonitor:
@MainActor
final class NetworkMonitor: ObservableObject {
@Published private(set) var isOnline: Bool = true
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "poly.example.NetworkMonitor")
init() {
monitor.pathUpdateHandler = { [weak self] path in
let online = path.status == .satisfied
Task { @MainActor in self?.isOnline = online }
}
monitor.start(queue: queue)
}
deinit { monitor.cancel() }
}In a view:
struct ContentView: View {
@StateObject var session = PolyMessaging.chat()
@StateObject var network = NetworkMonitor()
var body: some View {
VStack(spacing: 0) {
// OS-level offline pill (red) stacks ABOVE the SDK's reconnect pill (yellow).
// Both can be visible simultaneously.
OfflineBanner(isOnline: network.isOnline)
ConnectionBanner(status: session.connection)
// ...message list + composer...
}
}
}Under the hood: when the OS reports path.status != .satisfied, the SDK's reachability watcher drops its dead socket within ~100ms and session.connection flips to .reconnecting. The two banners measure different things — the offline pill is the device, the reconnect pill is the socket — so it's fine (and meaningful) to show both.
See Integration guide › Connection & reconnect.
Show pulsing placeholder rows only while the WebSocket is opening for the first time. Warm resumes already have messages in memory, so they skip the skeleton.
The SDK signals:
session.isReady // false until the SDK has finished its handshake and can send
session.messages // non-empty on warm resume → skip the skeleton entirelyIn a view:
ScrollViewReader { proxy in
ScrollView {
if !session.isReady && session.messages.isEmpty {
LoadingSkeleton()
} else {
LazyVStack(spacing: 8) {
ForEach(session.messages) { message in
MessageBubbleView(message: message, /* ... */)
.id(message.id)
}
if session.isAgentTyping {
TypingIndicator(avatarUrl: session.lastAgentMessage?.avatarUrl)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
}Under the hood: isReady stays false until the REST + WebSocket handshake completes and the session can send; the SDK flips it to true the moment a session id is in hand. On a cold relaunch where there's a stored session within the timeout, messages is hydrated from the cache before isReady flips — the messages.isEmpty half of the gate is what skips the skeleton in that path.
Streaming: agent replies grow token-by-token by default (
Configuration.streamingEnabled: true— ChatGPT-style). SetstreamingEnabled: falseto render completed bubbles only. See the root README's Streaming section and07-Playgroundfor a live toggle.
See Integration guide › Loading & empty states.
Once the SDK has given up reconnecting, replace the whole chat with a single retry button. The chat is useless in this state until the user explicitly retries.
The SDK calls:
session.failureReason // PolyError? — non-nil after the reconnect budget is exhausted
session.client.resume() // re-arm the connection from the retry buttonIn a view:
var body: some View {
NavigationView {
Group {
if let reason = session.failureReason {
TerminalErrorScreen(reason: reason) {
Task { try? await session.client.resume() }
}
} else {
mainChat // banners + message list + composer
}
}
.navigationTitle("Chat")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if !session.hasEnded && session.failureReason == nil {
Button("End Chat") { Task { try? await session.end() } }
}
}
}
}
}The screen itself uses String(describing: reason) for the subtitle — PolyError doesn't conform to LocalizedError, so .localizedDescription would just say "The operation couldn't be completed". String(describing:) gives the case name (auth(unauthorized), session(sessionExpired), etc.) which is far more useful.
Under the hood: failureReason is set only after the SDK's exponential-backoff reconnect ladder (with jitter) is exhausted, or on a terminal session error (auth, session-expired, session-ended). Transient blips don't trip it — those just flip connection to .reconnecting and back. That's why this screen is full-screen and gated on failureReason rather than on connection.
See Integration guide › Terminal errors.
| Action | What you should see |
|---|---|
| Toggle airplane mode mid-chat | Red offline banner; messages stay composable; toggle off → yellow reconnect banner → cleared |
| Kill network during cold launch | Loading skeleton → eventually terminal-error screen → tap "Try Again" |
| Cold launch with a stored session within ~10 min | No skeleton → restored messages render immediately |
- live agent handoff →
05-Handoff/ - resume / start-new on a dedicated connect screen, in-place restart →
06-FullReference/ - runtime configuration, raw transport, diagnostics →
07-Playground/
- UIKit counterpart:
Examples/UIKit/04-Resilience/ - SDK reference: root README → Integration guide
- Install the package: root README → Install