# CLAUDE.md

This file covers the iOS follower app in `packages/ios-app/`.

## Scope

`packages/ios-app/SliccFollower/` is a native iOS SwiftUI app (`SliccFollower`) that connects to a SLICC leader over WebRTC and presents the leader's chat + sprinkles + (limited) federated CDP to a local user. It is **a follower only** — it does not host an agent runtime.

This package is NOT an npm workspace. It is a Swift Package Manager project (`Package.swift`) plus a generated Xcode project (`SliccFollower.xcodeproj`, regenerated by `project.yml` via XcodeGen).

## Layout

| Path                                                                                                                                                                                          | Purpose                                                                                                                                                                                                           |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SliccFollower/App/SliccFollowerApp.swift`, `App/AppState.swift`                                                                                                                              | App entry + central `@MainActor AppState` (connection lifecycle, per-scoop message buffers, sprinkle state, CDP bridge wiring)                                                                                    |
| `SliccFollower/Models/SyncProtocol.swift`                                                                                                                                                     | `Codable` mirror (partial — see "Protocol Mirror Invariant" below) of `packages/webapp/src/scoops/tray-sync-protocol.ts`                                                                                          |
| `SliccFollower/Models/ChatMessage.swift`, `Models/TrayTypes.swift`                                                                                                                            | Chat + signaling data types                                                                                                                                                                                       |
| `SliccFollower/Sync/FollowerSyncManager.swift`                                                                                                                                                | **DEAD CODE — slated for deletion.** Predecessor of the inline `AppState.handleDataChannelMessage` switch; zero instantiation sites in the iOS app today. All data-channel routing lives in `AppState`.           |
| `SliccFollower/Sync/Keepalive.swift`                                                                                                                                                          | `DataChannelKeepalive` ping/pong actor (used by `AppState`)                                                                                                                                                       |
| `SliccFollower/Networking/TraySignaling.swift`, `TrayFollowerConnector.swift`, `WebRTCManager.swift`                                                                                          | Signaling client + WebRTC peer/data-channel setup                                                                                                                                                                 |
| `SliccFollower/CDP/CDPBridge.swift`, `CDPTarget.swift`                                                                                                                                        | Hosts WKWebViews as CDP targets the leader can drive remotely                                                                                                                                                     |
| `SliccFollower/Views/ChatView.swift`, `MessageListView.swift`                                                                                                                                 | SwiftUI chat surface. `MessageListView` is the active renderer; `MessageWebView.swift` is a retired WKWebView-based predecessor that lingers on disk but is no longer wired                                       |
| `SliccFollower/Views/SprinkleWebView.swift`, `InlineSprinkleView.swift`, `SprinkleSidebarView.swift`                                                                                          | Renders `.shtml` sprinkle content received from the leader via `sprinkle.content`. Bridge calls (`lick`, `setState`, etc.) intercepted via `WKScriptMessageHandler` — VFS APIs are stubbed (graceful degradation) |
| `SliccFollower/Views/TabsCarouselView.swift`                                                                                                                                                  | Carousel of locally hosted CDP target WKWebViews                                                                                                                                                                  |
| Other views (`ChatFixture.swift`, `ConnectionStatusView.swift`, `ContentView.swift`, `InputBar.swift`, `MarkdownText.swift`, `MessageBubble.swift`, `SettingsView.swift`, `SliccIcons.swift`) | Top-level shell + smaller UI fragments — not exhaustive                                                                                                                                                           |
| `SliccFollower/WebView/`                                                                                                                                                                      | Static `chat.html` / `chat.css` / `chat.js` resources bundled by the retired `MessageWebView` path; still copied by the current build                                                                             |
| `SliccFollower/Resources/Assets.xcassets`                                                                                                                                                     | App icon + asset catalog                                                                                                                                                                                          |

`Package.swift` declares only the library product — there is no XCTest target yet, so `swift test` is a no-op until one is added.

## Protocol Mirror Invariant

`Models/SyncProtocol.swift` mirrors a **subset** of `packages/webapp/src/scoops/tray-sync-protocol.ts` — see the matrix in `docs/architecture.md` "Multi-Browser Sync (Tray) Architecture". What's TS-only today:

- Federated `fs.request` / `fs.response` in both directions.
- Follower-originated `cdp.request` and `tab.open` (iOS only _responds_ to leader-initiated requests — `CDPBridge.swift` sends `cdp.response` / `cdp.event` / `tab.opened` back, but never originates).
- The leader→follower reply path for follower-originated CDP/tab.open requests (`cdp.response` / `cdp.event` / `tab.opened` / `tab.open.error` received by a follower that asked for it). Since iOS doesn't originate, it never has to consume the reply.
- `tab.open.error` send-side — iOS embeds CDP errors in `cdp.response.error` and always sends `.tabOpened` for `tab.open`.

The doc-comment headers above the `LeaderToFollowerMessage` and `FollowerToLeaderMessage` enum declarations in `SyncProtocol.swift` declare the omission explicitly — `// MARK: -` boundaries are the stable anchors; line numbers shift whenever the headers expand.

