Control UI: preserve chat scroll when scrolled up

Closes #217
This commit is contained in:
Shadow
2026-01-12 22:11:43 -06:00
parent c9fdd68232
commit 7ce902b096
4 changed files with 33 additions and 10 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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%"}"
>
<div class="chat-thread" role="log" aria-live="polite">
<div
class="chat-thread"
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
>
${props.loading
? html`<div class="muted">Loading chat…</div>`
: nothing}