diff --git a/CHANGELOG.md b/CHANGELOG.md index f921767e2..6e1e6d494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz) - Security: guard skill installer downloads with SSRF checks (block private/localhost URLs). - Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly. - Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123. diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 58113fd4c..f1176d8b1 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -210,7 +210,7 @@ export async function refreshChat(host: ChatHost) { }), refreshChatAvatar(host), ]); - scheduleChatScroll(host as unknown as Parameters[0], true); + scheduleChatScroll(host as unknown as Parameters[0]); } export const flushChatQueueForEvent = flushChatQueue; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 2c03dd1ab..2312040da 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -480,6 +480,8 @@ export function renderApp(state: AppViewState) { onAbort: () => void state.handleAbortChat(), onQueueRemove: (id) => state.removeQueuedMessage(id), onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), + showNewMessages: state.chatNewMessagesBelow, + onScrollToBottom: () => state.scrollToBottom(), // Sidebar props for tool output viewing sidebarOpen: state.sidebarOpen, sidebarContent: state.sidebarContent, diff --git a/ui/src/ui/app-scroll.test.ts b/ui/src/ui/app-scroll.test.ts new file mode 100644 index 000000000..b75fd3ce7 --- /dev/null +++ b/ui/src/ui/app-scroll.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleChatScroll, scheduleChatScroll, resetChatScroll } from "./app-scroll"; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** Minimal ScrollHost stub for unit tests. */ +function createScrollHost(overrides: { + scrollHeight?: number; + scrollTop?: number; + clientHeight?: number; + overflowY?: string; +} = {}) { + const { + scrollHeight = 2000, + scrollTop = 1500, + clientHeight = 500, + overflowY = "auto", + } = 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 host = { + updateComplete: Promise.resolve(), + querySelector: vi.fn().mockReturnValue(container), + style: { setProperty: vi.fn() } as unknown as CSSStyleDeclaration, + chatScrollFrame: null as number | null, + chatScrollTimeout: null as number | null, + chatHasAutoScrolled: false, + chatUserNearBottom: true, + chatNewMessagesBelow: false, + logsScrollFrame: null as number | null, + logsAtBottom: true, + topbarObserver: null as ResizeObserver | null, + }; + + return { host, container }; +} + +function createScrollEvent(scrollHeight: number, scrollTop: number, clientHeight: number) { + return { + currentTarget: { scrollHeight, scrollTop, clientHeight }, + } as unknown as Event; +} + +/* ------------------------------------------------------------------ */ +/* handleChatScroll – threshold tests */ +/* ------------------------------------------------------------------ */ + +describe("handleChatScroll", () => { + it("sets chatUserNearBottom=true when within the 450px threshold", () => { + const { host } = createScrollHost({}); + // distanceFromBottom = 2000 - 1600 - 400 = 0 → clearly near bottom + const event = createScrollEvent(2000, 1600, 400); + handleChatScroll(host, event); + expect(host.chatUserNearBottom).toBe(true); + }); + + it("sets chatUserNearBottom=true when distance is just under threshold", () => { + const { host } = createScrollHost({}); + // distanceFromBottom = 2000 - 1151 - 400 = 449 → just under threshold + const event = createScrollEvent(2000, 1151, 400); + handleChatScroll(host, event); + expect(host.chatUserNearBottom).toBe(true); + }); + + it("sets chatUserNearBottom=false when distance is exactly at threshold", () => { + const { host } = createScrollHost({}); + // distanceFromBottom = 2000 - 1150 - 400 = 450 → at threshold (uses strict <) + const event = createScrollEvent(2000, 1150, 400); + handleChatScroll(host, event); + expect(host.chatUserNearBottom).toBe(false); + }); + + it("sets chatUserNearBottom=false when scrolled well above threshold", () => { + const { host } = createScrollHost({}); + // distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold + const event = createScrollEvent(2000, 500, 400); + handleChatScroll(host, event); + expect(host.chatUserNearBottom).toBe(false); + }); + + it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => { + const { host } = createScrollHost({}); + // distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near" + // distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near" + const event = createScrollEvent(2000, 1100, 400); + handleChatScroll(host, event); + expect(host.chatUserNearBottom).toBe(false); + }); +}); + +/* ------------------------------------------------------------------ */ +/* scheduleChatScroll – respects user scroll position */ +/* ------------------------------------------------------------------ */ + +describe("scheduleChatScroll", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 1; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("scrolls to bottom when user is near bottom (no force)", async () => { + const { host, container } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 1600, + clientHeight: 400, + }); + // distanceFromBottom = 2000 - 1600 - 400 = 0 → near bottom + host.chatUserNearBottom = true; + + scheduleChatScroll(host); + await host.updateComplete; + + expect(container.scrollTop).toBe(container.scrollHeight); + }); + + it("does NOT scroll when user is scrolled up and no force", async () => { + const { host, container } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 500, + clientHeight: 400, + }); + // distanceFromBottom = 2000 - 500 - 400 = 1100 → not near bottom + host.chatUserNearBottom = false; + const originalScrollTop = container.scrollTop; + + scheduleChatScroll(host); + await host.updateComplete; + + expect(container.scrollTop).toBe(originalScrollTop); + }); + + it("does NOT scroll with force=true when user has explicitly scrolled up", async () => { + const { host, container } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 500, + clientHeight: 400, + }); + // User has scrolled up — chatUserNearBottom is false + host.chatUserNearBottom = false; + host.chatHasAutoScrolled = true; // Already past initial load + const originalScrollTop = container.scrollTop; + + scheduleChatScroll(host, true); + await host.updateComplete; + + // force=true should still NOT override explicit user scroll-up after initial load + expect(container.scrollTop).toBe(originalScrollTop); + }); + + it("DOES scroll with force=true on initial load (chatHasAutoScrolled=false)", async () => { + const { host, container } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 500, + clientHeight: 400, + }); + host.chatUserNearBottom = false; + host.chatHasAutoScrolled = false; // Initial load + + scheduleChatScroll(host, true); + await host.updateComplete; + + // On initial load, force should work regardless + expect(container.scrollTop).toBe(container.scrollHeight); + }); + + it("sets chatNewMessagesBelow when not scrolling due to user position", async () => { + const { host } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 500, + clientHeight: 400, + }); + host.chatUserNearBottom = false; + host.chatHasAutoScrolled = true; + host.chatNewMessagesBelow = false; + + scheduleChatScroll(host); + await host.updateComplete; + + expect(host.chatNewMessagesBelow).toBe(true); + }); +}); + +/* ------------------------------------------------------------------ */ +/* Streaming: rapid chatStream changes should not reset scroll */ +/* ------------------------------------------------------------------ */ + +describe("streaming scroll behavior", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 1; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("multiple rapid scheduleChatScroll calls do not scroll when user is scrolled up", async () => { + const { host, container } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 500, + clientHeight: 400, + }); + host.chatUserNearBottom = false; + host.chatHasAutoScrolled = true; + const originalScrollTop = container.scrollTop; + + // Simulate rapid streaming token updates + scheduleChatScroll(host); + scheduleChatScroll(host); + scheduleChatScroll(host); + await host.updateComplete; + + expect(container.scrollTop).toBe(originalScrollTop); + }); + + it("streaming scrolls correctly when user IS at bottom", async () => { + const { host, container } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 1600, + clientHeight: 400, + }); + host.chatUserNearBottom = true; + host.chatHasAutoScrolled = true; + + // Simulate streaming + scheduleChatScroll(host); + await host.updateComplete; + + expect(container.scrollTop).toBe(container.scrollHeight); + }); +}); + +/* ------------------------------------------------------------------ */ +/* resetChatScroll */ +/* ------------------------------------------------------------------ */ + +describe("resetChatScroll", () => { + it("resets state for new chat session", () => { + const { host } = createScrollHost({}); + host.chatHasAutoScrolled = true; + host.chatUserNearBottom = false; + + resetChatScroll(host); + + expect(host.chatHasAutoScrolled).toBe(false); + expect(host.chatUserNearBottom).toBe(true); + }); +}); diff --git a/ui/src/ui/app-scroll.ts b/ui/src/ui/app-scroll.ts index b589cf2d5..f31977b9d 100644 --- a/ui/src/ui/app-scroll.ts +++ b/ui/src/ui/app-scroll.ts @@ -1,3 +1,6 @@ +/** Distance (px) from the bottom within which we consider the user "near bottom". */ +const NEAR_BOTTOM_THRESHOLD = 450; + type ScrollHost = { updateComplete: Promise; querySelector: (selectors: string) => Element | null; @@ -6,6 +9,7 @@ type ScrollHost = { chatScrollTimeout: number | null; chatHasAutoScrolled: boolean; chatUserNearBottom: boolean; + chatNewMessagesBelow: boolean; logsScrollFrame: number | null; logsAtBottom: boolean; topbarObserver: ResizeObserver | null; @@ -42,16 +46,25 @@ export function scheduleChatScroll(host: ScrollHost, force = false) { return; } const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight; - const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200; + + // force=true only overrides when we haven't auto-scrolled yet (initial load). + // After initial load, respect the user's scroll position. + const effectiveForce = force && !host.chatHasAutoScrolled; + const shouldStick = + effectiveForce || host.chatUserNearBottom || distanceFromBottom < NEAR_BOTTOM_THRESHOLD; + if (!shouldStick) { + // User is scrolled up — flag that new content arrived below. + host.chatNewMessagesBelow = true; return; } - if (force) { + if (effectiveForce) { host.chatHasAutoScrolled = true; } target.scrollTop = target.scrollHeight; host.chatUserNearBottom = true; - const retryDelay = force ? 150 : 120; + host.chatNewMessagesBelow = false; + const retryDelay = effectiveForce ? 150 : 120; host.chatScrollTimeout = window.setTimeout(() => { host.chatScrollTimeout = null; const latest = pickScrollTarget(); @@ -60,7 +73,10 @@ export function scheduleChatScroll(host: ScrollHost, force = false) { } const latestDistanceFromBottom = latest.scrollHeight - latest.scrollTop - latest.clientHeight; - const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200; + const shouldStickRetry = + effectiveForce || + host.chatUserNearBottom || + latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD; if (!shouldStickRetry) { return; } @@ -99,7 +115,11 @@ export function handleChatScroll(host: ScrollHost, event: Event) { return; } const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - host.chatUserNearBottom = distanceFromBottom < 200; + host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD; + // Clear the "new messages below" indicator when user scrolls back to bottom. + if (host.chatUserNearBottom) { + host.chatNewMessagesBelow = false; + } } export function handleLogsScroll(host: ScrollHost, event: Event) { @@ -114,6 +134,7 @@ export function handleLogsScroll(host: ScrollHost, event: Event) { export function resetChatScroll(host: ScrollHost) { host.chatHasAutoScrolled = false; host.chatUserNearBottom = true; + host.chatNewMessagesBelow = false; } export function exportLogs(lines: string[], label: string) { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 9e8d44a0a..494644d31 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -56,6 +56,8 @@ export type AppViewState = { chatQueue: ChatQueueItem[]; nodesLoading: boolean; nodes: Array>; + chatNewMessagesBelow: boolean; + scrollToBottom: () => void; devicesLoading: boolean; devicesError: string | null; devicesList: DevicePairingList | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 8b9643b43..8a81b21df 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -1,6 +1,7 @@ import { LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import type { EventLogEntry } from "./app-events"; +import type { AppViewState } from "./app-view-state"; import type { DevicePairingList } from "./controllers/devices"; import type { ExecApprovalRequest } from "./controllers/exec-approval"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals"; @@ -57,6 +58,7 @@ import { handleChatScroll as handleChatScrollInternal, handleLogsScroll as handleLogsScrollInternal, resetChatScroll as resetChatScrollInternal, + scheduleChatScroll as scheduleChatScrollInternal, } from "./app-scroll"; import { applySettings as applySettingsInternal, @@ -252,6 +254,7 @@ export class OpenClawApp extends LitElement { private chatScrollTimeout: number | null = null; private chatHasAutoScrolled = false; private chatUserNearBottom = true; + @state() chatNewMessagesBelow = false; private nodesPollInterval: number | null = null; private logsPollInterval: number | null = null; private debugPollInterval: number | null = null; @@ -318,6 +321,11 @@ export class OpenClawApp extends LitElement { resetChatScrollInternal(this as unknown as Parameters[0]); } + scrollToBottom() { + resetChatScrollInternal(this as unknown as Parameters[0]); + scheduleChatScrollInternal(this as unknown as Parameters[0], true); + } + async loadAssistantIdentity() { await loadAssistantIdentityInternal(this); } @@ -479,6 +487,6 @@ export class OpenClawApp extends LitElement { } render() { - return renderApp(this); + return renderApp(this as unknown as AppViewState); } } diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index 9b6ac275b..1682dcfa9 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -106,6 +106,12 @@ export const icons = { check: html` `, + arrowDown: html` + + + + + `, copy: html` diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index b94d2f5cc..13fb74012 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -53,6 +53,9 @@ export type ChatProps = { // Image attachments attachments?: ChatAttachment[]; onAttachmentsChange?: (attachments: ChatAttachment[]) => void; + // Scroll control + showNewMessages?: boolean; + onScrollToBottom?: () => void; // Event handlers onRefresh: () => void; onToggleFocusMode: () => void; @@ -340,6 +343,20 @@ export function renderChat(props: ChatProps) { : nothing } + ${ + props.showNewMessages + ? html` + + ` + : nothing + } +
${renderAttachmentPreview(props)}