From 511632f47c905f3b40534176c2b1bef7dc11ed22 Mon Sep 17 00:00:00 2001 From: kiranjd Date: Tue, 6 Jan 2026 11:23:27 +0530 Subject: [PATCH 1/2] fix(ui): scroll chat to bottom on initial load The chat view was starting at the top showing oldest messages instead of scrolling to the bottom to show the latest messages (like WhatsApp). Root causes: 1. scheduleChatScroll() was called without force flag in refreshActiveTab() 2. The scroll was targeting .chat-thread element which has overflow:visible and doesn't actually scroll - the window scrolls instead Fixes: - Pass force flag (!chatHasAutoScrolled) when loading chat tab - Wait for Lit updateComplete before scrolling to ensure DOM is ready - Scroll the window instead of the .chat-thread container - Use behavior: 'instant' for immediate scroll without animation --- ui/src/ui/app.ts | 50 +++++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 2c0dab719..0a8bc02be 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -437,25 +437,35 @@ export class ClawdbotApp extends LitElement { clearTimeout(this.chatScrollTimeout); this.chatScrollTimeout = null; } - this.chatScrollFrame = requestAnimationFrame(() => { - this.chatScrollFrame = null; - const container = this.querySelector(".chat-thread") as HTMLElement | null; - if (!container) return; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - const shouldStick = force || distanceFromBottom < 140; - if (!shouldStick) return; - if (force) this.chatHasAutoScrolled = true; - container.scrollTop = container.scrollHeight; - this.chatScrollTimeout = window.setTimeout(() => { - this.chatScrollTimeout = null; - const latest = this.querySelector(".chat-thread") as HTMLElement | null; - if (!latest) return; - const latestDistanceFromBottom = - latest.scrollHeight - latest.scrollTop - latest.clientHeight; - if (!force && latestDistanceFromBottom >= 180) return; - latest.scrollTop = latest.scrollHeight; - }, 120); + // Wait for Lit render to complete, then scroll + void this.updateComplete.then(() => { + this.chatScrollFrame = requestAnimationFrame(() => { + this.chatScrollFrame = null; + if (force) { + // Force scroll window to bottom unconditionally + this.chatHasAutoScrolled = true; + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }); + // Retry after images/content load + this.chatScrollTimeout = window.setTimeout(() => { + this.chatScrollTimeout = null; + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }); + }, 150); + return; + } + // Stick to bottom if already near bottom + const distanceFromBottom = + document.body.scrollHeight - window.scrollY - window.innerHeight; + const shouldStick = distanceFromBottom < 200; + if (!shouldStick) return; + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }); + this.chatScrollTimeout = window.setTimeout(() => { + this.chatScrollTimeout = null; + const latestDistanceFromBottom = + document.body.scrollHeight - window.scrollY - window.innerHeight; + if (latestDistanceFromBottom >= 250) return; + window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }); + }, 120); + }); }); } @@ -689,7 +699,7 @@ export class ClawdbotApp extends LitElement { if (this.tab === "nodes") await loadNodes(this); if (this.tab === "chat") { await Promise.all([loadChatHistory(this), loadSessions(this)]); - this.scheduleChatScroll(); + this.scheduleChatScroll(!this.chatHasAutoScrolled); } if (this.tab === "config") { await loadConfigSchema(this); From 5b183b4fe398bf0ba48c17f4a6d4fb521fa7727a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 07:49:12 +0100 Subject: [PATCH 2/2] fix(ui): scroll chat to bottom on initial load --- CHANGELOG.md | 1 + ui/src/ui/app.ts | 44 +++++++++++++++++++++++++------------------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 051868266..e448c8c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ - Control UI: animate reading indicator dots (honors reduced-motion). - Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). - Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268. +- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274. - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). - Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split. diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 0a8bc02be..1b46c70f8 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -437,34 +437,40 @@ export class ClawdbotApp extends LitElement { clearTimeout(this.chatScrollTimeout); this.chatScrollTimeout = null; } + const pickScrollTarget = () => { + const container = this.querySelector(".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; + }; // Wait for Lit render to complete, then scroll void this.updateComplete.then(() => { this.chatScrollFrame = requestAnimationFrame(() => { this.chatScrollFrame = null; - if (force) { - // Force scroll window to bottom unconditionally - this.chatHasAutoScrolled = true; - window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }); - // Retry after images/content load - this.chatScrollTimeout = window.setTimeout(() => { - this.chatScrollTimeout = null; - window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }); - }, 150); - return; - } - // Stick to bottom if already near bottom + const target = pickScrollTarget(); + if (!target) return; const distanceFromBottom = - document.body.scrollHeight - window.scrollY - window.innerHeight; - const shouldStick = distanceFromBottom < 200; + target.scrollHeight - target.scrollTop - target.clientHeight; + const shouldStick = force || distanceFromBottom < 200; if (!shouldStick) return; - window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }); + if (force) this.chatHasAutoScrolled = true; + target.scrollTop = target.scrollHeight; + const retryDelay = force ? 150 : 120; this.chatScrollTimeout = window.setTimeout(() => { this.chatScrollTimeout = null; + const latest = pickScrollTarget(); + if (!latest) return; const latestDistanceFromBottom = - document.body.scrollHeight - window.scrollY - window.innerHeight; - if (latestDistanceFromBottom >= 250) return; - window.scrollTo({ top: document.body.scrollHeight, behavior: "instant" }); - }, 120); + latest.scrollHeight - latest.scrollTop - latest.clientHeight; + if (!force && latestDistanceFromBottom >= 250) return; + latest.scrollTop = latest.scrollHeight; + }, retryDelay); }); }); }