The 80% chat in UIKit — adds typing indicator, connection banner, suggestion pills, delivery state (Sending… + failed retry), end + start new chat, and a failure overlay on top of 01-Hello. The UIKit twin of Examples/SwiftUI/02-Standard/.
- Interface: Storyboard (
Resources/Main.storyboard) wires only the nav controller,ChatViewController, and the EndUIBarButtonItem(outlet + action). Everything else (table, banner, input bar, suggestions, failure overlay) is built programmatically inviewDidLoad. - Lifecycle:
AppDelegate(@main) +SceneDelegate(scene-based, generated by Xcode).
Setup and send() are unchanged from 01-Hello — read it first. This README only covers what's new.
open StandardUIKit.xcodeproj # from this folder
# Cmd+R on an iPhone simulatorSet your connector token in App/AppDelegate.swift (currently "YOUR_CONNECTOR_TOKEN").
- Typing indicator —
session.$isAgentTyping,await session.sendTyping() - Reconnect banner —
session.$connection(.reconnecting) - Suggestion pills —
AgentMessage.suggestions,session.clearSuggestions(for:) - End / start-new chat —
try await session.end(),session.$hasEnded,try await session.client.startNewSession() - Delivery state + retry —
UserMessage.delivery, plus an inline retry button on.failedbubbles (session.send(text)again) - Failure overlay —
session.$failureReason,try await session.client.resume() - Keyboard ride-along —
view.keyboardLayoutGuide.topAnchor
The SDK invariants behind each pattern are in the root README's Integration guide; this example shows them composed into one view controller.
Each subsection leads with the SDK call(s) (the actual API), then shows how it's wired into the view controller.
Listen for the agent + announce your own typing:
session.$isAgentTyping // Combine publisher of Bool — true while the agent composes;
// auto-clears on next agent message or after the typing timeout (~10s)
await session.sendTyping() // safe every keystroke; SDK throttles STARTED frames
// to ≤1 per 3s and auto-emits STOPPED ~5s after your last callIn a view controller:
private let typingIndicator = TypingDotsView() // your own animated-dots view (defined inside ChatViewController.swift)
private var bag = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
// ...your existing setup...
inputField.addAction(UIAction { [weak self] _ in
Task { await self?.session.sendTyping() }
}, for: .editingChanged)
session.$isAgentTyping
.receive(on: RunLoop.main)
.sink { [weak self] typing in self?.setTypingIndicatorVisible(typing) }
.store(in: &bag)
}
private func setTypingIndicatorVisible(_ visible: Bool) {
// Show/hide typingIndicator however you've laid it out (table footer here).
tableView.tableFooterView = visible ? typingFooter : UIView(frame: .zero)
}Under the hood: isAgentTyping is SDK-managed — true while the agent composes (driven by its thinking/streaming signals), auto-cleared on the next agent message or after the typing timeout (~10s), so you never run a timer. sendTyping() throttles outgoing STARTED frames to ≤1 per 3s and auto-emits STOPPED ~5s after your last call, so it's safe to fire on every keystroke.
See Integration guide › Typing.
Show only during transient reconnects:
session.$connection // Combine publisher of ConnectionStatus — cases:
// .idle / .connecting / .open / .reconnecting(attempt:) /
// .closing / .closed(_) / .failed(reason:)
// — show a banner only on .reconnecting (transient drops resolve as
// .open → .reconnecting(n) → .open, no .closed flash).
// .failed is terminal — handled by the failure overlay below.In a view controller:
private let connectionBanner = UIView() // your own banner view (yellow pill)
private let connectionSpinner = UIActivityIndicatorView(style: .medium)
private var bag = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
// ...your existing setup (lay out banner above the table)...
session.$connection
.receive(on: RunLoop.main)
.sink { [weak self] status in
if case .reconnecting = status {
self?.connectionBanner.isHidden = false
self?.connectionSpinner.startAnimating()
} else {
self?.connectionBanner.isHidden = true
self?.connectionSpinner.stopAnimating()
}
}
.store(in: &bag)
}Under the hood: session.connection is SDK-driven — a transient drop surfaces as .open → .reconnecting(n) → .open (auto-reconnect with backoff and jitter, no .closed flash), so you only need to react to .reconnecting. .failed arrives only after the reconnect budget is exhausted (handled by the failure overlay below).
See Integration guide › Connection & reconnect.
Render + dismiss the agent's quick replies:
agent.suggestions // [ResponseSuggestion] — agent messages only (user/system don't have these)
// Each: ResponseSuggestion(messageText: String, ...)
// Show pills only on the LAST agent message; they scroll with history.
session.clearSuggestions(for: message.id) // empties them locally so pills vanish before send() resolves
try? await session.send(suggestion.messageText)In a view controller — the diffable data source appends a .suggestions row after the last agent message that carries suggestions, hosted in a custom SuggestionsCell:
private enum Row: Hashable { case message(UUID); case suggestions(UUID) }
private var dataSource: UITableViewDiffableDataSource<Int, Row>!
private func render(_ messages: [ChatMessage]) {
var snapshot = NSDiffableDataSourceSnapshot<Int, Row>()
snapshot.appendSections([0])
var rows = messages.map { Row.message($0.id) }
// Append the suggestions row when the last agent message still carries pills.
if !session.hasEnded,
let last = messages.last,
case .agent(let agent) = last,
!agent.suggestions.isEmpty {
rows.append(.suggestions(last.id))
}
snapshot.appendItems(rows)
dataSource.apply(snapshot, animatingDifferences: true)
}
// In the cell provider for the .suggestions row:
case .suggestions(let id):
let cell = tableView.dequeueReusableCell(withIdentifier: SuggestionsCell.reuseID,
for: indexPath) as! SuggestionsCell
if case .agent(let agent) = session.messages.first(where: { $0.id == id }) {
cell.configure(suggestions: agent.suggestions) { [weak self] suggestion in
self?.session.clearSuggestions(for: id)
Task { try? await self?.session.send(suggestion.messageText) }
}
}
return cellSuggestionsCell is your own cell that lays out the pill buttons (a UIStackView of UIButtons is enough).
Under the hood: AgentMessage.suggestions are quick replies the agent attached to that message (agent messages only). clearSuggestions(for:) empties them in the model so the pills vanish before send(_:) resolves — feels instant. Sending replaces the last-message slot with the user message, so the suggestions row falls out of the diff naturally.
See Integration guide › Suggestions.
End the session + start a fresh one:
try await session.end() // user-initiated end; flips hasEnded; no "conversation ended" pill
session.$hasEnded // Combine publisher of Bool — true after end() OR an
// agent-/server-initiated end (server-end also appends a
// "conversation ended" .system message)
try await session.client.startNewSession() // begin a fresh conversation on the same surface
// — ChatSession auto-clears messages + resets hasEnded
// when the session id changesIn a view controller:
@IBAction func endTapped(_ sender: Any) {
Task { try? await session.end() }
}
@objc private func startNewChatTapped() {
Task { try? await session.client.startNewSession() }
}
override func viewDidLoad() {
super.viewDidLoad()
// ...your existing setup...
Publishers.CombineLatest(session.$isReady, session.$hasEnded)
.receive(on: RunLoop.main)
.sink { [weak self] _, ended in
self?.inputBar.isHidden = ended
self?.chatEndedView.isHidden = !ended
// Remove the End bar-button when the chat ends (iOS 15 has no `.isHidden`).
self?.navigationItem.rightBarButtonItem = ended ? nil : self?.endBarButton
}
.store(in: &bag)
}Under the hood: session.end() flips hasEnded. startNewSession() creates a fresh session — when the session id changes, ChatSession clears messages and resets the latched flags for you.
See Integration guide › Starting, resuming & ending a session.
Track delivery + retry a failed send:
m.delivery // Delivery enum (user messages only):
// .pending — sent optimistically; bubble shows immediately
// .sent — server echoed (matched by local id)
// .failed — retries (up to 3×) exhausted; show retry affordance
try? await session.send(m.text) // re-send the same textThe example's MessageCell lays the bubble + a left-side retry button + a "Sending…/Tap to retry" caption in a vertical outer stack, and surfaces a (String) -> Void retry callback the data source wires up:
final class MessageCell: UITableViewCell {
static let reuseID = "MessageCell"
private let bubble = UIView() // colored capsule
private let label = UILabel() // bubble text
private let retryButton = UIButton(type: .system) // exclamationmark.circle.fill, user-only on .failed
private let deliveryLabel = UILabel() // "Sending..." / "Tap to retry"
// ...other subviews (avatar, agent-name caption) + autolayout in init...
func configure(with message: ChatMessage,
onRetry: ((String) -> Void)? = nil,
showSendingLabel: Bool = false) {
if case .user(let m) = message {
label.text = m.text
let failed = (m.delivery == .failed)
retryButton.isHidden = !failed
if failed {
bubble.backgroundColor = UIColor.systemRed.withAlphaComponent(0.15)
deliveryLabel.text = "Tap to retry"
deliveryLabel.textColor = .systemRed
deliveryLabel.isHidden = false
} else if showSendingLabel && m.delivery == .pending {
deliveryLabel.text = "Sending..."
deliveryLabel.textColor = .secondaryLabel
deliveryLabel.isHidden = false
}
}
// ...agent + system branches...
}
}
// In the diffable data source — pass the retry closure:
let cell = tableView.dequeueReusableCell(withIdentifier: MessageCell.reuseID, for: indexPath) as! MessageCell
let pending: Bool = { if case .user(let m) = message, m.delivery == .pending { return true } else { return false } }()
cell.configure(
with: message,
onRetry: { [weak self] text in
Task { try? await self?.session.send(text) }
},
showSendingLabel: pending
)Under the hood: UserMessage.delivery is optimistic — .pending immediately, then the SDK matches the server echo (via a local id) → .sent; if no echo arrives after retries (up to 3×) it settles on .failed. The example re-sends the same text on retry and leaves the failed bubble in place — if you want the retry to replace the failed bubble instead, call session.removeMessage(draftId: m.draftId) before session.send(...) (see 06-FullReference for that flow).
See Integration guide › Delivery state & retry.
Surface a terminal failure + offer retry:
session.$failureReason // Combine publisher of PolyError? — non-nil when the chat can't auto-recover:
// invalid apiKey (initial connect 401/403),
// reconnect budget exhausted,
// session expired (idle past sessionTimeoutSeconds, default 10 min)
try await session.client.resume() // re-attempt the connection from your retry buttonIn a view controller:
private let failureOverlay = UIView() // your own full-screen overlay
private let failureLabel = UILabel()
private let reconnectButton = UIButton(type: .system)
private var bag = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
// ...your existing setup (add failureOverlay on top of the chat)...
reconnectButton.addAction(UIAction { _ in
Task { try? await self.session.client.resume() }
}, for: .touchUpInside)
session.$failureReason
.receive(on: RunLoop.main)
.sink { [weak self] reason in
self?.failureOverlay.isHidden = (reason == nil)
// PolyError isn't LocalizedError, so use String(describing:).
self?.failureLabel.text = reason.map { String(describing: $0) }
}
.store(in: &bag)
}Under the hood: failureReason is set whenever the chat can't auto-recover — an invalid apiKey rejected at the initial connect, the auto-reconnect budget exhausted, or the session expiring. Recovery is consumer-driven — call client.resume() to retry.
See Integration guide › Terminal errors.
The SDK doesn't get involved here. Pin your input bar to view.keyboardLayoutGuide.topAnchor (instead of the safe-area bottom) so it rides the keyboard with no notification observers:
private let inputBar = UIView() // your own composer container
override func viewDidLoad() {
super.viewDidLoad()
// ...your existing layout (add inputBar to view, etc.)...
inputBar.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor).isActive = true
tableView.keyboardDismissMode = .interactive
}See Integration guide › Avatars & keyboard.
Main.storyboard hard-codes customModule="StandardUIKit" on the view controller. If you rename the Xcode target, update the Module field in the Identity Inspector to match — or set it to None to let UIKit resolve the class from any module.
- attachments, URL cards, call actions →
03-RichContent/ - offline detection, full-screen terminal error →
04-Resilience/ - live agent handoff →
05-Handoff/
- SwiftUI counterpart:
Examples/SwiftUI/02-Standard/ - SDK reference: root README → Integration guide
- Install the package: root README → Install