mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix: preserve chat pane lifecycle
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user