mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): keep chat author identity readable (#112357)
* fix(ui): keep attributed chat identity readable * test(ui): verify hover-only chat metadata
This commit is contained in:
committed by
GitHub
parent
9b3b84a18b
commit
3c3a712913
@@ -15,11 +15,13 @@ const {
|
||||
attachGatewayWsMessageHandlerMock,
|
||||
attachWorkerWsMessageHandlerMock,
|
||||
broadcastPresenceSnapshotMock,
|
||||
touchPresenceMock,
|
||||
upsertPresenceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
attachGatewayWsMessageHandlerMock: vi.fn(),
|
||||
attachWorkerWsMessageHandlerMock: vi.fn((_params: unknown) => vi.fn()),
|
||||
broadcastPresenceSnapshotMock: vi.fn(),
|
||||
touchPresenceMock: vi.fn(),
|
||||
upsertPresenceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -30,6 +32,7 @@ vi.mock("./ws-connection/worker-connection.js", () => ({
|
||||
attachWorkerWsMessageHandler: attachWorkerWsMessageHandlerMock,
|
||||
}));
|
||||
vi.mock("../../infra/system-presence.js", () => ({
|
||||
touchPresence: touchPresenceMock,
|
||||
upsertPresence: upsertPresenceMock,
|
||||
}));
|
||||
vi.mock("./presence-events.js", () => ({
|
||||
@@ -88,6 +91,7 @@ describe("attachGatewayWsConnectionHandler", () => {
|
||||
attachGatewayWsMessageHandlerMock.mockReset();
|
||||
attachWorkerWsMessageHandlerMock.mockClear();
|
||||
broadcastPresenceSnapshotMock.mockReset();
|
||||
touchPresenceMock.mockReset();
|
||||
upsertPresenceMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -240,13 +244,16 @@ describe("attachGatewayWsConnectionHandler", () => {
|
||||
socket,
|
||||
connect: { client: { id: "openclaw-control-ui", mode: "webchat" } },
|
||||
connId: "ping-client",
|
||||
presenceKey: "ping-client",
|
||||
usesSharedGatewayAuth: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(25_000);
|
||||
expect(socket.ping).toHaveBeenCalledTimes(1);
|
||||
expect(touchPresenceMock).not.toHaveBeenCalled();
|
||||
socket.emit("pong");
|
||||
expect(touchPresenceMock).toHaveBeenCalledWith("ping-client");
|
||||
|
||||
vi.advanceTimersByTime(25_000);
|
||||
expect(socket.ping).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { RawData, WebSocket, WebSocketServer } from "ws";
|
||||
import { WORKER_PROTOCOL_MAX_PAYLOAD_BYTES } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { GATEWAY_STARTUP_PENDING_CLOSE_CAUSE } from "../../../packages/gateway-protocol/src/startup-unavailable.js";
|
||||
import { getRuntimeConfig } from "../../config/io.js";
|
||||
import { upsertPresence } from "../../infra/system-presence.js";
|
||||
import { touchPresence, upsertPresence } from "../../infra/system-presence.js";
|
||||
import { logRejectedLargePayload } from "../../logging/diagnostic-payload.js";
|
||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { removeRemoteNodeInfo } from "../../skills/runtime/remote.js";
|
||||
@@ -454,6 +454,9 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
|
||||
socket.on("pong", () => {
|
||||
awaitingPong = false;
|
||||
if (client?.presenceKey) {
|
||||
touchPresence(client.presenceKey);
|
||||
}
|
||||
});
|
||||
|
||||
const isNoisySwiftPmHelperClose = (userAgent: string | undefined, remote: string | undefined) =>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// Covers in-memory system presence merging and expiry behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { listSystemPresence, updateSystemPresence, upsertPresence } from "./system-presence.js";
|
||||
import {
|
||||
listSystemPresence,
|
||||
touchPresence,
|
||||
updateSystemPresence,
|
||||
upsertPresence,
|
||||
} from "./system-presence.js";
|
||||
|
||||
describe("system-presence", () => {
|
||||
afterEach(() => {
|
||||
@@ -144,6 +149,25 @@ describe("system-presence", () => {
|
||||
expect(update.key).toBe(keyPrefix);
|
||||
});
|
||||
|
||||
it("keeps connection-owned presence alive when its heartbeat is acknowledged", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(Date.now());
|
||||
|
||||
const connectionId = randomUUID();
|
||||
upsertPresence(connectionId, {
|
||||
host: "control-ui",
|
||||
instanceId: connectionId,
|
||||
mode: "webchat",
|
||||
reason: "connect",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(4 * 60 * 1000);
|
||||
expect(touchPresence(connectionId)).toBe(true);
|
||||
vi.advanceTimersByTime(4 * 60 * 1000);
|
||||
|
||||
expect(listSystemPresence().map((entry) => entry.instanceId)).toContain(connectionId);
|
||||
});
|
||||
|
||||
it("prunes stale non-self entries after TTL", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(Date.now());
|
||||
|
||||
@@ -285,6 +285,20 @@ export function upsertPresence(key: string, presence: Partial<SystemPresence>) {
|
||||
entries.set(normalizedKey, merged);
|
||||
}
|
||||
|
||||
/** Renews an existing connection-owned presence row without recreating expired metadata. */
|
||||
export function touchPresence(key: string): boolean {
|
||||
const normalizedKey = normalizePresenceKey(key);
|
||||
if (!normalizedKey) {
|
||||
return false;
|
||||
}
|
||||
const existing = entries.get(normalizedKey);
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
entries.set(normalizedKey, { ...existing, ts: Date.now() });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function listSystemPresence(): SystemPresence[] {
|
||||
ensureSelfPresence();
|
||||
// prune expired
|
||||
|
||||
@@ -243,7 +243,11 @@ describe("createApplicationGateway reconnecting snapshot", () => {
|
||||
seq: 2,
|
||||
stateVersion: { presence: 2, health: 1 },
|
||||
});
|
||||
expect(gateway.snapshot.selfUser).toBeNull();
|
||||
expect(gateway.snapshot.selfUser).toMatchObject({
|
||||
id: "profile-1",
|
||||
name: "Ada Lovelace",
|
||||
avatarUrl: "/api/users/profile-1/avatar?v=3",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears identity while disconnected", () => {
|
||||
|
||||
@@ -116,7 +116,9 @@ export function createApplicationGateway(
|
||||
const entries = readPresenceEntries(event.payload);
|
||||
if (entries) {
|
||||
const selfUser = resolveSelfPresenceUser(entries, client?.instanceId);
|
||||
if (!sameSelfUser(snapshot.selfUser, selfUser)) {
|
||||
// A live connection owns its authenticated identity until onClose. Older
|
||||
// gateways can omit still-connected clients after presence TTL pruning.
|
||||
if (selfUser && !sameSelfUser(snapshot.selfUser, selfUser)) {
|
||||
setSnapshot({ ...snapshot, selfUser });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Control UI E2E tests cover attributed chat identity placement.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, expect, type Browser, type Page } from "playwright/test";
|
||||
import { afterAll, beforeAll, describe, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} 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 server: ControlUiE2eServer;
|
||||
|
||||
async function captureProof(page: Page, name: string) {
|
||||
const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
if (!artifactDir) {
|
||||
return;
|
||||
}
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(artifactDir, name),
|
||||
});
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI attributed chat identity", () => {
|
||||
beforeAll(async () => {
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("uses one avatar placement and keeps shared-thread authors readable", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 760, width: 1180 } });
|
||||
const page = await context.newPage();
|
||||
const now = Date.now();
|
||||
await installMockGateway(page, {
|
||||
presenceUsers: [
|
||||
{ self: true, id: "profile-riley", name: "Riley", email: "riley@example.test" },
|
||||
{ id: "profile-colin", name: "Colin", email: "colin@example.test" },
|
||||
],
|
||||
historyMessages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "The shared thread now keeps every participant easy to identify.",
|
||||
timestamp: now - 180_000,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "Can we keep one clear avatar and show who wrote each message?",
|
||||
timestamp: now - 120_000,
|
||||
__openclaw: { senderId: "profile-riley", senderName: "Riley" },
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Yes — one author marker is enough, with the name kept readable.",
|
||||
timestamp: now - 90_000,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "This is much easier to scan in a team conversation.",
|
||||
timestamp: now - 30_000,
|
||||
__openclaw: { senderId: "profile-colin", senderName: "Colin" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`${server.baseUrl}chat?session=agent%3Amain%3Amain`);
|
||||
await page.getByText("This is much easier to scan in a team conversation.").waitFor();
|
||||
|
||||
const userGroups = page.locator(".chat-group.user");
|
||||
await expect(userGroups).toHaveCount(2);
|
||||
await expect(page.locator(".chat-avatar.user")).toHaveCount(2);
|
||||
|
||||
await expect(
|
||||
page.locator(".chat-group-footer--persistent-identity .chat-sender-name"),
|
||||
).toHaveText(["Riley", "Colin"]);
|
||||
await expect(page.locator(".chat-author-avatar")).toHaveCount(0);
|
||||
const hoverDetails = userGroups.last().locator(".chat-group-timestamp");
|
||||
await expect(hoverDetails).toHaveCSS("opacity", "0");
|
||||
await captureProof(page, "after-default.png");
|
||||
|
||||
await userGroups.last().hover();
|
||||
await expect(hoverDetails).toHaveCSS("opacity", "1");
|
||||
await expect(page.locator(".chat-author-avatar")).toHaveCount(0);
|
||||
await captureProof(page, "after-hover.png");
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type QuestionPrompt,
|
||||
} from "../../app/question-prompt.ts";
|
||||
import { loadSettings, patchSettings } from "../../app/settings.ts";
|
||||
import { readPresenceEntries, resolveCurrentSelfUser } from "../../app/user-profile.ts";
|
||||
import {
|
||||
BROWSER_ANNOTATION_EVENT,
|
||||
type BrowserAnnotationDraft,
|
||||
@@ -3277,6 +3278,12 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
// split; bottom strips do not.
|
||||
const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0);
|
||||
const detailSplitWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
|
||||
const gatewaySnapshot = this.context.gateway.snapshot;
|
||||
const selfUser = resolveCurrentSelfUser({
|
||||
snapshotUser: gatewaySnapshot.selfUser,
|
||||
presenceEntries: readPresenceEntries(gatewaySnapshot.hello?.snapshot),
|
||||
presenceInstanceId: gatewaySnapshot.client?.instanceId,
|
||||
});
|
||||
const props: ChatProps = {
|
||||
transcript: this.transcript,
|
||||
paneId: this.paneId,
|
||||
@@ -3584,9 +3591,9 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
onSplitRatioChange: state.handleSplitRatioChange,
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
userId: this.context.gateway.snapshot.selfUser?.id ?? null,
|
||||
userName: this.context.gateway.snapshot.selfUser?.name ?? state.userName,
|
||||
userAvatar: this.context.gateway.snapshot.selfUser?.avatarUrl ?? state.userAvatar,
|
||||
userId: selfUser?.id ?? null,
|
||||
userName: selfUser?.name ?? state.userName,
|
||||
userAvatar: selfUser?.avatarUrl ?? state.userAvatar,
|
||||
localMediaPreviewRoots: state.localMediaPreviewRoots,
|
||||
embedSandboxMode: state.embedSandboxMode,
|
||||
allowExternalEmbedUrls: state.allowExternalEmbedUrls,
|
||||
|
||||
@@ -1687,7 +1687,7 @@ describe("grouped chat rendering", () => {
|
||||
expect(avatar?.tagName).toBe("DIV");
|
||||
});
|
||||
|
||||
it("renders a durable sender label and avatar chip in user message metadata", async () => {
|
||||
it("keeps the sender name visible without duplicating a gutter avatar", () => {
|
||||
const container = document.createElement("div");
|
||||
const group: MessageGroup = {
|
||||
kind: "group",
|
||||
@@ -1712,6 +1712,7 @@ describe("grouped chat rendering", () => {
|
||||
assistantName: "OpenClaw",
|
||||
assistantAvatar: null,
|
||||
userName: "Local User",
|
||||
showAvatarGutter: true,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
@@ -1719,11 +1720,12 @@ describe("grouped chat rendering", () => {
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(".chat-group.user .chat-sender-name")?.textContent,
|
||||
).toBe("alice");
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(".chat-author-avatar__initials")?.textContent?.trim(),
|
||||
).toBe("AE");
|
||||
});
|
||||
expect(
|
||||
container.querySelector(".chat-group-footer--persistent-identity .chat-sender-name")
|
||||
?.textContent,
|
||||
).toBe("alice");
|
||||
expect(container.querySelector(".chat-avatar.user")).not.toBeNull();
|
||||
expect(container.querySelector(".chat-author-avatar")).toBeNull();
|
||||
});
|
||||
|
||||
it("tints attributed user groups with the sender's stable identity hue", () => {
|
||||
@@ -1790,6 +1792,7 @@ describe("grouped chat rendering", () => {
|
||||
assistantName: "OpenClaw",
|
||||
userId: "profile-1",
|
||||
userName: "Fuller Stack",
|
||||
showAvatarGutter: true,
|
||||
},
|
||||
),
|
||||
container,
|
||||
@@ -1798,9 +1801,13 @@ describe("grouped chat rendering", () => {
|
||||
expect(
|
||||
container.querySelector<HTMLElement>(".chat-group.user .chat-sender-name")?.textContent,
|
||||
).toBe("Fuller Stack");
|
||||
expect(
|
||||
container.querySelector(".chat-group-footer--persistent-identity .chat-sender-name")
|
||||
?.textContent,
|
||||
).toBe("Fuller Stack");
|
||||
});
|
||||
|
||||
it("renders an author avatar for a user group with sender identity", async () => {
|
||||
it("renders a compact author avatar when the gutter is hidden", async () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderMessageGroup(
|
||||
@@ -1823,11 +1830,14 @@ describe("grouped chat rendering", () => {
|
||||
showReasoning: true,
|
||||
showToolCalls: true,
|
||||
assistantName: "OpenClaw",
|
||||
showAvatarGutter: false,
|
||||
},
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-avatar.user")).toBeNull();
|
||||
expect(container.querySelector(".chat-group-persistent-author")).toBeNull();
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector(".chat-author-avatar__initials")?.textContent?.trim()).toBe(
|
||||
"AE",
|
||||
@@ -1862,6 +1872,7 @@ describe("grouped chat rendering", () => {
|
||||
showReasoning: true,
|
||||
showToolCalls: true,
|
||||
assistantName: "OpenClaw",
|
||||
showAvatarGutter: false,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
@@ -794,6 +794,7 @@ type RenderMessageGroupOptions = {
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
userAvatar?: string | null;
|
||||
showAvatarGutter?: boolean;
|
||||
basePath?: string;
|
||||
localMediaPreviewRoots?: readonly string[];
|
||||
assistantAttachmentAuthToken?: string | null;
|
||||
@@ -876,6 +877,8 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
: isWorkspaceConflict
|
||||
? t("chat.workspaceConflict.eventSender")
|
||||
: normalizedRole;
|
||||
const showAvatarGutter = opts.showAvatarGutter !== false;
|
||||
const persistUserIdentity = normalizedRole === "user" && showAvatarGutter;
|
||||
const roleClass =
|
||||
normalizedRole === "user"
|
||||
? "user"
|
||||
@@ -925,20 +928,22 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
class="chat-group tool chat-group--activity chat-group--with-footer"
|
||||
data-chat-row-key=${group.key}
|
||||
>
|
||||
${renderChatAvatar(
|
||||
group.role,
|
||||
{
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
},
|
||||
{
|
||||
name: opts.userName ?? null,
|
||||
avatar: opts.userAvatar ?? null,
|
||||
},
|
||||
opts.basePath,
|
||||
opts.assistantAttachmentAuthToken,
|
||||
group.sender,
|
||||
)}
|
||||
${showAvatarGutter
|
||||
? renderChatAvatar(
|
||||
group.role,
|
||||
{
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
},
|
||||
{
|
||||
name: opts.userName ?? null,
|
||||
avatar: opts.userAvatar ?? null,
|
||||
},
|
||||
opts.basePath,
|
||||
opts.assistantAttachmentAuthToken,
|
||||
group.sender,
|
||||
)
|
||||
: nothing}
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-activity-group ${activityExpanded ? "is-open" : ""}">
|
||||
<button
|
||||
@@ -1023,20 +1028,22 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
style=${senderHue === null ? nothing : `--chat-sender-hue: ${senderHue}`}
|
||||
data-chat-row-key=${group.key}
|
||||
>
|
||||
${renderChatAvatar(
|
||||
group.role,
|
||||
{
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
},
|
||||
{
|
||||
name: opts.userName ?? null,
|
||||
avatar: opts.userAvatar ?? null,
|
||||
},
|
||||
opts.basePath,
|
||||
opts.assistantAttachmentAuthToken,
|
||||
group.sender,
|
||||
)}
|
||||
${showAvatarGutter
|
||||
? renderChatAvatar(
|
||||
group.role,
|
||||
{
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
},
|
||||
{
|
||||
name: opts.userName ?? null,
|
||||
avatar: opts.userAvatar ?? null,
|
||||
},
|
||||
opts.basePath,
|
||||
opts.assistantAttachmentAuthToken,
|
||||
group.sender,
|
||||
)
|
||||
: nothing}
|
||||
<div class="chat-group-messages">
|
||||
${group.messages.map((item, index) => {
|
||||
const actionDetails = messageActionDetails[index];
|
||||
@@ -1057,7 +1064,11 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
<div class="chat-group-footer">
|
||||
<div
|
||||
class="chat-group-footer ${persistUserIdentity
|
||||
? "chat-group-footer--persistent-identity"
|
||||
: ""}"
|
||||
>
|
||||
<div class="chat-group-footer__meta">
|
||||
${opts.onRewind && normalizedRole === "user"
|
||||
? renderRewindButton(opts.onRewind, Boolean(opts.rewindDisabled), "left")
|
||||
@@ -1065,7 +1076,9 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
${opts.onDelete && normalizedRole === "user"
|
||||
? renderDeleteButton(opts.onDelete, "left")
|
||||
: nothing}
|
||||
${normalizedRole === "user" ? renderChatAuthorAvatar(group.sender) : nothing}
|
||||
${normalizedRole === "user" && !showAvatarGutter
|
||||
? renderChatAuthorAvatar(group.sender)
|
||||
: nothing}
|
||||
<span class="chat-sender-name">${who}</span>
|
||||
${renderMessageMeta(group.timestamp, meta)}
|
||||
</div>
|
||||
|
||||
@@ -1254,6 +1254,7 @@ function renderChatThreadContents(
|
||||
userId: props.userId ?? null,
|
||||
userName: props.userName ?? null,
|
||||
userAvatar: props.userAvatar ?? null,
|
||||
showAvatarGutter: !isDirectThread,
|
||||
basePath: props.basePath,
|
||||
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
|
||||
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null,
|
||||
|
||||
@@ -106,6 +106,46 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Shared threads keep the author name visible beside the gutter avatar. The
|
||||
other footer details stay focusable but leave the row layout until reveal. */
|
||||
.chat-group-footer--persistent-identity {
|
||||
position: relative;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-group-footer--persistent-identity .chat-delete-wrap,
|
||||
.chat-group-footer--persistent-identity .chat-group-timestamp,
|
||||
.chat-group-footer--persistent-identity .msg-meta,
|
||||
.chat-group-footer--persistent-identity > .chat-group-footer-actions {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-group:hover .chat-group-footer--persistent-identity,
|
||||
.chat-group:focus-within .chat-group-footer--persistent-identity,
|
||||
.chat-group-footer--persistent-identity:has(details.msg-meta[open]) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-group:hover .chat-group-footer--persistent-identity .chat-delete-wrap,
|
||||
.chat-group:hover .chat-group-footer--persistent-identity .chat-group-timestamp,
|
||||
.chat-group:hover .chat-group-footer--persistent-identity .msg-meta,
|
||||
.chat-group:focus-within .chat-group-footer--persistent-identity .chat-delete-wrap,
|
||||
.chat-group:focus-within .chat-group-footer--persistent-identity .chat-group-timestamp,
|
||||
.chat-group:focus-within .chat-group-footer--persistent-identity .msg-meta,
|
||||
.chat-group-footer--persistent-identity:has(details.msg-meta[open]) .chat-delete-wrap,
|
||||
.chat-group-footer--persistent-identity:has(details.msg-meta[open]) .chat-group-timestamp,
|
||||
.chat-group-footer--persistent-identity:has(details.msg-meta[open]) .msg-meta,
|
||||
.chat-group:hover .chat-group-footer--persistent-identity > .chat-group-footer-actions,
|
||||
.chat-group:focus-within .chat-group-footer--persistent-identity > .chat-group-footer-actions,
|
||||
.chat-group-footer--persistent-identity:has(details.msg-meta[open]) > .chat-group-footer-actions {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-group-footer__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -536,6 +576,15 @@ img.chat-avatar {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-group-footer--persistent-identity .chat-delete-wrap,
|
||||
.chat-group-footer--persistent-identity .chat-group-timestamp,
|
||||
.chat-group-footer--persistent-identity .msg-meta,
|
||||
.chat-group-footer--persistent-identity > .chat-group-footer-actions {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-group-footer {
|
||||
margin-top: 4px;
|
||||
opacity: 1;
|
||||
|
||||
Reference in New Issue
Block a user