feat(ui): author avatar chips on user messages (incl. queued) (#111309)

* feat(ui): author avatar chips on user messages with gravatar and initials fallback

Renders a small author avatar on user-authored chat messages, including
queued/pending sends, so multi-user gateways show at a glance who wrote
which prompt. Resolution: profile avatar URL (when available) -> Gravatar
by SHA-256 email hash (d=404) -> deterministic initials with stable color.
Assistant/tool output stays unmarked, and messages without sender identity
render unchanged, keeping single-user gateways visually identical.

* fix(ui): clear avatar fallback state on image load; document gravatar tradeoff

* fix(ui): gate email-hash avatars behind a gateway proxy base URL

Without a same-origin proxy base the email tier is disabled outright, so
rendering a transcript can never disclose hashed sender emails or viewer
IPs to a third-party avatar host. The gateway-side proxy route (follow-up)
supplies the base and becomes the only avatar fetch path.

* fix(ui): restrict profile avatar URLs to same-origin relative paths

* fix(ui): validate profile avatar URLs with the URL parser, not string prefixes

Backslashes normalize to slashes and tabs/newlines are stripped by browser
URL parsing, so startsWith checks can be bypassed into cross-origin fetches.
Resolving against a probe origin and requiring the origin to survive is
parser-equivalent and closes the class.
This commit is contained in:
Peter Steinberger
2026-07-19 02:08:05 -07:00
committed by GitHub
parent e2ca148d4b
commit ba06fe1541
21 changed files with 632 additions and 21 deletions
+4
View File
@@ -361,6 +361,10 @@ export class GatewayBrowserClient {
});
}
get instanceId(): string | undefined {
return this.opts.instanceId;
}
start() {
this.client.start();
}
+5
View File
@@ -2,6 +2,8 @@
* Chat message types for the UI layer.
*/
import type { SenderIdentity } from "./sender-label.ts";
export type ChatAttachment = {
id: string;
dataUrl?: string;
@@ -41,6 +43,7 @@ export type ChatQueueItem = {
sendRequestStartedAtMs?: number;
sessionKey?: string;
agentId?: string;
sender?: SenderIdentity;
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision;
};
@@ -89,6 +92,7 @@ export type MessageGroup = {
key: string;
role: string;
senderLabel?: string | null;
sender?: SenderIdentity;
messages: Array<{ message: unknown; key: string; duplicateCount?: number }>;
timestamp: number;
isStreaming: boolean;
@@ -126,6 +130,7 @@ export type NormalizedMessage = {
timestamp: number;
id?: string;
senderLabel?: string | null;
sender?: SenderIdentity;
audioAsVoice?: boolean;
replyTarget?:
| {
@@ -0,0 +1,30 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { resolveCurrentUserIdentity } from "./current-user-identity.ts";
describe("resolveCurrentUserIdentity", () => {
it("selects only this browser connection's presence identity", () => {
const hello = {
snapshot: {
presence: [
{ instanceId: "other-browser", user: { id: "other@example.com" } },
{
instanceId: "this-browser",
user: {
id: "alice@example.com",
name: "Alice Example",
avatarUrl: "/avatars/alice.png",
},
},
],
},
};
expect(resolveCurrentUserIdentity(hello, "this-browser")).toEqual({
id: "alice@example.com",
name: "Alice Example",
profileAvatarUrl: "/avatars/alice.png",
});
expect(resolveCurrentUserIdentity(hello, "missing-browser")).toBeNull();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { normalizeSenderIdentity, type SenderIdentity } from "./sender-label.ts";
type HelloWithPresence = {
snapshot?: unknown;
};
/** Finds this browser connection's authenticated user in the Gateway presence snapshot. */
export function resolveCurrentUserIdentity(
hello: HelloWithPresence | null | undefined,
instanceId: string | null | undefined,
): SenderIdentity | null {
const normalizedInstanceId = instanceId?.trim();
const snapshot = hello?.snapshot;
if (!normalizedInstanceId || !snapshot || typeof snapshot !== "object") {
return null;
}
const presence = (snapshot as { presence?: unknown }).presence;
if (!Array.isArray(presence)) {
return null;
}
const ownPresence = presence.find((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
return (entry as { instanceId?: unknown }).instanceId === normalizedInstanceId;
});
if (!ownPresence || typeof ownPresence !== "object" || Array.isArray(ownPresence)) {
return null;
}
const user = (ownPresence as { user?: unknown }).user;
if (!user || typeof user !== "object" || Array.isArray(user)) {
return null;
}
const record = user as Record<string, unknown>;
return normalizeSenderIdentity({
id: record.id ?? record.email,
name: record.name,
profileAvatarUrl: record.avatarUrl,
});
}
+7 -7
View File
@@ -604,13 +604,13 @@ describe("message-normalizer", () => {
});
it("formats durable sender metadata for transcript attribution", () => {
expect(
normalizeMessage({
role: "user",
content: "Prompt from Alice",
__openclaw: { senderId: "alice@example.com" },
}).senderLabel,
).toBe("alice");
const emailSender = normalizeMessage({
role: "user",
content: "Prompt from Alice",
__openclaw: { senderId: "alice@example.com" },
});
expect(emailSender.senderLabel).toBe("alice");
expect(emailSender.sender).toEqual({ id: "alice@example.com" });
expect(
normalizeMessage({
role: "user",
+9 -6
View File
@@ -14,7 +14,7 @@ import { splitMediaFromOutput } from "../../../../src/media/parse.js";
import { parseInlineDirectives } from "../../../../src/utils/directive-tags.js";
import { getMediaFileExtension } from "../media-file-extension.ts";
import type { NormalizedMessage, MessageContentItem } from "./chat-types.ts";
import { formatSenderLabel } from "./sender-label.ts";
import { formatSenderLabel, normalizeSenderIdentity } from "./sender-label.ts";
export function normalizeRoleForGrouping(role: string): string {
const lower = role.toLowerCase();
@@ -519,14 +519,16 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
rawOpenClawMeta && typeof rawOpenClawMeta === "object" && !Array.isArray(rawOpenClawMeta)
? (rawOpenClawMeta as Record<string, unknown>)
: undefined;
const sender = normalizeSenderIdentity({
id: openClawMeta?.senderId,
name: openClawMeta?.senderName,
username: openClawMeta?.senderUsername,
profileAvatarUrl: openClawMeta?.senderProfileAvatarUrl,
});
const senderLabel =
typeof m.senderLabel === "string" && m.senderLabel.trim()
? m.senderLabel.trim()
: formatSenderLabel({
id: openClawMeta?.senderId,
name: openClawMeta?.senderName,
username: openClawMeta?.senderUsername,
});
: formatSenderLabel(sender);
content = stripMessageDisplayMetadata(content);
@@ -536,6 +538,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
timestamp,
id,
senderLabel,
...(sender ? { sender } : {}),
...(audioAsVoice ? { audioAsVoice: true } : {}),
...(replyTarget ? { replyTarget } : {}),
};
+39 -1
View File
@@ -1,7 +1,15 @@
type SenderIdentity = {
export type SenderIdentity = {
id?: string;
name?: string;
username?: string;
profileAvatarUrl?: string;
};
type SenderIdentityInput = {
id?: unknown;
name?: unknown;
username?: unknown;
profileAvatarUrl?: unknown;
};
function normalizeLabelPart(value: unknown): string | null {
@@ -20,3 +28,33 @@ export function formatSenderLabel(sender: SenderIdentity | null | undefined): st
}
return /^([^@\s]+)@[^@\s]+$/.exec(id)?.[1] ?? id;
}
export function normalizeSenderIdentity(
sender: SenderIdentityInput | null | undefined,
): SenderIdentity | null {
const id = normalizeLabelPart(sender?.id);
const name = normalizeLabelPart(sender?.name);
const username = normalizeLabelPart(sender?.username);
const profileAvatarUrl = normalizeLabelPart(sender?.profileAvatarUrl);
if (!id && !name && !username && !profileAvatarUrl) {
return null;
}
return {
...(id ? { id } : {}),
...(name ? { name } : {}),
...(username ? { username } : {}),
...(profileAvatarUrl ? { profileAvatarUrl } : {}),
};
}
export function senderIdentityKey(sender: SenderIdentity | null | undefined): string | null {
if (!sender) {
return null;
}
return [
sender.id ?? "",
sender.name ?? "",
sender.username ?? "",
sender.profileAvatarUrl ?? "",
].join("\u0000");
}
+84
View File
@@ -0,0 +1,84 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { resolveAvatar } from "./identity-avatar.ts";
describe("resolveAvatar", () => {
it("uses a normalized email id for the proxied avatar hash", async () => {
await expect(
resolveAvatar({ id: " Alice@Example.com ", avatarProxyBaseUrl: "/api/avatars/" }),
).resolves.toEqual({
kind: "gravatar",
url: "/api/avatars/ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976?s=64",
});
});
it("never contacts a third-party avatar host without a proxy base", async () => {
await expect(resolveAvatar({ id: "alice@example.com" })).resolves.toMatchObject({
kind: "initials",
initials: "A",
});
});
it("falls back to initials for a non-email id", async () => {
await expect(resolveAvatar({ id: "profile_123" })).resolves.toMatchObject({
kind: "initials",
initials: "P",
});
});
it("derives up to two initials from a display name", async () => {
await expect(resolveAvatar({ name: "Ada Lovelace Byron" })).resolves.toMatchObject({
kind: "initials",
initials: "AL",
});
});
it("keeps the initials color deterministic", async () => {
const first = await resolveAvatar({ id: "profile_123", name: "Ada Lovelace" });
const second = await resolveAvatar({ id: "profile_123", name: "Renamed User" });
expect(first.kind).toBe("initials");
expect(second.kind).toBe("initials");
if (first.kind === "initials" && second.kind === "initials") {
expect(first.colorSeed).toBe(second.colorSeed);
}
});
it("lets an already-resolved profile avatar win", async () => {
await expect(
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/avatars/alice.png" }),
).resolves.toEqual({ kind: "profile", url: "/avatars/alice.png" });
});
});
describe("resolveAvatar profile URL origin restriction", () => {
it("rejects absolute profile URLs from sender metadata", async () => {
await expect(
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "https://evil.example/a.png" }),
).resolves.toMatchObject({ kind: "initials" });
});
it("rejects protocol-relative profile URLs", async () => {
await expect(
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "//evil.example/a.png" }),
).resolves.toMatchObject({ kind: "initials" });
});
it("rejects backslash and control-character parser bypasses", async () => {
for (const url of [
"/\\evil.example/a.png",
"\\/evil.example/a.png",
"/\t/evil.example/a.png",
"htt\nps://evil.example/a.png",
]) {
await expect(
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: url }),
).resolves.toMatchObject({ kind: "initials" });
}
});
it("accepts same-origin relative profile URLs", async () => {
await expect(
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/avatars/alice.png" }),
).resolves.toEqual({ kind: "profile", url: "/avatars/alice.png" });
});
});
+94
View File
@@ -0,0 +1,94 @@
import { formatSenderLabel, type SenderIdentity } from "./chat/sender-label.ts";
export type IdentityAvatarInput = SenderIdentity & {
profileAvatarUrl?: string;
/**
* Base URL of a gateway-side avatar proxy (same-origin). When absent, the
* email-hash avatar tier is disabled entirely: the browser must never
* contact a third-party avatar host directly, because that leaks a
* dictionary-recoverable sender email hash plus the viewer's IP per render.
*/
avatarProxyBaseUrl?: string;
};
export type ResolvedIdentityAvatar =
| { kind: "profile"; url: string }
| { kind: "gravatar"; url: string }
| { kind: "initials"; initials: string; colorSeed: number };
const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+$/;
function initialsFromLabel(label: string): string {
const words = label.trim().split(/\s+/u).filter(Boolean).slice(0, 2);
const initials = words.map((word) => Array.from(word)[0] ?? "").join("");
return initials.toUpperCase() || "?";
}
const ORIGIN_PROBE = "https://origin-probe.invalid";
/** True only when the value resolves inside the embedding origin for any base. */
function isOriginRelativePath(value: string): boolean {
try {
return new URL(value, ORIGIN_PROBE).origin === ORIGIN_PROBE;
} catch {
return false;
}
}
function stableColorSeed(value: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
async function sha256Hex(value: string): Promise<string | null> {
if (!globalThis.crypto?.subtle) {
return null;
}
try {
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
} catch {
return null;
}
}
/** Resolves profile, Gravatar, then deterministic initials without fetching profile data. */
export async function resolveAvatar(input: IdentityAvatarInput): Promise<ResolvedIdentityAvatar> {
const profileAvatarUrl = input.profileAvatarUrl?.trim();
// Same-origin only: profile URLs arrive via sender metadata, and an
// absolute URL would let a sender make every viewing browser contact an
// arbitrary host. Validate with the URL parser (not string prefixes) so
// browser normalization quirks — backslashes as slashes, stripped tab or
// newline characters — cannot smuggle in a cross-origin target.
if (profileAvatarUrl && isOriginRelativePath(profileAvatarUrl)) {
return { kind: "profile", url: profileAvatarUrl };
}
const id = input.id?.trim();
const proxyBase = input.avatarProxyBaseUrl?.trim().replace(/\/+$/, "");
if (proxyBase && id && EMAIL_PATTERN.test(id)) {
const hash = await sha256Hex(id.toLowerCase());
if (hash) {
return {
kind: "gravatar",
url: `${proxyBase}/${hash}?s=64`,
};
}
}
const label = formatSenderLabel(input) ?? "?";
return {
kind: "initials",
initials: initialsFromLabel(label),
colorSeed: stableColorSeed(id || label),
};
}
+8 -1
View File
@@ -10,6 +10,7 @@ import {
replaceSlashCommands,
type SlashCommandDef,
} from "../../lib/chat/commands.ts";
import { resolveCurrentUserIdentity } from "../../lib/chat/current-user-identity.ts";
import {
scopedAgentIdForSession,
visibleSessionMatches,
@@ -316,7 +317,13 @@ export async function dispatchChatSlashCommand(
}
if (result.pendingCurrentRun && host.chatRunId && targetIsCurrent()) {
enqueuePendingRunMessage(host, `/${name} ${args}`.trim(), host.chatRunId);
enqueuePendingRunMessage(
host,
`/${name} ${args}`.trim(),
host.chatRunId,
undefined,
resolveCurrentUserIdentity(host.hello, host.client?.instanceId) ?? undefined,
);
}
if (result.sessionPatch && "modelOverride" in result.sessionPatch) {
+20
View File
@@ -547,6 +547,26 @@ describe("renderChatComposer controls", () => {
expect(onQueueSteer.mock.calls).toEqual([["queued-1"], ["waiting-idle-1"]]);
});
it("renders the queued author's avatar before the turn is submitted", async () => {
const { container } = renderComposer({
queue: [
{
id: "waiting-idle-1",
text: "queued during the run",
createdAt: 4,
sendState: "waiting-idle",
sender: { id: "profile_123", name: "Alice Example" },
},
],
});
await vi.waitFor(() => {
expect(
container.querySelector(".chat-queue__item .chat-author-avatar__initials")?.textContent,
).toContain("AE");
});
});
it("renders failed sends as retryable and running commands as inert", () => {
const onQueueRetry = vi.fn();
let view = renderComposer({
+5
View File
@@ -1,5 +1,6 @@
// Control UI page module owns Chat queue storage and queue item cleanup.
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
import type { SenderIdentity } from "../../lib/chat/sender-label.ts";
import {
scopedAgentIdForSession,
visibleSessionMatches,
@@ -305,6 +306,7 @@ export function enqueueChatMessage(
attachments?: ChatAttachment[],
refreshSessions?: boolean,
localCommand?: { args: string; name: string },
sender?: SenderIdentity,
): ChatQueueItem | null {
const trimmed = text.trim();
const hasAttachments = Boolean(attachments && attachments.length > 0);
@@ -321,6 +323,7 @@ export function enqueueChatMessage(
localCommandName: localCommand?.name,
sessionKey: host.sessionKey,
agentId: scopedAgentIdForSession(host, host.sessionKey),
...(sender ? { sender } : {}),
};
host.chatQueue = [...host.chatQueue, item];
return item;
@@ -331,6 +334,7 @@ export function enqueuePendingRunMessage(
text: string,
pendingRunId: string,
attachments?: ChatAttachment[],
sender?: SenderIdentity,
) {
const trimmed = text.trim();
const hasAttachments = Boolean(attachments && attachments.length > 0);
@@ -346,6 +350,7 @@ export function enqueuePendingRunMessage(
kind: "steered",
attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined,
pendingRunId,
...(sender ? { sender } : {}),
},
];
}
+14 -4
View File
@@ -19,6 +19,7 @@ import type {
ChatQueueSkillWorkshopRevision,
} from "../../lib/chat/chat-types.ts";
import { parseSlashCommand } from "../../lib/chat/commands.ts";
import { resolveCurrentUserIdentity } from "../../lib/chat/current-user-identity.ts";
import type { ControlUiFollowUpMode } from "../../lib/chat/follow-up-mode.ts";
import { extractSideQuestionDisplayText } from "../../lib/chat/side-question.ts";
import {
@@ -454,6 +455,7 @@ function enqueuePendingSendMessage(
if (!trimmed && !hasAttachments) {
return null;
}
const sender = resolveCurrentUserIdentity(host.hello, host.client?.instanceId);
const pending: ChatQueueItem = {
id: generateUUID(),
text: trimmed,
@@ -466,6 +468,7 @@ function enqueuePendingSendMessage(
sendSubmittedAtMs: submittedAtMs,
sessionKey: host.sessionKey,
agentId: scopedAgentIdForSession(host, host.sessionKey),
...(sender ? { sender } : {}),
...(skillWorkshopRevision ? { skillWorkshopRevision } : {}),
...(replyToId ? { replyToId } : {}),
};
@@ -2266,10 +2269,17 @@ export async function handleSendChat(
host.chatMessage = "";
resetChatInputHistoryNavigation(host);
}
const queued = enqueueChatMessage(host, message, undefined, isChatResetCommand(message), {
args: parsed.args,
name: parsed.command.key,
});
const queued = enqueueChatMessage(
host,
message,
undefined,
isChatResetCommand(message),
{
args: parsed.args,
name: parsed.command.key,
},
resolveCurrentUserIdentity(host.hello, host.client?.instanceId) ?? undefined,
);
if (queued) {
queued.sendState = reconnectSafeQueuedSendState(host);
}
+5
View File
@@ -2031,11 +2031,16 @@ describe("buildCachedChatItems", () => {
createdAt: 2,
sendSubmittedAtMs: 10,
sendState: "sending",
sender: { id: "alice@example.com", name: "Alice Example" },
},
],
});
expect(groups.map((group) => group.role)).toEqual(["assistant", "user"]);
expect(groupAt(groups, 1).sender).toEqual({
id: "alice@example.com",
name: "Alice Example",
});
expect(messageRecord(groupAt(groups, 1)).content).toStrictEqual([
{ type: "text", text: "first visible send" },
]);
+15 -2
View File
@@ -30,6 +30,7 @@ import {
stripMessageDisplayMetadataText,
} from "../../lib/chat/message-normalizer.ts";
import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts";
import { senderIdentityKey } from "../../lib/chat/sender-label.ts";
import {
extractToolCardsCached,
extractToolPreview,
@@ -398,6 +399,7 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
role.toLowerCase() === "user" || role.toLowerCase() === "assistant"
? (normalized.senderLabel ?? null)
: null;
const sender = role.toLowerCase() === "user" ? normalized.sender : undefined;
const timestamp = normalized.timestamp || Date.now();
const shouldSplitBySender = role.toLowerCase() === "user" || role.toLowerCase() === "assistant";
const startsProjectedTurn =
@@ -407,7 +409,9 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
!currentGroup ||
startsProjectedTurn ||
currentGroup.role !== role ||
(shouldSplitBySender && currentGroup.senderLabel !== senderLabel)
(shouldSplitBySender &&
(currentGroup.senderLabel !== senderLabel ||
senderIdentityKey(currentGroup.sender) !== senderIdentityKey(sender)))
) {
if (currentGroup) {
result.push(currentGroup);
@@ -417,6 +421,7 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
key: `group:${role}:${item.key}`,
role,
senderLabel,
...(sender ? { sender } : {}),
messages: [{ message: item.message, key: item.key, duplicateCount: item.duplicateCount }],
timestamp,
isStreaming: false,
@@ -1093,6 +1098,12 @@ function queuedSendThreadMessage(item: ChatQueueItem): Record<string, unknown> |
kind: "pending-send",
id: item.id,
state: item.sendState,
...(item.sender?.id ? { senderId: item.sender.id } : {}),
...(item.sender?.name ? { senderName: item.sender.name } : {}),
...(item.sender?.username ? { senderUsername: item.sender.username } : {}),
...(item.sender?.profileAvatarUrl
? { senderProfileAvatarUrl: item.sender.profileAvatarUrl }
: {}),
},
};
}
@@ -1525,6 +1536,7 @@ function sameMessageGroup(previous: MessageGroup, next: MessageGroup): boolean {
return (
previous.role === next.role &&
previous.senderLabel === next.senderLabel &&
senderIdentityKey(previous.sender) === senderIdentityKey(next.sender) &&
previous.isStreaming === next.isStreaming &&
previous.turnSucceeded === next.turnSucceeded &&
previous.messages.length === next.messages.length &&
@@ -1625,7 +1637,8 @@ function stabilizeChatItems(
!prior ||
claimedGroupKeys.has(prior.key) ||
prior.role !== item.role ||
prior.senderLabel !== item.senderLabel
prior.senderLabel !== item.senderLabel ||
senderIdentityKey(prior.sender) !== senderIdentityKey(item.sender)
) {
continue;
}
@@ -0,0 +1,80 @@
import { html, nothing, type TemplateResult } from "lit";
import { until } from "lit/directives/until.js";
import { formatSenderLabel } from "../../../lib/chat/sender-label.ts";
import {
resolveAvatar,
type IdentityAvatarInput,
type ResolvedIdentityAvatar,
} from "../../../lib/identity-avatar.ts";
function renderInitialsAvatar(
avatar: Extract<ResolvedIdentityAvatar, { kind: "initials" }>,
fallback = false,
) {
const hue = avatar.colorSeed % 360;
return html`
<span
class="chat-author-avatar__initials ${fallback ? "chat-author-avatar__fallback" : ""}"
style=${`--chat-author-avatar-hue: ${hue}`}
aria-hidden="true"
>
${avatar.initials}
</span>
`;
}
function renderResolvedAvatar(
avatar: ResolvedIdentityAvatar,
fallback: Extract<ResolvedIdentityAvatar, { kind: "initials" }>,
): TemplateResult {
if (avatar.kind === "initials") {
return renderInitialsAvatar(avatar);
}
return html`
<img
class="chat-author-avatar__image"
src=${avatar.url}
alt=""
aria-hidden="true"
@error=${(event: Event) => {
const image = event.currentTarget;
if (image instanceof HTMLImageElement) {
image.closest<HTMLElement>(".chat-author-avatar")?.classList.add("is-fallback");
}
}}
@load=${(event: Event) => {
// Lit reuses DOM parts across renders; a prior sender's error state
// must not hide a successfully loaded avatar for the next source.
const image = event.currentTarget;
if (image instanceof HTMLImageElement) {
image.closest<HTMLElement>(".chat-author-avatar")?.classList.remove("is-fallback");
}
}}
/>
${renderInitialsAvatar(fallback, true)}
`;
}
/** Small author marker shared by transcript bubbles and the pending-send queue. */
export function renderChatAuthorAvatar(
sender: IdentityAvatarInput | null | undefined,
): TemplateResult | typeof nothing {
const label = formatSenderLabel(sender);
if (!sender || !label) {
return nothing;
}
const resolved = Promise.all([resolveAvatar(sender), resolveAvatar({ name: label })]).then(
([avatar, fallback]) => {
const initials =
fallback.kind === "initials"
? fallback
: ({ kind: "initials", initials: "?", colorSeed: 0 } as const);
return renderResolvedAvatar(avatar, initials);
},
);
return html`
<span class="chat-author-avatar" role="img" aria-label=${label} title=${label}>
${until(resolved, nothing)}
</span>
`;
}
@@ -67,6 +67,7 @@ import {
renderChatAttachmentInputs,
renderChatAttachmentMenu,
} from "./chat-attachments.ts";
import { renderChatAuthorAvatar } from "./chat-author-avatar.ts";
import { renderChatPlanChecklist } from "./chat-plan-checklist.ts";
import { createGatewayQuestionPanelProps } from "./chat-question-card.ts";
import {
@@ -1099,6 +1100,7 @@ function renderChatQueueItem(item: ChatQueueItem, props: ChatQueueProps) {
<span class="chat-queue__icon" aria-hidden="true">
${failed ? icons.alertTriangle : icons.clock}
</span>
${renderChatAuthorAvatar(item.sender)}
${steered
? html`<span class="chat-queue__badge chat-queue__badge--steered"
>${t("chat.queue.steered")}</span
@@ -1258,6 +1258,118 @@ describe("grouped chat rendering", () => {
).toBe("alice");
});
it("renders an author avatar for a user group with sender identity", async () => {
const container = document.createElement("div");
render(
renderMessageGroup(
{
kind: "group",
key: "attributed-user",
role: "user",
senderLabel: "Alice Example",
sender: { id: "profile_123", name: "Alice Example" },
messages: [
{
key: "attributed-message",
message: { role: "user", content: "hello", timestamp: 1000 },
},
],
timestamp: 1000,
isStreaming: false,
},
{
showReasoning: true,
showToolCalls: true,
assistantName: "OpenClaw",
},
),
container,
);
await vi.waitFor(() => {
expect(container.querySelector(".chat-author-avatar__initials")?.textContent?.trim()).toBe(
"AE",
);
});
expect(container.querySelector(".chat-author-avatar")?.getAttribute("title")).toBe(
"Alice Example",
);
});
it("falls back to initials when a user avatar image fails", async () => {
const container = document.createElement("div");
const group: MessageGroup = {
kind: "group",
key: "gravatar-user",
role: "user",
senderLabel: "alice",
// profileAvatarUrl exercises the img tier; bare emails render initials
// only (no third-party avatar fetch without a gateway proxy base).
sender: { id: "alice@example.com", profileAvatarUrl: "/avatars/alice.png" },
messages: [
{
key: "gravatar-message",
message: { role: "user", content: "hello", timestamp: 1000 },
},
],
timestamp: 1000,
isStreaming: false,
};
render(
renderMessageGroup(group, {
showReasoning: true,
showToolCalls: true,
assistantName: "OpenClaw",
}),
container,
);
const image = await vi.waitFor(() => {
const result = container.querySelector<HTMLImageElement>(".chat-author-avatar__image");
expect(result).not.toBeNull();
return result!;
});
image.dispatchEvent(new Event("error"));
expect(container.querySelector(".chat-author-avatar")?.classList.contains("is-fallback")).toBe(
true,
);
expect(container.querySelector(".chat-author-avatar__fallback")?.textContent?.trim()).toBe("A");
});
it("does not render an author avatar for a user group without sender identity", () => {
const container = document.createElement("div");
renderGroupedMessage(container, { role: "user", content: "hello", timestamp: 1000 }, "user");
expect(container.querySelector(".chat-author-avatar")).toBeNull();
});
it("never renders a user author avatar on assistant output", () => {
const container = document.createElement("div");
const group: MessageGroup = {
kind: "group",
key: "assistant-with-sender",
role: "assistant",
senderLabel: "Forwarded Agent",
sender: { id: "agent@example.com", name: "Forwarded Agent" },
messages: [
{
key: "assistant-message",
message: { role: "assistant", content: "hello", timestamp: 1000 },
},
],
timestamp: 1000,
isStreaming: false,
};
render(
renderMessageGroup(group, {
showReasoning: true,
showToolCalls: true,
assistantName: "OpenClaw",
}),
container,
);
expect(container.querySelector(".chat-author-avatar")).toBeNull();
});
it("uses assistant senderLabel for forwarded assistant-side groups", () => {
const container = document.createElement("div");
const group: MessageGroup = {
@@ -58,6 +58,7 @@ import { getSafeLocalStorage } from "../../../local-storage.ts";
import { renderChatAvatar } from "../chat-avatar.ts";
import { persistedMessageEntryId } from "../chat-thread.ts";
import type { PlanStatus } from "../tool-stream.ts";
import { renderChatAuthorAvatar } from "./chat-author-avatar.ts";
import { renderChatPlanChecklist } from "./chat-plan-checklist.ts";
import { renderChatQuestionSummary } from "./chat-question-card.ts";
import type { SidebarContent } from "./chat-sidebar.ts";
@@ -1005,6 +1006,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
${opts.onDelete && normalizedRole === "user"
? renderDeleteButton(opts.onDelete, "left")
: nothing}
${normalizedRole === "user" ? renderChatAuthorAvatar(group.sender) : nothing}
<span class="chat-sender-name">${who}</span>
${renderMessageMeta(group.timestamp, meta)}
</div>
@@ -3,6 +3,7 @@ import type {
ChatQueueItem,
ChatQueueSkillWorkshopRevision,
} from "../../lib/chat/chat-types.ts";
import { normalizeSenderIdentity } from "../../lib/chat/sender-label.ts";
import {
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
@@ -774,6 +775,7 @@ function serializeQueueItem(item: ChatQueueItem): ChatQueueItem | null {
const sendError =
item.sendState === "waiting-model" ? INTERRUPTED_SETTINGS_WAIT_ERROR : item.sendError;
const skillWorkshopRevision = normalizeSkillWorkshopRevision(item.skillWorkshopRevision);
const sender = normalizeSenderIdentity(item.sender);
return {
id,
text,
@@ -789,6 +791,7 @@ function serializeQueueItem(item: ChatQueueItem): ChatQueueItem | null {
...(item.localCommandName ? { localCommandName: item.localCommandName } : {}),
...(item.sessionKey ? { sessionKey: item.sessionKey } : {}),
...(item.agentId ? { agentId: item.agentId } : {}),
...(sender ? { sender } : {}),
...(skillWorkshopRevision ? { skillWorkshopRevision } : {}),
...(sendState ? { sendState } : {}),
...(sendError ? { sendError } : {}),
@@ -819,6 +822,10 @@ function normalizeQueueItem(value: unknown): ChatQueueItem | null {
.filter((item): item is ChatAttachment => item !== null)
: [];
const item: ChatQueueItem = { id, text, createdAt };
const sender = normalizeSenderIdentity(entry.sender as Record<string, unknown> | undefined);
if (sender) {
item.sender = sender;
}
if (entry.kind === "queued" || entry.kind === "steered") {
item.kind = entry.kind;
}
+50
View File
@@ -123,6 +123,56 @@
text-overflow: ellipsis;
}
.chat-author-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 20px;
width: 20px;
height: 20px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 68%, transparent);
border-radius: 50%;
box-shadow: 0 1px 2px color-mix(in srgb, var(--fg) 9%, transparent);
vertical-align: middle;
}
.chat-author-avatar__image,
.chat-author-avatar__initials {
width: 100%;
height: 100%;
border-radius: inherit;
}
.chat-author-avatar__image {
display: block;
object-fit: cover;
}
.chat-author-avatar__initials {
display: inline-flex;
align-items: center;
justify-content: center;
background: hsl(var(--chat-author-avatar-hue) 32% 42%);
color: white;
font-size: 8px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
}
.chat-author-avatar__fallback {
display: none;
}
.chat-author-avatar.is-fallback .chat-author-avatar__image {
display: none;
}
.chat-author-avatar.is-fallback .chat-author-avatar__fallback {
display: inline-flex;
}
.chat-group-timestamp {
font-size: 12px; /* was 11px */
color: var(--muted);