What you saw
Every send in an existing session made the message blink: it faded in, vanished, and faded in again. The reply did the same a moment later, when its first words arrived.
Why
The timeline keys each row by its message id. During a send, two rows change id while they are on screen:
- The sent message. Until the environment confirms the send, the bubble is an echo with the id
send:<commandId>. Whenturn.sendanswers (orturn.startedarrives), the echo is replaced by the recorded user message, which has a different id. React treated that as a new row: it threw away the bubble's element and mounted a fresh one. The fresh one plays the 200 mschat-animate-slide-upentrance from opacity 0 again. - The reply. The assistant row is
turn:<turnId>:assistantuntil the reply's first text message exists, then it takes that message's id. The whole row, including the thinking state, thoughts and tools, was remounted the same way, so it blinked to opacity 0 and slid in again.
On a fast local environment the confirmation lands about 35 ms after Enter, so the entrance just restarts. When confirmation is slower, the bubble is fully visible first, so you see it vanish and come back.
Fix
The client already knows exactly which message confirmed which echo: the turn.send response and the turn.started event both carry the command id. So:
environment-client: when a confirmation clears an echo from the outbox, the thread recordsconfirmedEchoes[messageId] = commandId. A failed echo that a later snapshot or history page shows was accepted is recorded the same way.- This is client-owned echo identity, not server state: nothing is guessed or merged.
- It survives wholesale snapshot replacement.
- It is left out of the IndexedDB cache, because it only matters to rows the current page drew.
- Projection (
environment-thread.ts):- A confirmed message gets
rowKey = send:<commandId>, the key its echo had. It is skipped while the echo is still listed, so two rows can never share a key. - Every reply row gets
rowKey = turn:<turnId>:assistant.
- A confirmed message gets
- Timeline (
ChatView.tsx): row identity isrowKey ?? externalId. That identity is used for:- the React key;
- the virtualizer item key, so the measured height carries over;
- the entrance check and the remembered scroll anchor.
UserMessage: thumbnails are keyed by their stored artifact. On an image send, the echo and the confirmed message name the same bytes under different part ids, and keying by artifact stops the preview from blanking for a frame.
Hosts that don't set rowKey (desktop/Convex) behave exactly as before.
Verification
Real app. I ran a side server and web build on 43123/5176 with a copy of the data and the tunnel stripped. A Playwright probe sampled every timeline row each frame, recording element identity and opacity, across three sends:
| before | after | |
|---|---|---|
| Sent bubble, existing session | element replaced ~35 ms after Enter; entrance restarts from opacity 0 | one element throughout |
| Reply row when its first text arrives | element replaced; blinks 1 → 0 → 1 | one element, no opacity dip |
Tests.
- New reducer tests: which message confirmed which echo (response first, or event first), turns this client didn't send, snapshot survival, and the failed-echo reconcile path.
- New projection tests: the echo key carries over, no duplicate keys while an echo is listed, and the reply key stays stable.
- New app test: the bubble's DOM node is the same element before and after confirmation, and the reply row is the same element before and after its first text. It fails on
mainand passes here. environment-client/app-core/webtypecheck, lint and tests pass.boundary.test, oneroutes.testcase and oneprovider-healthcase fail only under full-suite load and pass alone, same as onmain.
Left alone
On a brand-new session's first send, the timeline still remounts once, because MessageTimeline is keyed by session id and the session gets its id mid-send. The bubble's entrance gets cut short (a small snap, not a blink), and only when the session is created within the 200 ms entrance. That is the launch path from #192 and a structural change of its own, so it isn't in this PR.
🤖 Generated with Claude Code
Activity
4[Medium impact] The PR appears safe to merge.
Summary
Keeps sent bubbles and reply rows mounted when their message IDs change.
- Sent messages and replies stay in the same chat rows.
- Image previews stay mounted when send confirmation changes part IDs.
No actionable issues found. Tests and browser checks were not run during this review.
Acknowledged scope: calledforth explicitly deferred the first-send timeline remount in a brand-new session because changing session identity is a separate structural change.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A["Echo: send command key"] --> B["Confirmation records message-to-command mapping"]
B --> C["Confirmed message keeps echo row key"]
D["Reply starts with turn row key"] --> E["First text changes message ID"]
E --> F["Reply keeps turn row key"]
C --> G["Existing row updates in place"]
F --> GReviews (1) · Last reviewed commit: "Keep a sent message and its reply on scr..." · Reviewed by Greptile
🚀 Deploying Preview to Cloudflare 🚀
Preview Deployments by commit
View logs ↗