When you change the protocol:

1. Update the TS union in `tray-sync-protocol.ts`.
2. Update the Swift `Codable` enum (`LeaderToFollowerMessage` and/or `FollowerToLeaderMessage`) **including the matching arm in the `init(from:)` decoder switch**. Adding only the enum case without the decoder branch falls through to `.unknown` on `LeaderToFollowerMessage` (silent drop) or throws on `FollowerToLeaderMessage` (loud).
3. Update the leader broadcast in `tray-leader-sync.ts`.
4. Update each follower that needs to handle the new message:
   - **Browser follower (TS)**: extend the `handleLeaderMessage` switch in `tray-follower-sync.ts`, plus the page-side controller wiring if the change is user-visible.
   - **iOS follower (Swift)**: edit `AppState.handleDataChannelMessage`. That's the only live dispatch point — every message (including chat events like `agent_event`, `snapshot`, `user_message_echo`) flows through it today. `FollowerSyncManager.swift` is dead code preserved for now; do NOT add new behavior there.
5. Bump tests on both sides. Note: `swift test` is unavailable today (no XCTest target), so Swift parity is enforced by inspection until a test target lands.

Skipping the iOS update lets `LeaderToFollowerMessage.init(from:)` quietly drop the new message via the `.unknown` case — no crash, no log, the feature just doesn't reach the iOS user. This is the most common form of protocol drift in this codebase.

## What this app supports vs the browser follower

Both followers now implement sprinkle rendering (the TS implementation landed in this branch and matches the iOS surface). iOS remains the longer-deployed reference; when adding a follower-side feature on the TS side, model the behavior on `AppState`:

- Connection lifecycle: `connect()` / `disconnect()` / `dataChannelOpened()` / `handleDisconnect(reason:)`
- Message dispatch: `handleDataChannelMessage(_ data: Data)` switch
- Sprinkles: `refreshSprinkles()`, `fetchSprinkleContent(_:)` with chunk reassembly + waiter dedup, `sendSprinkleLick(_:body:targetScoop:)`, `handleSprinkleContent(...)`
- Multi-scoop: `selectScoop`, `swipeToNextScoop` / `swipeToPreviousScoop`, per-scoop `messagesByScoop` buffer + scheduled flush throttling
- Agent events: `handleAgentEvent(_:scoopJid:)` with the same scoop-targeted buffer update + per-render-loop throttle

## Build

```bash
cd packages/ios-app
swift build                                      # Library build (CI smoke)
xcodegen generate                                # Regenerate SliccFollower.xcodeproj from project.yml
xcodebuild -project SliccFollower.xcodeproj …    # IPA / TestFlight builds
```

TestFlight automation lives in `scripts/package-and-upload-testflight.sh` (consumes secrets via `setup-testflight-secrets.sh`).

## Related Guides

- `packages/webapp/src/scoops/tray-sync-protocol.ts` — canonical protocol (the file this app's `SyncProtocol.swift` partially mirrors)
- `packages/webapp/src/scoops/tray-leader-sync.ts` — leader-side broadcast/respond logic
- `packages/webapp/src/scoops/tray-follower-sync.ts` — browser follower
- `packages/webapp/src/ui/sprinkle-follower-controller.ts` — browser follower's page-side sprinkle renderer (mirrors `SprinkleWebView` behavior)
- `docs/architecture.md` "Multi-Browser Sync (Tray) Architecture" — cross-float overview
