#252

Sync catch-up (2/2): whole chats and the whole sidebar, replay or snapshot by size

Draftcalledforthwants to merge intofrom

On hold for a decision: on a 2 Mbit/s link, reloading a large chat now takes about 6.4 s, where it took 3.6 s before, because the whole sidebar (767 KB) is sent ahead of the chat. See "Before and after" below.

Phase F5b of the sync foundations (docs/sync-foundations.md §6). Protocol v20, no migration. Builds on F5a (v19), which made messages of any size safe to send.

What changes for you

  • A chat is always whole. Opening a chat, reconnecting or reloading brings the entire conversation, every thought and tool call included. The "Load older messages" button is gone, and so are the cases where reconnecting after a long gap silently dropped the thoughts and tool calls of older turns, or replaced a long thought with "N characters of thinking not loaded".
  • The sidebar is always whole. After any gap the browser receives every session, project and composer preference at once and replaces its copy. A chat deleted while you were away disappears; the old page-by-page catalog walk, which could miss deletions while other chats were moving, is gone.
  • Catching up picks the smaller download. The server compares, byte for byte, the replay of what you missed with the whole chat (or the whole sidebar) and sends the smaller one. A short gap in a long chat is a few events; a long gap, or a burst of streamed text into a short chat, is the chat itself. There is no longer any cap on how much a replay may carry.

How it works

  • Exact sizes. SUM(octet_length(event_json)) plus the fixed record wrapper and the envelope gives the replay's exact size before it is built; the replay answer is the stored event JSON concatenated, so that size is what is sent. A thread's snapshot is only built when its stored parts could make it smaller. Ties go to replay. New snapshot reason: snapshot_smaller.
  • Whole snapshots. A thread snapshot is every turn, message, thought, tool and pending request, read with one query per table; the environment snapshot is every session, workspace, composer preference (with its revision) and provider profile.
  • The browser replaces. applySnapshot replaces the server-owned part of a scope and keeps the identity of everything that did not change, so a catch-up that changes nothing re-renders nothing. Every scope, the sidebar included, catches up through subscription.replay; a replay is applied in one store update.
  • Deleted: session.list, session.history, composer.preferences.get, their cursors and page limits, the session page budget, the reasoning budget, the client's catalog walk and reconcileCachedSessions, every snapshot/history merge helper, and the chat's paging UI. Desktop (Convex) keeps its own small copy of the old transcript filing in legacy-history.ts.

Scenarios (convergence harness)

  • S4 a long chat and a gap past the old replay cap: older thoughts and tools were lost; now converges (guard).
  • S6(b) thoughts over 384 KiB: a fresh load showed a "not loaded" note; now the whole text (guard).
  • S9 a parent and child deleted while away, a moving sidebar: the deleted chats stayed listed; now they go (guard).
  • S10 a long chat, then a reload with the cache: older thoughts and tools were lost; now converges (guard).
  • S12 and G2 are sized so their short gaps are still replays; S9r likewise. S19 now orders its race on the provider catalog read, since the catalog walk is gone.

Measurements

On a copy of a real environment database (140 chats, 28,168 events; migrated by this server), the real server, the real client and a shaped link. Times are ms from connecting; "client" is the browser's own work (message handling and store updates), the rest is download.

Sizes. Largest chat: 212 KB for its newest 100 messages before, 568 KB whole now (618 KB the largest of all; p50 36 KB, p90 176 KB). The sidebar: 74 sessions / 262 KB in the old snapshot, plus a 704 KB catalog walk; now all 140 sessions, preferences and profiles in one 767 KB answer. Of that, ~658 KB is each session's composer state (its slash-command list is ~22 KB per session).

Processing, in process (median; cold first run in brackets). Largest chat, whole: server build 14 ms, client parse 1.5 ms, validate 2.8 ms (4.7), apply 0.2 ms (1.4). Whole sidebar: server 24 ms, client parse + validate 7.7 ms (11.9), apply 0.1 ms. Before, the newest page alone took 2.5 ms on the client and the older pages another 14–27 ms.

End to end.

LinkRunLargest chat current, beforeafterWhole chat, beforeafterClient, before → after
loopbackcold557349736349144 → 85 ms
loopbackreload with cache248217(newest page)21755 → 55 ms
mobile 2 Mbit/s, 75 mscold4,8796,2257,5016,22576 → 73 ms
mobile 2 Mbit/s, 75 msreload with cache3,6476,368(newest page)6,36870 → 54 ms
slow 320 kbit/s, 150 mscold26,54836,30040,42936,30079 → 84 ms
slow 320 kbit/s, 150 msreload with cache25,91436,602(newest page)36,60258 → 75 ms

Reading: the browser's work stays under 0.1 s; everything else is download. The whole chat now arrives sooner than main's newest page plus older pages (mobile 7.5 → 6.2 s), but the first current screen of a large chat after a reload is later (mobile 3.6 → 6.4 s): main sent 212 KB of the newest messages after a 500 KB catalog; this sends the whole 767 KB sidebar, then the whole 568 KB chat, in that order on one connection. The cached copy still paints at once. The biggest lever is outside this PR: moving each session's composer state out of the sidebar summary would cut ~85% of the sidebar answer; caching cursors with the cache would turn a reload into a replay.

Tests

  • Server: typecheck and lint clean; 951 passed.
  • Protocol 284, environment client 283, app-core 484 (the boundary test passed alone, a known flake under load), web 355; web and desktop typecheck clean; desktop adapter tests 44.
  • Convergence: fast set 65 passed; nightly set 87 passed (on the final commit).

What to look at

  • apps/server/src/db/replay.ts: the decision and replayPayloadBytes; the unit test that compares the formula with the serialized answer.
  • packages/environment-client/src/state.ts: applySnapshot/replaceEnvironment and share.
  • The reload numbers above: whole chats and a whole sidebar cost more bytes up front on a slow link (see Measurements). The cache still paints the old copy at once.

🤖 Generated with Claude Code

🤖 Generated with Claude Code