# iOS: chat hosts, the collection view, and the outbox

> How the iPhone app keeps visited chats, applies snapshots against newer live state, renders the timeline in a UIKit collection view, anchors scrolling, verifies a send whose acknowledgement was lost, and shows the queue, an accepted steer, and messages from other sessions.

Handbook / iOS app · Checked against the source on 2026-10-05 · https://www.hbcode.ai/docs/ios/chat

## You choose the agent when you create the session

The new-session form has an Agent picker. SessionAgent has exactly two values, codex and claude. The agent is fixed for the life of the session, so the form asks before session.create. The form stores the choice in UserDefaults under session.newSessionAgent, and before a first choice it preselects Codex. The picker is disabled while a create request runs.

The form also lists the saved conversations of the chosen agent for the project folder, through run.chatExistingSessions, and shows one with run.chatPreviewExistingSession. Changing the agent reloads the list. One keyed session.create request carries the agent and, when you picked a conversation, its conversationId, and the new session continues that conversation. A conversation row shows a model and a client name only for Codex, because only Codex threads report them.

After that, a Claude session uses the same chat host, the same rows, and the same run.chat* methods as a Codex session. The phone code branches on the agent in a few places only.

| Part | Codex session | Claude session |
| --- | --- | --- |
| Model picker families | astra, sol, terra, luna. The matrix needs sol, terra, and luna. | fable, opus, sonnet, haiku. The matrix shows the families that the desktop reports. |
| Speed mode | The entry carries the mode from the composer. | The entry always carries standard. |
| Access level names, from read-only to unrestricted | Read only, Full auto, Full access, and Never ask. | Plan mode, Accept edits, Auto mode, and Bypass permissions. |
| storageFormat in the history envelope | indexed | native |

- [You choose the agent when you create the session](https://www.hbcode.ai/docs/agents.md#agent-choice): What the desktop does with the agent and the conversation ID.

## The app caches up to six chat hosts and shows one

**A chat you leave keeps its rows but gets no live output until you come back**

Two chats on the same desktop over time. While chat A is on screen it holds the timeline lease, and its live blocks arrive. When you open chat B, chat A is parked: it keeps its rows, but its new blocks are not delivered, because B now holds the lease and only B’s live blocks arrive. Chat B starts with a first load. When you go back to chat A, one load merges the newer rows into the kept ones, the chat comes back at the bottom behind a brief cover, and its live blocks arrive again. Below, the host cache keeps six chats with the most recently selected first. Opening a seventh chat releases the least recently used one, which then starts again with a first load.

- **Host cache** (WorkspaceChatHostCache): Keeps up to six chat view models, most recently selected first, and mounts only the selected one. Opening a seventh chat releases the least recently used.
- **Timeline lease** (chat:timeline-interest): Only the chat on screen holds it. The phone renews it every 20 seconds, and the relay keeps it for 60. Without it, that chat’s live blocks never reach this phone.
- **Catch-up load** (run.chatLoad): Coming back starts one latest-window load that merges into the kept rows. A chat on screen also checks about every 60 seconds, or every 10 while a run, message, or load is pending.

WorkspaceChatHostCache keeps the view models of recently visited chats, keyed by desktop, project folder, and session, and holds at most six. Selecting a chat while SwiftUI renders only stages its entry, and the selection’s task commits it afterwards, so the cache never publishes a change in the middle of a view update. Only the selected host is mounted.

Each host owns one latest-window load scheduler with one active operation and one pending request for its current target. Requests coalesce, and a snapshot applies only when its target and the fences it captured still match. A parked host keeps its rows and live overlay, and its scheduler starts nothing until the host is selected and active again. Its scroll position and reveal state belong to the unmounted view, so it comes back at the bottom behind a brief cover. The iPhone app has no batch reconciliation and does not load a chat you are not looking at.

A finished, failed, aborted, or cancelled run, desktop recovery, returning to the foreground, a different selected desktop, an explicit refresh, selecting the chat again, and a newer session updatedAt mark the selected target stale and may queue its load. While the chat stays on screen, it also loads the latest window about every 60 seconds, or every 10 seconds while a run, message, or load is pending, with up to 30 percent added to each wait. This periodic load runs only when the app is active, the desktop can take a request, the relay is not reconnecting, no load is active or pending, no older page loads, and the timeline is not an empty one that the desktop has not confirmed. A failed load leaves the visible rows as they are and marks the host for a fresh load on its next activation. Subagent threads open in their own viewer with a separate view model that loads through run.chatLoadThread and run.chatLoadThreadPageBefore.

The first page has 12 rows and an older page has 100. A failed first load is tried again up to 3 times. The wait starts at 0.75 seconds, doubles with each try, varies by 20 percent in both directions, and never passes 3 seconds. Two reloads of the same chat start at least 2 seconds apart.

## The history envelope is decoded exactly

WorkspaceChatLoadEnvelope requires the exact canonical set of top-level keys. Several fields must be present even when their value is null. The decoder validates a nonblank boundaryToken and decodes timeline blocks through the shared page decoder. Every row needs a type among user, system, activity, agent-reply, and thinking, a nonblank id, an origin of canonical or live, a non-empty sourceOrder of unsigned integers, and an integer timestamp. Live rows need a runId, and live user rows need an operationId. A duplicate splice key fails the whole page.

Initial history response fields — schema outline:

```
boundaryToken: nonblank string
nativeSessionId: string | null
loadState: typed load state
storageFormat: "indexed" | "native"
loadedEntries: integer
hasOlderEntries: boolean
oldestCursor: string | null
timeline: typed blocks[]
liveOverlay: { settlementEpoch, revision, blocks }
runtime: object | null
activeRun: object | null
```

The live overlay decoder requires exact integer epoch and revision values within the JavaScript safe range. A revision of zero cannot contain blocks. Every overlay block must have live origin, and typed block identities must be unique. Rejecting an invalid envelope keeps malformed data from becoming apparently valid empty history.

## A snapshot is applied against newer live state

**A late snapshot adds its history but never rolls the live rows back**

The chat view model asks the desktop for the latest window and records its live-event counter, 7 in this example. The desktop reads the window at overlay revision 41, but the response travels longer than a live event with revision 42, which the view model shows at once and which moves the counter to 8. When the snapshot lands, the view model first checks its overlay, which would drop the whole response on an older epoch or on the same revision with different blocks. Because the counter moved, the response’s live rows and active run are dropped. Its canonical rows and pagination commit, and its overlay at revision 41 is ignored, so revision 42 stays on screen.

- **Live-event counter** (timelineLiveEventRevision): Goes up when a live event raises the overlay revision, or when a run starts or finishes. Every latest-window load records the value it started with.
- **Overlay version** (settlementEpoch · revision): Within one epoch the higher revision wins and a lower one is ignored. A live event with a newer epoch is refused and triggers a latest-window reload.
- **Stale parts** (removingStaleLiveRows): If the counter moved during the load, the response’s live rows and active run are dropped. Its canonical rows still apply.

If history is unavailable, the visible window and pagination stay, and only an overlay from the same epoch applies. Outbox entries and echoes then reconcile against confirmed history, the timeline revision goes up when anything changed, and a refresh that replaces a non-empty window with rows that don’t overlap it advances the presentation epoch.

## A UIKit collection view inside SwiftUI

**Streaming redraws only the visible cells that changed, at most six times a second, and waits while you drag**

Two seconds of a streaming chat, drawn to scale. Live overlay changes reach the view model at most every 100 milliseconds. SwiftUI renders them at most every sixth of a second, and each render reconfigures only the visible cells whose content changed. When a row is added, the coordinator applies one diffable snapshot without animation. While you drag the list or it slows down, updates to visible rows or to the row list wait while renders go on. A check runs every 250 milliseconds, and once the list is at rest only the newest held update applies.

- **Render cadence** (liveStreamingRenderMinInterval): Live overlay changes reach SwiftUI at most every 1/6 second, or every 0.5 seconds while the composer or a preview is open.
- **Coordinator** (WorkspaceChatTimelineCollectionView.Coordinator): Compares item IDs with the applied snapshot. With the same IDs it reconfigures only the visible cells that changed, and rows off screen pick up new content when they scroll in.
- **Display rows** (WorkspaceChatTimelineLayerProjectionCache): Built by the chat tab from the view model’s rows. Thinking rows leave the list, the active run’s live thinking shows as the pinned status, and subagent panel rows are hidden.
- **Held update** (deferredUpdateRecoveryIntervalNanoseconds): While you drag or the list decelerates, changes to visible rows or to the row list wait. Only the newest is applied, when the list stops or a 250 ms check finds it at rest.

The timeline is a UICollectionView wrapped in a UIViewRepresentable. It uses a compositional layout with one vertical section and 80-point estimated heights, a diffable data source whose items are row ID strings, and cells whose content is a UIHostingConfiguration around the SwiftUI row. Self-sizing invalidation includes constraints, so a row that grows is measured again. The chat tab builds the display rows from the view model’s rows: thinking rows leave the list, and the active run’s live thinking becomes the pinned status. The desktop sends no row for a subagent or a workflow. The collection itself never adds or drops a message.

Live overlay changes reach SwiftUI at most six times a second, or twice a second while the composer or a preview is open, and during a drag only the newest held update is kept.

## First reveal, older pages, and following the bottom

**A newly shown chat stays covered until its layout is the same for three passes, and never longer than about two seconds**

Three first reveals drawn to scale over two seconds, with one mark for each 32-millisecond settle pass. In the first, three passes wait for a Markdown row to finish preparing, one pass finds the layout moved, and three still passes follow, so the cover lifts after about 0.22 seconds. In the second, the history is short: the layout settles, an older page is requested, passes wait for it, and the cover lifts once the layout settles again. In the third, the layout keeps resizing, the count of still passes never reaches three, and the cover lifts anyway at pass 63, about two seconds in.

- **Settle pass** (passDelayNanoseconds): Every 32 ms the view compares bounds, content height, offset, insets, and each visible row’s top and height with the pass before. Still means within 0.5 pt, so the first pass after a reset never counts.
- **Prepared rows** (visiblePreparedMarkdownRowsAreReady): Passes do not count while a visible Markdown row is preparing or a snapshot is applying.
- **Short history** (olderTimelinePreloadThreshold): With 1,800 pt or less above the screen, the settled view requests an older page and settles again, up to two pages before the cover lifts.
- **Deadline** (maximumSettlementPassCount): After 63 passes, about two seconds, the cover lifts even if the layout never held still.

The cover lifts after about two seconds even if the layout never holds still, and a short history loads up to two older pages under it first. The presentation epoch pairs the session with a timeline identity kept per viewport, parent-chat or subagent-<thread ID>. It advances when a refresh replaces the window with rows that don’t overlap it, when an empty, unconfirmed chat starts a fresh load, or when the thread changes, so scroll assumptions from one window never carry into another.

Older pages load when you scroll within 1,800 points of the top, at most every 0.25 seconds, and a load counts as stale after 8 seconds. Before rows are prepended the view records the first visible row and its offset, and after the update it scrolls that row back into place and restores the offset. Live rows are held back for up to 1.5 seconds while the restore runs, so a streaming answer cannot move the rows you are reading.

Following the bottom runs on a CADisplayLink. The speed is the remaining distance divided by 1.5 seconds, clamped between 80 and 2,200 points per second, and with Reduce Motion on the view jumps instead. Settle passes run every 32 milliseconds, or 350 milliseconds after an animated scroll, and end after two stable passes within 1 point and half a point of content height change, or after eight passes.

The floating controls rest on one bottom line in the main timeline and in the thread viewer: the menu, scroll to latest, Stop, and Switch desktop. WorkspaceChatBottomChrome owns the line. Each control is 52 points wide, 16 points from the screen edge, and 10 points from its neighbor. The bottom edge of each control is 57 points above the bottom safe area. The run clock and the pinned status sit under the line and take no room from it, and the last row of the timeline rests 125 points above the safe area. The menu, scroll to latest, and Switch desktop have one face, WorkspaceFloatingControlFace: the floating color at 94 percent, a sheen in dark mode, and a shadow. The closed menu button shows one badge: the count of queued messages while the queue has some, else the count of active sub-agents. Its VoiceOver label names both counts.

## The phone’s copy of the outbox

The desktop owns delivery. The phone keeps a replay set in UserDefaults under workspace-chat-local-outbox-v2, where each session scope stores its operation order and each operation as either pending, with the full entry, or terminal. A terminal operation fences stale writers for the rest of the process and is compacted away on the next launch, which starts a new generation.

| Entry field | Who sets it, and what it means |
| --- | --- |
| queueId, operationId | Set by the phone. The queue slot and the immutable message. Both must be nonblank. |
| userText, promptText | Set by the phone, or by the desktop for a session message. The text as its author wrote it, and the text that the agent gets. |
| status | Set by both. queued or sending. Any other value fails the decode. |
| sendMode, expectedActiveRunId | Set by the phone. queue, steer-running, or interrupt-running. A steer must carry a nonblank run ID, and the other modes must carry none. The initializer, the decoder, and the encoder all check this. |
| pendingMutation | Set by the phone. The kind, submit or remove, with its own mutation ID. A submit also records its intent. A retry repeats the same request. |
| accepted object | Set by the desktop. runId, threadId, turnId, and acceptedAt of the running turn that took a steer. |
| handoff object | Set by the desktop. A stop-and-send-now in progress, with the phase requested, replay-authorized, or dedicated. |
| holdReason | Set by the desktop. Why delivery waits. It is not a failure. The field error is. |
| message | Set by the desktop on a message from another session: the message fact of the user row that the entry becomes, with the sender title and “This session”. It has no link and no delivery, and any other message fact fails the decode. It is absent when you wrote the message. |

Outbox changes for one session run as a serial chain. A submit waits 20 seconds for the desktop’s receipt. A permanent rejection puts the text back into the composer when the draft can be restored.

**The phone can’t tell a lost submit from a lost receipt, so it checks first**

Two cases on the same timing, with the phone above and the desktop below. In the first, the submit is lost before it reaches the desktop. In the second, the desktop saves the message and stores its receipt under the idempotency key, but the receipt is lost. The phone sees the same thing in both: it waits 20 seconds, keeps the entry queued, and when the connection is usable again, or after 10 to 13 seconds, it loads the desktop’s outbox. In the first case the entry is missing everywhere, so the phone resends it with the same key and the desktop saves it once. In the second case the entry is listed, so nothing is resent. Had it already left the outbox without reaching the timeline, the resend with the same key would get the stored receipt back.

- **Unknown outcome** (ServerRelayError.timeout): A 20-second timeout or a dropped connection is not a rejection. The entry stays queued with “Desktop did not confirm this message”, and nothing is sent again yet.
- **Outbox check** (run.chatOutboxLoad): Runs when the connection becomes usable, or every 10 to 13 seconds while a message waits in the open chat. Listed, visible in the timeline, or already accepted means nothing is resent.
- **Same key** (run.chatOutboxSubmit:<mutation ID>): A resend reuses the pending mutation and the saved entry, so the key and the params repeat. The desktop keeps a finished answer for 48 hours and returns it instead of saving twice.

Attachments upload before the message. The desktop returns an upload ID and a chunk size, the phone caps chunks at 1 MiB and base64-encodes them, and the calls use the keys <upload key>:begin, :chunk:<n>, :finish, and :cancel. The upload key is files.chatAttachmentUpload:<session>: followed by a 64-bit FNV-1a hash of the session, project, file name, MIME type, path, and size, so a retry of the same file reuses the same keys. Files can be up to 1 GiB, and a PNG is re-encoded as JPEG at quality 0.88 when its decoded image needs at most 64 MiB.

- [Message delivery](https://www.hbcode.ai/docs/message-delivery.md#uncertain-delivery): What the desktop does with an operation whose outcome is uncertain.
- [Two idempotency layers](https://www.hbcode.ai/docs/relay-protocol.md#idempotency): How the desktop replays a stored response for a repeated key.

## The queue sheet and the desktop hold

The queue sheet lists the entries of the open chat that are queued, have text or attachments, and belong to the thread on screen. An entry that is sending is not in the sheet. Each row shows the status, the label line of a message from another session in the words of the desktop, the text, the model and reasoning effort of a saved selection, the attachments, the hold reason, and the error.

| Action | What the phone does |
| --- | --- |
| Drag the handle | Reorders the local entries first, then sends the full order of queue IDs with run.chatOutboxReorder. |
| Send now | Moves the entry to the front, marks it sending, and sends run.chatOutboxSubmit with the intent automatic. |
| Edit, then save | Sends run.chatOutboxSubmit for a new entry in the same queue slot. The entry has a new operation ID and the intent enqueue, and the request names the old operation as superseded. |
| Edit, then send | Makes the same new entry, moves it to the front, marks it sending, and sends run.chatOutboxSubmit with the intent automatic. |
| Delete | Records a pending removal with its own mutation ID and sends it. The removal counts when the desktop answers updated and no longer lists the entry. The method is run.chatOutboxRemove. |

The three mutations need an idempotency key, and run.chatOutboxLoad reads the queue. A drag ends by itself after 8 seconds, and 2 seconds after it leaves the list. A change of the entries under a drag cancels the drag.

The desktop can hold an entry and say why in holdReason. The phone never sets this field and does not treat it as a failure. The row shows the reason as a secondary caption under the text. Send now is a submit with the intent automatic, and the desktop takes every intent other than enqueue as your approval of a held message from another session. An edit removes the message fact and the hold reason. The new entry has a new operation ID, and its prompt text is the edited text, so the agent gets it as your own message.

- [Messages between sessions](https://www.hbcode.ai/docs/message-delivery.md#session-messages): When the desktop holds a message and what approves it.
- [What the composer actions do](https://www.hbcode.ai/docs/message-delivery.md#send-actions): Queue, steer, interrupt, and replace latest on the desktop.

## An accepted steer and “Stop and send now”

**An accepted Codex steer waits for the current step, and one tap turns it into a turn of its own**

A Codex session over time, not to scale, with the phone above, the desktop in the middle, and the running turn below. You send a message while a run is active. The phone submits it as a steer for that run, the running turn accepts it, and the desktop marks the entry accepted with the run, thread, and turn. The user row then shows “Sends after the current step” with the action “Stop and send now”, because Codex applies the steer only when the current step ends. You tap the action. The phone sends one keyed request that names the entry and the accepted run, thread, and turn, and hides the action. The desktop stops the run and replays the message as a turn of its own, and the entry leaves the queue. A failed or blocked request shows “Not confirmed” without the action.

- **Accepted steer** (accepted: runId · threadId · turnId · acceptedAt): Only the desktop sets it, when the running turn took the steer. It does not prove that the message is in the history.
- **Caption and action** (WorkspaceChatUserOutboxDelivery): Shown for an entry that is sending, has no error and no handoff, and whose accepted run is the active run. In a subagent view the accepted thread must be that thread.
- **Request** (run.chatOutboxStopAndSendNow): Carries sessionId, queueId, operationId, expectedRunId, expectedThreadId, and expectedTurnId. One tap makes one mutation ID for the key, and the call waits up to 60 seconds. The desktop checks the exact turn.
- **Outcome** (stopping · notConfirmed): While the request runs the action is hidden. An error or the outcome blocked shows “Not confirmed” without the action, and the steer stays where it is.

A message that you send during a run is a steer for the active run, in a Codex session and in a Claude session. The figure shows a Codex session. The desktop snapshot of the outbox owns the accepted state, and the local replay set does not. The view model keeps the last desktop entries of each session and looks up the entry by operation ID when it builds the pending user row.

The action is offered only while the accepted run is the active run. A change of the active run publishes a new outbox revision when an accepted entry names the old or the new run, so the caption and the action leave the row. The phone does not track the active turn. It sends the turn ID that the desktop gave it, and the desktop compares it with the turn that really runs.

A request without one of its six identities is refused on the phone before anything is sent. The phase of an operation stays in memory. After “Not confirmed” the row offers no second try for that operation. After an error the phone also loads the desktop outbox again.

- [Turn start, thread owner, and steering](https://www.hbcode.ai/docs/codex-turns.md#turn-lifecycle): When Codex applies a steer and what the desktop does with a stop-and-send-now.
- [Claude live runs](https://www.hbcode.ai/docs/claude-agent-sdk.md#live-runs): When Claude reads a steer and how the desktop takes it back.

## Messages from other sessions

**A message between sessions shows on the phone in three places, never with its envelope**

An example with two sessions on one desktop: “API review”, a Claude session, sends a message to “Fix tests”, a Codex session. In the timeline of “API review” the message is a band with the label line “This session → Fix tests”. The desktop queues it for “Fix tests”, where the queue sheet shows a row with the label line “API review → This session”, the message text, and the reason why the desktop holds it. After delivery the timeline of “Fix tests” shows a user row with the label line “API review → This session”. A tap on the name of the other end opens that end at the row of the message. The prompt text with the envelope for the agent is in the outbox entry, but no view shows it.

- **Message fact** (message: direction · from · to · peer · delivery · link): The desktop makes every word of the row. The phone shows the words and makes none. A fact with a value that the app does not know fails the decode.
- **Queue row** (userText · message · holdReason): The row text is userText, never promptText. The label line comes from the message fact of the entry, and the hold reason is a second caption that only the desktop sets.
- **Peer link** (message.link · anchor): A tap on the name of the other end loads its timeline with the anchor. The row of the message shows in the middle with a mark. The way back is the link on that row.

An agent in one session can send a message to another session of the same desktop, and agents in one session send messages to each other. The phone cannot create such a message. It shows the message fact that the desktop puts on the row. WorkspaceChatMessageBand shows each message row: the label line with the two ends, for example “This session → Fix tests” or “API review → This session”, the signal Sent, Needs approval, or Not sent, and the text as Markdown. The desktop removes the envelope, so the row text is only the message.

The decoder is strict. A message fact with a value that this build does not know, or with a blank word, fails the decode. The phone makes no words of its own: the row title, the two ends, and “Untitled session” come from the desktop. The wire has no detail for a message row. A tap on the band opens the message detail, which the phone builds from the row title, the fact, and the text. The detail keeps the line breaks of the sender.

When the fact has a link and the chat is in the workspace, the name of the other end in the label line is the link. It has the link color, the agent mark of the other end, and a touch area of at least 44 points. VoiceOver opens the detail with the default action, and the named action “Go to <peer>” follows the link. WorkspaceMessageJumpModel shows the session and the thread of the other end. The chat of that place takes the anchor once and loads the window around the row. The collection view puts the row in the middle of the screen and marks it. It keeps the row in the middle in each layout pass until you drag, tap the status bar, or the chat gets another scroll command. There is no Back control: the way back is the link on the row at the other end. In the message detail, the same link closes the detail first and then follows the link.

A window with a newerCursor is a page in the past. While such a page shows, each reload of the timeline loads the same page again with a row anchor at its row, also after a reconciliation, a relay gap, or the end of a run. The reveal stays, so the rows do not move and no row gets a new mark. The phone does not apply the null activeRun of a page in the past, so Stop stays while a run is active. A notice from the desktop, such as “This message is not in the saved history.”, shows over the timeline.

- [Messages between sessions](https://www.hbcode.ai/docs/message-delivery.md#session-messages): The envelope, the limits, and the hold rules on the desktop.
- [The session message tool](https://www.hbcode.ai/docs/tools/agent-tools.md#session-messages): The tool contract that the agent sees.
- [Message rows and the anchored load](https://www.hbcode.ai/docs/timeline.md#anchored-load): The message fact, the typed anchor, and the window of 40 rows.

## Tests that pin this behavior

- WorkspaceChatTimelineSnapshotOrderingTests: only the active operation may apply its snapshot, and a changed target generation rejects an earlier one.
- WorkspaceChatTimelinePaginationReloadTests: a recovery reload keeps the rows until its snapshot applies, and a pending reload survives a transient connection loss.
- WorkspaceChatLiveTimelineConfirmationTests: a live row settles only through a shared typed identity, never through matching text or timestamps.
- WorkspaceChatMessageLinkRoundTripTests: each link lands on the exact row in the middle of a page in the past, between two sessions and between the main timeline and an agent thread.

These suites live in VibeUITests, which runs hosted in the app, so they need an iOS Simulator destination. Physical-device behavior such as the first reveal with saved Markdown history is still checked by hand.
