From b90f28e89506682bc0ad901d2fbf29ee5278feb6 Mon Sep 17 00:00:00 2001 From: nickmopen Date: Sun, 10 May 2026 23:07:19 -0500 Subject: [PATCH] =?UTF-8?q?fix(ui):=20prevent=20programmatic=20scrollTo=20?= =?UTF-8?q?from=20flipping=20chatUserNearBott=E2=80=A6=20(#76991)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): prevent programmatic scrollTo from flipping chatUserNearBottom during streaming * fix(ui): preserve user scroll-up events that arrive during programmatic scroll guard window * test(ui): add unit coverage for programmatic scroll guard boundary and retry path * fix(ui): preserve chat scroll bookkeeping * chore: drop unrelated slack formatting * test: narrow inbound dedupe claim result --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + extensions/slack/src/monitor/media.test.ts | 6 +- ui/src/ui/app-scroll.test.ts | 180 +++++++++++++++++++++ ui/src/ui/app-scroll.ts | 26 +++ ui/src/ui/app.ts | 2 + 5 files changed, 212 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 200e0cc825b..d9a786ae85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1095,6 +1095,7 @@ Docs: https://docs.openclaw.ai - Memory/LanceDB: declare `apache-arrow` in the bundled memory plugin package so LanceDB installs include its runtime peer. Fixes #76910. Thanks @afiqfiles-max. - CLI/devices: retry explicit device-pair approval with `operator.admin` after a pairing-scope ownership denial, so existing admin-capable paired-device tokens can recover new Control UI/browser pairing after upgrades instead of requiring manual JSON edits. Fixes #76956. Thanks @neo19482. - CLI/devices: stop local pairing fallback when the active Gateway names a pending request that is absent from the local pairing store, so profile or state-dir mismatches no longer make `openclaw devices list/approve` inspect the wrong store while a real device stays blocked. Thanks @vincentkoc. +- Control UI/webchat: fix streaming assistant responses causing the chat viewport to scroll upward by guarding `handleChatScroll` against scroll events triggered by the auto-scroll logic itself; introduces a `chatIsProgrammaticScroll` flag that suppresses near-bottom state updates during programmatic `scrollTo` calls so streaming output stays pinned to the bottom. Thanks @nickmopen. - Google Meet: use the local call-control microphone button instead of disabled remote participant mute buttons, and block realtime speech when the OpenClaw Meet microphone remains muted. - Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health. - Plugins/install: recover the install ledger from the managed npm root when `plugins/installs.json` is empty or partial, so reinstalling Discord and Codex no longer makes the other installed plugin disappear. diff --git a/extensions/slack/src/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts index fd09488c6e4..a05690d7bc2 100644 --- a/extensions/slack/src/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -141,9 +141,9 @@ function expectSaveMediaBufferCall(mock: unknown, contentType: string, maxBytes: } function expectVerboseLogContains(expected: string): void { - const messages = vi.mocked(logVerbose).mock.calls.map((call) => - typeof call[0] === "string" ? call[0] : "", - ); + const messages = vi + .mocked(logVerbose) + .mock.calls.map((call) => (typeof call[0] === "string" ? call[0] : "")); expect(messages.some((message) => message.includes(expected))).toBe(true); } diff --git a/ui/src/ui/app-scroll.test.ts b/ui/src/ui/app-scroll.test.ts index 80eadc2c238..f1ac6da3478 100644 --- a/ui/src/ui/app-scroll.test.ts +++ b/ui/src/ui/app-scroll.test.ts @@ -44,6 +44,8 @@ function createScrollHost( chatUserNearBottom: true, chatHeaderControlsHidden: false, chatNewMessagesBelow: false, + chatIsProgrammaticScroll: false, + chatProgrammaticScrollTarget: 0, logsScrollFrame: null as number | null, logsAtBottom: true, topbarObserver: null as ResizeObserver | null, @@ -309,5 +311,183 @@ describe("resetChatScroll", () => { expect(host.chatUserNearBottom).toBe(true); expect(host.chatLastScrollTop).toBe(0); expect(host.chatHeaderControlsHidden).toBe(false); + expect(host.chatIsProgrammaticScroll).toBe(false); + expect(host.chatProgrammaticScrollTarget).toBe(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Programmatic scroll guard */ +/* ------------------------------------------------------------------ */ + +describe("programmatic scroll guard", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 1; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("handleChatScroll suppresses own scroll event when scrollTop is at the programmatic target", () => { + const { host } = createScrollHost({}); + host.chatUserNearBottom = true; + host.chatIsProgrammaticScroll = true; + // Simulates scrollTo(scrollHeight=1000): expected scrollTop = 1000 - 400 = 600. + host.chatProgrammaticScrollTarget = 1000; + + // Our own scroll event: scrollTop is at the clamped target position. + const event = createScrollEvent(1000, 600, 400); + handleChatScroll(host, event); + + // Must remain true — our scroll-to-bottom event must not flip near-bottom state. + expect(host.chatUserNearBottom).toBe(true); + }); + + it("handleChatScroll processes user scroll-up that arrives during the guard window", () => { + const { host } = createScrollHost({}); + host.chatUserNearBottom = true; + host.chatIsProgrammaticScroll = true; + // We had targeted the bottom of a 3000px page. + host.chatProgrammaticScrollTarget = 3000; + + // User scrolled up to 500 during the guard window — far below the target (2600). + const event = createScrollEvent(3000, 500, 400); // distanceFromBottom = 2100 > 450 + handleChatScroll(host, event); + + // Must flip to false — user intentionally scrolled up, streaming must not re-pin them. + expect(host.chatUserNearBottom).toBe(false); + }); + + it("scheduleChatScroll sets chatIsProgrammaticScroll before scrolling and clears it after rAF", async () => { + const { host } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 1600, + clientHeight: 400, + }); + host.chatUserNearBottom = true; + host.chatHasAutoScrolled = true; + + scheduleChatScroll(host); + await host.updateComplete; + + // After rAF cleanup the flag must be cleared. + expect(host.chatIsProgrammaticScroll).toBe(false); + // Target was set to container scrollHeight before scrollTo. + expect(host.chatProgrammaticScrollTarget).toBe(2000); + // And scroll must have happened. + expect(host.chatUserNearBottom).toBe(true); + }); + + it("after programmatic scroll is done, a real user scroll-up correctly flips chatUserNearBottom to false", async () => { + const { host } = createScrollHost({ + scrollHeight: 3000, + scrollTop: 500, + clientHeight: 400, + }); + host.chatUserNearBottom = true; + // Flag already cleared — simulates the state after the rAF cleanup ran. + host.chatIsProgrammaticScroll = false; + + // User genuinely scrolled far from bottom — must be respected. + const event = createScrollEvent(3000, 500, 400); // distanceFromBottom = 2100 > 450 + handleChatScroll(host, event); + + expect(host.chatUserNearBottom).toBe(false); + }); + + it("guard boundary: scrollTop exactly one pixel below threshold is NOT suppressed (user scroll-up passes through)", () => { + const { host } = createScrollHost({}); + host.chatUserNearBottom = true; + host.chatIsProgrammaticScroll = true; + // Programmatic target = 1000, clientHeight = 400 → threshold = 600. + // scrollTop = 599 → 599 >= 600 is false → guard does NOT suppress the event. + host.chatProgrammaticScrollTarget = 1000; + + const event = createScrollEvent(1000, 599, 400); // distanceFromBottom = 1 + handleChatScroll(host, event); + + // Event was processed: user is near bottom (dist=1 < 450) but the guard did not block it. + expect(host.chatUserNearBottom).toBe(true); + // chatLastScrollTop must have been updated — confirms the event was not short-circuited. + expect(host.chatLastScrollTop).toBe(599); + }); + + it("guard boundary: scrollTop exactly at threshold is suppressed", () => { + const { host } = createScrollHost({}); + host.chatUserNearBottom = true; + host.chatIsProgrammaticScroll = true; + host.chatProgrammaticScrollTarget = 1000; + host.chatLastScrollTop = 0; + + // scrollTop = 600 → 600 >= 600 is true → guard suppresses the event. + const event = createScrollEvent(1000, 600, 400); + handleChatScroll(host, event); + + // Scroll bookkeeping still advances so the next user scroll has the right direction. + expect(host.chatLastScrollTop).toBe(600); + }); + + it("suppressed programmatic scroll event does not mutate chatNewMessagesBelow", () => { + const { host } = createScrollHost({}); + host.chatUserNearBottom = true; + host.chatNewMessagesBelow = false; + host.chatIsProgrammaticScroll = true; + host.chatProgrammaticScrollTarget = 2000; + + // Our own scroll event at the programmatic target position. + const event = createScrollEvent(2000, 1600, 400); + handleChatScroll(host, event); + + // Event was suppressed — chatNewMessagesBelow must stay unchanged. + expect(host.chatNewMessagesBelow).toBe(false); + }); + + it("suppressed programmatic scroll preserves direction bookkeeping for the next user scroll-up", () => { + const { host } = createScrollHost({}); + host.chatUserNearBottom = true; + host.chatHeaderControlsHidden = true; + host.chatIsProgrammaticScroll = true; + host.chatProgrammaticScrollTarget = 3000; + host.chatLastScrollTop = 0; + + handleChatScroll(host, createScrollEvent(3000, 2600, 400)); + expect(host.chatLastScrollTop).toBe(2600); + + host.chatIsProgrammaticScroll = false; + handleChatScroll(host, createScrollEvent(3000, 2000, 400)); + + expect(host.chatHeaderControlsHidden).toBe(false); + expect(host.chatUserNearBottom).toBe(false); + }); + + it("retry timeout sets and clears chatIsProgrammaticScroll", async () => { + const { host, container } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 1600, + clientHeight: 400, + }); + host.chatUserNearBottom = true; + host.chatHasAutoScrolled = true; + + scheduleChatScroll(host); + await host.updateComplete; + + // After the initial rAF the flag must already be cleared. + expect(host.chatIsProgrammaticScroll).toBe(false); + + // Advance past the retry delay (120ms) — retry scrollTop assignment fires. + vi.advanceTimersByTime(150); + + // After the retry's synchronous scrollTop assignment, the flag is set true. + // A subsequent rAF clears it — but our mock runs rAF synchronously. + expect(host.chatIsProgrammaticScroll).toBe(false); + // Retry must have updated the programmatic target and scrolled. + expect(host.chatProgrammaticScrollTarget).toBe(container.scrollHeight); }); }); diff --git a/ui/src/ui/app-scroll.ts b/ui/src/ui/app-scroll.ts index 2031b634226..445ad28a6e3 100644 --- a/ui/src/ui/app-scroll.ts +++ b/ui/src/ui/app-scroll.ts @@ -14,6 +14,8 @@ type ScrollHost = { chatUserNearBottom: boolean; chatHeaderControlsHidden: boolean; chatNewMessagesBelow: boolean; + chatIsProgrammaticScroll: boolean; + chatProgrammaticScrollTarget: number; logsScrollFrame: number | null; logsAtBottom: boolean; topbarObserver: ResizeObserver | null; @@ -75,11 +77,17 @@ export function scheduleChatScroll(host: ScrollHost, force = false, smooth = fal typeof window.matchMedia !== "function" || !window.matchMedia("(prefers-reduced-motion: reduce)").matches); const scrollTop = target.scrollHeight; + host.chatProgrammaticScrollTarget = scrollTop; + host.chatIsProgrammaticScroll = true; if (typeof target.scrollTo === "function") { target.scrollTo({ top: scrollTop, behavior: smoothEnabled ? "smooth" : "auto" }); } else { target.scrollTop = scrollTop; } + // Clear the flag after the scroll event has fired (sync or next microtask). + requestAnimationFrame(() => { + host.chatIsProgrammaticScroll = false; + }); host.chatUserNearBottom = true; host.chatNewMessagesBelow = false; const retryDelay = effectiveForce ? 150 : 120; @@ -98,7 +106,12 @@ export function scheduleChatScroll(host: ScrollHost, force = false, smooth = fal if (!shouldStickRetry) { return; } + host.chatProgrammaticScrollTarget = latest.scrollHeight; + host.chatIsProgrammaticScroll = true; latest.scrollTop = latest.scrollHeight; + requestAnimationFrame(() => { + host.chatIsProgrammaticScroll = false; + }); host.chatUserNearBottom = true; }, retryDelay); }); @@ -135,6 +148,17 @@ export function handleChatScroll(host: ScrollHost, event: Event) { const scrollTop = Math.max(0, container.scrollTop); const delta = scrollTop - host.chatLastScrollTop; host.chatLastScrollTop = scrollTop; + // Ignore scroll events that we ourselves triggered — they must not flip + // chatUserNearBottom to false while streaming content grows the page. + // Only suppress if scrollTop is still at or above the position we scrolled to; + // if it dropped below, the user scrolled up during the guard window and we must + // process the event so streaming stops pinning them back to the bottom. + if ( + host.chatIsProgrammaticScroll && + container.scrollTop >= host.chatProgrammaticScrollTarget - container.clientHeight + ) { + return; + } const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD; const hasUsefulScroll = container.scrollHeight - container.clientHeight > NEAR_BOTTOM_THRESHOLD; @@ -168,6 +192,8 @@ export function resetChatScroll(host: ScrollHost) { host.chatLastScrollTop = 0; host.chatHeaderControlsHidden = false; host.chatNewMessagesBelow = false; + host.chatIsProgrammaticScroll = false; + host.chatProgrammaticScrollTarget = 0; } export function exportLogs(lines: string[], label: string) { diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 6edbbdc2544..b39499bea3e 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -592,6 +592,8 @@ export class OpenClawApp extends LitElement { chatLastScrollTop = 0; chatHasAutoScrolled = false; chatUserNearBottom = true; + chatIsProgrammaticScroll = false; + chatProgrammaticScrollTarget = 0; @state() chatNewMessagesBelow = false; nodesPollInterval: number | null = null; logsPollInterval: number | null = null;