Skip to content

🤖 fix: stop chat send & streaming-indicator layout flashes; add shimmer transcript loading#3426

Merged
ammario merged 2 commits into
mainfrom
chat-send-a8pp
May 29, 2026
Merged

🤖 fix: stop chat send & streaming-indicator layout flashes; add shimmer transcript loading#3426
ammario merged 2 commits into
mainfrom
chat-send-a8pp

Conversation

@ammar-agent
Copy link
Copy Markdown
Collaborator

@ammar-agent ammar-agent commented May 29, 2026

Summary

Eliminates the recurring chat layout flashes and polishes the transcript loading state. Three related changes:

  1. Send-time flash — the composer no longer resizes the transcript viewport, and bottom-stick is owned by the browser's native scroll anchoring instead of a per-frame scrollTop chase.
  2. Streaming-indicator flash — the streaming barrier no longer reflows or unmounts when it transitions from startingstreaming.
  3. Transcript loading — the centered "Loading transcript…" text is replaced with a vercel-style shimmer row skeleton.

Background

The transcript is a flex column [header / flex-1 scrollport / composer]. The composer was an auto-height flex sibling, so every send (which collapses the textarea) resized the scrollport clientHeight from below. Bottom-stick had overflow-anchor disabled and was re-implemented as a bounded 60-frame requestAnimationFrame loop writing scrollTop = scrollHeight - clientHeight. On one send, the composer collapse, the optimistic streaming-barrier mount, the user-message echo, the first tokens, and font-display: swap all change geometry across adjacent frames, and the JS pin races those paints — the recurring "flash". ~19 fixes over ~60 days iterated on the rAF loop, debounce constants, and min-height lanes without converging because they corrected layout after it had already changed.

The streaming indicator flashed separately on starting → streaming for two reasons: (a) the token-stats element (~N tokens @ … t/s) mounted exactly at that transition, reflowing the barrier row; and (b) shouldShowStreamingBarrier = isStreamStarting || canInterrupt could be false for a frameisStreamStarting was gated on !hasAuthoritativeStreamLifecycle, so when a streaming lifecycle event landed before stream-start populated the interruptible stream, neither flag was set and the whole barrier briefly unmounted.

Implementation

Send-time flash — one invariant: the browser owns bottom anchoring, and the send path never resizes the scroll viewport from below.

  • Floating composer (Pillar D): the composer renders in an absolute dock at the bottom of the (now relative) chat column, out of flex flow, so its height changes no longer alter the scrollport clientHeight. The scrollport reserves clearance with padding-bottom: calc(15px + var(--composer-h)), published by a ResizeObserver on the dock. The jump-to-bottom chip sits above the dock.
  • Native bottom anchoring (Pillar A): a 0-height bottom sentinel is the last child of the scrollport. While locked, the transcript content opts out of anchoring (overflow-anchor: none) so the sentinel is the sole anchor candidate; the browser keeps it pinned as content appends above it. On scroll-up, row anchoring is restored.
  • useAutoScroll: removed the 60-frame settle loop; jumpToBottom/relock establish the bottom once and anchoring holds it. A single non-looping ResizeObserver + reanchorBottom are the safety net. Intent/relock logic and /btw scroll-hold are unchanged.

Streaming-indicator flash — make the barrier geometry phase-invariant and keep it mounted across the whole turn.

  • StreamingBarrierView always renders the token-stats slot (with placeholder values) and only toggles its visibility, so the row geometry is identical in starting and streaming — nothing mounts on transition.
  • WorkspaceStore derives isStreamStarting from pendingStreamStartTime (set on send, cleared by every terminal handler — end/abort/error) rather than the lifecycle phase. This keeps shouldShowStreamingBarrier continuously true from send until the stream is interruptible, with no gap, and can't get stuck (a stale non-idle lifecycle after stream-end no longer matters). Mirrored in the lighter getWorkspaceShellStatus selector.

Transcript loadingTranscriptHydrationSkeleton renders a few conversation-shaped turns (short user bubble + assistant prose lines) with the shared shimmer Skeleton, fading toward the bottom. It is top-aligned in normal transcript flow (not vertically centered), so it sits where real messages will and doesn't jump when hydration completes. The empty-transcript placeholder is unchanged.

