mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(ui): keep composer sends on one stable bubble through the history handoff and add a subtle entry animation (#112567)
* fix(ui): keep composer sends on one stable bubble through the history handoff and add a subtle entry animation * fix(ui): keep userTurnSendIdentity module-local
This commit is contained in:
committed by
GitHub
parent
988e640c7a
commit
6bf03cd674
@@ -0,0 +1,297 @@
|
||||
// Control UI E2E tests cover the pending-send bubble handoff to authoritative history.
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
type MockGatewayControls,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
|
||||
let browser: Browser;
|
||||
let page: Page | undefined;
|
||||
let server: ControlUiE2eServer | undefined;
|
||||
|
||||
type FrameSample = {
|
||||
t: number;
|
||||
present: boolean;
|
||||
rowKeys: string[];
|
||||
};
|
||||
|
||||
type SamplerWindow = Window & {
|
||||
openclawSendFrameSamples?: FrameSample[];
|
||||
openclawSendFrameSamplerStop?: () => void;
|
||||
};
|
||||
|
||||
const PROBE_TEXT = "Flicker probe message 4242";
|
||||
const USER_ECHO_ENTRY_ID = "pending-handoff-user-echo";
|
||||
|
||||
async function startFrameSampler(currentPage: Page): Promise<void> {
|
||||
await currentPage.evaluate((probeText) => {
|
||||
const win = window as SamplerWindow;
|
||||
const frames: FrameSample[] = [];
|
||||
win.openclawSendFrameSamples = frames;
|
||||
let running = true;
|
||||
win.openclawSendFrameSamplerStop = () => {
|
||||
running = false;
|
||||
};
|
||||
const sample = () => {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
const rows = [...document.querySelectorAll<HTMLElement>("[data-virtual-row-key]")].filter(
|
||||
(row) => (row.textContent ?? "").includes(probeText),
|
||||
);
|
||||
frames.push({
|
||||
t: performance.now(),
|
||||
present: rows.length > 0,
|
||||
rowKeys: rows.map((row) => row.dataset.virtualRowKey ?? ""),
|
||||
});
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}, PROBE_TEXT);
|
||||
}
|
||||
|
||||
async function stopFrameSampler(currentPage: Page): Promise<FrameSample[]> {
|
||||
return currentPage.evaluate(() => {
|
||||
const win = window as SamplerWindow;
|
||||
win.openclawSendFrameSamplerStop?.();
|
||||
return win.openclawSendFrameSamples ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Presence gaps (frames where the probe text vanished after first paint) and
|
||||
* the distinct row keys the probe bubble rendered under, in order. */
|
||||
function analyzeFrameSamples(frames: FrameSample[]): { gapFrames: number; keyTimeline: string[] } {
|
||||
const firstVisible = frames.findIndex((frame) => frame.present);
|
||||
expect(firstVisible).toBeGreaterThanOrEqual(0);
|
||||
// Counting through the final sample also catches a permanent disappearance.
|
||||
expect(frames[frames.length - 1]?.present).toBe(true);
|
||||
let gapFrames = 0;
|
||||
for (let index = firstVisible; index < frames.length; index++) {
|
||||
if (frames[index]?.present === false) {
|
||||
gapFrames += 1;
|
||||
}
|
||||
}
|
||||
const keyTimeline: string[] = [];
|
||||
for (const frame of frames) {
|
||||
for (const key of frame.rowKeys) {
|
||||
if (keyTimeline[keyTimeline.length - 1] !== key) {
|
||||
keyTimeline.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { gapFrames, keyTimeline };
|
||||
}
|
||||
|
||||
const BASE_HISTORY = [
|
||||
{
|
||||
content: [{ text: "Ready.", type: "text" }],
|
||||
role: "assistant",
|
||||
timestamp: Date.now() - 5_000,
|
||||
__openclaw: { seq: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
async function openChatAndSubmitProbe(
|
||||
currentPage: Page,
|
||||
gateway: MockGatewayControls,
|
||||
opts?: { deferSend?: boolean },
|
||||
): Promise<string> {
|
||||
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
|
||||
await currentPage.getByText("Ready.").waitFor({ timeout: 10_000 });
|
||||
await gateway.waitForRequest("sessions.list");
|
||||
if (opts?.deferSend) {
|
||||
await gateway.deferNext("chat.send");
|
||||
}
|
||||
await startFrameSampler(currentPage);
|
||||
await currentPage.locator(".agent-chat__input textarea").fill(PROBE_TEXT);
|
||||
await currentPage.locator(".agent-chat__input textarea").press("Enter");
|
||||
const send = await gateway.waitForRequest("chat.send");
|
||||
const runId = (send.params as { idempotencyKey?: string }).idempotencyKey ?? "";
|
||||
expect(runId).toBeTruthy();
|
||||
await currentPage
|
||||
.locator("[data-virtual-row-key]")
|
||||
.getByText(PROBE_TEXT, { exact: true })
|
||||
.waitFor({ timeout: 10_000 });
|
||||
return runId;
|
||||
}
|
||||
|
||||
async function finishRunAndSettle(
|
||||
currentPage: Page,
|
||||
gateway: MockGatewayControls,
|
||||
runId: string,
|
||||
userEcho: Record<string, unknown>,
|
||||
): Promise<FrameSample[]> {
|
||||
await gateway.setHistoryMessages([
|
||||
...BASE_HISTORY,
|
||||
userEcho,
|
||||
{
|
||||
content: [{ text: "Run complete.", type: "text" }],
|
||||
role: "assistant",
|
||||
timestamp: Date.now() + 1,
|
||||
__openclaw: { seq: 3 },
|
||||
},
|
||||
]);
|
||||
// The terminal reconciliation must re-read history; baseline before the
|
||||
// final so either trigger (final event or terminal session row) counts.
|
||||
const historyRequestsBeforeTerminal = (await gateway.getRequests("chat.history")).length;
|
||||
const finalMessage = {
|
||||
content: [{ text: "Run complete.", type: "text" }],
|
||||
role: "assistant",
|
||||
timestamp: Date.now() + 1,
|
||||
__openclaw: { seq: 3 },
|
||||
};
|
||||
await gateway.emitChatFinal({ runId, text: "Run complete." });
|
||||
await currentPage
|
||||
.locator(".chat-bubble")
|
||||
.getByText("Run complete.", { exact: true })
|
||||
.waitFor({ timeout: 10_000 });
|
||||
// The Gateway persists the assistant turn and publishes it with a terminal
|
||||
// session row; this is what triggers the authoritative history reload.
|
||||
await gateway.emitGatewayEvent("session.message", {
|
||||
activeRunIds: [],
|
||||
clientRunId: runId,
|
||||
hasActiveRun: false,
|
||||
message: finalMessage,
|
||||
messageId: "pending-handoff-final-1",
|
||||
messageSeq: 3,
|
||||
session: {
|
||||
activeRunIds: [],
|
||||
hasActiveRun: false,
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
status: "done",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
sessionKey: "main",
|
||||
});
|
||||
// The handoff must complete: history is re-read and the probe bubble becomes
|
||||
// the authoritative copy (its data-entry-id comes only from loaded history,
|
||||
// never from the pending queue projection).
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("chat.history")).length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(historyRequestsBeforeTerminal);
|
||||
await expect
|
||||
.poll(
|
||||
() => currentPage.locator(`.chat-bubble[data-entry-id="${USER_ECHO_ENTRY_ID}"]`).count(),
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBe(1);
|
||||
// Let a few more frames elapse so trailing samples cover the settled state.
|
||||
await currentPage.waitForTimeout(500);
|
||||
return stopFrameSampler(currentPage);
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI chat send pending handoff", () => {
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
try {
|
||||
server = await startControlUiE2eServer();
|
||||
} catch (error) {
|
||||
await browser.close();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await page
|
||||
?.context()
|
||||
.close()
|
||||
.catch(() => {});
|
||||
page = undefined;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close().catch(() => {});
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("keeps the submitted user turn visible through run completion and history reload", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
|
||||
const currentPage = await context.newPage();
|
||||
page = currentPage;
|
||||
const gateway = await installMockGateway(currentPage, { historyMessages: BASE_HISTORY });
|
||||
|
||||
const runId = await openChatAndSubmitProbe(currentPage, gateway);
|
||||
// A locally submitted turn plays the composer entry animation exactly once.
|
||||
expect(await currentPage.locator(".chat-bubble--user-turn-enter").count()).toBe(1);
|
||||
|
||||
const frames = await finishRunAndSettle(currentPage, gateway, runId, {
|
||||
content: [{ text: PROBE_TEXT, type: "text" }],
|
||||
role: "user",
|
||||
timestamp: Date.now(),
|
||||
__openclaw: { id: USER_ECHO_ENTRY_ID, idempotencyKey: runId, seq: 2 },
|
||||
});
|
||||
|
||||
const { gapFrames, keyTimeline } = analyzeFrameSamples(frames);
|
||||
// The submitted text must never disappear once visible.
|
||||
expect(gapFrames).toBe(0);
|
||||
// The bubble keeps one identity through the pending -> history handoff, so
|
||||
// the DOM node is never remounted (no animation replay, no layout jump).
|
||||
expect(new Set(keyTimeline).size).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the submitted user turn visible when the session echo lands before the send ack", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
|
||||
const currentPage = await context.newPage();
|
||||
page = currentPage;
|
||||
const gateway = await installMockGateway(currentPage, { historyMessages: BASE_HISTORY });
|
||||
|
||||
const runId = await openChatAndSubmitProbe(currentPage, gateway, { deferSend: true });
|
||||
|
||||
// The Gateway persists and broadcasts the user turn before the ack resolves.
|
||||
const userEcho = {
|
||||
content: [{ text: PROBE_TEXT, type: "text" }],
|
||||
role: "user",
|
||||
timestamp: Date.now(),
|
||||
__openclaw: { id: USER_ECHO_ENTRY_ID, idempotencyKey: runId, seq: 2 },
|
||||
};
|
||||
await gateway.setHistoryMessages([...BASE_HISTORY, userEcho]);
|
||||
const historyRequestsBefore = (await gateway.getRequests("chat.history")).length;
|
||||
await gateway.emitGatewayEvent("session.message", {
|
||||
activeRunIds: [runId],
|
||||
clientRunId: runId,
|
||||
hasActiveRun: true,
|
||||
message: userEcho,
|
||||
messageId: "pending-handoff-echo-1",
|
||||
messageSeq: 2,
|
||||
session: {
|
||||
activeRunIds: [runId],
|
||||
hasActiveRun: true,
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
status: "running",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
sessionKey: "main",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("chat.history")).length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(historyRequestsBefore);
|
||||
await currentPage.waitForTimeout(300);
|
||||
await gateway.resolveDeferred("chat.send");
|
||||
|
||||
const frames = await finishRunAndSettle(currentPage, gateway, runId, userEcho);
|
||||
|
||||
const { gapFrames, keyTimeline } = analyzeFrameSamples(frames);
|
||||
expect(gapFrames).toBe(0);
|
||||
expect(new Set(keyTimeline).size).toBe(1);
|
||||
// The single stable key above proves the node never remounted, so the
|
||||
// entry animation cannot have replayed; at most the one submitted turn
|
||||
// still carries the (inert, completed) animation class.
|
||||
expect(await currentPage.locator(".chat-bubble--user-turn-enter").count()).toBeLessThanOrEqual(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8257,6 +8257,7 @@ describe("handleSendChat", () => {
|
||||
},
|
||||
],
|
||||
timestamp: expect.any(Number),
|
||||
__openclaw: { idempotencyKey: expect.stringMatching(/:user$/) },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ import {
|
||||
formatTerminalChatSendAckError,
|
||||
chatMessagesContainQueuedSend,
|
||||
OFFLINE_QUEUE_STORAGE_ERROR,
|
||||
preserveQueuedUserTurn,
|
||||
sendQueuedChatMessageWithQueueMode as sendQueuedChatMessageWithQueueModeLifecycle,
|
||||
steerQueuedChatMessage as steerQueuedChatMessageLifecycle,
|
||||
type SteerSendDependencies,
|
||||
@@ -814,6 +815,9 @@ async function sendQueuedChatMessage(
|
||||
hasAttachments ? attachments : undefined,
|
||||
),
|
||||
timestamp: startedAt,
|
||||
// Send identity keeps this optimistic turn on the same rendered
|
||||
// bubble key as the pending row and the authoritative history copy.
|
||||
__openclaw: { idempotencyKey: `${runId}:user` },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1390,6 +1394,10 @@ async function readCurrentStoredChatHistory(
|
||||
}
|
||||
syncChatQueueFromStoredOutbox(host, currentOutbox);
|
||||
if (chatMessagesContainQueuedSend(history.messages, item)) {
|
||||
// Server history owns the turn, but the visible transcript may not have
|
||||
// reloaded yet; materialize the turn locally before dropping the queue row
|
||||
// or the bubble vanishes until loadChatHistory below resolves.
|
||||
preserveQueuedUserTurn(host, item);
|
||||
const removed = removeQueuedMessageWithoutReleasing(host, item.id, outbox.sessionKey);
|
||||
if (!removed) {
|
||||
return "blocked";
|
||||
|
||||
@@ -844,10 +844,28 @@ function annotateToolTurnOutcome(
|
||||
return items;
|
||||
}
|
||||
|
||||
function isPendingSendMessage(message: unknown): boolean {
|
||||
export function isPendingSendMessage(message: unknown): boolean {
|
||||
return asRecord(asRecord(message)?.["__openclaw"])?.kind === "pending-send";
|
||||
}
|
||||
|
||||
/** Every projection of one composer submit (pending queue row, locally
|
||||
* materialized turn, authoritative history) shares this identity so the
|
||||
* rendered bubble keeps one Lit key and never remounts mid-handoff. */
|
||||
function userTurnSendIdentity(message: unknown): string | null {
|
||||
const record = asRecord(message);
|
||||
if (typeof record?.role !== "string" || record.role.toLowerCase() !== "user") {
|
||||
return null;
|
||||
}
|
||||
const idempotencyKey = asRecord(record["__openclaw"])?.idempotencyKey;
|
||||
if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) {
|
||||
return null;
|
||||
}
|
||||
const base = idempotencyKey.endsWith(":user")
|
||||
? idempotencyKey.slice(0, -":user".length)
|
||||
: idempotencyKey;
|
||||
return `send:${base}`;
|
||||
}
|
||||
|
||||
function sourceMessageId(message: unknown): string | null {
|
||||
const record = asRecord(message);
|
||||
if (!record) {
|
||||
@@ -874,6 +892,13 @@ function transcriptMessageSourceKey(message: unknown): string | null {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
// Send identity outranks transcript ids: the same submit is re-projected with
|
||||
// different id/seq metadata across the pending -> history handoff, and a key
|
||||
// change there remounts the bubble (visible flicker).
|
||||
const sendIdentity = userTurnSendIdentity(message);
|
||||
if (sendIdentity) {
|
||||
return sendIdentity;
|
||||
}
|
||||
const id = sourceMessageId(message);
|
||||
if (id) {
|
||||
return `id:${id}`;
|
||||
@@ -1328,7 +1353,9 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
|
||||
}
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: `pending-send:${queued.id}`,
|
||||
// Mirror buildMessageKeys for a send-identity source key so the pending
|
||||
// row and its history successor resolve to the same Lit key.
|
||||
key: queued.sendRunId ? `msg:send:${queued.sendRunId}:0` : `pending-send:${queued.id}`,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ import { detectTextDirection } from "../../../lib/text-direction.ts";
|
||||
import { getSafeLocalStorage } from "../../../local-storage.ts";
|
||||
import { renderChatAvatar } from "../chat-avatar.ts";
|
||||
import type { ChatRunStartupPhase } from "../chat-run-startup.ts";
|
||||
import { persistedMessageEntryId } from "../chat-thread.ts";
|
||||
import { isPendingSendMessage, persistedMessageEntryId } from "../chat-thread.ts";
|
||||
import type { PlanStatus } from "../tool-stream.ts";
|
||||
import {
|
||||
visibleWorkspaceConflictPaths,
|
||||
@@ -873,6 +873,9 @@ function buildGroupedMessageRenderOptions(
|
||||
boardProvider: opts.boardProvider,
|
||||
agentId: opts.agentId,
|
||||
entryId: persistedMessageEntryId(item.message) ?? undefined,
|
||||
entryAnimated:
|
||||
normalizeRoleForGrouping(group.role) === "user" &&
|
||||
shouldAnimateUserTurnEntry(item.key, item.message),
|
||||
onOpenWorkspaceFile: opts.onOpenWorkspaceFile,
|
||||
duplicateCount: item.duplicateCount ?? 1,
|
||||
showReasoning: opts.showReasoning,
|
||||
@@ -899,6 +902,47 @@ function buildGroupedMessageRenderOptions(
|
||||
};
|
||||
}
|
||||
|
||||
/** One-shot entry animation state for submitted user turns, keyed by message
|
||||
* key (send identity). An entry records first sight for the send's lifetime —
|
||||
* value is the animation start, or 0 for seen-without-animating — so
|
||||
* re-renders during the animation keep the class while later renders or
|
||||
* virtualizer remounts of the same (possibly still pending) row never replay
|
||||
* it. Insertion-ordered cap bounds the map instead of time-based pruning,
|
||||
* which would forget long-lived pending rows; keys are per-send UUIDs, so the
|
||||
* map is never reset across panes or sessions. */
|
||||
const userTurnEntrySeenByMessageKey = new Map<string, number>();
|
||||
const USER_TURN_ENTRY_ANIMATION_WINDOW_MS = 400;
|
||||
/** Only just-submitted bubbles animate; restored outbox rows render still.
|
||||
* Accepted tradeoff: a full page reload within this window re-animates the
|
||||
* just-submitted bubble once, which matches the fresh paint around it. */
|
||||
const USER_TURN_ENTRY_FRESH_SUBMIT_MS = 2_000;
|
||||
const USER_TURN_ENTRY_SEEN_CAP = 256;
|
||||
|
||||
function shouldAnimateUserTurnEntry(messageKey: string, message: unknown): boolean {
|
||||
const now = Date.now();
|
||||
const seen = userTurnEntrySeenByMessageKey.get(messageKey);
|
||||
if (seen !== undefined) {
|
||||
return seen > 0 && now - seen < USER_TURN_ENTRY_ANIMATION_WINDOW_MS;
|
||||
}
|
||||
// Only a locally pending submit starts the animation; loaded history and
|
||||
// remote echoes render without one.
|
||||
if (!isPendingSendMessage(message)) {
|
||||
return false;
|
||||
}
|
||||
const submittedAt = (message as { timestamp?: unknown }).timestamp;
|
||||
const freshSubmit =
|
||||
typeof submittedAt === "number" && now - submittedAt < USER_TURN_ENTRY_FRESH_SUBMIT_MS;
|
||||
while (userTurnEntrySeenByMessageKey.size >= USER_TURN_ENTRY_SEEN_CAP) {
|
||||
const oldest = userTurnEntrySeenByMessageKey.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
userTurnEntrySeenByMessageKey.delete(oldest);
|
||||
}
|
||||
userTurnEntrySeenByMessageKey.set(messageKey, freshSubmit ? now : 0);
|
||||
return freshSubmit;
|
||||
}
|
||||
|
||||
export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroupOptions) {
|
||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
||||
const isWorkspaceConflict = group.messages.every((item) =>
|
||||
@@ -2678,6 +2722,8 @@ function renderGroupedMessage(
|
||||
allowExternalEmbedUrls?: boolean;
|
||||
onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void;
|
||||
entryId?: string;
|
||||
/** Freshly submitted user turn: play the one-shot composer entry animation. */
|
||||
entryAnimated?: boolean;
|
||||
},
|
||||
onOpenSidebar?: (content: SidebarContent) => void,
|
||||
) {
|
||||
@@ -2735,6 +2781,7 @@ function renderGroupedMessage(
|
||||
"chat-bubble",
|
||||
isToolShell ? "chat-bubble--tool-shell" : "",
|
||||
opts.isStreaming ? "streaming" : "",
|
||||
opts.entryAnimated ? "chat-bubble--user-turn-enter" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
@@ -22,6 +22,24 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Freshly submitted composer text flows up into the transcript. Applied only
|
||||
to the locally pending bubble (see shouldAnimateUserTurnEntry); the bubble
|
||||
keeps a stable key through the history handoff so this never replays. */
|
||||
@keyframes chat-user-turn-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-bubble--user-turn-enter {
|
||||
animation: chat-user-turn-enter var(--duration-normal) var(--ease-out) backwards;
|
||||
}
|
||||
|
||||
.chat-group.chat-group--with-footer {
|
||||
--chat-group-avatar-column: 1;
|
||||
--chat-group-content-column: 2;
|
||||
|
||||
Reference in New Issue
Block a user