#222

Stop a sent message (and its reply) from flickering

Opencalledforthwants to merge intofrom

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:

  1. The sent message. Until the environment confirms the send, the bubble is an echo with the id send:<commandId>. When turn.send answers (or turn.started arrives), 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 ms chat-animate-slide-up entrance from opacity 0 again.
  2. The reply. The assistant row is turn:<turnId>:assistant until 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 records confirmedEchoes[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.
  • Timeline (ChatView.tsx): row identity is rowKey ?? 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:

beforeafter
Sent bubble, existing sessionelement replaced ~35 ms after Enter; entrance restarts from opacity 0one element throughout
Reply row when its first text arriveselement replaced; blinks 1 → 0 → 1one 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 main and passes here.
  • environment-client / app-core / web typecheck, lint and tests pass. boundary.test, one routes.test case and one provider-health case fail only under full-suite load and pass alone, same as on main.

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
cloudflare-workers-and-pages[bot]12d ago

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

StatusDeployment URLCommitUpdated (UTC)See this deployment's details
  • Build: Failed ❌

View logs ↗
2daaed72026-10-18T18:34:52.973ZView logs ↗
greptile-apps[bot]12d ago


[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 --> G

Reviews (1) · Last reviewed commit: "Keep a sent message and its reply on scr..." · Reviewed by Greptile