diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index b689d1384d66..56952f5ddacc 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -13,17 +13,20 @@ import { SESSION_DRAG_MIME } from "../../lib/sessions/drag.ts"; import { searchForSession } from "../../lib/sessions/index.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; import { ChatPage } from "./chat-page.ts"; +import type { ChatMessageCache } from "./session-message-cache.ts"; import type { SplitDropZone } from "./split-drop-zone.ts"; import { createSplitLayout, type ChatSplitLayout } from "./split-layout.ts"; type RenderedPane = HTMLElement & { paneId: string; + chatMessagesBySession: ChatMessageCache; sessionKey: string; active: boolean; showPaneHeader: boolean; paneTitle: string; narrow: boolean; onOpenSplitView?: () => void; + onClosePane?: (paneId: string) => void; }; type RenderedDivider = HTMLElement & { orientation: "horizontal" | "vertical" }; @@ -121,16 +124,51 @@ describe("chat page split layout host", () => { const panes = page.querySelectorAll("openclaw-chat-pane"); expect(panes).toHaveLength(1); - expect(itemAt(panes, 0, "rendered pane").paneId).toBe("single"); + expect(itemAt(panes, 0, "rendered pane").paneId).toBe("p1"); expect(itemAt(panes, 0, "rendered pane").sessionKey).toBe("main"); expect(itemAt(panes, 0, "rendered pane").active).toBe(true); expect(itemAt(panes, 0, "rendered pane").showPaneHeader).toBe(false); + expect(itemAt(panes, 0, "rendered pane").classList.contains("chat-split-view__pane")).toBe( + false, + ); expect(page.querySelector("resizable-divider")).toBeNull(); // The pane renders the opener in its floating toggle cluster; the page // only hands down the callback on wide single-pane layouts. expect(typeof itemAt(panes, 0, "rendered pane").onOpenSplitView).toBe("function"); }); + it("retains the classic pane element while split view opens and closes", async () => { + const page = new ChatPage(); + page.data = { sessionKey: "main" }; + document.body.append(page); + await page.updateComplete; + + const classicPane = itemAt( + page.querySelectorAll("openclaw-chat-pane"), + 0, + "classic pane", + ); + classicPane.onOpenSplitView?.(); + await page.updateComplete; + + const splitPanes = [...page.querySelectorAll("openclaw-chat-pane")]; + expect(splitPanes).toHaveLength(2); + expect(splitPanes[0]).toBe(classicPane); + expect(classicPane.classList.contains("chat-split-view__pane")).toBe(true); + const addedPane = itemAt(splitPanes, 1, "added split pane"); + addedPane.onClosePane?.(addedPane.paneId); + await page.updateComplete; + + const survivingPane = itemAt( + page.querySelectorAll("openclaw-chat-pane"), + 0, + "surviving pane", + ); + expect(survivingPane).toBe(classicPane); + expect(survivingPane.showPaneHeader).toBe(false); + expect(survivingPane.classList.contains("chat-split-view__pane")).toBe(false); + }); + it("withholds the split-view opener on narrow single-pane viewports", async () => { stubMatchMedia(true); const page = new ChatPage(); @@ -190,6 +228,7 @@ describe("chat page split layout host", () => { ).toBe(true); expect(panes.map((pane) => pane.showPaneHeader)).toEqual([true, true]); expect(panes.every((pane) => pane.onOpenSplitView === undefined)).toBe(true); + expect(panes[0]?.chatMessagesBySession).toBe(panes[1]?.chatMessagesBySession); }); it("renders only the active pane from a preserved split on narrow viewports", async () => { diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index 0fafb2764e19..29726b2114b2 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -14,6 +14,7 @@ import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import "../../styles/chat.css"; import "./chat-pane.ts"; +import type { ChatMessageCache } from "./session-message-cache.ts"; import { resolveSplitDropZone, splitDropIndicatorRect, @@ -22,8 +23,6 @@ import { } from "./split-drop-zone.ts"; import { closePane, - createSinglePaneLayout, - createSplitLayout, findPane, insertPane, panesOf, @@ -74,6 +73,9 @@ export class ChatPage extends OpenClawLightDomElement { private dragFrame = 0; private pendingDragOver: { pane: ChatPaneElement; x: number; y: number } | null = null; private consumedDraftData: ChatRouteData | null = null; + private readonly chatMessagesBySession: ChatMessageCache = new Map(); + private classicColumnId = "c1"; + private classicPaneId = "p1"; override connectedCallback() { super.connectedCallback(); @@ -299,7 +301,12 @@ export class ChatPage extends OpenClawLightDomElement { if (!currentSessionKey) { return; } - const next = insertPane(createSinglePaneLayout(currentSessionKey), "p1", trimmed, zone.edge); + const next = insertPane( + this.classicLayout(currentSessionKey), + this.classicPaneId, + trimmed, + zone.edge, + ); this.persistLayout(next); this.updateRoute(trimmed, true); return; @@ -361,7 +368,9 @@ export class ChatPage extends OpenClawLightDomElement { private readonly openSplitView = () => { const sessionKey = this.data?.sessionKey?.trim(); if (sessionKey) { - this.persistLayout(createSplitLayout(sessionKey)); + this.persistLayout( + insertPane(this.classicLayout(sessionKey), this.classicPaneId, sessionKey, "right"), + ); } }; @@ -390,6 +399,13 @@ export class ChatPage extends OpenClawLightDomElement { } const survivingPane = panesOf(layout).find((pane) => pane.id !== paneId); const next = closePane(layout, paneId); + if (!next && survivingPane) { + const survivingLocation = findPane(layout, survivingPane.id); + if (survivingLocation) { + this.classicColumnId = survivingLocation.column.id; + this.classicPaneId = survivingPane.id; + } + } this.persistLayout(next); if (!next && survivingPane) { this.updateRoute(survivingPane.sessionKey, true); @@ -416,7 +432,7 @@ export class ChatPage extends OpenClawLightDomElement { /** Header + pane travel together so each pane owns its title bar in-flow — * no fixed toolbar layer mirroring the split geometry. The pane renders its * own header so the workspace toggle can read per-pane workspace state. */ - private renderPaneCell(pane: ChatSplitPane, active: boolean, weight: number) { + private renderPaneCell(pane: ChatSplitPane, active: boolean, weight: number, splitMode: boolean) { const sessions = this.context?.sessions?.state.result?.sessions ?? []; const title = resolveSessionDisplayName( pane.sessionKey, @@ -424,23 +440,25 @@ export class ChatPage extends OpenClawLightDomElement { ); return html`
this.handleFocusPane(pane.id)} @focusin=${() => this.handleFocusPane(pane.id)} > @@ -448,12 +466,26 @@ export class ChatPage extends OpenClawLightDomElement { `; } - private renderSplitLayout(layout: ChatSplitLayout) { + private classicLayout(sessionKey = this.data?.sessionKey?.trim() ?? ""): ChatSplitLayout { + return { + columns: [ + { + id: this.classicColumnId, + panes: [{ id: this.classicPaneId, sessionKey }], + paneWeights: [1], + }, + ], + columnWeights: [1], + activePaneId: this.classicPaneId, + }; + } + + private renderSplitLayout(layout: ChatSplitLayout, splitMode: boolean) { if (this.narrow) { const activePane = findPane(layout, layout.activePaneId)?.pane; return activePane ? html`
- ${this.renderPaneCell(activePane, true, 1)} + ${this.renderPaneCell(activePane, true, 1, splitMode)}
` : nothing; } @@ -479,6 +511,7 @@ export class ChatPage extends OpenClawLightDomElement { pane, pane.id === layout.activePaneId, splitWeight(column.paneWeights, paneIndex, "rendered split pane weight"), + splitMode, )} ${paneIndex < column.panes.length - 1 ? html` @@ -536,22 +569,10 @@ export class ChatPage extends OpenClawLightDomElement { override render() { const indicator = this.dropIndicator; + const layout = this.layout; return html`
- ${this.layout - ? this.renderSplitLayout(this.layout) - : html` - - `} + ${this.renderSplitLayout(layout ?? this.classicLayout(), Boolean(layout))} ${indicator ? html`
void }; context: ApplicationContext; state: ChatPageHost; @@ -43,13 +45,11 @@ type TestChatPane = HTMLElement & { onPaneSessionChange?: (paneId: string, sessionKey: string) => void; sessionKey: string; catalogSession: SessionCatalogSession | null; - catalogItemMessage: ( - item: SessionCatalogTranscriptItem, - index: number, - ) => Record | null; + catalogItemMessage: (item: SessionCatalogTranscriptItem) => Record | null; handleTranscriptScroll: (event: Event) => void; handleTranscriptHistoryIntent: (event: Event) => void; historyAutoLoadBlocked: boolean; + historyObserverArmed: boolean; transcriptScrollTop: number | null; syncHistoryObserver: () => void; loadCatalogSession: (key: CatalogSessionKey, older: boolean) => Promise; @@ -57,12 +57,6 @@ type TestChatPane = HTMLElement & { prependUniqueCatalogMessages: (messages: unknown[]) => unknown[]; loadOlderMessages: () => Promise; hasOlderMessages: () => boolean; - restoreHistoryAnchor: () => void; - pendingHistoryAnchor: { - sessionKey: string; - element: HTMLElement; - viewportTop: number; - } | null; loadingOlder: boolean; catalogCursor: string | undefined; olderCursorsSeen: Set; @@ -182,7 +176,9 @@ describe("chat pane initialization", () => { it("sets the pane route before attaching outbox projection", () => { const pane = document.createElement("openclaw-chat-pane") as unknown as TestChatPane; const targetSessionKey = "agent:main:pane-b"; + const sharedMessages = new Map(); pane.sessionKey = targetSessionKey; + pane.chatMessagesBySession = sharedMessages; pane.context = { basePath: "", gateway: { snapshot: { hello: null } }, @@ -210,14 +206,17 @@ describe("chat pane initialization", () => { } as unknown as ApplicationContext; const stopAfterAttach = new Error("stop after attach"); let attachedSessionKey: string | undefined; + let attachedMessages: ChatMessageCache | undefined; vi.spyOn(pane.chatState, "attach").mockImplementation((state) => { attachedSessionKey = state.sessionKey; + attachedMessages = state.chatMessagesBySession; throw stopAfterAttach; }); try { expect(() => pane.connectedCallback()).toThrow(stopAfterAttach); expect(attachedSessionKey).toBe(targetSessionKey); + expect(attachedMessages).toBe(sharedMessages); } finally { pane.disconnectedCallback(); } @@ -523,7 +522,7 @@ describe("chat pane catalog session lifecycle", () => { const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); - const message = pane.catalogItemMessage(item as SessionCatalogTranscriptItem, 0) as { + const message = pane.catalogItemMessage(item as SessionCatalogTranscriptItem) as { content: Array<{ text: string }>; }; @@ -535,13 +534,10 @@ describe("chat pane catalog session lifecycle", () => { const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); - const message = pane.catalogItemMessage( - { - type: "toolResult", - raw: { aggregatedOutput: "x".repeat(5000) }, - } as SessionCatalogTranscriptItem, - 0, - ) as { content: Array<{ text: string }> }; + const message = pane.catalogItemMessage({ + type: "toolResult", + raw: { aggregatedOutput: "x".repeat(5000) }, + } as SessionCatalogTranscriptItem) as { content: Array<{ text: string }> }; // The 500-char preview cap keeps a single huge tool result from injecting // megabytes into one chat message; the "Tool result\n\n" prefix adds a bit. @@ -553,7 +549,16 @@ describe("chat pane catalog session lifecycle", () => { const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); - expect(pane.catalogItemMessage({ type: "other" }, 0)).toBeNull(); + expect(pane.catalogItemMessage({ type: "other" })).toBeNull(); + }); + + it("preserves provider order when catalog items omit timestamps", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); + + expect( + pane.catalogItemMessage({ id: "u1", type: "userMessage", text: "older question" }), + ).not.toHaveProperty("timestamp"); }); it("exhausts pagination when an older read does not advance the cursor", async () => { @@ -823,6 +828,47 @@ describe("chat pane native history pagination", () => { } }); + it("reuses an unchanged armed history observer across pane updates", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + pane.historyObserverArmed = true; + const thread = document.createElement("div"); + thread.className = "chat-thread"; + Object.defineProperty(thread, "scrollHeight", { value: 400 }); + Object.defineProperty(thread, "clientHeight", { value: 200 }); + const sentinel = document.createElement("div"); + sentinel.className = "chat-history-sentinel"; + thread.append(sentinel); + pane.append(thread); + const observe = vi.fn(); + const disconnect = vi.fn(); + const construct = vi.fn(); + class FakeIntersectionObserver { + constructor() { + construct(); + } + disconnect() { + disconnect(); + } + observe(target: Element) { + observe(target); + } + } + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + try { + pane.syncHistoryObserver(); + pane.syncHistoryObserver(); + + expect(construct).toHaveBeenCalledOnce(); + expect(observe).toHaveBeenCalledOnce(); + expect(observe).toHaveBeenCalledWith(sentinel); + expect(disconnect).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("keeps multiple projected messages from the same transcript sequence", () => { const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); @@ -844,14 +890,16 @@ describe("chat pane native history pagination", () => { it("deduplicates projected catalog transcript records by catalog message id", () => { const client = { request: vi.fn() } as unknown as GatewayBrowserClient; const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); - const current = pane.catalogItemMessage( - { id: "catalog-item-1", type: "userMessage", text: "newer projection" }, - 0, - ); - const overlapping = pane.catalogItemMessage( - { id: "catalog-item-1", type: "userMessage", text: "older projection" }, - 1, - ); + const current = pane.catalogItemMessage({ + id: "catalog-item-1", + type: "userMessage", + text: "newer projection", + }); + const overlapping = pane.catalogItemMessage({ + id: "catalog-item-1", + type: "userMessage", + text: "older projection", + }); if (!current || !overlapping) { throw new Error("expected catalog transcript projections"); } @@ -860,7 +908,7 @@ describe("chat pane native history pagination", () => { expect(pane.prependUniqueCatalogMessages([overlapping])).toEqual([current]); }); - it("prepends a strictly older page, preserves the viewport, and exhausts", async () => { + it("prepends a strictly older page and exhausts", async () => { const request = vi.fn(async () => ({ messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], hasMore: false, @@ -870,27 +918,6 @@ describe("chat pane native history pagination", () => { const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; - const thread = document.createElement("div"); - thread.className = "chat-thread"; - thread.scrollTop = 40; - let rowTop = 20; - thread.getBoundingClientRect = () => - ({ top: 0, bottom: 100, left: 0, right: 100, width: 100, height: 100 }) as DOMRect; - const row = document.createElement("div"); - row.dataset.chatRowKey = "row-3"; - Object.defineProperty(row, "isConnected", { value: true }); - row.getBoundingClientRect = () => - ({ - top: rowTop, - bottom: rowTop + 20, - left: 0, - right: 100, - width: 100, - height: 20, - }) as DOMRect; - thread.append(row); - pane.append(thread); - await pane.loadOlderMessages(); expect(request).toHaveBeenCalledWith("chat.history", { @@ -900,14 +927,7 @@ describe("chat pane native history pagination", () => { }); expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]); expect(state.chatHistoryPagination).toEqual({ hasMore: false, totalMessages: 4 }); - expect(pane.pendingHistoryAnchor).toEqual({ - sessionKey: state.sessionKey, - element: row, - viewportTop: 20, - }); - rowTop = 320; - pane.restoreHistoryAnchor(); - expect(thread.scrollTop).toBe(340); + expect(state.lastError).toBeNull(); expect(pane.hasOlderMessages()).toBe(false); await pane.loadOlderMessages(); @@ -937,33 +957,6 @@ describe("chat pane native history pagination", () => { expect(pane.loadingOlder).toBe(false); }); - it("does not double-correct when native scroll anchoring preserved the visible row", () => { - const client = { request: vi.fn() } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - const thread = document.createElement("div"); - thread.className = "chat-thread"; - thread.scrollTop = 40; - thread.getBoundingClientRect = () => - ({ top: 0, bottom: 100, left: 0, right: 100, width: 100, height: 100 }) as DOMRect; - const row = document.createElement("div"); - row.dataset.chatRowKey = "row-3"; - Object.defineProperty(row, "isConnected", { value: true }); - row.getBoundingClientRect = () => - ({ top: 20, bottom: 40, left: 0, right: 100, width: 100, height: 20 }) as DOMRect; - thread.append(row); - pane.append(thread); - pane.pendingHistoryAnchor = { - sessionKey: state.sessionKey, - element: row, - viewportTop: 20, - }; - - pane.restoreHistoryAnchor(); - - expect(thread.scrollTop).toBe(40); - expect(pane.pendingHistoryAnchor).toBeNull(); - }); - it("refreshes the tail instead of mixing an older page from a replacement session", async () => { const request = vi .fn() diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index b90c00c36e38..ebd1a6f3d817 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -140,17 +140,12 @@ import { hasAbortableSessionRun, reconcileStaleChatRunAfterSessionStatePublication, } from "./run-lifecycle.ts"; -import { scheduleChatScroll, scheduleCommittedChatScroll } from "./scroll.ts"; -import { clearChatMessagesFromCache } from "./session-message-cache.ts"; +import { scheduleChatScroll } from "./scroll.ts"; +import { clearChatMessagesFromCache, type ChatMessageCache } from "./session-message-cache.ts"; import { configureToolTitleFetcher } from "./tool-titles.ts"; type ChatPageContext = ApplicationContext; type PaneSessionChangeOptions = { replace?: boolean }; -type ChatHistoryAnchor = { - sessionKey: string; - element: HTMLElement; - viewportTop: number; -}; const CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS = 500; const CHAT_HISTORY_INTENT_EDGE_PX = 300; const CHAT_HISTORY_INTENT_IDLE_MS = 200; @@ -258,6 +253,7 @@ class ChatPane extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context!: ChatPageContext; @property({ attribute: false }) paneId = "single"; + @property({ attribute: false }) chatMessagesBySession?: ChatMessageCache; // Empty means "no route/layout opinion yet": the pane boots on the page // state's default session and must not canonicalize or write global session // bindings until the container supplies a real key (classic mode renders @@ -312,6 +308,9 @@ class ChatPane extends OpenClawLightDomElement { private catalogRequestedSessionKey: string | null = null; private olderLoadGeneration = 0; private historyObserver: IntersectionObserver | null = null; + private historyObserverRoot: HTMLElement | null = null; + private historyObserverSentinel: HTMLElement | null = null; + private historyObserverBootstrap = false; private historyObserverArmed = false; private historyAutoLoadBlocked = false; private historyBootstrapPagesLoaded = 0; @@ -319,7 +318,6 @@ class ChatPane extends OpenClawLightDomElement { private historyIntentTimer: number | null = null; private historyTouchY: number | null = null; private transcriptScrollTop: number | null = null; - private pendingHistoryAnchor: ChatHistoryAnchor | null = null; private nativePaginationSnapshot: ChatHistoryPagination | null = null; // Older cursors already requested this session. A provider that cycles cursors // (c1 -> c2 -> c1) on empty/duplicate pages would otherwise loop forever, since @@ -758,14 +756,19 @@ class ChatPane extends OpenClawLightDomElement { void this.loadCatalogSession(key, false); } - private catalogItemMessage( - item: SessionCatalogTranscriptItem, - index: number, - ): Record | null { - const timestamp = item.timestamp ? Date.parse(item.timestamp) : Date.now() + index; + private catalogItemMessage(item: SessionCatalogTranscriptItem): Record | null { + const parsedTimestamp = item.timestamp ? Date.parse(item.timestamp) : Number.NaN; + const timestamp = Number.isFinite(parsedTimestamp) ? parsedTimestamp : null; const text = item.text?.trim() ? item.text : null; if (item.type === "userMessage") { - return text ? { role: "user", content: text, timestamp, messageId: item.id } : null; + return text + ? { + role: "user", + content: text, + ...(timestamp == null ? {} : { timestamp }), + messageId: item.id, + } + : null; } let content = text; if (item.type === "reasoning") { @@ -790,7 +793,7 @@ class ChatPane extends OpenClawLightDomElement { return { role: "assistant", content: [{ type: "text", text: content }], - timestamp, + ...(timestamp == null ? {} : { timestamp }), messageId: item.id, }; } @@ -881,10 +884,9 @@ class ChatPane extends OpenClawLightDomElement { } const messages = page.items .toReversed() - .map((item, index) => this.catalogItemMessage(item, index)) + .map((item) => this.catalogItemMessage(item)) .filter((message) => message !== null); const nextMessages = older ? this.prependUniqueCatalogMessages(messages) : messages; - const grew = nextMessages.length > this.catalogMessages.length; // Exhaust when the cursor cannot make new forward progress: absent, unchanged, // or already visited this session (a provider cycling c1 -> c2 -> c1). Any of // these stops the re-armed observer from looping. An advancing, never-seen @@ -895,8 +897,6 @@ class ChatPane extends OpenClawLightDomElement { (!page.nextCursor || page.nextCursor === requestedOlderCursor || this.olderCursorsSeen.has(page.nextCursor)); - this.pendingHistoryAnchor = - older && grew ? this.currentHistoryAnchor(state.sessionKey) : null; this.catalogMessages = nextMessages; this.catalogCursor = olderExhausted ? undefined : page.nextCursor; const currentState = this.state ?? state; @@ -949,63 +949,21 @@ class ChatPane extends OpenClawLightDomElement { this.historyIntentTimer = null; } this.transcriptScrollTop = null; - this.pendingHistoryAnchor = null; this.olderCursorsSeen.clear(); this.olderOffsetsSeen.clear(); this.nativePaginationSnapshot = null; + this.clearHistoryObserver(); + } + + private clearHistoryObserver(): void { this.historyObserver?.disconnect(); this.historyObserver = null; - } - - private restoreHistoryAnchor(): void { - const anchor = this.pendingHistoryAnchor; - if (!anchor) { - return; - } - this.pendingHistoryAnchor = null; - const state = this.state; - const thread = this.querySelector(".chat-thread"); - if ( - !state || - !thread || - state.sessionKey !== anchor.sessionKey || - !anchor.element.isConnected - ) { - return; - } - // Chromium usually preserves this through native scroll anchoring. Measure - // the row relative to the viewport so this only corrects engines that did not. - const nextViewportTop = - anchor.element.getBoundingClientRect().top - thread.getBoundingClientRect().top; - const delta = nextViewportTop - anchor.viewportTop; - if (Math.abs(delta) > 1) { - thread.scrollTop += delta; - } - } - - private currentHistoryAnchor(sessionKey: string): ChatHistoryAnchor | null { - const thread = this.querySelector(".chat-thread"); - if (!thread) { - return null; - } - const viewportRect = thread.getBoundingClientRect(); - const rows = thread.querySelectorAll("[data-chat-row-key]"); - for (const element of rows) { - const rect = element.getBoundingClientRect(); - if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) { - return { - sessionKey, - element, - viewportTop: rect.top - viewportRect.top, - }; - } - } - return null; + this.historyObserverRoot = null; + this.historyObserverSentinel = null; + this.historyObserverBootstrap = false; } private syncHistoryObserver(): void { - this.historyObserver?.disconnect(); - this.historyObserver = null; const catalogSession = Boolean(this.state && parseCatalogSessionKey(this.state.sessionKey)); const historyLoading = catalogSession ? this.catalogLoading : this.state?.chatLoading; if (historyLoading) { @@ -1013,7 +971,6 @@ class ChatPane extends OpenClawLightDomElement { if (this.loadingOlder) { this.olderLoadGeneration += 1; this.loadingOlder = false; - this.pendingHistoryAnchor = null; } } if ( @@ -1021,11 +978,13 @@ class ChatPane extends OpenClawLightDomElement { this.loadingOlder || !this.hasOlderMessages() ) { + this.clearHistoryObserver(); return; } const root = this.querySelector(".chat-thread"); const sentinel = root?.querySelector(".chat-history-sentinel") ?? null; if (!root || !sentinel) { + this.clearHistoryObserver(); return; } this.transcriptScrollTop ??= root.scrollTop; @@ -1035,15 +994,26 @@ class ChatPane extends OpenClawLightDomElement { !threadIsScrollable && this.historyBootstrapPagesLoaded < CHAT_HISTORY_BOOTSTRAP_PAGE_LIMIT; if (this.historyAutoLoadBlocked) { + this.clearHistoryObserver(); return; } if (!this.historyObserverArmed && !bootstrap) { + this.clearHistoryObserver(); if (!threadIsScrollable) { this.historyAutoLoadBlocked = true; this.requestUpdate(); } return; } + if ( + this.historyObserver && + this.historyObserverRoot === root && + this.historyObserverSentinel === sentinel && + this.historyObserverBootstrap === bootstrap + ) { + return; + } + this.clearHistoryObserver(); this.historyObserver = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { @@ -1056,6 +1026,9 @@ class ChatPane extends OpenClawLightDomElement { }, { root, rootMargin: "300px 0px 0px", threshold: 0 }, ); + this.historyObserverRoot = root; + this.historyObserverSentinel = sentinel; + this.historyObserverBootstrap = bootstrap; this.historyObserver.observe(sentinel); } @@ -1196,7 +1169,6 @@ class ChatPane extends OpenClawLightDomElement { const messages = Array.isArray(result.messages) ? result.messages : []; const nextMessages = this.prependUniqueNativeMessages(messages, state.chatMessages); const grew = nextMessages.length > state.chatMessages.length; - this.pendingHistoryAnchor = grew ? this.currentHistoryAnchor(state.sessionKey) : null; state.chatMessages = nextMessages; const appliedPagination: ChatHistoryPagination = exhausted ? { @@ -1219,7 +1191,6 @@ class ChatPane extends OpenClawLightDomElement { } finally { if (generation === this.olderLoadGeneration) { if (!prepended) { - this.pendingHistoryAnchor = null; this.historyAutoLoadBlocked = this.hasOlderMessages(); } else if (!this.hasOlderMessages()) { this.historyAutoLoadBlocked = false; @@ -1532,7 +1503,13 @@ class ChatPane extends OpenClawLightDomElement { this.removeEventListener("pointerdown", this.handlePaneFocus); this.removeEventListener("focusin", this.handlePaneFocus); }); - const pageState = createPageState(this.context, chatState.createRenderLifecycle(), this); + const pageState = createPageState( + this.context, + chatState.createRenderLifecycle(), + this, + this.chatMessagesBySession, + ); + pageState.chatScrollToEnd = (options) => this.transcript.scrollToEnd(options); pageState.createChatSession = async () => { await this.createSession(); }; @@ -1620,7 +1597,6 @@ class ChatPane extends OpenClawLightDomElement { } override updated() { - this.restoreHistoryAnchor(); this.syncHistoryObserver(); } @@ -2219,8 +2195,6 @@ class ChatPane extends OpenClawLightDomElement { allowExternalEmbedUrls: state.allowExternalEmbedUrls, chatMessageMaxWidth: state.chatMessageMaxWidth, assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never), - onAssistantAttachmentLoaded: () => - scheduleCommittedChatScroll(state, false, false, { source: "resize" }), basePath: state.basePath, }; if (!this.showPaneHeader) { diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index 2d062a5fc751..6bcd394896f4 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -256,6 +256,7 @@ export type ChatPageHost = ChatHost & chatFollowLocked: boolean; chatIsProgrammaticScroll: boolean; chatProgrammaticScrollTarget: number; + chatScrollToEnd?: (options: { behavior?: ScrollBehavior }) => void; sidebarOpen: boolean; sidebarContent: SidebarContent | null; splitRatio: number; @@ -1194,6 +1195,7 @@ export function createPageState( context: ApplicationContext, renderLifecycle: RenderLifecycle, page: ChatPageElement, + chatMessagesBySession: ChatMessageCache = new Map(), ): ChatPageHost { const settings = loadSettings(); const identity = loadLocalUserIdentity(); @@ -1275,7 +1277,7 @@ export function createPageState( chatQueueByScope: {} as Record, chatComposerFallbackByScope: {} as Record, chatSendingScopeKey: null, - chatMessagesBySession: new Map(), + chatMessagesBySession, eventLogBuffer: [] as unknown[], basePath: context.basePath, chatNewMessagesBelow: false, @@ -1600,7 +1602,6 @@ export class ChatStateController implements Reactiv private chatThreadResizeTargets: | { thread: Element; - content: Element; } | undefined; private pendingCreatedSessionComposer: PendingCreatedSessionComposer | null = null; @@ -1807,25 +1808,19 @@ export class ChatStateController implements Reactiv return; } const thread = state.querySelector(".chat-thread"); - const content = state.querySelector(".chat-thread-inner"); - if ( - thread && - content && - this.chatThreadResizeTargets?.thread === thread && - this.chatThreadResizeTargets.content === content - ) { + if (thread && this.chatThreadResizeTargets?.thread === thread) { return; } this.chatThreadResizeObserver?.disconnect(); this.chatThreadResizeObserver = null; this.chatThreadResizeTargets = undefined; - if (!thread || !content) { + if (!thread) { return; } - // Streamed markdown and mobile composer controls can finish sizing after - // Lit's update. Follow the rendered geometry so the viewport stays pinned. + // The transcript virtualizer owns row measurement and content-height + // correction. This observer only follows viewport-size changes. this.chatThreadResizeObserver = new ResizeObserver(() => { const currentState = this.stateValue; if (!currentState) { @@ -1834,8 +1829,7 @@ export class ChatStateController implements Reactiv scheduleCommittedChatScroll(currentState, false, false, { source: "resize" }); }); this.chatThreadResizeObserver.observe(thread); - this.chatThreadResizeObserver.observe(content); - this.chatThreadResizeTargets = { thread, content }; + this.chatThreadResizeTargets = { thread }; } hostConnected() { diff --git a/ui/src/pages/chat/scroll.test.ts b/ui/src/pages/chat/scroll.test.ts index 339e22e13e22..d9a300cee375 100644 --- a/ui/src/pages/chat/scroll.test.ts +++ b/ui/src/pages/chat/scroll.test.ts @@ -53,6 +53,7 @@ function createScrollHost( chatNewMessagesBelow: false, chatIsProgrammaticScroll: false, chatProgrammaticScrollTarget: 0, + chatScrollToEnd: undefined as ((options: { behavior?: ScrollBehavior }) => void) | undefined, }; return { host, container }; @@ -210,6 +211,22 @@ describe("scheduleChatScroll", () => { expect(container.scrollTop).toBe(container.scrollHeight); }); + it("delegates end scrolling to the transcript owner when available", async () => { + const { host, container } = createScrollHost({ + scrollHeight: 2000, + scrollTop: 1600, + clientHeight: 400, + }); + const scrollToEnd = vi.fn(); + host.chatScrollToEnd = scrollToEnd; + + scheduleChatScroll(host); + await host.updateComplete; + + expect(scrollToEnd).toHaveBeenCalledWith({ behavior: "auto" }); + expect(container.scrollTop).toBe(1600); + }); + it("does NOT scroll when user is scrolled up and no force", async () => { const { host, container } = createScrollHost({ scrollHeight: 2000, diff --git a/ui/src/pages/chat/scroll.ts b/ui/src/pages/chat/scroll.ts index 00ac59c91fe9..53da16dfee30 100644 --- a/ui/src/pages/chat/scroll.ts +++ b/ui/src/pages/chat/scroll.ts @@ -21,6 +21,7 @@ type ChatScrollHost = { chatNewMessagesBelow: boolean; chatIsProgrammaticScroll: boolean; chatProgrammaticScrollTarget: number; + chatScrollToEnd?: (options: { behavior?: ScrollBehavior }) => void; }; function queryHost(host: Partial, selectors: string): Element | null { @@ -140,12 +141,19 @@ export function scheduleCommittedChatScroll( const scrollTop = target.scrollHeight; host.chatProgrammaticScrollTarget = scrollTop; host.chatIsProgrammaticScroll = true; - if (typeof target.scrollTo === "function") { + if (host.chatScrollToEnd) { + host.chatScrollToEnd({ behavior: smoothEnabled ? "smooth" : "auto" }); + } else if (typeof target.scrollTo === "function") { target.scrollTo({ top: scrollTop, behavior: smoothEnabled ? "smooth" : "auto" }); } else { target.scrollTop = scrollTop; } - scheduleProgrammaticScrollGuardClear(host, generation, target, smoothEnabled); + scheduleProgrammaticScrollGuardClear( + host, + generation, + target, + smoothEnabled || Boolean(host.chatScrollToEnd), + ); host.chatUserNearBottom = true; setNewMessagesBelow(host, false); }); diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index e04e6b313d45..f4c41fcd7741 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -105,10 +105,6 @@ openclaw-chat-pane { opacity 0.12s ease; } - .chat-split-view openclaw-chat-pane { - animation: chat-pane-in 0.16s ease; - } - @keyframes chat-drop-indicator-in { from { opacity: 0; @@ -118,13 +114,6 @@ openclaw-chat-pane { opacity: 1; } } - - @keyframes chat-pane-in { - from { - opacity: 0; - transform: scale(0.985); - } - } } .chat-pane__header {