/** Distance (px) from the bottom within which we consider the user "near bottom". */
const NEAR_BOTTOM_THRESHOLD = 450 ;
type ScrollHost = {
updateComplete: Promise<unknown>;
querySelector: (selectors: string) => Element | null ;
style: CSSStyleDeclaration;
chatScrollFrame: number | null ;
chatScrollTimeout: number | null ;
chatHasAutoScrolled: boolean ;
chatUserNearBottom: boolean ;
chatNewMessagesBelow: boolean ;
logsScrollFrame: number | null ;
logsAtBottom: boolean ;
topbarObserver: ResizeObserver | null ;
};
function queryHost(host: Partial<ScrollHost>, selectors: string): Element | null {
return typeof host.querySelector === "function" ? host.querySelector(selectors) : null ;
}
export function scheduleChatScroll(host: ScrollHost, force = false , smooth = false ) {
if (host.chatScrollFrame) {
cancelAnimationFrame(host.chatScrollFrame);
}
if (host.chatScrollTimeout != null ) {
clearTimeout(host.chatScrollTimeout);
host.chatScrollTimeout = null ;
}
const pickScrollTarget = () => {
const container = queryHost(host, ".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 host.updateComplete.then(() => {
host.chatScrollFrame = requestAnimationFrame(() => {
host.chatScrollFrame = null ;
const target = pickScrollTarget();
if (!target) {
return ;
}
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
// 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 (effectiveForce) {
host.chatHasAutoScrolled = true ;
}
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 ;
host.chatScrollTimeout = window.setTimeout(() => {
host.chatScrollTimeout = null ;
const latest = pickScrollTarget();
if (!latest) {
return ;
}
const latestDistanceFromBottom =
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry =
effectiveForce ||
host.chatUserNearBottom ||
latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD;
if (!shouldStickRetry) {
return ;
}
latest.scrollTop = latest.scrollHeight;
host.chatUserNearBottom = true ;
}, retryDelay);
});
});
}
export function scheduleLogsScroll(host: ScrollHost, force = false ) {
if (host.logsScrollFrame) {
cancelAnimationFrame(host.logsScrollFrame);
}
void host.updateComplete.then(() => {
host.logsScrollFrame = requestAnimationFrame(() => {
host.logsScrollFrame = null ;
const container = queryHost(host, ".log-stream" ) as HTMLElement | null ;
if (!container) {
return ;
}
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const shouldStick = force || distanceFromBottom < 80 ;
if (!shouldStick) {
return ;
}
container.scrollTop = container.scrollHeight;
});
});
}
export function handleChatScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null ;
if (!container) {
return ;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
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) {
const container = event.currentTarget as HTMLElement | null ;
if (!container) {
return ;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.logsAtBottom = distanceFromBottom < 80 ;
}
export function resetChatScroll(host: ScrollHost) {
host.chatHasAutoScrolled = false ;
host.chatUserNearBottom = true ;
host.chatNewMessagesBelow = false ;
}
export function exportLogs(lines: string[], label: string) {
if (lines.length === 0 ) {
return ;
}
const blob = new Blob([`${lines.join("\n" )}\n`], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a" );
const stamp = new Date().toISOString().slice(0 , 19 ).replace(/[:T]/g, "-" );
anchor.href = url;
anchor.download = `openclaw-logs-${label}-${stamp}.log`;
anchor.click();
URL.revokeObjectURL(url);
}
export function observeTopbar(host: ScrollHost) {
if (typeof ResizeObserver === "undefined" ) {
return ;
}
const topbar = queryHost(host, ".topbar" );
if (!topbar) {
return ;
}
const update = () => {
const { height } = topbar.getBoundingClientRect();
host.style.setProperty("--topbar-height" , `${height}px`);
};
update();
host.topbarObserver = new ResizeObserver(() => update());
host.topbarObserver.observe(topbar);
}
Messung V0.5 in Prozent C=99 H=98 G=98
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland