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:
Peter Steinberger
2026-07-21 19:40:05 -07:00
committed by GitHub
parent 9b3b84a18b
commit 3c3a712913
12 changed files with 281 additions and 44 deletions
+7
View File
@@ -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);
+4 -1
View File
@@ -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) =>
+25 -1
View File
@@ -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());
+14
View File
@@ -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
+5 -1
View File
@@ -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", () => {
+3 -1
View File
@@ -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();
});
});
+10 -3
View File
@@ -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,
);
+43 -30
View File
@@ -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,
+49
View File
@@ -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;