fix: consolidate chat scroll ownership

This commit is contained in:
Shakker
2026-07-14 20:16:15 +01:00
committed by Shakker
parent 8da6548cce
commit d4cc1a8b72
5 changed files with 7 additions and 100 deletions
+3 -2
View File
@@ -139,7 +139,7 @@ import {
hasAbortableSessionRun,
reconcileStaleChatRunAfterSessionStatePublication,
} from "./run-lifecycle.ts";
import { scheduleChatScroll } from "./scroll.ts";
import { scheduleChatScroll, scheduleCommittedChatScroll } from "./scroll.ts";
import { clearChatMessagesFromCache } from "./session-message-cache.ts";
import { configureToolTitleFetcher } from "./tool-titles.ts";
@@ -2195,7 +2195,8 @@ class ChatPane extends OpenClawLightDomElement {
allowExternalEmbedUrls: state.allowExternalEmbedUrls,
chatMessageMaxWidth: state.chatMessageMaxWidth,
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never),
onAssistantAttachmentLoaded: () => state.scrollToBottom(),
onAssistantAttachmentLoaded: () =>
scheduleCommittedChatScroll(state, false, false, { source: "resize" }),
basePath: state.basePath,
};
if (!this.showPaneHeader) {
-1
View File
@@ -328,7 +328,6 @@ function makeHost(overrides?: Partial<TestChatHost>): TestChatHost {
chatScrollCommitCleanup: null,
chatScrollFrame: null,
chatScrollGuardFrame: null,
chatScrollTimeout: null,
chatScrollGeneration: 0,
chatLastScrollTop: 0,
chatLastScrollHeight: 0,
-2
View File
@@ -248,7 +248,6 @@ export type ChatPageHost = ChatHost &
chatStreamRenderFrame: number | null;
chatScrollFrame: number | null;
chatScrollGuardFrame: number | null;
chatScrollTimeout: number | null;
chatScrollGeneration: number;
chatLastScrollTop: number;
chatLastScrollHeight: number;
@@ -1291,7 +1290,6 @@ export function createPageState(
chatStreamRenderFrame: null,
chatScrollFrame: null,
chatScrollGuardFrame: null,
chatScrollTimeout: null,
chatScrollGeneration: 0,
chatLastScrollTop: 0,
chatLastScrollHeight: 0,
+3 -47
View File
@@ -18,28 +18,16 @@ function createScrollHost(
scrollHeight?: number;
scrollTop?: number;
clientHeight?: number;
overflowY?: string;
} = {},
) {
const {
scrollHeight = 2000,
scrollTop = 1500,
clientHeight = 500,
overflowY = "auto",
} = overrides;
const { scrollHeight = 2000, scrollTop = 1500, clientHeight = 500 } = overrides;
const container = {
scrollHeight,
scrollTop,
clientHeight,
style: { overflowY } as unknown as CSSStyleDeclaration,
};
// Make getComputedStyle return the overflowY value
vi.spyOn(window, "getComputedStyle").mockReturnValue({
overflowY,
} as unknown as CSSStyleDeclaration);
const renderLifecycle: RenderLifecycle = {
invalidate: vi.fn(),
afterCommit: vi.fn((effect) => {
@@ -56,7 +44,6 @@ function createScrollHost(
chatScrollCommitCleanup: null as (() => void) | null,
chatScrollFrame: null as number | null,
chatScrollGuardFrame: null as number | null,
chatScrollTimeout: null as number | null,
chatScrollGeneration: 0,
chatLastScrollTop: 0,
chatLastScrollHeight: 0,
@@ -506,22 +493,18 @@ describe("resetChatScroll", () => {
expect(host.chatProgrammaticScrollTarget).toBe(0);
});
it("cancels frame id zero and the late-size retry", () => {
it("cancels frame id zero and the programmatic guard", () => {
const { host } = createScrollHost({});
const cancelFrame = vi.spyOn(window, "cancelAnimationFrame");
const clearTimer = vi.spyOn(window, "clearTimeout");
host.chatScrollFrame = 0;
host.chatScrollGuardFrame = 7;
host.chatScrollTimeout = 9;
cancelChatScroll(host);
expect(cancelFrame).toHaveBeenCalledWith(0);
expect(cancelFrame).toHaveBeenCalledWith(7);
expect(clearTimer).toHaveBeenCalledWith(9);
expect(host.chatScrollFrame).toBeNull();
expect(host.chatScrollGuardFrame).toBeNull();
expect(host.chatScrollTimeout).toBeNull();
});
});
@@ -656,7 +639,7 @@ describe("programmatic scroll guard", () => {
expect(host.chatNewMessagesBelow).toBe(false);
});
it("does not retry a smooth manual scroll after the user scrolls up", async () => {
it("stops a smooth manual scroll after the user scrolls up", async () => {
const frameCallbacks: FrameRequestCallback[] = [];
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
frameCallbacks.push(callback);
@@ -678,9 +661,7 @@ describe("programmatic scroll guard", () => {
container.scrollTop = 400;
handleChatScroll(host, createScrollEvent(2000, 400, 400));
vi.advanceTimersByTime(200);
expect(host.chatScrollTimeout).toBeNull();
expect(host.chatIsProgrammaticScroll).toBe(false);
expect(container.scrollTop).toBe(400);
});
@@ -715,29 +696,4 @@ describe("programmatic scroll guard", () => {
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);
});
});
+1 -48
View File
@@ -12,7 +12,6 @@ type ChatScrollHost = {
chatScrollCommitCleanup: (() => void) | null;
chatScrollFrame: number | null;
chatScrollGuardFrame: number | null;
chatScrollTimeout: number | null;
chatScrollGeneration: number;
chatLastScrollTop: number;
chatLastScrollHeight?: number;
@@ -42,10 +41,6 @@ function cancelCommittedChatScroll(host: ChatScrollHost): void {
cancelAnimationFrame(host.chatScrollGuardFrame);
host.chatScrollGuardFrame = null;
}
if (host.chatScrollTimeout != null) {
clearTimeout(host.chatScrollTimeout);
host.chatScrollTimeout = null;
}
host.chatIsProgrammaticScroll = false;
}
@@ -91,18 +86,7 @@ function scheduleProgrammaticScrollGuardClear(
}
function pickScrollTarget(host: ChatScrollHost): HTMLElement | null {
const container = queryHost(host, ".chat-thread") as HTMLElement | null;
if (container) {
const overflowY = getComputedStyle(container).overflowY;
const canScroll =
overflowY === "auto" ||
overflowY === "scroll" ||
container.scrollHeight - container.clientHeight > 1;
if (canScroll) {
return container;
}
}
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
return queryHost(host, ".chat-thread") as HTMLElement | null;
}
/** Schedule layout work when the caller already runs after the DOM commit. */
@@ -164,33 +148,6 @@ export function scheduleCommittedChatScroll(
scheduleProgrammaticScrollGuardClear(host, generation, target, smoothEnabled);
host.chatUserNearBottom = true;
setNewMessagesBelow(host, false);
// Markdown, images, and mobile controls can grow after the first layout.
const retryDelay = effectiveForce ? 150 : 120;
host.chatScrollTimeout = window.setTimeout(() => {
host.chatScrollTimeout = null;
if (generation !== host.chatScrollGeneration) {
return;
}
const latest = pickScrollTarget(host);
if (!latest) {
return;
}
const latestDistanceFromBottom = latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry =
manualScroll ||
effectiveForce ||
(!host.chatFollowLocked &&
(host.chatUserNearBottom || latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD));
if (!shouldStickRetry) {
return;
}
host.chatProgrammaticScrollTarget = latest.scrollHeight;
host.chatIsProgrammaticScroll = true;
latest.scrollTop = latest.scrollHeight;
scheduleProgrammaticScrollGuardClear(host, generation, latest, false);
host.chatUserNearBottom = true;
}, retryDelay);
});
}
@@ -237,10 +194,6 @@ export function handleChatScroll(host: ChatScrollHost, event: Event): void {
cancelAnimationFrame(host.chatScrollGuardFrame);
host.chatScrollGuardFrame = null;
}
if (host.chatScrollTimeout != null) {
clearTimeout(host.chatScrollTimeout);
host.chatScrollTimeout = null;
}
host.chatIsProgrammaticScroll = false;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;