From 7ce902b0967f6ef507ebcf43cf8eedead3ab93e7 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 12 Jan 2026 22:11:43 -0600 Subject: [PATCH] Control UI: preserve chat scroll when scrolled up Closes #217 --- CHANGELOG.md | 1 + ui/src/ui/app-render.ts | 15 ++++++++------- ui/src/ui/app.ts | 19 +++++++++++++++++-- ui/src/ui/views/chat.ts | 8 +++++++- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 471ef8d39..c9ef940d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm) ### Fixes +- Control UI: keep chat scroll position unless user is near the bottom. (#217 — thanks @thewilloftheshadow) - Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight) - Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow) - Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 6013538a7..74640f9f9 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -494,13 +494,14 @@ export function renderApp(state: AppViewState) { ...state.settings, useNewChatLayout: !state.settings.useNewChatLayout, }), - onDraftChange: (next) => (state.chatMessage = next), - onSend: () => state.handleSendChat(), - canAbort: Boolean(state.chatRunId), - onAbort: () => void state.handleAbortChat(), - onQueueRemove: (id) => state.removeQueuedMessage(id), - onNewSession: () => - state.handleSendChat("/new", { restoreDraft: true }), + onChatScroll: (event) => state.handleChatScroll(event), + onDraftChange: (next) => (state.chatMessage = next), + onSend: () => state.handleSendChat(), + canAbort: Boolean(state.chatRunId), + onAbort: () => void state.handleAbortChat(), + onQueueRemove: (id) => state.removeQueuedMessage(id), + onNewSession: () => + state.handleSendChat("/new", { restoreDraft: true }), // Sidebar props for tool output viewing sidebarOpen: state.sidebarOpen, sidebarContent: state.sidebarContent, diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 40d4b5ad8..3c8e67038 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -400,6 +400,7 @@ export class ClawdbotApp extends LitElement { private chatScrollFrame: number | null = null; private chatScrollTimeout: number | null = null; private chatHasAutoScrolled = false; + private chatUserNearBottom = true; private nodesPollInterval: number | null = null; private logsPollInterval: number | null = null; private logsScrollFrame: number | null = null; @@ -525,10 +526,12 @@ export class ClawdbotApp extends LitElement { if (!target) return; const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight; - const shouldStick = force || distanceFromBottom < 200; + const shouldStick = + force || this.chatUserNearBottom || distanceFromBottom < 200; if (!shouldStick) return; if (force) this.chatHasAutoScrolled = true; target.scrollTop = target.scrollHeight; + this.chatUserNearBottom = true; const retryDelay = force ? 150 : 120; this.chatScrollTimeout = window.setTimeout(() => { this.chatScrollTimeout = null; @@ -536,8 +539,11 @@ export class ClawdbotApp extends LitElement { if (!latest) return; const latestDistanceFromBottom = latest.scrollHeight - latest.scrollTop - latest.clientHeight; - if (!force && latestDistanceFromBottom >= 250) return; + const shouldStickRetry = + force || this.chatUserNearBottom || latestDistanceFromBottom < 200; + if (!shouldStickRetry) return; latest.scrollTop = latest.scrollHeight; + this.chatUserNearBottom = true; }, retryDelay); }); }); @@ -600,6 +606,14 @@ export class ClawdbotApp extends LitElement { }); } + handleChatScroll(event: Event) { + const container = event.currentTarget as HTMLElement | null; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + this.chatUserNearBottom = distanceFromBottom < 200; + } + handleLogsScroll(event: Event) { const container = event.currentTarget as HTMLElement | null; if (!container) return; @@ -630,6 +644,7 @@ export class ClawdbotApp extends LitElement { resetChatScroll() { this.chatHasAutoScrolled = false; + this.chatUserNearBottom = true; } toggleToolOutput(id: string, expanded: boolean) { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 104281458..2b6fb5a3f 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -59,6 +59,7 @@ export type ChatProps = { onOpenSidebar?: (content: string) => void; onCloseSidebar?: () => void; onSplitRatioChange?: (ratio: number) => void; + onChatScroll?: (event: Event) => void; }; export function renderChat(props: ChatProps) { @@ -109,7 +110,12 @@ export function renderChat(props: ChatProps) { class="chat-main" style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}" > -
+
${props.loading ? html`
Loading chat…
` : nothing}