fix(webchat): respect user scroll position during streaming and refresh (thanks @marcomarandiz)
Merges #7226
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)
|
||||||
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
||||||
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
||||||
- Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123.
|
- Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123.
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export async function refreshChat(host: ChatHost) {
|
|||||||
}),
|
}),
|
||||||
refreshChatAvatar(host),
|
refreshChatAvatar(host),
|
||||||
]);
|
]);
|
||||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const flushChatQueueForEvent = flushChatQueue;
|
export const flushChatQueueForEvent = flushChatQueue;
|
||||||
|
|||||||
@@ -480,6 +480,8 @@ export function renderApp(state: AppViewState) {
|
|||||||
onAbort: () => void state.handleAbortChat(),
|
onAbort: () => void state.handleAbortChat(),
|
||||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||||
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
||||||
|
showNewMessages: state.chatNewMessagesBelow,
|
||||||
|
onScrollToBottom: () => state.scrollToBottom(),
|
||||||
// Sidebar props for tool output viewing
|
// Sidebar props for tool output viewing
|
||||||
sidebarOpen: state.sidebarOpen,
|
sidebarOpen: state.sidebarOpen,
|
||||||
sidebarContent: state.sidebarContent,
|
sidebarContent: state.sidebarContent,
|
||||||
|
|||||||
273
ui/src/ui/app-scroll.test.ts
Normal file
273
ui/src/ui/app-scroll.test.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { handleChatScroll, scheduleChatScroll, resetChatScroll } from "./app-scroll";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Minimal ScrollHost stub for unit tests. */
|
||||||
|
function createScrollHost(overrides: {
|
||||||
|
scrollHeight?: number;
|
||||||
|
scrollTop?: number;
|
||||||
|
clientHeight?: number;
|
||||||
|
overflowY?: string;
|
||||||
|
} = {}) {
|
||||||
|
const {
|
||||||
|
scrollHeight = 2000,
|
||||||
|
scrollTop = 1500,
|
||||||
|
clientHeight = 500,
|
||||||
|
overflowY = "auto",
|
||||||
|
} = overrides;
|
||||||
|
|
||||||
|
const container = {
|
||||||
|
scrollHeight,
|
||||||
|
scrollTop,
|
||||||
|
clientHeight,
|
||||||
|
style: { overflowY } as unknown as CSSStyleDeclaration,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make getComputedStyle return the overflowY value
|
||||||
|
vi.spyOn(window, "getComputedStyle").mockReturnValue({
|
||||||
|
overflowY,
|
||||||
|
} as unknown as CSSStyleDeclaration);
|
||||||
|
|
||||||
|
const host = {
|
||||||
|
updateComplete: Promise.resolve(),
|
||||||
|
querySelector: vi.fn().mockReturnValue(container),
|
||||||
|
style: { setProperty: vi.fn() } as unknown as CSSStyleDeclaration,
|
||||||
|
chatScrollFrame: null as number | null,
|
||||||
|
chatScrollTimeout: null as number | null,
|
||||||
|
chatHasAutoScrolled: false,
|
||||||
|
chatUserNearBottom: true,
|
||||||
|
chatNewMessagesBelow: false,
|
||||||
|
logsScrollFrame: null as number | null,
|
||||||
|
logsAtBottom: true,
|
||||||
|
topbarObserver: null as ResizeObserver | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { host, container };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScrollEvent(scrollHeight: number, scrollTop: number, clientHeight: number) {
|
||||||
|
return {
|
||||||
|
currentTarget: { scrollHeight, scrollTop, clientHeight },
|
||||||
|
} as unknown as Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* handleChatScroll – threshold tests */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
describe("handleChatScroll", () => {
|
||||||
|
it("sets chatUserNearBottom=true when within the 450px threshold", () => {
|
||||||
|
const { host } = createScrollHost({});
|
||||||
|
// distanceFromBottom = 2000 - 1600 - 400 = 0 → clearly near bottom
|
||||||
|
const event = createScrollEvent(2000, 1600, 400);
|
||||||
|
handleChatScroll(host, event);
|
||||||
|
expect(host.chatUserNearBottom).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets chatUserNearBottom=true when distance is just under threshold", () => {
|
||||||
|
const { host } = createScrollHost({});
|
||||||
|
// distanceFromBottom = 2000 - 1151 - 400 = 449 → just under threshold
|
||||||
|
const event = createScrollEvent(2000, 1151, 400);
|
||||||
|
handleChatScroll(host, event);
|
||||||
|
expect(host.chatUserNearBottom).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets chatUserNearBottom=false when distance is exactly at threshold", () => {
|
||||||
|
const { host } = createScrollHost({});
|
||||||
|
// distanceFromBottom = 2000 - 1150 - 400 = 450 → at threshold (uses strict <)
|
||||||
|
const event = createScrollEvent(2000, 1150, 400);
|
||||||
|
handleChatScroll(host, event);
|
||||||
|
expect(host.chatUserNearBottom).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets chatUserNearBottom=false when scrolled well above threshold", () => {
|
||||||
|
const { host } = createScrollHost({});
|
||||||
|
// distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold
|
||||||
|
const event = createScrollEvent(2000, 500, 400);
|
||||||
|
handleChatScroll(host, event);
|
||||||
|
expect(host.chatUserNearBottom).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => {
|
||||||
|
const { host } = createScrollHost({});
|
||||||
|
// distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near"
|
||||||
|
// distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near"
|
||||||
|
const event = createScrollEvent(2000, 1100, 400);
|
||||||
|
handleChatScroll(host, event);
|
||||||
|
expect(host.chatUserNearBottom).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* scheduleChatScroll – respects user scroll position */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
describe("scheduleChatScroll", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||||
|
cb(0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls to bottom when user is near bottom (no force)", async () => {
|
||||||
|
const { host, container } = createScrollHost({
|
||||||
|
scrollHeight: 2000,
|
||||||
|
scrollTop: 1600,
|
||||||
|
clientHeight: 400,
|
||||||
|
});
|
||||||
|
// distanceFromBottom = 2000 - 1600 - 400 = 0 → near bottom
|
||||||
|
host.chatUserNearBottom = true;
|
||||||
|
|
||||||
|
scheduleChatScroll(host);
|
||||||
|
await host.updateComplete;
|
||||||
|
|
||||||
|
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT scroll when user is scrolled up and no force", async () => {
|
||||||
|
const { host, container } = createScrollHost({
|
||||||
|
scrollHeight: 2000,
|
||||||
|
scrollTop: 500,
|
||||||
|
clientHeight: 400,
|
||||||
|
});
|
||||||
|
// distanceFromBottom = 2000 - 500 - 400 = 1100 → not near bottom
|
||||||
|
host.chatUserNearBottom = false;
|
||||||
|
const originalScrollTop = container.scrollTop;
|
||||||
|
|
||||||
|
scheduleChatScroll(host);
|
||||||
|
await host.updateComplete;
|
||||||
|
|
||||||
|
expect(container.scrollTop).toBe(originalScrollTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT scroll with force=true when user has explicitly scrolled up", async () => {
|
||||||
|
const { host, container } = createScrollHost({
|
||||||
|
scrollHeight: 2000,
|
||||||
|
scrollTop: 500,
|
||||||
|
clientHeight: 400,
|
||||||
|
});
|
||||||
|
// User has scrolled up — chatUserNearBottom is false
|
||||||
|
host.chatUserNearBottom = false;
|
||||||
|
host.chatHasAutoScrolled = true; // Already past initial load
|
||||||
|
const originalScrollTop = container.scrollTop;
|
||||||
|
|
||||||
|
scheduleChatScroll(host, true);
|
||||||
|
await host.updateComplete;
|
||||||
|
|
||||||
|
// force=true should still NOT override explicit user scroll-up after initial load
|
||||||
|
expect(container.scrollTop).toBe(originalScrollTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DOES scroll with force=true on initial load (chatHasAutoScrolled=false)", async () => {
|
||||||
|
const { host, container } = createScrollHost({
|
||||||
|
scrollHeight: 2000,
|
||||||
|
scrollTop: 500,
|
||||||
|
clientHeight: 400,
|
||||||
|
});
|
||||||
|
host.chatUserNearBottom = false;
|
||||||
|
host.chatHasAutoScrolled = false; // Initial load
|
||||||
|
|
||||||
|
scheduleChatScroll(host, true);
|
||||||
|
await host.updateComplete;
|
||||||
|
|
||||||
|
// On initial load, force should work regardless
|
||||||
|
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets chatNewMessagesBelow when not scrolling due to user position", async () => {
|
||||||
|
const { host } = createScrollHost({
|
||||||
|
scrollHeight: 2000,
|
||||||
|
scrollTop: 500,
|
||||||
|
clientHeight: 400,
|
||||||
|
});
|
||||||
|
host.chatUserNearBottom = false;
|
||||||
|
host.chatHasAutoScrolled = true;
|
||||||
|
host.chatNewMessagesBelow = false;
|
||||||
|
|
||||||
|
scheduleChatScroll(host);
|
||||||
|
await host.updateComplete;
|
||||||
|
|
||||||
|
expect(host.chatNewMessagesBelow).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Streaming: rapid chatStream changes should not reset scroll */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
describe("streaming scroll behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||||
|
cb(0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple rapid scheduleChatScroll calls do not scroll when user is scrolled up", async () => {
|
||||||
|
const { host, container } = createScrollHost({
|
||||||
|
scrollHeight: 2000,
|
||||||
|
scrollTop: 500,
|
||||||
|
clientHeight: 400,
|
||||||
|
});
|
||||||
|
host.chatUserNearBottom = false;
|
||||||
|
host.chatHasAutoScrolled = true;
|
||||||
|
const originalScrollTop = container.scrollTop;
|
||||||
|
|
||||||
|
// Simulate rapid streaming token updates
|
||||||
|
scheduleChatScroll(host);
|
||||||
|
scheduleChatScroll(host);
|
||||||
|
scheduleChatScroll(host);
|
||||||
|
await host.updateComplete;
|
||||||
|
|
||||||
|
expect(container.scrollTop).toBe(originalScrollTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streaming scrolls correctly when user IS at bottom", async () => {
|
||||||
|
const { host, container } = createScrollHost({
|
||||||
|
scrollHeight: 2000,
|
||||||
|
scrollTop: 1600,
|
||||||
|
clientHeight: 400,
|
||||||
|
});
|
||||||
|
host.chatUserNearBottom = true;
|
||||||
|
host.chatHasAutoScrolled = true;
|
||||||
|
|
||||||
|
// Simulate streaming
|
||||||
|
scheduleChatScroll(host);
|
||||||
|
await host.updateComplete;
|
||||||
|
|
||||||
|
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* resetChatScroll */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
describe("resetChatScroll", () => {
|
||||||
|
it("resets state for new chat session", () => {
|
||||||
|
const { host } = createScrollHost({});
|
||||||
|
host.chatHasAutoScrolled = true;
|
||||||
|
host.chatUserNearBottom = false;
|
||||||
|
|
||||||
|
resetChatScroll(host);
|
||||||
|
|
||||||
|
expect(host.chatHasAutoScrolled).toBe(false);
|
||||||
|
expect(host.chatUserNearBottom).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/** Distance (px) from the bottom within which we consider the user "near bottom". */
|
||||||
|
const NEAR_BOTTOM_THRESHOLD = 450;
|
||||||
|
|
||||||
type ScrollHost = {
|
type ScrollHost = {
|
||||||
updateComplete: Promise<unknown>;
|
updateComplete: Promise<unknown>;
|
||||||
querySelector: (selectors: string) => Element | null;
|
querySelector: (selectors: string) => Element | null;
|
||||||
@@ -6,6 +9,7 @@ type ScrollHost = {
|
|||||||
chatScrollTimeout: number | null;
|
chatScrollTimeout: number | null;
|
||||||
chatHasAutoScrolled: boolean;
|
chatHasAutoScrolled: boolean;
|
||||||
chatUserNearBottom: boolean;
|
chatUserNearBottom: boolean;
|
||||||
|
chatNewMessagesBelow: boolean;
|
||||||
logsScrollFrame: number | null;
|
logsScrollFrame: number | null;
|
||||||
logsAtBottom: boolean;
|
logsAtBottom: boolean;
|
||||||
topbarObserver: ResizeObserver | null;
|
topbarObserver: ResizeObserver | null;
|
||||||
@@ -42,16 +46,25 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
|
|
||||||
|
// 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) {
|
if (!shouldStick) {
|
||||||
|
// User is scrolled up — flag that new content arrived below.
|
||||||
|
host.chatNewMessagesBelow = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (force) {
|
if (effectiveForce) {
|
||||||
host.chatHasAutoScrolled = true;
|
host.chatHasAutoScrolled = true;
|
||||||
}
|
}
|
||||||
target.scrollTop = target.scrollHeight;
|
target.scrollTop = target.scrollHeight;
|
||||||
host.chatUserNearBottom = true;
|
host.chatUserNearBottom = true;
|
||||||
const retryDelay = force ? 150 : 120;
|
host.chatNewMessagesBelow = false;
|
||||||
|
const retryDelay = effectiveForce ? 150 : 120;
|
||||||
host.chatScrollTimeout = window.setTimeout(() => {
|
host.chatScrollTimeout = window.setTimeout(() => {
|
||||||
host.chatScrollTimeout = null;
|
host.chatScrollTimeout = null;
|
||||||
const latest = pickScrollTarget();
|
const latest = pickScrollTarget();
|
||||||
@@ -60,7 +73,10 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
|
|||||||
}
|
}
|
||||||
const latestDistanceFromBottom =
|
const latestDistanceFromBottom =
|
||||||
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
|
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
|
||||||
const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
|
const shouldStickRetry =
|
||||||
|
effectiveForce ||
|
||||||
|
host.chatUserNearBottom ||
|
||||||
|
latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD;
|
||||||
if (!shouldStickRetry) {
|
if (!shouldStickRetry) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -99,7 +115,11 @@ export function handleChatScroll(host: ScrollHost, event: Event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
host.chatUserNearBottom = distanceFromBottom < 200;
|
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) {
|
export function handleLogsScroll(host: ScrollHost, event: Event) {
|
||||||
@@ -114,6 +134,7 @@ export function handleLogsScroll(host: ScrollHost, event: Event) {
|
|||||||
export function resetChatScroll(host: ScrollHost) {
|
export function resetChatScroll(host: ScrollHost) {
|
||||||
host.chatHasAutoScrolled = false;
|
host.chatHasAutoScrolled = false;
|
||||||
host.chatUserNearBottom = true;
|
host.chatUserNearBottom = true;
|
||||||
|
host.chatNewMessagesBelow = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportLogs(lines: string[], label: string) {
|
export function exportLogs(lines: string[], label: string) {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export type AppViewState = {
|
|||||||
chatQueue: ChatQueueItem[];
|
chatQueue: ChatQueueItem[];
|
||||||
nodesLoading: boolean;
|
nodesLoading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
|
chatNewMessagesBelow: boolean;
|
||||||
|
scrollToBottom: () => void;
|
||||||
devicesLoading: boolean;
|
devicesLoading: boolean;
|
||||||
devicesError: string | null;
|
devicesError: string | null;
|
||||||
devicesList: DevicePairingList | null;
|
devicesList: DevicePairingList | null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LitElement } from "lit";
|
import { LitElement } from "lit";
|
||||||
import { customElement, state } from "lit/decorators.js";
|
import { customElement, state } from "lit/decorators.js";
|
||||||
import type { EventLogEntry } from "./app-events";
|
import type { EventLogEntry } from "./app-events";
|
||||||
|
import type { AppViewState } from "./app-view-state";
|
||||||
import type { DevicePairingList } from "./controllers/devices";
|
import type { DevicePairingList } from "./controllers/devices";
|
||||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
|
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
|
||||||
@@ -57,6 +58,7 @@ import {
|
|||||||
handleChatScroll as handleChatScrollInternal,
|
handleChatScroll as handleChatScrollInternal,
|
||||||
handleLogsScroll as handleLogsScrollInternal,
|
handleLogsScroll as handleLogsScrollInternal,
|
||||||
resetChatScroll as resetChatScrollInternal,
|
resetChatScroll as resetChatScrollInternal,
|
||||||
|
scheduleChatScroll as scheduleChatScrollInternal,
|
||||||
} from "./app-scroll";
|
} from "./app-scroll";
|
||||||
import {
|
import {
|
||||||
applySettings as applySettingsInternal,
|
applySettings as applySettingsInternal,
|
||||||
@@ -252,6 +254,7 @@ export class OpenClawApp extends LitElement {
|
|||||||
private chatScrollTimeout: number | null = null;
|
private chatScrollTimeout: number | null = null;
|
||||||
private chatHasAutoScrolled = false;
|
private chatHasAutoScrolled = false;
|
||||||
private chatUserNearBottom = true;
|
private chatUserNearBottom = true;
|
||||||
|
@state() chatNewMessagesBelow = false;
|
||||||
private nodesPollInterval: number | null = null;
|
private nodesPollInterval: number | null = null;
|
||||||
private logsPollInterval: number | null = null;
|
private logsPollInterval: number | null = null;
|
||||||
private debugPollInterval: number | null = null;
|
private debugPollInterval: number | null = null;
|
||||||
@@ -318,6 +321,11 @@ export class OpenClawApp extends LitElement {
|
|||||||
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
|
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
|
||||||
|
scheduleChatScrollInternal(this as unknown as Parameters<typeof scheduleChatScrollInternal>[0], true);
|
||||||
|
}
|
||||||
|
|
||||||
async loadAssistantIdentity() {
|
async loadAssistantIdentity() {
|
||||||
await loadAssistantIdentityInternal(this);
|
await loadAssistantIdentityInternal(this);
|
||||||
}
|
}
|
||||||
@@ -479,6 +487,6 @@ export class OpenClawApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return renderApp(this);
|
return renderApp(this as unknown as AppViewState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ export const icons = {
|
|||||||
check: html`
|
check: html`
|
||||||
<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></svg>
|
<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></svg>
|
||||||
`,
|
`,
|
||||||
|
arrowDown: html`
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 5v14" />
|
||||||
|
<path d="m19 12-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
copy: html`
|
copy: html`
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export type ChatProps = {
|
|||||||
// Image attachments
|
// Image attachments
|
||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||||
|
// Scroll control
|
||||||
|
showNewMessages?: boolean;
|
||||||
|
onScrollToBottom?: () => void;
|
||||||
// Event handlers
|
// Event handlers
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onToggleFocusMode: () => void;
|
onToggleFocusMode: () => void;
|
||||||
@@ -340,6 +343,20 @@ export function renderChat(props: ChatProps) {
|
|||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
${
|
||||||
|
props.showNewMessages
|
||||||
|
? html`
|
||||||
|
<button
|
||||||
|
class="chat-new-messages"
|
||||||
|
type="button"
|
||||||
|
@click=${props.onScrollToBottom}
|
||||||
|
>
|
||||||
|
New messages ${icons.arrowDown}
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
<div class="chat-compose">
|
<div class="chat-compose">
|
||||||
${renderAttachmentPreview(props)}
|
${renderAttachmentPreview(props)}
|
||||||
<div class="chat-compose__row">
|
<div class="chat-compose__row">
|
||||||
|
|||||||
Reference in New Issue
Block a user