fix: preserve chat pane lifecycle

This commit is contained in:
Shakker
2026-07-15 02:27:06 +01:00
committed by Shakker
parent d1c8bd48af
commit fbfa69456d
8 changed files with 249 additions and 214 deletions
+40 -1
View File
@@ -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<RenderedPane>("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<RenderedPane>("openclaw-chat-pane"),
0,
"classic pane",
);
classicPane.onOpenSplitView?.();
await page.updateComplete;
const splitPanes = [...page.querySelectorAll<RenderedPane>("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<RenderedPane>("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 () => {
+48 -27
View File
@@ -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`
<div
class="chat-split-view__cell ${active ? "chat-split-view__cell--active" : ""}"
class="chat-split-view__cell ${splitMode && active ? "chat-split-view__cell--active" : ""}"
style="flex: ${weight} 1 0"
@pointerdown=${() => this.handleFocusPane(pane.id)}
@focusin=${() => this.handleFocusPane(pane.id)}
>
<openclaw-chat-pane
class="chat-split-view__pane"
class=${splitMode ? "chat-split-view__pane" : ""}
.paneId=${pane.id}
.chatMessagesBySession=${this.chatMessagesBySession}
.sessionKey=${pane.sessionKey}
.active=${active}
.draft=${active ? this.routeDraftForActivePane(pane.sessionKey) : undefined}
.showPaneHeader=${true}
.showPaneHeader=${splitMode}
.paneTitle=${title}
.narrow=${this.narrow}
.onSplitDown=${this.handleSplitDown}
.onSplitRight=${this.handleSplitRight}
.onClosePane=${this.handleClosePane}
.onOpenSplitView=${splitMode || this.narrow ? undefined : this.openSplitView}
.onSplitDown=${splitMode ? this.handleSplitDown : undefined}
.onSplitRight=${splitMode ? this.handleSplitRight : undefined}
.onClosePane=${splitMode ? this.handleClosePane : undefined}
.onFocusPane=${this.handleFocusPane}
.onPaneSessionChange=${this.handlePaneSessionChange}
></openclaw-chat-pane>
@@ -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`<div class="chat-split-view chat-split-view--narrow">
${this.renderPaneCell(activePane, true, 1)}
${this.renderPaneCell(activePane, true, 1, splitMode)}
</div>`
: 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`
<div class="chat-split-view__drop-container">
${this.layout
? this.renderSplitLayout(this.layout)
: html`
<openclaw-chat-pane
.paneId=${"single"}
.sessionKey=${this.data?.sessionKey ?? ""}
.active=${true}
.draft=${this.routeDraftForActivePane()}
.showPaneHeader=${false}
.onFocusPane=${this.handleFocusPane}
.onPaneSessionChange=${this.handlePaneSessionChange}
.onOpenSplitView=${this.narrow ? undefined : this.openSplitView}
></openclaw-chat-pane>
`}
${this.renderSplitLayout(layout ?? this.classicLayout(), Boolean(layout))}
${indicator
? html`<div
class="chat-split-view__drop-indicator ${indicator.zone.kind === "center"
+77 -84
View File
@@ -22,10 +22,12 @@ import type { ChatPageHost } from "./chat-state.ts";
import { createBackgroundTasksProps } from "./components/chat-background-tasks.ts";
import { createSessionWorkspaceProps } from "./components/chat-session-workspace.ts";
import type { SidebarContent } from "./components/chat-sidebar.ts";
import type { ChatMessageCache } from "./session-message-cache.ts";
type TestChatPane = HTMLElement & {
catalogMessages: unknown[];
active: boolean;
chatMessagesBySession?: ChatMessageCache;
chatState: { attach: (state: ChatPageHost) => 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<string, unknown> | null;
catalogItemMessage: (item: SessionCatalogTranscriptItem) => Record<string, unknown> | null;
handleTranscriptScroll: (event: Event) => void;
handleTranscriptHistoryIntent: (event: Event) => void;
historyAutoLoadBlocked: boolean;
historyObserverArmed: boolean;
transcriptScrollTop: number | null;
syncHistoryObserver: () => void;
loadCatalogSession: (key: CatalogSessionKey, older: boolean) => Promise<boolean>;
@@ -57,12 +57,6 @@ type TestChatPane = HTMLElement & {
prependUniqueCatalogMessages: (messages: unknown[]) => unknown[];
loadOlderMessages: () => Promise<void>;
hasOlderMessages: () => boolean;
restoreHistoryAnchor: () => void;
pendingHistoryAnchor: {
sessionKey: string;
element: HTMLElement;
viewportTop: number;
} | null;
loadingOlder: boolean;
catalogCursor: string | undefined;
olderCursorsSeen: Set<string>;
@@ -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()
+49 -75
View File
@@ -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<string, unknown> | null {
const timestamp = item.timestamp ? Date.parse(item.timestamp) : Date.now() + index;
private catalogItemMessage(item: SessionCatalogTranscriptItem): Record<string, unknown> | 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<HTMLElement>(".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<HTMLElement>(".chat-thread");
if (!thread) {
return null;
}
const viewportRect = thread.getBoundingClientRect();
const rows = thread.querySelectorAll<HTMLElement>("[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<HTMLElement>(".chat-thread");
const sentinel = root?.querySelector<HTMLElement>(".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) {
+8 -14
View File
@@ -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<string, ChatQueueItem[]>,
chatComposerFallbackByScope: {} as Record<string, ChatComposerMemoryFallback>,
chatSendingScopeKey: null,
chatMessagesBySession: new Map(),
chatMessagesBySession,
eventLogBuffer: [] as unknown[],
basePath: context.basePath,
chatNewMessagesBelow: false,
@@ -1600,7 +1602,6 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
private chatThreadResizeTargets:
| {
thread: Element;
content: Element;
}
| undefined;
private pendingCreatedSessionComposer: PendingCreatedSessionComposer | null = null;
@@ -1807,25 +1808,19 @@ export class ChatStateController<TState extends ChatPageHost> 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<TState extends ChatPageHost> implements Reactiv
scheduleCommittedChatScroll(currentState, false, false, { source: "resize" });
});
this.chatThreadResizeObserver.observe(thread);
this.chatThreadResizeObserver.observe(content);
this.chatThreadResizeTargets = { thread, content };
this.chatThreadResizeTargets = { thread };
}
hostConnected() {
+17
View File
@@ -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,
+10 -2
View File
@@ -21,6 +21,7 @@ type ChatScrollHost = {
chatNewMessagesBelow: boolean;
chatIsProgrammaticScroll: boolean;
chatProgrammaticScrollTarget: number;
chatScrollToEnd?: (options: { behavior?: ScrollBehavior }) => void;
};
function queryHost(host: Partial<ChatScrollHost>, 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);
});
-11
View File
@@ -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 {