Validation

  • useAutoScroll unit tests reworked onto the discrete-pin + ResizeObserver paths (24/24).
  • floatingComposerAnchoring happy-dom test: composer dock is a separate subtree, sentinel is last child + sole overflow-anchor:auto, anchor policy toggles back on scroll release.
  • composerLayoutStability e2e (real Electron/Chromium): tall draft + collapse-on-send keep scrollport clientHeight fixed; calc(var(--composer-h)) clearance ≥ composer height; after settle the transcript is pinned and the last message clears the composer.
  • New StreamingBarrierView tests: the stats slot is reserved-but-hidden during starting and revealed (same element) once streaming.
  • New WorkspaceStore regression guard: a streaming lifecycle event arriving before the stream is interruptible keeps isStreamStarting true (barrier never unmounts). Full WorkspaceStore suite green.
  • New Streaming barrier story + TranscriptHydrationSkeleton story for Chromatic. make static-check clean.

Risks

  • Touches the hot transcript scroll path and the streaming-state derivation. Mitigations: native anchoring is primary with a single ResizeObserver safety net; the real-browser e2e gate runs in CI; the isStreamStarting change only broadens the existing pending-start window (and is keyed off a signal already cleared on all terminals), guarded by a new regression test and the full store suite.
  • Expected Chromatic visual diffs — the composer now floats and the transcript carries bottom padding (send-flash work), plus two new stories. The streaming-stats and skeleton changes are otherwise structural / additive.
  • Mobile (max-width:768px) keeps the dock at the column bottom with existing safe-area handling; worth a manual touch-viewport check.

Pains

happy-dom cannot evaluate native scroll anchoring or calc(var()), and requestAnimationFrame is throttled under headless xvfb — so the composer-stability sampler became a Playwright-driven clientHeight loop, and real anchoring is covered only by the e2e gate.


Generated with mux • Model: anthropic:claude-opus-4-8 • Thinking: max • Cost: $37.92

… bottom anchoring

Root cause: the composer was an auto-height flex sibling under the flex-1
transcript, so every send resized the scrollport from below; bottom-stick was a
60-frame rAF scrollTop chase (overflow-anchor disabled) that raced those resizes
and the barrier/echo/font-swap appends.

- Float the composer in an absolute dock so its height never changes the
  scrollport clientHeight; reserve clearance via padding-bottom calc(var(--composer-h)).
- Add a 0-height bottom sentinel that is the sole overflow-anchor:auto element
  while locked, so native CSS scroll anchoring pins the bottom on append. Remove
  the rAF settle loop; keep a single ResizeObserver + reanchorBottom safety net.
- Preserve all useAutoScroll intent/relock logic; jump chip sits above the dock.
- Tests: rework useAutoScroll unit tests off the rAF loop; add floatingComposerAnchoring
  (happy-dom structural/anchor-policy) + composerLayoutStability e2e (real-browser gate).
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Streaming indicator (StreamingBarrier):
- Reserve the token-stats slot in StreamingBarrierView so it no longer
  mounts on the starting->streaming transition (reflow flash); toggle
  visibility instead of mounting/unmounting.
- Close the barrier unmount gap in WorkspaceStore: key isStreamStarting
  off pendingStreamStartTime (cleared on all terminal events) instead of
  the lifecycle phase, so shouldShowStreamingBarrier never drops to false
  for a frame when a 'streaming' lifecycle event lands before stream-start.

Transcript loading:
- Replace centered 'Loading transcript...' text with a vercel-style shimmer
  row skeleton (TranscriptHydrationSkeleton), top-aligned in transcript flow.

Tests/stories: barrier stats-slot visibility, store gap regression guard,
Streaming barrier story, skeleton story.
@ammar-agent ammar-agent changed the title 🤖 fix: stop chat-send layout flash via floating composer + native bottom anchoring 🤖 fix: stop chat send & streaming-indicator layout flashes; add shimmer transcript loading May 29, 2026
@ammar-agent
Copy link
Copy Markdown
Collaborator Author

@codex review

Since your approval, this PR was extended with two related changes (same chat-transcript stability theme):

  1. Streaming-indicator flash (starting → streaming):
    • StreamingBarrierView now always renders the token-stats slot and only toggles its visibility, so the row geometry is identical across the transition (no mount/reflow).
    • WorkspaceStore derives isStreamStarting from pendingStreamStartTime (cleared by every terminal handler) instead of the lifecycle phase, closing a 1-frame gap where shouldShowStreamingBarrier could go false and unmount the barrier. New regression test covers a streaming lifecycle event arriving before the stream is interruptible.
  2. Vercel-style transcript loading: centered "Loading transcript…" text replaced with a shimmer row skeleton (TranscriptHydrationSkeleton), top-aligned.

New tests + two Storybook stories added; full WorkspaceStore suite + make static-check green.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ammario ammario merged commit d923715 into main May 29, 2026
24 checks passed
@ammario ammario deleted the chat-send-a8pp branch May 29, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants