From 70e06a6dd74653055348515c1e5f7b9b15ef7237 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 3 Jul 2026 23:06:25 -0400 Subject: [PATCH 1/2] Poll for scroll-settle in ai-assistant scrolling tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "open a room" scroll assertions in the ai-assistant scrolling suite read the conversation's scroll position synchronously immediately after the room renders. The panel auto-scrolls to the newest message when the last message registers its scroller, and re-scrolls whenever that message's subtree mutates — so avatars, card pills, and markdown that finish rendering after the test runloop has otherwise settled shift the layout and briefly move the scroll position before the component corrects it. A single synchronous read races that correction and intermittently observes distanceFromBottom past the bottom threshold. Replace those point-in-time reads with a waitUntil poll that tolerates the late re-scroll, and report the exact scroll geometry (scrollHeight / clientHeight / scrollTop / distanceFromBottom) on timeout so a future failure is diagnosable instead of a bare "expected true". The two checks that verify enqueuing a streaming event does not synchronously jank the scroll stay synchronous by design. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-assistant-panel/scrolling-test.gts | 67 ++++++++++++++----- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/host/tests/integration/components/ai-assistant-panel/scrolling-test.gts b/packages/host/tests/integration/components/ai-assistant-panel/scrolling-test.gts index 95107150c93..43d7ac934df 100644 --- a/packages/host/tests/integration/components/ai-assistant-panel/scrolling-test.gts +++ b/packages/host/tests/integration/components/ai-assistant-panel/scrolling-test.gts @@ -1,4 +1,4 @@ -import { waitFor, click, triggerEvent } from '@ember/test-helpers'; +import { waitFor, waitUntil, click, triggerEvent } from '@ember/test-helpers'; import { settled } from '@ember/test-helpers'; import GlimmerComponent from '@glimmer/component'; @@ -194,6 +194,50 @@ module('Integration | ai-assistant-panel | scrolling', function (hooks) { return conversationElement.scrollTop < 20; } + function describeScrollPosition() { + let conversationElement = document.querySelector( + '[data-test-ai-assistant-conversation]', + ); + if (!conversationElement) { + return 'no [data-test-ai-assistant-conversation] element'; + } + let { scrollHeight, clientHeight, scrollTop } = conversationElement; + let distanceFromBottom = Math.abs(scrollHeight - clientHeight - scrollTop); + return `scrollHeight=${scrollHeight} clientHeight=${clientHeight} scrollTop=${scrollTop} distanceFromBottom=${distanceFromBottom} bottomThreshold=${BOTTOM_THRESHOLD}`; + } + + // The auto-scroll that keeps the newest message in view fires when the last + // message registers its scroller and again whenever that message's subtree + // mutates. Avatars, card pills, and markdown can finish rendering (and shift + // layout) after the test runloop has otherwise settled, which moves the + // scroll position before the component re-scrolls to correct it. A single + // synchronous read races that correction, so poll until the conversation + // settles at the target position. On timeout, report the exact geometry so a + // future failure is diagnosable instead of a bare `expected true`. + async function assertScrolledToBottom( + assert: Assert, + message = 'AI assistant is scrolled to bottom', + ) { + try { + await waitUntil(() => isAiAssistantScrolledToBottom(), { timeout: 2000 }); + assert.ok(true, message); + } catch { + assert.ok(false, `${message} — ${describeScrollPosition()}`); + } + } + + async function assertScrolledToTop( + assert: Assert, + message = 'AI assistant is scrolled to top', + ) { + try { + await waitUntil(() => isAiAssistantScrolledToTop(), { timeout: 2000 }); + assert.ok(true, message); + } catch { + assert.ok(false, `${message} — ${describeScrollPosition()}`); + } + } + function fillRoomWithReadMessages( roomId: string, messagesHaveBeenRead = true, @@ -265,11 +309,8 @@ module('Integration | ai-assistant-panel | scrolling', function (hooks) { }); await waitFor('[data-test-message-idx="40"]'); await click('[data-test-unread-messages-button]'); - await new Promise((r) => setTimeout(r, 2000)); // wait for animated scroll to complete - assert.ok( - isAiAssistantScrolledToBottom(), - 'AI assistant is scrolled to bottom', - ); + // poll until the animated scroll completes and settles at the bottom + await assertScrolledToBottom(assert); }); test('it does not show unread message indicator when new message received and scrolled to bottom', async function (assert) { @@ -307,8 +348,8 @@ module('Integration | ai-assistant-panel | scrolling', function (hooks) { await settled(); await click('[data-test-open-ai-assistant]'); await waitFor('[data-test-message-idx="39"]'); - assert.ok( - isAiAssistantScrolledToTop(), + await assertScrolledToTop( + assert, 'AI assistant is scrolled to top (where the first unread message is)', ); }); @@ -329,10 +370,7 @@ module('Integration | ai-assistant-panel | scrolling', function (hooks) { await settled(); await click('[data-test-open-ai-assistant]'); await waitFor('[data-test-message-idx="39"]'); - assert.ok( - isAiAssistantScrolledToBottom(), - 'AI assistant is scrolled to bottom', - ); + await assertScrolledToBottom(assert); }); test('scrolling stays at the bottom if a message is streaming in', async function (assert) { @@ -351,10 +389,7 @@ module('Integration | ai-assistant-panel | scrolling', function (hooks) { await settled(); await click('[data-test-open-ai-assistant]'); await waitFor('[data-test-message-idx="39"]'); - assert.ok( - isAiAssistantScrolledToBottom(), - 'AI assistant is scrolled to bottom', - ); + await assertScrolledToBottom(assert); let eventId = simulateRemoteMessage(roomId, '@aibot:localhost', { body: `thinking...`, From f338c6d611aba831b42366515dfb68aed35af7b4 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Sat, 4 Jul 2026 08:00:24 -0400 Subject: [PATCH 2/2] Enrich scroll diagnostics: signed delta and thrown-error reason Report distanceFromBottom as a signed value so the geometry dump shows whether the conversation sits above the fold (positive) or scrolled past the bottom (negative); the scrolled-to-bottom predicate keeps comparing the absolute value against the threshold. Also surface the caught waitUntil rejection so a timeout is distinguishable from a predicate exception (e.g. a missing conversation element). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-assistant-panel/scrolling-test.gts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/host/tests/integration/components/ai-assistant-panel/scrolling-test.gts b/packages/host/tests/integration/components/ai-assistant-panel/scrolling-test.gts index 43d7ac934df..345eee171f6 100644 --- a/packages/host/tests/integration/components/ai-assistant-panel/scrolling-test.gts +++ b/packages/host/tests/integration/components/ai-assistant-panel/scrolling-test.gts @@ -202,7 +202,11 @@ module('Integration | ai-assistant-panel | scrolling', function (hooks) { return 'no [data-test-ai-assistant-conversation] element'; } let { scrollHeight, clientHeight, scrollTop } = conversationElement; - let distanceFromBottom = Math.abs(scrollHeight - clientHeight - scrollTop); + // Signed: positive means content still sits below the fold (not scrolled + // far enough down), negative means scrolled past the bottom. The + // scrolled-to-bottom check compares the absolute value against the + // threshold, so the sign is diagnostic-only. + let distanceFromBottom = scrollHeight - clientHeight - scrollTop; return `scrollHeight=${scrollHeight} clientHeight=${clientHeight} scrollTop=${scrollTop} distanceFromBottom=${distanceFromBottom} bottomThreshold=${BOTTOM_THRESHOLD}`; } @@ -221,8 +225,9 @@ module('Integration | ai-assistant-panel | scrolling', function (hooks) { try { await waitUntil(() => isAiAssistantScrolledToBottom(), { timeout: 2000 }); assert.ok(true, message); - } catch { - assert.ok(false, `${message} — ${describeScrollPosition()}`); + } catch (e) { + let reason = e instanceof Error ? e.message : String(e); + assert.ok(false, `${message} — ${describeScrollPosition()} (${reason})`); } } @@ -233,8 +238,9 @@ module('Integration | ai-assistant-panel | scrolling', function (hooks) { try { await waitUntil(() => isAiAssistantScrolledToTop(), { timeout: 2000 }); assert.ok(true, message); - } catch { - assert.ok(false, `${message} — ${describeScrollPosition()}`); + } catch (e) { + let reason = e instanceof Error ? e.message : String(e); + assert.ok(false, `${message} — ${describeScrollPosition()} (${reason})`); } }