fix(ui): smooth chat refresh scroll and suppress new-messages badge flash

This commit is contained in:
Tyler Yust
2026-02-07 20:21:27 -08:00
parent 191da1feb5
commit bc475f0172
8 changed files with 44 additions and 10 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
- Cron: route text-only isolated agent announces through the shared subagent announce flow; add exponential backoff for repeated errors; preserve future `nextRunAtMs` on restart; include current-boundary schedule matches; prevent stale threadId reuse across targets; and add per-job execution timeout. (#11641) Thanks @tyler6204.
- Subagents: stabilize announce timing, preserve compaction metrics across retries, clamp overflow-prone long timeouts, and cap impossible context usage token totals. (#11551) Thanks @tyler6204.
- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.

View File

@@ -202,7 +202,7 @@ export async function handleSendChat(
});
}
export async function refreshChat(host: ChatHost) {
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
await Promise.all([
loadChatHistory(host as unknown as OpenClawApp),
loadSessions(host as unknown as OpenClawApp, {
@@ -210,7 +210,9 @@ export async function refreshChat(host: ChatHost) {
}),
refreshChatAvatar(host),
]);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
if (opts?.scheduleScroll !== false) {
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
}
}
export const flushChatQueueForEvent = flushChatQueue;

View File

@@ -22,6 +22,7 @@ type LifecycleHost = {
basePath: string;
tab: Tab;
chatHasAutoScrolled: boolean;
chatManualRefreshInFlight: boolean;
chatLoading: boolean;
chatMessages: unknown[];
chatToolMessages: unknown[];
@@ -65,6 +66,9 @@ export function handleDisconnected(host: LifecycleHost) {
}
export function handleUpdated(host: LifecycleHost, changed: Map<PropertyKey, unknown>) {
if (host.tab === "chat" && host.chatManualRefreshInFlight) {
return;
}
if (
host.tab === "chat" &&
(changed.has("chatMessages") ||

View File

@@ -126,9 +126,23 @@ export function renderChatControls(state: AppViewState) {
<button
class="btn btn--sm btn--icon"
?disabled=${state.chatLoading || !state.connected}
@click=${() => {
(state as unknown as OpenClawApp).resetToolStream();
void refreshChat(state as unknown as Parameters<typeof refreshChat>[0]);
@click=${async () => {
const app = state as unknown as OpenClawApp;
app.chatManualRefreshInFlight = true;
app.chatNewMessagesBelow = false;
await app.updateComplete;
app.resetToolStream();
try {
await refreshChat(state as unknown as Parameters<typeof refreshChat>[0], {
scheduleScroll: false,
});
app.scrollToBottom({ smooth: true });
} finally {
requestAnimationFrame(() => {
app.chatManualRefreshInFlight = false;
app.chatNewMessagesBelow = false;
});
}
}}
title="Refresh chat data"
>

View File

@@ -1115,7 +1115,7 @@ export function renderApp(state: AppViewState) {
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
showNewMessages: state.chatNewMessagesBelow,
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
onScrollToBottom: () => state.scrollToBottom(),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,

View File

@@ -15,7 +15,7 @@ type ScrollHost = {
topbarObserver: ResizeObserver | null;
};
export function scheduleChatScroll(host: ScrollHost, force = false) {
export function scheduleChatScroll(host: ScrollHost, force = false, smooth = false) {
if (host.chatScrollFrame) {
cancelAnimationFrame(host.chatScrollFrame);
}
@@ -61,7 +61,17 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
if (effectiveForce) {
host.chatHasAutoScrolled = true;
}
target.scrollTop = target.scrollHeight;
const smoothEnabled =
smooth &&
(typeof window === "undefined" ||
typeof window.matchMedia !== "function" ||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const scrollTop = target.scrollHeight;
if (typeof target.scrollTo === "function") {
target.scrollTo({ top: scrollTop, behavior: smoothEnabled ? "smooth" : "auto" });
} else {
target.scrollTop = scrollTop;
}
host.chatUserNearBottom = true;
host.chatNewMessagesBelow = false;
const retryDelay = effectiveForce ? 150 : 120;

View File

@@ -64,6 +64,7 @@ export type AppViewState = {
chatAvatarUrl: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];
chatManualRefreshInFlight: boolean;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
chatNewMessagesBelow: boolean;
@@ -71,7 +72,7 @@ export type AppViewState = {
sidebarContent: string | null;
sidebarError: string | null;
splitRatio: number;
scrollToBottom: () => void;
scrollToBottom: (opts?: { smooth?: boolean }) => void;
devicesLoading: boolean;
devicesError: string | null;
devicesList: DevicePairingList | null;

View File

@@ -136,6 +136,7 @@ export class OpenClawApp extends LitElement {
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@state() chatAttachments: ChatAttachment[] = [];
@state() chatManualRefreshInFlight = false;
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;
@@ -395,11 +396,12 @@ export class OpenClawApp extends LitElement {
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
}
scrollToBottom() {
scrollToBottom(opts?: { smooth?: boolean }) {
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
scheduleChatScrollInternal(
this as unknown as Parameters<typeof scheduleChatScrollInternal>[0],
true,
Boolean(opts?.smooth),
);
}