Skip to article
PlanToCodeDocsGet the app

HandbookiOS app

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

A chat you leave keeps its rows but gets no live output until you come back
A chat you leave keeps its rows but gets no live output until you come backTwo 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.
You read chat A
You open chat B
You go back to chat A
Chat A
Desktop
Chat B
on screen · lease renewed every 20 s
parked: rows kept, nothing live
back at the bottom, behind a brief cover
on screen · holds the lease
A’s live blocks
A’s new blocks: not delivered, no lease
first load
B’s live blocks
one load merges newer rows into the kept ones
Host cachemost recent first
G
A
B
C
D
E
F
a seventh chat releases F, which then starts with a first load
  • 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.

Initial history response fields — schema outline
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 | 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
A late snapshot adds its history but never rolls the live rows backThe 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.
Desktop
Chat view model
load the latest window
counter 7 recorded
reads the window at revision 41
snapshot · revision 41
live blocks · revision 42
revision 42 shown · counter 8
snapshot lands
In this example, applying the snapshot as it arrived would put revision 41 back on screen.
older epoch, or same revision with other blocks?
if so, all of it is dropped and reloaded
counter moved from 7 to 8
its live rows and active run are dropped
history window
canonical rows and pagination commit
overlay revision 41 against 42
ignored: revision 42 stays on screen
  • 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

Streaming redraws only the visible cells that changed, at most six times a second, and waits while you drag
Streaming redraws only the visible cells that changed, at most six times a second, and waits while you dragTwo 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.
Text streams into one row
A row is added
You drag the list
Overlay changes
Renders
Collection view
SwiftUI to UIKit
at most once per 100 ms
at most one render per 1/6 s
same item IDs: only visible cells that changed are reconfigured
new item ID: one snapshot, no animation
held while you drag
checked every 250 ms
at rest: the newest held update applies once
0 s
0.5 s
1 s
1.5 s
2 s
  • 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

A newly shown chat stays covered until its layout holds still for three passes, and never longer than about two seconds
A newly shown chat stays covered until its layout holds still for three passes, and never longer than about two secondsThree 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.
Rows settle
Short history
Keeps resizing
pass 63: the cover lifts anyway
waiting for a Markdown row, one pass moves, three still passes
uncovered at about 0.22 s
settled, then an older page is requested
waiting for the page
settles again: uncovered
keeps resizing: the count never reaches three
0 s
0.5 s
1 s
1.5 s
2 s
While covered, the list is hidden and ignores touches. A settle that runs after the chat was already shown ends as soon as you drag.
  • 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.

The phone can’t tell a lost submit from a lost receipt, so it checks first
The phone can’t tell a lost submit from a lost receipt, so it checks firstTwo 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.
If the submit never reached the desktop
Phone
Desktop
relay
waiting 20 s
timeout: the entry stays queued
until the link is usable, or 10–13 s
outbox check
not listed
missing everywhere
submit
resend, same key
saved once
If the desktop saved it and only the receipt was lost
Phone
Desktop
relay
waiting 20 s
listed
listed: nothing is resent
saved, receipt stored under the key
receipt
Had it already left the outbox, a resend would get the stored receipt back.
  • 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.