From 972f4d6b0401920f86b666b5c6c744eff47e4f3a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 21:55:46 -0700 Subject: [PATCH] fix(chat): eliminate streaming scroll jank (0.0.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiled the auto-scroll-to-bottom effect during real LLM streaming and found it was firing 37 scrollTo() calls in 2.87s — 33 of them with `behavior: 'smooth'`. Each smooth-scroll animation queues behind the previous one and gets interrupted before completing, producing visibly jerky scroll throughout streaming. Fix: drop `requestAnimationFrame(() => scrollTo({ behavior }))` and use direct `scrollTop = scrollHeight`. That's instant, idempotent, and no animation queue to thrash. Same effect, none of the jank. Verified live with the smoke app + a LangGraph dev backend hitting gpt-4o-mini. The same prompt that previously triggered 33 smooth scrolls now triggers 0 scrollTo calls and 1 scrollTop write — exactly the silent, instant settle we want. Bumps @ngaf/chat to 0.0.8. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/chat/package.json | 2 +- .../src/lib/compositions/chat/chat.component.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/libs/chat/package.json b/libs/chat/package.json index e50723eb8..edae9af47 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.7", + "version": "0.0.8", "exports": { ".": { "types": "./index.d.ts", diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 068180f65..b93ef7ca1 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -207,6 +207,12 @@ export class ChatComponent { }); }); + // Auto-scroll-to-bottom. Fires on every signal update during streaming + // (each token mutates the last message's content), so this MUST be cheap + // and idempotent. Earlier this used scrollTo({ behavior: 'smooth' }) per + // token, which queues overlapping smooth-scroll animations (~12/sec) + // and produces visibly jerky scroll. Direct `scrollTop = scrollHeight` + // is instant, free, and only repaints when the value actually changes. effect(() => { let count: number; let msgs: ReturnType['messages']>; @@ -217,11 +223,12 @@ export class ChatComponent { if (!el) return; const isNewMessage = count !== this.prevMessageCount; this.prevMessageCount = count; + // Tolerance: if the user has scrolled up more than 150px from the + // bottom, treat it as "parked reading" and don't auto-scroll. Once + // they scroll back near the bottom, streaming resumes pushing. const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; if (isNewMessage || isNearBottom) { - requestAnimationFrame(() => { - el.scrollTo({ top: el.scrollHeight, behavior: isNewMessage ? 'instant' : 'smooth' }); - }); + el.scrollTop = el.scrollHeight; } }); }