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, and verifies a send whose acknowledgement was lost.
Checked against the source on 17 September 2026
On this page
Six cached hosts, one on screen
- Host cacheWorkspaceChatHostCache
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 leasechat: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 loadrun.codexChatLoad
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. 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.codexChatLoadThread and run.codexChatLoadThreadPageBefore.
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 an operationId, and a duplicate splice key fails the whole page.
boundaryToken: nonblank string
codexSessionId: string | null
loadState: typed load state
storageFormat: "indexed"
loadedEntries: integer
hasOlderEntries: boolean
oldestCursor: string | null
timeline: typed blocks[]
liveOverlay: { settlementEpoch, revision, blocks }
runtime: object | null
activeRun: object | nullThe 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
- Live-event countertimelineLiveEventRevision
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 versionsettlementEpoch · 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 partsremovingStaleLiveRows
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
- Render cadenceliveStreamingRenderMinInterval
Live overlay changes reach SwiftUI at most every 1/6 second, or every 0.5 seconds while the composer or a preview is open.
- CoordinatorWorkspaceChatTimelineCollectionView.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 rowsWorkspaceChatTimelineLayerProjectionCache
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 updatedeferredUpdateRecoveryIntervalNanoseconds
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, the active run’s live thinking becomes the pinned status, and subagent panel rows are hidden. 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
- Settle passpassDelayNanoseconds
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 rowsvisiblePreparedMarkdownRowsAreReady
Passes do not count while a visible Markdown row is preparing or a snapshot is applying.
- Short historyolderTimelinePreloadThreshold
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.
- DeadlinemaximumSettlementPassCount
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 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. An entry carries its operation ID, status, whether the desktop accepted it, a pending mutation with its own mutation ID, and the send mode, and steering a running turn also records the active run ID.
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.
- Unknown outcomeServerRelayError.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 checkrun.codexChatOutboxLoad
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 keyrun.codexChatOutboxSubmit:<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.
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.
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.