diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 1ef29ccb54d1..101261c95603 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -361,6 +361,10 @@ export class GatewayBrowserClient { }); } + get instanceId(): string | undefined { + return this.opts.instanceId; + } + start() { this.client.start(); } diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 29ee94ccf678..7c1e4d0ca23c 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -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?: | { diff --git a/ui/src/lib/chat/current-user-identity.test.ts b/ui/src/lib/chat/current-user-identity.test.ts new file mode 100644 index 000000000000..457f13715ef7 --- /dev/null +++ b/ui/src/lib/chat/current-user-identity.test.ts @@ -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(); + }); +}); diff --git a/ui/src/lib/chat/current-user-identity.ts b/ui/src/lib/chat/current-user-identity.ts new file mode 100644 index 000000000000..dc7fac9fb6bb --- /dev/null +++ b/ui/src/lib/chat/current-user-identity.ts @@ -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; + return normalizeSenderIdentity({ + id: record.id ?? record.email, + name: record.name, + profileAvatarUrl: record.avatarUrl, + }); +} diff --git a/ui/src/lib/chat/message-normalizer.test.ts b/ui/src/lib/chat/message-normalizer.test.ts index 6ca583e2f4cc..84d54fe89b50 100644 --- a/ui/src/lib/chat/message-normalizer.test.ts +++ b/ui/src/lib/chat/message-normalizer.test.ts @@ -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", diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index 084d426de5fa..7f24a7b66942 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -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) : 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 } : {}), }; diff --git a/ui/src/lib/chat/sender-label.ts b/ui/src/lib/chat/sender-label.ts index 8c8e1822ca5b..573925211884 100644 --- a/ui/src/lib/chat/sender-label.ts +++ b/ui/src/lib/chat/sender-label.ts @@ -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"); +} diff --git a/ui/src/lib/identity-avatar.test.ts b/ui/src/lib/identity-avatar.test.ts new file mode 100644 index 000000000000..e9ffa7e36025 --- /dev/null +++ b/ui/src/lib/identity-avatar.test.ts @@ -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" }); + }); +}); diff --git a/ui/src/lib/identity-avatar.ts b/ui/src/lib/identity-avatar.ts new file mode 100644 index 000000000000..fbbc76a17224 --- /dev/null +++ b/ui/src/lib/identity-avatar.ts @@ -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 { + 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 { + 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), + }; +} diff --git a/ui/src/pages/chat/chat-commands.ts b/ui/src/pages/chat/chat-commands.ts index 362d4416fa86..dc3ff8f38d34 100644 --- a/ui/src/pages/chat/chat-commands.ts +++ b/ui/src/pages/chat/chat-commands.ts @@ -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) { diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index afb035cad4c4..21ec2fc35554 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -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({ diff --git a/ui/src/pages/chat/chat-queue.ts b/ui/src/pages/chat/chat-queue.ts index 19018cbc3b45..c0e724ffaf38 100644 --- a/ui/src/pages/chat/chat-queue.ts +++ b/ui/src/pages/chat/chat-queue.ts @@ -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 } : {}), }, ]; } diff --git a/ui/src/pages/chat/chat-send.ts b/ui/src/pages/chat/chat-send.ts index 176a75502fe3..5b31fdaa4c13 100644 --- a/ui/src/pages/chat/chat-send.ts +++ b/ui/src/pages/chat/chat-send.ts @@ -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); } diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 6bd75dac3f8c..ac525735a81d 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -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" }, ]); diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index e06e29d900da..f0e2619e041f 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -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 { 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 { !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 { 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 | 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; } diff --git a/ui/src/pages/chat/components/chat-author-avatar.ts b/ui/src/pages/chat/components/chat-author-avatar.ts new file mode 100644 index 000000000000..7473238d5331 --- /dev/null +++ b/ui/src/pages/chat/components/chat-author-avatar.ts @@ -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, + fallback = false, +) { + const hue = avatar.colorSeed % 360; + return html` + + `; +} + +function renderResolvedAvatar( + avatar: ResolvedIdentityAvatar, + fallback: Extract, +): TemplateResult { + if (avatar.kind === "initials") { + return renderInitialsAvatar(avatar); + } + return html` + { + const image = event.currentTarget; + if (image instanceof HTMLImageElement) { + image.closest(".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(".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` + + ${until(resolved, nothing)} + + `; +} diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index 37aa6f783ba7..910367e7c2d1 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -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) { + ${renderChatAuthorAvatar(item.sender)} ${steered ? html`${t("chat.queue.steered")} { ).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(".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 = { diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index 78c9c4dd26ef..0d3bbf191e76 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -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} ${who} ${renderMessageMeta(group.timestamp, meta)} diff --git a/ui/src/pages/chat/composer-persistence.ts b/ui/src/pages/chat/composer-persistence.ts index 4ec4da0ccf30..a329b2175ae4 100644 --- a/ui/src/pages/chat/composer-persistence.ts +++ b/ui/src/pages/chat/composer-persistence.ts @@ -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 | undefined); + if (sender) { + item.sender = sender; + } if (entry.kind === "queued" || entry.kind === "steered") { item.kind = entry.kind; } diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 45400ad33cf4..0d7e1e8749b7 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -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);