feat(ui): identity settings and own-identity chip (#111371)

* feat(ui): identity settings and own-identity chip

* feat(ui): resolve own identity via users.self

* fix(ui): rebase identity settings onto landed identity stack

* fix(ui): footer identity chip prefers locally updated self profile

* fix(ui): settings CI conformance — split profile page, lean exports
This commit is contained in:
Peter Steinberger
2026-07-19 04:14:13 -07:00
committed by GitHub
parent 3b84a55d99
commit c95a8e3df1
25 changed files with 1541 additions and 918 deletions
+1
View File
@@ -1290,6 +1290,7 @@ class OpenClawShell extends OpenClawLightDomElement {
schema: runtimeConfig.configSchema,
value: runtimeConfig.configForm ?? runtimeConfig.configSnapshot?.config ?? null,
uiHints: runtimeConfig.configUiHints,
identityAvailable: Boolean(gatewaySnapshot.selfUser),
});
const onboarding = this.onboardingMode;
const navDrawerOpen = this.navDrawerOpen && !onboarding;
+86 -1
View File
@@ -18,8 +18,11 @@ const HELLO: GatewayHelloOk = {
class FakeGatewayClient {
started = 0;
stopped = 0;
readonly instanceId: string;
constructor(readonly opts: GatewayBrowserClientOptions) {}
constructor(readonly opts: GatewayBrowserClientOptions) {
this.instanceId = opts.instanceId ?? "";
}
start() {
this.started += 1;
@@ -165,6 +168,88 @@ describe("createApplicationGateway reconnecting snapshot", () => {
expect(gateway.snapshot.reconnecting).toBe(true);
});
it("projects only this browser connection's optional presence identity", () => {
const { gateway, current } = createStore();
gateway.start();
const instanceId = current().opts.instanceId;
current().opts.onHello?.({
...HELLO,
snapshot: {
presence: [
{ instanceId: "someone-else", user: { id: "other", name: "Other" } },
{
instanceId,
user: { id: "profile-1", email: "ada@example.test", name: "Ada" },
},
],
},
});
expect(gateway.snapshot.selfUser).toEqual({
id: "profile-1",
email: "ada@example.test",
name: "Ada",
});
gateway.updateSelfUser?.({ name: "Augusta Ada", avatarUrl: "/api/users/profile-1/avatar?v=2" });
expect(gateway.snapshot.selfUser).toMatchObject({
id: "profile-1",
name: "Augusta Ada",
avatarUrl: "/api/users/profile-1/avatar?v=2",
});
current().opts.onEvent?.({
type: "event",
event: "presence",
payload: {
presence: [
{
instanceId,
user: {
id: "profile-1",
email: "ada@example.test",
name: "Ada Lovelace",
avatarUrl: "/api/users/profile-1/avatar?v=3",
},
},
],
},
seq: 1,
stateVersion: { presence: 1, health: 1 },
});
expect(gateway.snapshot.selfUser).toMatchObject({
id: "profile-1",
name: "Ada Lovelace",
avatarUrl: "/api/users/profile-1/avatar?v=3",
});
current().opts.onEvent?.({
type: "event",
event: "presence",
payload: { presence: [{ instanceId: "anonymous" }] },
seq: 2,
stateVersion: { presence: 2, health: 1 },
});
expect(gateway.snapshot.selfUser).toBeNull();
});
it("clears identity while disconnected", () => {
const { gateway, current } = createStore();
gateway.start();
current().opts.onHello?.({
...HELLO,
snapshot: {
presence: [
{ instanceId: current().opts.instanceId, user: { id: "profile-1", name: "Ada" } },
],
},
});
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
expect(gateway.snapshot.selfUser).toBeNull();
});
it("does not copy selected-remote settings into an ephemeral document Gateway", () => {
const pageGateway = "ws://127.0.0.1:18789";
const remoteGateway = "wss://saved-remote.example.test";
+36
View File
@@ -16,11 +16,24 @@ import type {
ApplicationGatewaySnapshot,
} from "./context.ts";
import { loadSettings, patchSettings, persistSessionToken } from "./settings.ts";
import { readPresenceEntries, resolveSelfPresenceUser } from "./user-profile.ts";
type GatewayClientFactory = (opts: GatewayBrowserClientOptions) => GatewayBrowserClient;
const defaultClientFactory: GatewayClientFactory = (opts) => new GatewayBrowserClient(opts);
function sameSelfUser(
left: ApplicationGatewaySnapshot["selfUser"],
right: ApplicationGatewaySnapshot["selfUser"],
): boolean {
return (
left?.id === right?.id &&
left?.email === right?.email &&
left?.name === right?.name &&
left?.avatarUrl === right?.avatarUrl
);
}
export function createApplicationGateway(
initialSettings: ReturnType<typeof loadSettings>,
initialPassword = "",
@@ -45,6 +58,7 @@ export function createApplicationGateway(
sessionKey: settings.sessionKey,
lastError: null,
lastErrorCode: null,
selfUser: null,
};
let client: GatewayBrowserClient | null = null;
// Session lineage for this page lifetime: once a hello succeeded, later
@@ -96,6 +110,15 @@ export function createApplicationGateway(
settings = patchSettings(patch, { selectGateway });
};
const recordGatewayEvent = (event: Parameters<GatewayEventListener>[0]) => {
if (event.event === "presence") {
const entries = readPresenceEntries(event.payload);
if (entries) {
const selfUser = resolveSelfPresenceUser(entries, client?.instanceId);
if (!sameSelfUser(snapshot.selfUser, selfUser)) {
setSnapshot({ ...snapshot, selfUser });
}
}
}
eventLog = [{ ts: Date.now(), event: event.event, payload: event.payload }, ...eventLog].slice(
0,
250,
@@ -177,6 +200,10 @@ export function createApplicationGateway(
sessionKey,
lastError: null,
lastErrorCode: null,
selfUser: resolveSelfPresenceUser(
readPresenceEntries(hello.snapshot) ?? [],
nextClient.instanceId,
),
});
},
onRecoveryScopeChange: () => {
@@ -195,6 +222,7 @@ export function createApplicationGateway(
connected: false,
reconnecting: everConnected && willRetry,
hello: null,
selfUser: null,
lastError: error?.message ?? `disconnected (${code}): ${reason || "no reason"}`,
lastErrorCode: error?.code ?? null,
});
@@ -222,6 +250,7 @@ export function createApplicationGateway(
// recovery, banner "retry now") when a session already existed.
reconnecting: everConnected,
hello: null,
selfUser: null,
sessionKey: nextSessionKey,
lastError: null,
lastErrorCode: null,
@@ -264,6 +293,7 @@ export function createApplicationGateway(
connected: false,
reconnecting: false,
hello: null,
selfUser: null,
lastError: null,
lastErrorCode: null,
});
@@ -285,6 +315,12 @@ export function createApplicationGateway(
}
};
},
updateSelfUser: (patch) => {
if (!snapshot.selfUser) {
return;
}
setSnapshot({ ...snapshot, selfUser: { ...snapshot.selfUser, ...patch } });
},
};
return gateway;
}
+4
View File
@@ -1,5 +1,6 @@
import type { EventLogEntry } from "../api/event-log.ts";
import type { GatewayBrowserClient, GatewayEventListener, GatewayHelloOk } from "../api/gateway.ts";
import type { AuthenticatedUser } from "./user-profile.ts";
export type ApplicationGatewaySnapshot = {
client: GatewayBrowserClient | null;
@@ -15,6 +16,8 @@ export type ApplicationGatewaySnapshot = {
sessionKey: string;
lastError: string | null;
lastErrorCode: string | null;
/** Identity projected from this browser connection's own presence entry. */
selfUser?: AuthenticatedUser | null;
};
export type ApplicationGatewayConnection = {
@@ -39,4 +42,5 @@ export type ApplicationGateway = {
subscribe: (listener: (snapshot: ApplicationGatewaySnapshot) => void) => () => void;
subscribeEventLog: (listener: (events: readonly EventLogEntry[]) => void) => () => void;
subscribeEvents: (listener: GatewayEventListener) => () => void;
updateSelfUser?: (patch: Partial<Omit<AuthenticatedUser, "id">>) => void;
};
-15
View File
@@ -7,7 +7,6 @@ import {
loadSettings,
persistSessionToken,
resolvePageGatewaySettings,
saveLocalUserIdentity,
saveSettings,
type UiSettings,
} from "./settings.ts";
@@ -1078,20 +1077,6 @@ describe("loadSettings default gateway URL derivation", () => {
});
});
it("persists and clears normalized local user identity", () => {
expect(saveLocalUserIdentity({ name: " Buns ", avatar: " 🦞 " })).toEqual({
name: "Buns",
avatar: "🦞",
});
expect(loadLocalUserIdentity()).toEqual({ name: "Buns", avatar: "🦞" });
expect(saveLocalUserIdentity({ name: null, avatar: null })).toEqual({
name: null,
avatar: null,
});
expect(localStorage.getItem("openclaw.control.user.v1")).toBeNull();
});
it("normalizes invalid local user identity values on load", () => {
localStorage.setItem(
"openclaw.control.user.v1",
-16
View File
@@ -518,22 +518,6 @@ export function loadLocalUserIdentity(): LocalUserIdentity {
}
}
export function saveLocalUserIdentity(next: LocalUserIdentity): LocalUserIdentity {
const storage = getSafeLocalStorage();
const normalized = normalizeLocalUserIdentity(next);
try {
if (normalized.name === null && normalized.avatar === null) {
storage?.removeItem(LOCAL_USER_IDENTITY_KEY);
} else {
storage?.setItem(LOCAL_USER_IDENTITY_KEY, JSON.stringify(normalized));
}
} catch {
// best-effort — quota exceeded or security restrictions should not
// prevent in-memory identity updates from being applied
}
return normalized;
}
function persistSettings(next: UiSettings, options: { selectGateway?: boolean } = {}) {
persistSessionToken(next.gatewayUrl, next.token);
const storage = getSafeLocalStorage();
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
readPresenceEntries,
resolveCurrentSelfUser,
resolveSelfPresenceUser,
userProfileAvatarUrl,
} from "./user-profile.ts";
describe("connection user profile helpers", () => {
it("resolves identity only from the current live presence entry", () => {
const entries = [
{ instanceId: "other", user: { id: "other-profile", name: "Other" } },
{ instanceId: "self", user: { id: "old", name: "Old" }, reason: "disconnect" },
{ instanceId: "self", user: { id: "profile-1", name: "Ada" } },
];
expect(resolveSelfPresenceUser(entries, "self")).toEqual({ id: "profile-1", name: "Ada" });
expect(resolveSelfPresenceUser(entries, "anonymous")).toBeNull();
expect(resolveSelfPresenceUser(entries, undefined)).toBeNull();
});
it("prefers locally refreshed identity state over the presence snapshot", () => {
const presenceEntries = [{ instanceId: "self", user: { id: "profile-1", name: "Ada" } }];
expect(
resolveCurrentSelfUser({
snapshotUser: { id: "profile-1", name: "Augusta Ada" },
presenceEntries,
presenceInstanceId: "self",
}),
).toEqual({ id: "profile-1", name: "Augusta Ada" });
expect(resolveCurrentSelfUser({ presenceEntries, presenceInstanceId: "self" })).toEqual({
id: "profile-1",
name: "Ada",
});
expect(
resolveCurrentSelfUser({
snapshotUser: { id: "previous-profile", name: "Previous User" },
presenceEntries,
presenceInstanceId: "self",
}),
).toEqual({ id: "profile-1", name: "Ada" });
});
it("reads presence payloads and builds scoped cache-busted avatar URLs", () => {
const entries = [{ instanceId: "self", user: { id: "profile/1" } }];
expect(readPresenceEntries({ presence: entries })).toEqual(entries);
expect(readPresenceEntries({ presence: null })).toBeUndefined();
expect(
userProfileAvatarUrl(
"wss://gateway.example.test/control",
"profile/1",
42,
"https://gateway.example.test/control/profile",
),
).toBe("https://gateway.example.test/api/users/profile%2F1/avatar?v=42");
expect(
userProfileAvatarUrl(
"wss://remote.example.test",
"profile-1",
42,
"https://gateway.example.test/control/profile",
),
).toBeNull();
});
});
+77
View File
@@ -0,0 +1,77 @@
import type { PresenceEntry } from "../api/types.ts";
export type AuthenticatedUser = NonNullable<PresenceEntry["user"]>;
export function readPresenceEntries(value: unknown): PresenceEntry[] | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const presence = (value as { presence?: unknown }).presence;
return Array.isArray(presence) ? (presence as PresenceEntry[]) : undefined;
}
export function resolveSelfPresenceUser(
entries: readonly PresenceEntry[],
instanceId: string | undefined,
): AuthenticatedUser | null {
if (!instanceId) {
return null;
}
const entry = entries.find(
(candidate) => candidate.instanceId === instanceId && candidate.reason !== "disconnect",
);
return entry?.user?.id ? entry.user : null;
}
/** Prefers local profile edits for the current presence identity only. */
export function resolveCurrentSelfUser({
snapshotUser,
presenceEntries,
presenceInstanceId,
}: {
snapshotUser?: AuthenticatedUser | null;
presenceEntries?: readonly PresenceEntry[];
presenceInstanceId?: string;
}): AuthenticatedUser | null {
const presenceUser = resolveSelfPresenceUser(presenceEntries ?? [], presenceInstanceId);
// Gateway state folds newer presence into snapshotUser, so a matching profile is
// either the latest presence projection or the local profile edit it should retain.
return snapshotUser && (!presenceUser || snapshotUser.id === presenceUser.id)
? snapshotUser
: presenceUser;
}
export function userProfileAvatarUrl(
gatewayUrl: string,
profileId: string,
updatedAt: number,
documentHref = globalThis.location?.href,
): string | null {
if (!documentHref) {
return null;
}
try {
const url = new URL(gatewayUrl, documentHref);
if (url.protocol === "ws:") {
url.protocol = "http:";
} else if (url.protocol === "wss:") {
url.protocol = "https:";
}
// The authenticated avatar endpoint is HTTP-only and the Control UI CSP
// permits images from its own origin. Cross-origin gateways keep initials.
if (
!["http:", "https:"].includes(url.protocol) ||
url.origin !== new URL(documentHref).origin
) {
return null;
}
url.username = "";
url.password = "";
url.pathname = `/api/users/${encodeURIComponent(profileId)}/avatar`;
url.search = `?v=${updatedAt}`;
url.hash = "";
return url.href;
} catch {
return null;
}
}
+24
View File
@@ -9,6 +9,7 @@ import { pathForRoute } from "../app-route-paths.ts";
import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts";
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
import { t } from "../i18n/index.ts";
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
@@ -295,6 +296,14 @@ class AppSidebar extends AppSidebarSessionListElement {
const gatewayStatus = t("chat.gatewayStatus", {
status: this.connected ? t("common.online") : t("common.offline"),
});
const selfUser = this.connected
? resolveCurrentSelfUser({
snapshotUser: this.context?.gateway.snapshot.selfUser,
presenceEntries: readPresenceEntries(this.presencePayload),
presenceInstanceId: this.presenceInstanceId,
})
: null;
const selfLabel = selfUser?.name ?? selfUser?.email ?? selfUser?.id;
return html`
<div class="sidebar-footer-bar">
<span class="sidebar-brand__logo-slot sidebar-footer-bar__logo">
@@ -306,6 +315,21 @@ class AppSidebar extends AppSidebarSessionListElement {
/>
<openclaw-lobster-logo-standin .visit=${this.logoVisit}></openclaw-lobster-logo-standin>
</span>
${selfUser && selfLabel
? html`<button
type="button"
class="sidebar-footer-bar__identity"
title=${selfLabel}
aria-label=${t("profilePage.identity.openSettings", { name: selfLabel })}
@click=${() => this.onNavigate?.("profile", { hash: "#settings-profile-identity" })}
>
<openclaw-viewer-avatar
.user=${{ ...selfUser, watchedSessions: [] }}
variant="footer"
></openclaw-viewer-avatar>
<span class="sidebar-footer-bar__identity-name">${selfLabel}</span>
</button>`
: nothing}
<openclaw-viewer-facepile
.presencePayload=${this.presencePayload}
.selfInstanceId=${this.presenceInstanceId}
+38 -12
View File
@@ -4,7 +4,7 @@ import type { PresenceEntry } from "../api/types.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import "./tooltip.ts";
type PresenceViewer = {
export type PresenceViewer = {
id: string;
name?: string;
email?: string;
@@ -87,7 +87,7 @@ function projectPresencePayload(value: unknown, selfInstanceId?: string) {
return cachedPresenceProjection;
}
function presenceViewerLabel(user: PresenceViewer): string {
export function presenceViewerLabel(user: PresenceViewer): string {
return user.name ?? user.email ?? user.id;
}
@@ -112,6 +112,30 @@ function avatarColor(userId: string): string {
return `hsl(${(hash >>> 0) % 360} 48% 42%)`;
}
export type ViewerAvatarVariant = "session" | "footer" | "profile";
class ViewerAvatar extends OpenClawLightDomContentsElement {
@property({ attribute: false }) user: PresenceViewer | null = null;
@property() variant: ViewerAvatarVariant = "session";
override render() {
const user = this.user;
if (!user) {
return nothing;
}
const label = presenceViewerLabel(user);
return html`<span
class="viewer-avatar viewer-avatar--${this.variant}"
data-viewer-id=${user.id}
aria-label=${label}
>
${user.avatarUrl
? html`<img src=${user.avatarUrl} alt="" referrerpolicy="no-referrer" />`
: html`<span style=${`background: ${avatarColor(user.id)}`}>${initialsFor(user)}</span>`}
</span>`;
}
}
class ViewerFacepile extends OpenClawLightDomContentsElement {
@property({ attribute: false }) presencePayload: unknown;
@property({ attribute: false }) selfInstanceId?: string;
@@ -126,7 +150,9 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
? projection.users.filter(
(user) => user.id !== projection.selfUserId && user.watchedSessions.includes(sessionKey),
)
: projection.users;
: this.variant === "footer"
? projection.users.filter((user) => user.id !== projection.selfUserId)
: projection.users;
if (users.length === 0) {
return nothing;
}
@@ -140,13 +166,7 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
${visible.map((user) => {
const label = presenceViewerLabel(user);
return html`<openclaw-tooltip .content=${label}>
<span class="viewer-avatar" data-viewer-id=${user.id} aria-label=${label}>
${user.avatarUrl
? html`<img src=${user.avatarUrl} alt="" referrerpolicy="no-referrer" />`
: html`<span style=${`background: ${avatarColor(user.id)}`}
>${initialsFor(user)}</span
>`}
</span>
<openclaw-viewer-avatar .user=${user} .variant=${this.variant}></openclaw-viewer-avatar>
</openclaw-tooltip>`;
})}
${overflow.length > 0
@@ -162,12 +182,18 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
}
}
if (globalThis.customElements && !customElements.get("openclaw-viewer-facepile")) {
customElements.define("openclaw-viewer-facepile", ViewerFacepile);
if (globalThis.customElements) {
if (!customElements.get("openclaw-viewer-avatar")) {
customElements.define("openclaw-viewer-avatar", ViewerAvatar);
}
if (!customElements.get("openclaw-viewer-facepile")) {
customElements.define("openclaw-viewer-facepile", ViewerFacepile);
}
}
declare global {
interface HTMLElementTagNameMap {
"openclaw-viewer-avatar": ViewerAvatar;
"openclaw-viewer-facepile": ViewerFacepile;
}
}
+20
View File
@@ -2237,6 +2237,26 @@ export const en: TranslationMap = {
toolsEmpty: "No tool runs recorded yet.",
toolRun: "{count} run",
toolRuns: "{count} runs",
identity: {
title: "Identity",
description: "Your profile on this gateway.",
loading: "Loading your identity…",
profileUnavailable: "Your identity profile could not be loaded.",
avatar: "Avatar",
avatarDescription: "PNG, JPEG, or WebP. Images are resized to 256 × 256 or smaller.",
chooseAvatar: "Choose image",
processingAvatar: "Processing…",
displayName: "Display name",
displayNameDescription: "Shown to other people using this gateway.",
linkedEmails: "Linked emails",
linkedEmailsDescription: "Email addresses connected to this profile.",
openSettings: "Open identity settings for {name}",
avatarErrors: {
invalid: "That image could not be processed.",
sourceTooLarge: "Choose an image that is 10 MB or smaller.",
tooLarge: "The processed avatar is larger than 512 KB.",
},
},
},
tasksPage: {
active: "Active",
@@ -228,4 +228,23 @@ describe("findSettingsSearchBlocks", () => {
}),
).toEqual([]);
});
it("only exposes the identity block when the connection has an identity", () => {
const search = (identityAvailable: boolean) =>
findSettingsSearchBlocks({
query: "avatar",
schema: null,
value: null,
uiHints: {},
identityAvailable,
});
expect(search(false)).toEqual([]);
expect(search(true)).toEqual([
expect.objectContaining({
routeId: "profile",
hash: "#settings-profile-identity",
}),
]);
});
});
+12 -11
View File
@@ -86,18 +86,16 @@ const GENERAL_SETTINGS_BLOCKS = {
},
personal: {
routeId: "profile",
labelKey: "quickSettings.personal.title",
labelKey: "profilePage.identity.title",
hash: `#${PROFILE_SETTINGS_TARGET_IDS.identity}`,
searchKeys: [
"quickSettings.personal.user",
"quickSettings.personal.assistant",
"quickSettings.personal.localIdentity",
"quickSettings.personal.assistantIdentity",
"quickSettings.personal.avatarText",
"quickSettings.personal.chooseImage",
"quickSettings.personal.browserOnly",
"profilePage.identity.description",
"profilePage.identity.avatar",
"profilePage.identity.chooseAvatar",
"profilePage.identity.displayName",
"profilePage.identity.linkedEmails",
],
aliases: "avatar image",
aliases: "profile avatar image email",
},
} as const satisfies Record<string, StaticSettingsBlockDescriptor>;
@@ -271,6 +269,7 @@ export function findSettingsSearchBlocks(params: {
schema: unknown;
value: Record<string, unknown> | null;
uiHints: ConfigUiHints;
identityAvailable?: boolean;
}): SettingsSearchBlock[] {
if (!params.query.trim()) {
return [];
@@ -278,9 +277,11 @@ export function findSettingsSearchBlocks(params: {
const criteria = parseConfigSearchQuery(params.query);
const matches: SettingsSearchBlock[] =
criteria.tags.length === 0 && criteria.text
? STATIC_SETTINGS_BLOCKS.map(resolveStaticSettingsBlock).filter((block) =>
settingsSearchTextMatches(block.searchText, criteria.text),
? STATIC_SETTINGS_BLOCKS.filter(
(block) => params.identityAvailable || block !== GENERAL_SETTINGS_BLOCKS.personal,
)
.map(resolveStaticSettingsBlock)
.filter((block) => settingsSearchTextMatches(block.searchText, criteria.text))
: [];
const schema =
params.schema && typeof params.schema === "object" && !Array.isArray(params.schema)
@@ -0,0 +1,105 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import { processProfileAvatar, ProfileAvatarError } from "./avatar-processing.ts";
describe("profile avatar processing", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("rejects unreasonable source files before browser image decoding", async () => {
await expect(
processProfileAvatar(
new File([new Uint8Array(10 * 1024 * 1024 + 1)], "avatar.png", {
type: "image/png",
}),
),
).rejects.toMatchObject({ code: "source-too-large" } satisfies Partial<ProfileAvatarError>);
});
it("enforces the encoded avatar hard cap through the upload surface", async () => {
class StubImage {
decoding = "auto";
src = "";
naturalWidth = 256;
naturalHeight = 256;
decode = vi.fn(async () => undefined);
}
vi.stubGlobal("URL", {
createObjectURL: () => "blob:avatar",
revokeObjectURL: () => undefined,
});
vi.stubGlobal("Image", StubImage);
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
drawImage: vi.fn(),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation((callback, type) => {
callback(new Blob([new Uint8Array(512 * 1024 + 1)], { type: type ?? "image/png" }));
});
await expect(
processProfileAvatar(new File(["source"], "avatar.png", { type: "image/png" })),
).rejects.toMatchObject({ code: "too-large" } satisfies Partial<ProfileAvatarError>);
});
it("does not upscale smaller uploads through the upload surface", async () => {
class StubImage {
decoding = "auto";
src = "";
naturalWidth = 80;
naturalHeight = 60;
decode = vi.fn(async () => undefined);
}
vi.stubGlobal("URL", {
createObjectURL: () => "blob:avatar",
revokeObjectURL: () => undefined,
});
vi.stubGlobal("Image", StubImage);
const drawImage = vi.fn();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
drawImage,
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation((callback, type) => {
callback(new Blob([new Uint8Array([1])], { type: type ?? "image/png" }));
});
await processProfileAvatar(new File(["source"], "avatar.png", { type: "image/png" }));
expect(drawImage).toHaveBeenCalledWith(expect.any(StubImage), 0, 0, 80, 60);
});
it("decodes, downsizes, and encodes an uploaded image before the RPC payload", async () => {
const createObjectURL = vi.fn(() => "blob:avatar");
const revokeObjectURL = vi.fn();
class StubUrl extends URL {
static override createObjectURL = createObjectURL;
static override revokeObjectURL = revokeObjectURL;
}
class StubImage {
decoding = "auto";
src = "";
naturalWidth = 1024;
naturalHeight = 512;
decode = vi.fn(async () => undefined);
}
vi.stubGlobal("URL", StubUrl);
vi.stubGlobal("Image", StubImage);
const drawImage = vi.fn();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
drawImage,
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation((callback, type) => {
callback(new Blob([new Uint8Array([1, 2, 3])], { type: type ?? "image/png" }));
});
const result = await processProfileAvatar(
new File(["source"], "avatar.jpg", { type: "image/jpeg" }),
);
expect(drawImage).toHaveBeenCalledWith(expect.any(StubImage), 0, 0, 256, 128);
expect(result).toEqual({ mime: "image/png", avatarBase64: "AQID", byteLength: 3 });
expect(revokeObjectURL).toHaveBeenCalledWith("blob:avatar");
});
});
+102
View File
@@ -0,0 +1,102 @@
const MAX_PROFILE_AVATAR_EDGE = 256;
const MAX_PROFILE_AVATAR_BYTES = 512 * 1024;
const MAX_PROFILE_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
type ProcessedProfileAvatar = {
mime: "image/png" | "image/webp";
avatarBase64: string;
byteLength: number;
};
export class ProfileAvatarError extends Error {
constructor(readonly code: "invalid-image" | "source-too-large" | "too-large") {
super(code);
this.name = "ProfileAvatarError";
}
}
function fitAvatarDimensions(width: number, height: number) {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
throw new ProfileAvatarError("invalid-image");
}
const scale = Math.min(1, MAX_PROFILE_AVATAR_EDGE / Math.max(width, height));
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}
async function loadImage(file: File): Promise<HTMLImageElement> {
const objectUrl = URL.createObjectURL(file);
try {
const image = new Image();
image.decoding = "async";
image.src = objectUrl;
await image.decode();
return image;
} catch {
throw new ProfileAvatarError("invalid-image");
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function canvasBlob(
canvas: HTMLCanvasElement,
mime: ProcessedProfileAvatar["mime"],
quality?: number,
): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob(resolve, mime, quality);
});
}
function bytesToBase64(bytes: Uint8Array): string {
const chunks: string[] = [];
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)));
}
return btoa(chunks.join(""));
}
async function encodeAvatarBlob(
blob: Blob,
mime: ProcessedProfileAvatar["mime"],
): Promise<ProcessedProfileAvatar> {
if (blob.size > MAX_PROFILE_AVATAR_BYTES) {
throw new ProfileAvatarError("too-large");
}
const bytes = new Uint8Array(await blob.arrayBuffer());
return { mime, avatarBase64: bytesToBase64(bytes), byteLength: bytes.byteLength };
}
export async function processProfileAvatar(file: File): Promise<ProcessedProfileAvatar> {
if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) {
throw new ProfileAvatarError("invalid-image");
}
if (file.size > MAX_PROFILE_AVATAR_SOURCE_BYTES) {
throw new ProfileAvatarError("source-too-large");
}
const image = await loadImage(file);
const dimensions = fitAvatarDimensions(image.naturalWidth, image.naturalHeight);
const canvas = document.createElement("canvas");
canvas.width = dimensions.width;
canvas.height = dimensions.height;
const context = canvas.getContext("2d");
if (!context) {
throw new ProfileAvatarError("invalid-image");
}
context.drawImage(image, 0, 0, dimensions.width, dimensions.height);
const preferredMime = file.type === "image/webp" ? "image/webp" : "image/png";
let mime: ProcessedProfileAvatar["mime"] = preferredMime;
let blob = await canvasBlob(canvas, mime, mime === "image/webp" ? 0.9 : undefined);
if (!blob || blob.type !== mime || blob.size > MAX_PROFILE_AVATAR_BYTES) {
mime = "image/webp";
blob = await canvasBlob(canvas, mime, 0.82);
}
if (!blob || blob.type !== mime) {
throw new ProfileAvatarError("invalid-image");
}
return encodeAvatarBlob(blob, mime);
}
+63 -198
View File
@@ -1,239 +1,104 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { UserProfile } from "../../../../packages/gateway-protocol/src/index.ts";
import { renderIdentitySection } from "./identity-section.ts";
type IdentitySectionProps = Parameters<typeof renderIdentitySection>[0];
const PROFILE: UserProfile = {
id: "profile-1",
displayName: "Ada Lovelace",
avatarMime: "image/png",
mergedInto: null,
createdAt: 1,
updatedAt: 2,
emails: ["ada@example.test", "ada@work.test"],
hasAvatar: true,
};
function createProps(overrides: Partial<IdentitySectionProps> = {}): IdentitySectionProps {
return {
userAvatar: null,
onUserAvatarChange: vi.fn(),
assistantName: "OpenClaw",
assistantAvatar: null,
assistantAvatarUrl: null,
assistantAvatarSource: null,
assistantAvatarStatus: null,
assistantAvatarReason: null,
basePath: "",
profile: PROFILE,
avatarUrl: "/api/users/profile-1/avatar?v=2",
displayName: "Ada Lovelace",
busy: null,
error: null,
onDisplayNameInput: vi.fn(),
onSaveDisplayName: vi.fn(),
onAvatarSelect: vi.fn(),
...overrides,
};
}
function expectAssistantAvatarSource(container: Element): { label: string; source: string } {
const source = container.querySelector(".config-identity--assistant .config-identity__source");
return {
label: source?.querySelector("span")?.textContent?.trim() ?? "",
source: source?.querySelector("code")?.textContent?.trim() ?? "",
};
}
describe("renderIdentitySection", () => {
it("keeps the local user name fixed and shows the assistant identity", () => {
const container = document.createElement("div");
render(
renderIdentitySection(
createProps({
assistantName: "Nova",
assistantAvatar: "assets/avatars/nova-portrait.png",
assistantAvatarUrl: "blob:nova",
}),
),
container,
);
const titles = Array.from(container.querySelectorAll(".config-identity__title")).map((node) =>
node.textContent?.trim(),
);
expect(titles).toEqual(["You", "Nova"]);
expect(container.querySelector('input[placeholder="You"]')).toBeNull();
expect(
container
.querySelector(".config-identity--assistant .config-identity__avatar")
?.getAttribute("src"),
).toBe("blob:nova");
afterEach(() => {
document.body.replaceChildren();
});
it("anchors the section on the stable settings-search target id", () => {
it("renders the resolved profile through settings rows and the shared avatar", async () => {
const container = document.createElement("div");
document.body.append(container);
render(renderIdentitySection(createProps()), container);
const avatar = container.querySelector<HTMLElement>("openclaw-viewer-avatar");
await (avatar as (HTMLElement & { updateComplete?: Promise<unknown> }) | null)?.updateComplete;
expect(container.querySelector("#settings-profile-identity")).not.toBeNull();
});
it("falls back to the built-in logo when the assistant avatar request fails", () => {
const container = document.createElement("div");
render(
renderIdentitySection(
createProps({
assistantName: "Nova",
assistantAvatar: "assets/avatars/nova-portrait.png",
assistantAvatarUrl: "/openclaw/avatar/main",
assistantAvatarStatus: "local",
basePath: "/openclaw",
}),
),
container,
);
const avatar = container.querySelector<HTMLImageElement>(
".config-identity--assistant .config-identity__avatar",
);
expect(avatar?.getAttribute("src")).toBe("/openclaw/avatar/main");
avatar?.dispatchEvent(new Event("error"));
expect(avatar?.getAttribute("src")).toBe("/openclaw/apple-touch-icon.png");
expect(avatar?.classList.contains("config-identity__avatar--fallback")).toBe(true);
avatar?.dispatchEvent(new Event("error"));
expect(avatar?.getAttribute("src")).toBe("/openclaw/apple-touch-icon.png");
});
it("clears the fallback class after a rerendered assistant avatar loads", () => {
const container = document.createElement("div");
render(
renderIdentitySection(
createProps({
assistantName: "Nova",
assistantAvatar: "/avatar/main",
assistantAvatarUrl: "/avatar/main",
assistantAvatarStatus: "local",
}),
),
container,
);
const avatar = container.querySelector<HTMLImageElement>(
".config-identity--assistant .config-identity__avatar",
);
avatar?.dispatchEvent(new Event("error"));
expect(avatar?.classList.contains("config-identity__avatar--fallback")).toBe(true);
render(
renderIdentitySection(
createProps({
assistantName: "Nova",
assistantAvatar: "/avatar/recovered",
assistantAvatarUrl: "/avatar/recovered",
assistantAvatarStatus: "local",
}),
),
container,
);
const recoveredAvatar = container.querySelector<HTMLImageElement>(
".config-identity--assistant .config-identity__avatar",
);
expect(recoveredAvatar).toBe(avatar);
expect(recoveredAvatar?.getAttribute("src")).toBe("/avatar/recovered");
recoveredAvatar?.dispatchEvent(new Event("load"));
expect(recoveredAvatar?.classList.contains("config-identity__avatar--fallback")).toBe(false);
});
it("shows the configured avatar source when the assistant falls back to the logo", () => {
const container = document.createElement("div");
render(
renderIdentitySection(
createProps({
assistantName: "Nova",
assistantAvatar: "/avatar/main",
assistantAvatarUrl: null,
assistantAvatarSource: "assets/avatars/nova-portrait.png",
assistantAvatarStatus: "none",
assistantAvatarReason: "missing",
}),
),
container,
);
expect(
container
.querySelector(".config-identity--assistant .config-identity__avatar")
?.getAttribute("src"),
).toBe("/apple-touch-icon.png");
expect(expectAssistantAvatarSource(container)).toEqual({
label: "Configured avatar",
source: "assets/avatars/nova-portrait.png",
});
expect(container.querySelector(".config-identity__issue")?.textContent?.trim()).toBe(
"File not found",
[...container.querySelectorAll(".settings-row__title")].map((node) =>
node.textContent?.trim(),
),
).toEqual(["Avatar", "Display name", "Linked emails"]);
expect(avatar?.querySelector("img")?.getAttribute("src")).toBe(
"/api/users/profile-1/avatar?v=2",
);
expect(container.textContent).toContain("ada@example.test, ada@work.test");
});
it("keeps a bounded avatar source free of lone surrogates", () => {
it("edits and saves the display name with the standard input pattern", () => {
const onDisplayNameInput = vi.fn();
const onSaveDisplayName = vi.fn();
const container = document.createElement("div");
const source = `${"a".repeat(33)}😀${"m".repeat(20)}😀${"b".repeat(23)}`;
render(
renderIdentitySection(
createProps({
assistantAvatar: "/avatar/main",
assistantAvatarUrl: null,
assistantAvatarSource: source,
assistantAvatarStatus: "none",
}),
createProps({ displayName: "Ada", onDisplayNameInput, onSaveDisplayName }),
),
container,
);
expect(expectAssistantAvatarSource(container).source).toBe(
`${"a".repeat(33)}...${"b".repeat(23)}`,
);
const input = container.querySelector<HTMLInputElement>('.settings-input[type="text"]');
expect(input?.value).toBe("Ada");
input!.value = "Augusta Ada";
input!.dispatchEvent(new Event("input", { bubbles: true }));
container
.querySelector<HTMLFormElement>(".identity-name-control")
?.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
expect(onDisplayNameInput).toHaveBeenCalledWith("Augusta Ada");
expect(onSaveDisplayName).toHaveBeenCalledOnce();
});
it("keeps a malformed data-image header free of lone surrogates", () => {
it("forwards an allowlisted avatar file and resets the picker", () => {
const onAvatarSelect = vi.fn();
const container = document.createElement("div");
const source = `data:image/${"a".repeat(20)}😀tail`;
render(renderIdentitySection(createProps({ onAvatarSelect })), container);
render(
renderIdentitySection(
createProps({
assistantAvatar: "/avatar/main",
assistantAvatarUrl: null,
assistantAvatarSource: source,
assistantAvatarStatus: "none",
}),
),
container,
);
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
const file = new File(["avatar"], "avatar.webp", { type: "image/webp" });
Object.defineProperty(input, "files", { configurable: true, value: [file] });
input?.dispatchEvent(new Event("change", { bubbles: true }));
expect(expectAssistantAvatarSource(container).source).toBe(`data:image/${"a".repeat(20)},...`);
expect(input?.accept).toBe("image/png,image/jpeg,image/webp");
expect(input?.value).toBe("");
expect(onAvatarSelect).toHaveBeenCalledWith(file);
});
it("rejects oversized avatar uploads before reading them", () => {
const onUserAvatarChange = vi.fn();
const fileReader = vi.fn();
vi.stubGlobal("FileReader", fileReader);
it("reports mutation errors without inventing another settings surface", () => {
const container = document.createElement("div");
render(renderIdentitySection(createProps({ error: "Save failed" })), container);
try {
const container = document.createElement("div");
render(renderIdentitySection(createProps({ onUserAvatarChange })), container);
const input = Array.from(container.querySelectorAll('input[type="file"]')).find(
(node) => !node.closest(".config-identity--assistant"),
);
if (!(input instanceof HTMLInputElement)) {
throw new Error("Expected user avatar file input");
}
const file = new File([new Uint8Array(1_500_001)], "avatar.png", { type: "image/png" });
Object.defineProperty(input, "files", { configurable: true, value: [file] });
input.dispatchEvent(new Event("change"));
expect(fileReader).not.toHaveBeenCalled();
expect(onUserAvatarChange).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
expect(container.querySelector('[role="alert"]')?.textContent?.trim()).toBe("Save failed");
expect(container.querySelectorAll(".settings-group")).toHaveLength(1);
});
});
+98 -266
View File
@@ -1,287 +1,119 @@
// Profile identity section: local user avatar plus the assistant's configured
// avatar. Moved from the General settings page so identity has one home.
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { html, nothing } from "lit";
import type { UserProfile } from "../../../../packages/gateway-protocol/src/index.ts";
import {
normalizeLocalUserIdentity,
resolveLocalUserAvatarText,
resolveLocalUserAvatarUrl,
} from "../../app/user-identity.ts";
import { renderSettingsSection, renderSettingsStatus } from "../../components/settings-ui.ts";
renderSettingsRow,
renderSettingsSection,
renderSettingsValue,
} from "../../components/settings-ui.ts";
import type { PresenceViewer } from "../../components/viewer-facepile.ts";
import "../../components/viewer-facepile.ts";
import { t } from "../../i18n/index.ts";
import {
assistantAvatarFallbackUrl,
resolveAssistantTextAvatar,
resolveChatAvatarRenderUrl,
} from "../../lib/avatar.ts";
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
import { PROFILE_SETTINGS_TARGET_IDS } from "../config/settings-targets.ts";
type IdentitySectionProps = {
userAvatar: string | null;
onUserAvatarChange: (next: string | null) => void;
assistantName: string | null;
assistantAvatar: string | null;
assistantAvatarUrl: string | null;
assistantAvatarSource: string | null;
assistantAvatarStatus: "none" | "local" | "remote" | "data" | null;
assistantAvatarReason: string | null;
basePath: string;
profile: UserProfile;
avatarUrl: string | null;
displayName: string;
busy: "display-name" | "avatar" | "loading" | null;
error: string | null;
onDisplayNameInput: (value: string) => void;
onSaveDisplayName: () => void;
onAvatarSelect: (file: File) => void;
};
// Keep raw uploads comfortably below the 2 MB persisted data URL limit after
// base64 expansion and a small MIME/header prefix are added.
const MAX_LOCAL_USER_AVATAR_FILE_BYTES = 1_500_000;
function renderDefaultUserAvatar() {
return html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="8" r="4" />
<path d="M20 21a8 8 0 1 0-16 0" />
</svg>
`;
}
function renderLocalUserAvatarPreview(avatar: string | null | undefined) {
const identity = normalizeLocalUserIdentity({ name: null, avatar });
const avatarUrl = resolveLocalUserAvatarUrl(identity);
const avatarText = resolveLocalUserAvatarText(identity);
const userLabel = t("quickSettings.personal.you");
if (avatarUrl) {
return html`<img class="config-identity__avatar" src=${avatarUrl} alt=${userLabel} />`;
}
if (avatarText) {
return html`<div
class="config-identity__avatar config-identity__avatar--text"
aria-label=${userLabel}
>
${avatarText}
</div>`;
}
return html`
<div class="config-identity__avatar config-identity__avatar--default" aria-label=${userLabel}>
${renderDefaultUserAvatar()}
</div>
`;
}
function resolveAssistantPreviewAvatarUrl(props: IdentitySectionProps): string | null {
if (props.assistantAvatarStatus === "none" && props.assistantAvatarReason === "missing") {
return null;
}
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
});
}
function formatAssistantAvatarSource(value: string | null | undefined): string | null {
const source = normalizeOptionalString(value);
if (!source) {
return null;
}
if (/^data:image\//i.test(source)) {
const commaIndex = source.indexOf(",");
const header = sliceUtf16Safe(source, 0, commaIndex > 0 ? commaIndex : 32);
return `${header},...`;
}
return source.length > 72
? `${sliceUtf16Safe(source, 0, 34)}...${sliceUtf16Safe(source, -24)}`
: source;
}
function formatAssistantAvatarIssue(
status: IdentitySectionProps["assistantAvatarStatus"],
reason: string | null | undefined,
): string | null {
if (status === "remote") {
return t("quickSettings.personal.avatarIssues.remoteBlocked");
}
if (reason === "missing") {
return t("quickSettings.personal.avatarIssues.missing");
}
if (reason === "unsupported_extension") {
return t("quickSettings.personal.avatarIssues.unsupported");
}
if (reason === "outside_workspace") {
return t("quickSettings.personal.avatarIssues.outsideWorkspace");
}
if (reason === "too_large") {
return t("quickSettings.personal.avatarIssues.tooLarge");
}
return reason ? t("quickSettings.personal.avatarIssues.cannotRender") : null;
}
function handleAssistantAvatarPreviewError(event: Event, props: IdentitySectionProps) {
const image = event.currentTarget;
if (!(image instanceof HTMLImageElement)) {
return;
}
const fallbackUrl = assistantAvatarFallbackUrl(props.basePath);
if (image.getAttribute("src") === fallbackUrl) {
return;
}
image.src = fallbackUrl;
image.classList.add("config-identity__avatar--fallback");
}
function handleAssistantAvatarPreviewLoad(event: Event, props: IdentitySectionProps) {
const image = event.currentTarget;
if (!(image instanceof HTMLImageElement)) {
return;
}
// Lit reuses this image across URL rerenders, including classes added after an earlier failure.
if (image.getAttribute("src") !== assistantAvatarFallbackUrl(props.basePath)) {
image.classList.remove("config-identity__avatar--fallback");
}
}
function renderAssistantAvatarPreview(props: IdentitySectionProps) {
const assistantName =
normalizeOptionalString(props.assistantName) ?? t("quickSettings.personal.assistant");
const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props);
if (assistantAvatarUrl) {
return html`<img
class="config-identity__avatar"
src=${assistantAvatarUrl}
alt=${assistantName}
@error=${(event: Event) => handleAssistantAvatarPreviewError(event, props)}
@load=${(event: Event) => handleAssistantAvatarPreviewLoad(event, props)}
/>`;
}
const assistantAvatarText = resolveAssistantTextAvatar(props.assistantAvatar);
if (assistantAvatarText) {
return html`<div
class="config-identity__avatar config-identity__avatar--text"
aria-label=${assistantName}
>
${assistantAvatarText}
</div>`;
}
return html`
<img
class="config-identity__avatar config-identity__avatar--fallback"
src=${assistantAvatarFallbackUrl(props.basePath)}
alt=${assistantName}
/>
`;
}
function handleLocalUserAvatarFileSelect(e: Event, props: IdentitySectionProps) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !file.type.startsWith("image/") || file.size > MAX_LOCAL_USER_AVATAR_FILE_BYTES) {
input.value = "";
return;
}
const reader = new FileReader();
reader.addEventListener("load", () => {
props.onUserAvatarChange(typeof reader.result === "string" ? reader.result : null);
});
reader.readAsDataURL(file);
input.value = "";
function avatarViewer(profile: UserProfile, avatarUrl: string | null): PresenceViewer {
return {
id: profile.id,
name: profile.displayName ?? undefined,
email: profile.emails[0],
avatarUrl: avatarUrl ?? undefined,
watchedSessions: [],
};
}
export function renderIdentitySection(props: IdentitySectionProps) {
const identity = normalizeLocalUserIdentity({ name: null, avatar: props.userAvatar });
const avatarText = resolveLocalUserAvatarText(identity) ?? "";
const assistantName =
normalizeOptionalString(props.assistantName) ?? t("quickSettings.personal.assistant");
const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props);
const assistantAvatarRendered = Boolean(
assistantAvatarUrl || resolveAssistantTextAvatar(props.assistantAvatar),
);
const assistantAvatarSource = formatAssistantAvatarSource(props.assistantAvatarSource);
const assistantAvatarIssue = formatAssistantAvatarIssue(
props.assistantAvatarStatus,
props.assistantAvatarReason,
);
const assistantAvatarSubtitle = assistantAvatarIssue
? t("quickSettings.personal.fallbackAvatar")
: assistantAvatarRendered
? t("quickSettings.personal.configuredAvatar")
: t("quickSettings.personal.fallbackLogo");
// Escape hatch: identity blocks lead with an avatar preview, which the
// standard row anatomy (text left, one control right) cannot express.
const savedName = props.profile.displayName ?? "";
const nameChanged = props.displayName.trim() !== savedName;
const emails = props.profile.emails.join(", ");
return html`<div id=${PROFILE_SETTINGS_TARGET_IDS.identity}>
${renderSettingsSection(
{ title: t("quickSettings.personal.title") },
{
title: t("profilePage.identity.title"),
description: t("profilePage.identity.description"),
},
html`
<section class="config-identity" aria-label=${t("quickSettings.personal.localIdentity")}>
${renderLocalUserAvatarPreview(props.userAvatar)}
<div class="config-identity__copy">
<div class="config-identity__eyebrow">${t("quickSettings.personal.user")}</div>
<div class="config-identity__title">${t("quickSettings.personal.you")}</div>
<div class="config-identity__repair">
<label class="config-identity__field">
<span class="config-identity__field-label">
${t("quickSettings.personal.avatarText")}
</span>
${renderSettingsRow({
title: t("profilePage.identity.avatar"),
description: t("profilePage.identity.avatarDescription"),
control: html`
<span class="identity-avatar-control">
<openclaw-viewer-avatar
.user=${avatarViewer(props.profile, props.avatarUrl)}
variant="profile"
></openclaw-viewer-avatar>
<label class="btn btn--sm">
${props.busy === "avatar"
? t("profilePage.identity.processingAvatar")
: t("profilePage.identity.chooseAvatar")}
<input
class="settings-input"
type="text"
maxlength="16"
.value=${avatarText}
placeholder=${t("quickSettings.personal.avatarPlaceholder")}
@input=${(e: Event) => {
const value = (e.target as HTMLInputElement).value;
props.onUserAvatarChange(value.trim() ? value : null);
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
?disabled=${props.busy !== null}
@change=${(event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (file) {
props.onAvatarSelect(file);
}
}}
/>
</label>
<div class="config-identity__actions">
<label class="btn btn--sm">
${t("quickSettings.personal.chooseImage")}
<input
type="file"
accept="image/*"
hidden
@change=${(e: Event) => handleLocalUserAvatarFileSelect(e, props)}
/>
</label>
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${!identity.avatar}
@click=${() => {
props.onUserAvatarChange(null);
}}
>
${t("quickSettings.personal.clearAvatar")}
</button>
</div>
<div class="config-identity__hint muted">
${t("quickSettings.personal.browserOnly")}
</div>
</div>
</div>
</section>
<section
class="config-identity config-identity--assistant"
aria-label=${t("quickSettings.personal.assistantIdentity")}
>
${renderAssistantAvatarPreview(props)}
<div class="config-identity__copy">
<div class="config-identity__eyebrow">${t("quickSettings.personal.assistant")}</div>
<div class="config-identity__title">${assistantName}</div>
<div class="config-identity__sub">${assistantAvatarSubtitle}</div>
${assistantAvatarSource
? html`
<div class="config-identity__source" title=${props.assistantAvatarSource ?? ""}>
<span>${t("quickSettings.personal.configuredAvatar")}</span>
<code>${assistantAvatarSource}</code>
</div>
`
: nothing}
${assistantAvatarIssue
? html`<div class="config-identity__issue">
${renderSettingsStatus({ kind: "warn", label: assistantAvatarIssue })}
</div>`
: nothing}
</div>
</section>
</span>
`,
})}
${renderSettingsRow({
title: t("profilePage.identity.displayName"),
description: t("profilePage.identity.displayNameDescription"),
control: html`
<form
class="identity-name-control"
@submit=${(event: SubmitEvent) => {
event.preventDefault();
props.onSaveDisplayName();
}}
>
<input
class="settings-input"
type="text"
maxlength="256"
aria-label=${t("profilePage.identity.displayName")}
.value=${props.displayName}
?disabled=${props.busy !== null}
@input=${(event: Event) =>
props.onDisplayNameInput((event.currentTarget as HTMLInputElement).value)}
/>
<button
type="submit"
class="btn btn--sm"
?disabled=${props.busy !== null || !nameChanged}
>
${props.busy === "display-name" ? t("common.saving") : t("common.save")}
</button>
</form>
`,
})}
${renderSettingsRow({
title: t("profilePage.identity.linkedEmails"),
description: t("profilePage.identity.linkedEmailsDescription"),
control: emails ? renderSettingsValue(emails) : nothing,
})}
${props.error
? html`<div class="settings-row identity-error" role="alert">
<span class="settings-row__desc">${props.error}</span>
</div>`
: nothing}
`,
)}
</div>`;
+183 -1
View File
@@ -1,10 +1,12 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { UserProfile } from "../../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { CostUsageSummary, SessionsUsageResult } from "../../api/types.ts";
import type { RouteId } from "../../app-route-paths.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import type { AuthenticatedUser } from "../../app/user-profile.ts";
import { i18n, t } from "../../i18n/index.ts";
import { createApplicationContextProvider } from "../../test-helpers/application-context.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
@@ -85,7 +87,10 @@ function createSessionsResult(): SessionsUsageResult {
};
}
function createConnectedContext(request: GatewayBrowserClient["request"]) {
function createConnectedContext(
request: GatewayBrowserClient["request"],
selfUser: AuthenticatedUser | null = null,
) {
let snapshot: ApplicationGatewaySnapshot = {
client: { request } as GatewayBrowserClient,
connected: true,
@@ -95,6 +100,7 @@ function createConnectedContext(request: GatewayBrowserClient["request"]) {
sessionKey: "agent:main:main",
lastError: null,
lastErrorCode: null,
selfUser,
};
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const subscribe = () => () => undefined;
@@ -103,10 +109,25 @@ function createConnectedContext(request: GatewayBrowserClient["request"]) {
get snapshot() {
return snapshot;
},
connection: {
gatewayUrl: window.location.origin.replace(/^http/u, "ws"),
token: "",
bootstrapToken: "",
password: "",
},
subscribe(listener: (next: ApplicationGatewaySnapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
updateSelfUser(patch: Partial<Omit<AuthenticatedUser, "id">>) {
if (!snapshot.selfUser) {
return;
}
snapshot = { ...snapshot, selfUser: { ...snapshot.selfUser, ...patch } };
for (const listener of listeners) {
listener(snapshot);
}
},
},
agents: {
state: { agentsList: null },
@@ -150,6 +171,7 @@ beforeEach(async () => {
afterEach(async () => {
document.body.replaceChildren();
vi.restoreAllMocks();
vi.unstubAllGlobals();
await i18n.setLocale("en");
});
@@ -267,3 +289,163 @@ it("gates profile usage refreshes by payload age and page visibility", async ()
window.dispatchEvent(new Event("focus"));
await waitForFast(() => expect(request).toHaveBeenCalledTimes(8));
});
it("keeps identity UI and profile RPCs absent for unidentified connections", async () => {
const request = vi.fn(async (method: string) =>
method === "usage.cost" ? createCostSummary() : createSessionsResult(),
);
const harness = createConnectedContext(request as GatewayBrowserClient["request"]);
const provider = createApplicationContextProvider(harness.context);
const page = document.createElement(PROFILE_PAGE_TEST_TAG) as ProfilePageElement;
provider.append(page);
document.body.append(provider);
await page.updateComplete;
await Promise.resolve();
expect(request.mock.calls.some(([method]) => method === "users.self")).toBe(false);
expect(page.querySelector("#settings-profile-identity")).toBeNull();
});
it("bootstraps and refreshes the connected user's profile through users.self", async () => {
let profile: UserProfile = {
id: "profile-1",
displayName: "Ada",
avatarMime: null,
mergedInto: null,
createdAt: 1,
updatedAt: 2,
emails: ["ada@example.test", "ada@work.test"],
hasAvatar: false,
};
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "usage.cost") {
return createCostSummary();
}
if (method === "sessions.usage") {
return createSessionsResult();
}
if (method === "users.self") {
return { profile };
}
if (method === "users.setDisplayName") {
expect(params).toEqual({ profileId: "profile-1", displayName: "Augusta Ada" });
profile = { ...profile, displayName: "Augusta Ada", updatedAt: 3 };
return { profile };
}
if (method === "users.setAvatar") {
profile = {
...profile,
displayName: "Augusta Ada",
avatarMime: "image/png",
hasAvatar: true,
updatedAt: 4,
};
return { profile };
}
throw new Error(`unexpected method: ${method}`);
});
const harness = createConnectedContext(request as GatewayBrowserClient["request"], {
id: "profile-1",
email: "ada@example.test",
name: "Ada",
});
const provider = createApplicationContextProvider(harness.context);
const page = document.createElement(PROFILE_PAGE_TEST_TAG) as ProfilePageElement;
provider.append(page);
document.body.append(provider);
await waitForFast(() => expect(page.querySelector("#settings-profile-identity")).not.toBeNull());
const identityState = page as unknown as {
selfUser: AuthenticatedUser | null;
ownProfile: UserProfile | null;
};
expect(identityState.selfUser?.id).toBe(profile.id);
expect(identityState.ownProfile?.id).toBe(profile.id);
expect(page.textContent).toContain("ada@example.test, ada@work.test");
expect(request.mock.calls.some(([method]) => method === "users.list")).toBe(false);
const input = page.querySelector<HTMLInputElement>('.identity-name-control input[type="text"]');
input!.value = "Augusta Ada";
input!.dispatchEvent(new Event("input", { bubbles: true }));
await page.updateComplete;
page.querySelector<HTMLButtonElement>('.identity-name-control button[type="submit"]')?.click();
await waitForFast(() =>
expect(request.mock.calls.some(([method]) => method === "users.setDisplayName")).toBe(true),
);
await waitForFast(() =>
expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(2),
);
await page.updateComplete;
expect(page.querySelector<HTMLInputElement>(".identity-name-control input")?.value).toBe(
"Augusta Ada",
);
expect(harness.context.gateway.snapshot.selfUser?.name).toBe("Augusta Ada");
const displayNameInput = page.querySelector<HTMLInputElement>(".identity-name-control input")!;
displayNameInput.value = "Unsaved draft";
displayNameInput.dispatchEvent(new Event("input", { bubbles: true }));
await page.updateComplete;
class StubUrl extends URL {
static override createObjectURL = vi.fn(() => "blob:avatar");
static override revokeObjectURL = vi.fn();
}
class StubImage {
decoding = "auto";
src = "";
naturalWidth = 512;
naturalHeight = 256;
decode = vi.fn(async () => undefined);
}
vi.stubGlobal("URL", StubUrl);
vi.stubGlobal("Image", StubImage);
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
drawImage: vi.fn(),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation((callback, type) => {
callback(new Blob([new Uint8Array([1, 2, 3])], { type: type ?? "image/png" }));
});
const avatarInput = page.querySelector<HTMLInputElement>('input[type="file"]')!;
Object.defineProperty(avatarInput, "files", {
configurable: true,
value: [new File(["avatar"], "avatar.png", { type: "image/png" })],
});
avatarInput.dispatchEvent(new Event("change", { bubbles: true }));
await waitForFast(() =>
expect(request.mock.calls.some(([method]) => method === "users.setAvatar")).toBe(true),
);
await waitForFast(() =>
expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(3),
);
await page.updateComplete;
expect(harness.context.gateway.snapshot.selfUser?.avatarUrl).toContain(
"/api/users/profile-1/avatar?v=4",
);
expect(page.querySelector<HTMLInputElement>(".identity-name-control input")?.value).toBe(
"Unsaved draft",
);
request.mockClear();
page.querySelector<HTMLButtonElement>(".profile-refresh")?.click();
await waitForFast(() =>
expect(request.mock.calls.some(([method]) => method === "users.self")).toBe(true),
);
await waitForFast(() =>
expect(page.querySelector<HTMLInputElement>(".identity-name-control input")?.disabled).toBe(
false,
),
);
expect(page.querySelector<HTMLInputElement>(".identity-name-control input")?.value).toBe(
"Unsaved draft",
);
const pageWithState = page as ProfilePageElement & {
identityBusy: "display-name" | "avatar" | null;
};
pageWithState.identityBusy = "avatar";
request.mockClear();
page.querySelector<HTMLButtonElement>(".profile-refresh")?.click();
await Promise.resolve();
expect(request.mock.calls.some(([method]) => method === "users.self")).toBe(false);
});
+230 -252
View File
@@ -1,7 +1,12 @@
import "../../styles/config-quick.css";
import { consume } from "@lit/context";
import { html, nothing, svg } from "lit";
import { html, nothing } from "lit";
import { state } from "lit/decorators.js";
import type {
UserProfile,
UsersSelfResult,
UsersSetAvatarResult,
UsersSetDisplayNameResult,
} from "../../../../packages/gateway-protocol/src/index.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { CostUsageSummary, SessionsUsageResult } from "../../api/types.ts";
import { titleForRoute } from "../../app-navigation.ts";
@@ -10,7 +15,8 @@ import {
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { loadLocalUserIdentity, saveLocalUserIdentity } from "../../app/settings.ts";
import type { AuthenticatedUser } from "../../app/user-profile.ts";
import { resolveCurrentSelfUser, userProfileAvatarUrl } from "../../app/user-profile.ts";
import { icons } from "../../components/icons.ts";
import {
renderSettingsEmpty,
@@ -21,49 +27,27 @@ import {
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { resolveAgentAvatarUrl, resolveAssistantTextAvatar } from "../../lib/avatar.ts";
import { formatCost } from "../../lib/format.ts";
import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
} from "../../lib/gateway-errors.ts";
import { buildSessionUsageDateParams, requestSessionsUsage } from "../../lib/sessions/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PROFILE_SETTINGS_TARGET_IDS } from "../config/settings-targets.ts";
import {
decideUsageRefresh,
USAGE_PAYLOAD_TTL_MS,
type UsageRefreshReason,
} from "../usage/refresh-policy.ts";
import "../../styles/profile.css";
import { processProfileAvatar, ProfileAvatarError } from "./avatar-processing.ts";
import { renderIdentitySection } from "./identity-section.ts";
import {
buildHeatmap,
buildInsights,
computeStreaks,
firstActiveDate,
formatLongDuration,
formatTokenScale,
localDateString,
peakDay,
type ProfileHeatmap,
type ProfileInsights,
} from "./stats.ts";
const HEATMAP_CELL = 11;
const HEATMAP_GAP = 3;
const HEATMAP_PITCH = HEATMAP_CELL + HEATMAP_GAP;
const HEATMAP_LEFT = 30;
const HEATMAP_TOP = 18;
// Fixed reference week (2024-01-01 is a Monday) for localized weekday labels.
const WEEKDAY_LABEL_ROWS = [
{ row: 1, utcDay: Date.UTC(2024, 0, 1) },
{ row: 3, utcDay: Date.UTC(2024, 0, 3) },
{ row: 5, utcDay: Date.UTC(2024, 0, 5) },
];
function integerFormat(): Intl.NumberFormat {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
}
renderProfileHeatmap,
renderProfileInsights,
renderProfileStats,
} from "./profile-stat-sections.ts";
import { buildInsights, firstActiveDate, formatTokenScale, type ProfileInsights } from "./stats.ts";
function formatMonthYear(date: string): string {
return new Intl.DateTimeFormat(undefined, {
@@ -73,19 +57,6 @@ function formatMonthYear(date: string): string {
}).format(new Date(`${date}T12:00:00Z`));
}
function formatFullDate(date: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${date}T12:00:00Z`));
}
function streakLabel(days: number): string {
return t(days === 1 ? "profilePage.streakDay" : "profilePage.streakDays", {
count: integerFormat().format(days),
});
}
function toErrorMessage(error: unknown): string {
if (isMissingOperatorReadScopeError(error)) {
return formatMissingOperatorReadScopeMessage("usage");
@@ -96,6 +67,15 @@ function toErrorMessage(error: unknown): string {
return typeof error === "string" ? error : "request failed";
}
function toIdentityErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return typeof error === "string" && error.trim()
? error
: t("profilePage.identity.profileUnavailable");
}
export class ProfilePage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: false })
private context!: ApplicationContext;
@@ -104,11 +84,17 @@ export class ProfilePage extends OpenClawLightDomElement {
@state() private error: string | null = null;
@state() private costSummary: CostUsageSummary | null = null;
@state() private sessionsResult: SessionsUsageResult | null = null;
@state() private userAvatar: string | null = loadLocalUserIdentity().avatar;
@state() private selfUser: AuthenticatedUser | null = null;
@state() private ownProfile: UserProfile | null = null;
@state() private displayName = "";
@state() private identityLoading = false;
@state() private identityBusy: "display-name" | "avatar" | null = null;
@state() private identityError: string | null = null;
private client: GatewayBrowserClient | null = null;
private connected = false;
private requestId = 0;
private identityRequestId = 0;
private refreshTimer: number | null = null;
private lastProfileLoadedAtMs: number | null = null;
private pendingAutomaticProfileRefresh = false;
@@ -141,6 +127,7 @@ export class ProfilePage extends OpenClawLightDomElement {
document.removeEventListener("visibilitychange", this.handlePageActivation);
globalThis.removeEventListener("focus", this.handlePageActivation);
this.requestId += 1;
this.identityRequestId += 1;
this.clearRefreshTimer();
this.pendingAutomaticProfileRefresh = false;
this.profileReloadPending = false;
@@ -152,8 +139,13 @@ export class ProfilePage extends OpenClawLightDomElement {
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) {
const clientChanged = snapshot.client !== this.client;
const becameConnected = snapshot.connected && !this.connected;
const nextSelfUser = snapshot.connected
? resolveCurrentSelfUser({ snapshotUser: snapshot.selfUser })
: null;
const selfProfileChanged = nextSelfUser?.id !== this.selfUser?.id;
this.client = snapshot.client;
this.connected = snapshot.connected;
this.selfUser = nextSelfUser;
if (clientChanged) {
// Never keep one gateway's stats on screen while another gateway loads
// (or fails to load); the render branches key off costSummary presence.
@@ -167,6 +159,14 @@ export class ProfilePage extends OpenClawLightDomElement {
this.sessionsResult = null;
this.error = null;
}
if (clientChanged || selfProfileChanged) {
this.identityRequestId += 1;
this.ownProfile = null;
this.displayName = "";
this.identityLoading = false;
this.identityBusy = null;
this.identityError = null;
}
if (!snapshot.connected || !snapshot.client) {
this.profileReloadPending ||= this.loading;
this.requestId += 1;
@@ -174,6 +174,9 @@ export class ProfilePage extends OpenClawLightDomElement {
this.loading = false;
return;
}
if (nextSelfUser && (clientChanged || selfProfileChanged)) {
void this.loadIdentity();
}
void this.context.agents.ensureList().then((list) => {
if (list) {
void this.context.agentIdentity.ensure([list.defaultId]);
@@ -294,26 +297,184 @@ export class ProfilePage extends OpenClawLightDomElement {
this.requestProfileRefresh("focus");
}
private setLocalUserAvatar(avatar: string | null) {
const identity = saveLocalUserIdentity({ ...loadLocalUserIdentity(), avatar });
this.userAvatar = identity.avatar;
private async loadIdentity() {
const client = this.client;
if (!client || !this.connected) {
return;
}
const requestId = ++this.identityRequestId;
const currentProfile = this.ownProfile;
const displayNameDraft = this.displayName;
const hasUnsavedDisplayName =
currentProfile !== null && displayNameDraft.trim() !== (currentProfile.displayName ?? "");
this.identityLoading = true;
this.identityError = null;
try {
const result = await client.request<UsersSelfResult>("users.self", {});
if (requestId !== this.identityRequestId) {
return;
}
this.ownProfile = result.profile;
this.displayName = hasUnsavedDisplayName
? displayNameDraft
: (result.profile.displayName ?? "");
} catch (error) {
if (requestId === this.identityRequestId) {
this.identityError = toIdentityErrorMessage(error);
}
} finally {
if (requestId === this.identityRequestId) {
this.identityLoading = false;
}
}
}
private applyOwnProfile(profile: UserProfile) {
this.ownProfile = profile;
this.displayName = profile.displayName ?? "";
}
private async saveDisplayName() {
const client = this.client;
const profile = this.ownProfile;
if (!client || !profile || this.identityBusy || this.identityLoading) {
return;
}
this.identityBusy = "display-name";
this.identityError = null;
const identityRequestId = this.identityRequestId;
let shouldRefresh = false;
try {
const displayName = this.displayName.trim() || null;
const result = await client.request<UsersSetDisplayNameResult>("users.setDisplayName", {
profileId: profile.id,
displayName,
});
if (client !== this.client || identityRequestId !== this.identityRequestId) {
return;
}
this.applyOwnProfile(result.profile);
this.context.gateway.updateSelfUser?.({ name: result.profile.displayName ?? undefined });
shouldRefresh = true;
} catch (error) {
if (client === this.client && identityRequestId === this.identityRequestId) {
this.identityError = toIdentityErrorMessage(error);
}
} finally {
if (identityRequestId === this.identityRequestId && this.identityBusy === "display-name") {
this.identityBusy = null;
}
}
if (shouldRefresh && client === this.client && identityRequestId === this.identityRequestId) {
void this.loadIdentity();
}
}
private async saveAvatar(file: File) {
const client = this.client;
const profile = this.ownProfile;
if (!client || !profile || this.identityBusy || this.identityLoading) {
return;
}
this.identityBusy = "avatar";
this.identityError = null;
const identityRequestId = this.identityRequestId;
const displayNameDraft = this.displayName;
const hasUnsavedDisplayName = displayNameDraft.trim() !== (profile.displayName ?? "");
let shouldRefresh = false;
try {
const avatar = await processProfileAvatar(file);
if (client !== this.client || identityRequestId !== this.identityRequestId) {
return;
}
const result = await client.request<UsersSetAvatarResult>("users.setAvatar", {
profileId: profile.id,
mime: avatar.mime,
avatarBase64: avatar.avatarBase64,
});
if (client !== this.client || identityRequestId !== this.identityRequestId) {
return;
}
this.ownProfile = result.profile;
this.displayName = hasUnsavedDisplayName
? displayNameDraft
: (result.profile.displayName ?? "");
const avatarUrl = userProfileAvatarUrl(
this.context.gateway.connection.gatewayUrl,
result.profile.id,
result.profile.updatedAt,
);
if (avatarUrl) {
this.context.gateway.updateSelfUser?.({ avatarUrl });
}
shouldRefresh = true;
} catch (error) {
if (client === this.client && identityRequestId === this.identityRequestId) {
this.identityError =
error instanceof ProfileAvatarError
? t(
error.code === "too-large"
? "profilePage.identity.avatarErrors.tooLarge"
: error.code === "source-too-large"
? "profilePage.identity.avatarErrors.sourceTooLarge"
: "profilePage.identity.avatarErrors.invalid",
)
: toIdentityErrorMessage(error);
}
} finally {
if (identityRequestId === this.identityRequestId && this.identityBusy === "avatar") {
this.identityBusy = null;
}
}
if (shouldRefresh && client === this.client && identityRequestId === this.identityRequestId) {
void this.loadIdentity();
}
}
private renderIdentity() {
const assistantIdentity = this.context.config.current.assistantIdentity;
if (!this.selfUser) {
return nothing;
}
if (!this.ownProfile) {
return html`<div id=${PROFILE_SETTINGS_TARGET_IDS.identity}>
${renderSettingsSection(
{ title: t("profilePage.identity.title") },
renderSettingsEmpty(
this.identityLoading
? t("profilePage.identity.loading")
: (this.identityError ?? t("profilePage.identity.profileUnavailable")),
),
)}
</div>`;
}
const avatarUrl = this.ownProfile.hasAvatar
? userProfileAvatarUrl(
this.context.gateway.connection.gatewayUrl,
this.ownProfile.id,
this.ownProfile.updatedAt,
)
: null;
return renderIdentitySection({
userAvatar: this.userAvatar,
onUserAvatarChange: (avatar) => this.setLocalUserAvatar(avatar),
assistantName: assistantIdentity.name,
assistantAvatar: assistantIdentity.avatar,
assistantAvatarUrl: assistantIdentity.avatar,
assistantAvatarSource: assistantIdentity.avatarSource,
assistantAvatarStatus: assistantIdentity.avatarStatus,
assistantAvatarReason: assistantIdentity.avatarReason,
basePath: this.context.basePath,
profile: this.ownProfile,
avatarUrl,
displayName: this.displayName,
busy: this.identityLoading ? "loading" : this.identityBusy,
error: this.identityError,
onDisplayNameInput: (value) => {
this.displayName = value;
},
onSaveDisplayName: () => void this.saveDisplayName(),
onAvatarSelect: (file) => void this.saveAvatar(file),
});
}
private refreshManually() {
this.requestProfileRefresh("manual");
if (this.selfUser && !this.identityBusy) {
void this.loadIdentity();
}
}
private featuredAgent() {
const list = this.context.agents.state.agentsList;
const agentId = list?.defaultId ?? "main";
@@ -376,203 +537,19 @@ export class ProfilePage extends OpenClawLightDomElement {
`);
}
private renderStats(insights: ProfileInsights | null) {
const summary = this.costSummary;
if (!summary) {
return nothing;
}
const today = localDateString();
const streaks = computeStreaks(summary.daily, today);
const peak = peakDay(summary.daily);
const cells: Array<{ label: string; value: string; sub?: string }> = [
{
label: t("profilePage.statLifetimeTokens"),
value: formatTokenScale(summary.totals.totalTokens),
sub: summary.totals.totalCost > 0 ? `${formatCost(summary.totals.totalCost)}` : undefined,
},
{
label: t("profilePage.statPeakDay"),
value: formatTokenScale(peak?.totalTokens ?? 0),
sub: peak ? formatFullDate(peak.date) : undefined,
},
{
label: t("profilePage.statLongestSession"),
value:
insights?.longestSessionMs != null ? formatLongDuration(insights.longestSessionMs) : "—",
},
{ label: t("profilePage.statCurrentStreak"), value: streakLabel(streaks.current) },
{ label: t("profilePage.statLongestStreak"), value: streakLabel(streaks.longest) },
];
return renderSettingsGroup(html`
<section class="profile-stats">
${cells.map(
(cell) => html`
<div class="profile-stats__cell">
<div class="profile-stats__value">${cell.value}</div>
<div class="profile-stats__label">${cell.label}</div>
${cell.sub ? html`<div class="profile-stats__sub">${cell.sub}</div>` : nothing}
</div>
`,
)}
</section>
`);
}
private renderHeatmapSvg(heatmap: ProfileHeatmap) {
const weekCount = heatmap.weeks.length;
const width = HEATMAP_LEFT + weekCount * HEATMAP_PITCH;
const height = HEATMAP_TOP + 7 * HEATMAP_PITCH;
const numberFormat = integerFormat();
const weekdayFormat = new Intl.DateTimeFormat(undefined, {
weekday: "short",
timeZone: "UTC",
});
return html`
<div class="profile-heatmap__scroll">
<svg
class="profile-heatmap__svg"
width=${width}
height=${height}
viewBox="0 0 ${width} ${height}"
role="img"
aria-label=${t("profilePage.heatmapTitle")}
>
${heatmap.monthLabels.map((label, index) =>
label
? svg`<text class="profile-heatmap__month" x=${HEATMAP_LEFT + index * HEATMAP_PITCH} y="10">${label}</text>`
: nothing,
)}
${WEEKDAY_LABEL_ROWS.map(
({ row, utcDay }) =>
svg`<text class="profile-heatmap__weekday" x=${HEATMAP_LEFT - 6} y=${HEATMAP_TOP + row * HEATMAP_PITCH + HEATMAP_CELL - 2}>${weekdayFormat.format(new Date(utcDay))}</text>`,
)}
${heatmap.weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => {
if (!day) {
return nothing;
}
const tooltip = `${formatFullDate(day.date)} · ${t("profilePage.heatmapCellTokens", {
tokens: numberFormat.format(day.tokens),
})}`;
return svg`
<rect
class="profile-heatmap__cell profile-heatmap__cell--l${day.level}"
x=${HEATMAP_LEFT + weekIndex * HEATMAP_PITCH}
y=${HEATMAP_TOP + dayIndex * HEATMAP_PITCH}
width=${HEATMAP_CELL}
height=${HEATMAP_CELL}
rx="2.5"
><title>${tooltip}</title></rect>
`;
}),
)}
</svg>
</div>
`;
}
private renderHeatmap() {
const summary = this.costSummary;
if (!summary) {
return nothing;
}
const heatmap = buildHeatmap(summary.daily, localDateString());
const legend = html`
<div class="profile-heatmap__legend" aria-hidden="true">
<span>${t("profilePage.legendLess")}</span>
${[0, 1, 2, 3, 4].map(
(level) =>
html`<span class="profile-heatmap__swatch profile-heatmap__cell--l${level}"></span>`,
)}
<span>${t("profilePage.legendMore")}</span>
</div>
`;
return renderSettingsSection(
{
title: t("profilePage.heatmapTitle"),
description: t("profilePage.heatmapSub"),
actions: legend,
},
html`<div class="profile-heatmap">${this.renderHeatmapSvg(heatmap)}</div>`,
);
}
private renderInsights(insights: ProfileInsights | null) {
if (!insights) {
return nothing;
}
const numberFormat = integerFormat();
const rows: Array<{ label: string; value: string }> = [
{ label: t("profilePage.insightModel"), value: insights.topModel ?? "—" },
{ label: t("profilePage.insightMessages"), value: numberFormat.format(insights.messages) },
{ label: t("profilePage.insightToolCalls"), value: numberFormat.format(insights.toolCalls) },
{
label: t("profilePage.insightUniqueTools"),
value: numberFormat.format(insights.uniqueTools),
},
{ label: t("profilePage.insightAgents"), value: numberFormat.format(insights.agents) },
{
label: t("profilePage.insightSessions"),
value: insights.sessionsCapped
? t("profilePage.sessionsCapped", { count: numberFormat.format(insights.sessions) })
: numberFormat.format(insights.sessions),
},
];
const maxToolCount = insights.topTools[0]?.count ?? 0;
const insightsSection = renderSettingsSection(
{ title: t("profilePage.insightsTitle") },
html`
<dl class="settings-kv">
${rows.map(
(row) => html`
<dt>${row.label}</dt>
<dd>${row.value}</dd>
`,
)}
</dl>
`,
);
const toolsSection = renderSettingsSection(
{ title: t("profilePage.toolsTitle") },
insights.topTools.length === 0
? renderSettingsEmpty(t("profilePage.toolsEmpty"))
: html`
<div class="profile-tools">
${insights.topTools.map(
(tool) => html`
<div class="profile-tools__row">
<span class="profile-tools__name">${tool.name}</span>
<span class="profile-tools__bar" aria-hidden="true">
<span
class="profile-tools__bar-fill"
style="width: ${maxToolCount > 0
? Math.max(4, Math.round((tool.count / maxToolCount) * 100))
: 0}%"
></span>
</span>
<span class="profile-tools__count">
${t(tool.count === 1 ? "profilePage.toolRun" : "profilePage.toolRuns", {
count: integerFormat().format(tool.count),
})}
</span>
</div>
`,
)}
</div>
`,
);
return html`${insightsSection} ${toolsSection}`;
}
private renderBody() {
if (!this.connected || !this.client) {
return renderSettingsPage(renderSettingsGroup(renderSettingsEmpty(t("profilePage.offline"))));
}
const renderIdentityAwareState = (content: unknown) =>
renderSettingsPage(this.selfUser ? html`${this.renderIdentity()} ${content}` : content);
if (this.loading && !this.costSummary) {
return renderSettingsPage(renderSettingsGroup(renderSettingsEmpty(t("profilePage.loading"))));
return renderIdentityAwareState(
renderSettingsGroup(renderSettingsEmpty(t("profilePage.loading"))),
);
}
if (this.error && !this.costSummary) {
return renderSettingsPage(
return renderIdentityAwareState(
renderSettingsGroup(renderSettingsEmpty(this.error), { danger: true }),
);
}
@@ -590,8 +567,9 @@ export class ProfilePage extends OpenClawLightDomElement {
);
return renderSettingsPage(
hasActivity
? html`${this.renderHero(insights)} ${this.renderStats(insights)} ${this.renderIdentity()}
${this.renderHeatmap()} ${this.renderInsights(insights)}`
? html`${this.renderHero(insights)} ${renderProfileStats(this.costSummary, insights)}
${this.renderIdentity()} ${renderProfileHeatmap(this.costSummary)}
${renderProfileInsights(insights)}`
: html`${this.renderHero(insights)} ${this.renderIdentity()} ${emptyState}`,
);
}
@@ -602,7 +580,7 @@ export class ProfilePage extends OpenClawLightDomElement {
<div>
<div class="page-title">${titleForRoute("profile")}</div>
</div>
<button class="btn profile-refresh" @click=${() => this.requestProfileRefresh("manual")}>
<button class="btn profile-refresh" @click=${() => this.refreshManually()}>
${this.loading ? t("common.refreshing") : t("common.refresh")}
</button>
</section>
@@ -0,0 +1,237 @@
import { html, nothing, svg } from "lit";
import type { CostUsageSummary } from "../../api/types.ts";
import {
renderSettingsEmpty,
renderSettingsGroup,
renderSettingsSection,
} from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { formatCost } from "../../lib/format.ts";
import {
buildHeatmap,
computeStreaks,
formatLongDuration,
formatTokenScale,
localDateString,
peakDay,
type ProfileHeatmap,
type ProfileInsights,
} from "./stats.ts";
const HEATMAP_CELL = 11;
const HEATMAP_GAP = 3;
const HEATMAP_PITCH = HEATMAP_CELL + HEATMAP_GAP;
const HEATMAP_LEFT = 30;
const HEATMAP_TOP = 18;
// Fixed reference week (2024-01-01 is a Monday) for localized weekday labels.
const WEEKDAY_LABEL_ROWS = [
{ row: 1, utcDay: Date.UTC(2024, 0, 1) },
{ row: 3, utcDay: Date.UTC(2024, 0, 3) },
{ row: 5, utcDay: Date.UTC(2024, 0, 5) },
];
function integerFormat(): Intl.NumberFormat {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
}
function formatFullDate(date: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${date}T12:00:00Z`));
}
function streakLabel(days: number): string {
return t(days === 1 ? "profilePage.streakDay" : "profilePage.streakDays", {
count: integerFormat().format(days),
});
}
export function renderProfileStats(
summary: CostUsageSummary | null,
insights: ProfileInsights | null,
) {
if (!summary) {
return nothing;
}
const streaks = computeStreaks(summary.daily, localDateString());
const peak = peakDay(summary.daily);
const cells: Array<{ label: string; value: string; sub?: string }> = [
{
label: t("profilePage.statLifetimeTokens"),
value: formatTokenScale(summary.totals.totalTokens),
sub: summary.totals.totalCost > 0 ? `${formatCost(summary.totals.totalCost)}` : undefined,
},
{
label: t("profilePage.statPeakDay"),
value: formatTokenScale(peak?.totalTokens ?? 0),
sub: peak ? formatFullDate(peak.date) : undefined,
},
{
label: t("profilePage.statLongestSession"),
value:
insights?.longestSessionMs != null ? formatLongDuration(insights.longestSessionMs) : "—",
},
{ label: t("profilePage.statCurrentStreak"), value: streakLabel(streaks.current) },
{ label: t("profilePage.statLongestStreak"), value: streakLabel(streaks.longest) },
];
return renderSettingsGroup(html`
<section class="profile-stats">
${cells.map(
(cell) => html`
<div class="profile-stats__cell">
<div class="profile-stats__value">${cell.value}</div>
<div class="profile-stats__label">${cell.label}</div>
${cell.sub ? html`<div class="profile-stats__sub">${cell.sub}</div>` : nothing}
</div>
`,
)}
</section>
`);
}
function renderHeatmapSvg(heatmap: ProfileHeatmap) {
const weekCount = heatmap.weeks.length;
const width = HEATMAP_LEFT + weekCount * HEATMAP_PITCH;
const height = HEATMAP_TOP + 7 * HEATMAP_PITCH;
const numberFormat = integerFormat();
const weekdayFormat = new Intl.DateTimeFormat(undefined, {
weekday: "short",
timeZone: "UTC",
});
return html`
<div class="profile-heatmap__scroll">
<svg
class="profile-heatmap__svg"
width=${width}
height=${height}
viewBox="0 0 ${width} ${height}"
role="img"
aria-label=${t("profilePage.heatmapTitle")}
>
${heatmap.monthLabels.map((label, index) =>
label
? svg`<text class="profile-heatmap__month" x=${HEATMAP_LEFT + index * HEATMAP_PITCH} y="10">${label}</text>`
: nothing,
)}
${WEEKDAY_LABEL_ROWS.map(
({ row, utcDay }) =>
svg`<text class="profile-heatmap__weekday" x=${HEATMAP_LEFT - 6} y=${HEATMAP_TOP + row * HEATMAP_PITCH + HEATMAP_CELL - 2}>${weekdayFormat.format(new Date(utcDay))}</text>`,
)}
${heatmap.weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => {
if (!day) {
return nothing;
}
const tooltip = `${formatFullDate(day.date)} · ${t("profilePage.heatmapCellTokens", {
tokens: numberFormat.format(day.tokens),
})}`;
return svg`
<rect
class="profile-heatmap__cell profile-heatmap__cell--l${day.level}"
x=${HEATMAP_LEFT + weekIndex * HEATMAP_PITCH}
y=${HEATMAP_TOP + dayIndex * HEATMAP_PITCH}
width=${HEATMAP_CELL}
height=${HEATMAP_CELL}
rx="2.5"
><title>${tooltip}</title></rect>
`;
}),
)}
</svg>
</div>
`;
}
export function renderProfileHeatmap(summary: CostUsageSummary | null) {
if (!summary) {
return nothing;
}
const heatmap = buildHeatmap(summary.daily, localDateString());
const legend = html`
<div class="profile-heatmap__legend" aria-hidden="true">
<span>${t("profilePage.legendLess")}</span>
${[0, 1, 2, 3, 4].map(
(level) =>
html`<span class="profile-heatmap__swatch profile-heatmap__cell--l${level}"></span>`,
)}
<span>${t("profilePage.legendMore")}</span>
</div>
`;
return renderSettingsSection(
{
title: t("profilePage.heatmapTitle"),
description: t("profilePage.heatmapSub"),
actions: legend,
},
html`<div class="profile-heatmap">${renderHeatmapSvg(heatmap)}</div>`,
);
}
export function renderProfileInsights(insights: ProfileInsights | null) {
if (!insights) {
return nothing;
}
const numberFormat = integerFormat();
const rows: Array<{ label: string; value: string }> = [
{ label: t("profilePage.insightModel"), value: insights.topModel ?? "—" },
{ label: t("profilePage.insightMessages"), value: numberFormat.format(insights.messages) },
{ label: t("profilePage.insightToolCalls"), value: numberFormat.format(insights.toolCalls) },
{
label: t("profilePage.insightUniqueTools"),
value: numberFormat.format(insights.uniqueTools),
},
{ label: t("profilePage.insightAgents"), value: numberFormat.format(insights.agents) },
{
label: t("profilePage.insightSessions"),
value: insights.sessionsCapped
? t("profilePage.sessionsCapped", { count: numberFormat.format(insights.sessions) })
: numberFormat.format(insights.sessions),
},
];
const maxToolCount = insights.topTools[0]?.count ?? 0;
const insightsSection = renderSettingsSection(
{ title: t("profilePage.insightsTitle") },
html`
<dl class="settings-kv">
${rows.map(
(row) => html`
<dt>${row.label}</dt>
<dd>${row.value}</dd>
`,
)}
</dl>
`,
);
const toolsSection = renderSettingsSection(
{ title: t("profilePage.toolsTitle") },
insights.topTools.length === 0
? renderSettingsEmpty(t("profilePage.toolsEmpty"))
: html`
<div class="profile-tools">
${insights.topTools.map(
(tool) => html`
<div class="profile-tools__row">
<span class="profile-tools__name">${tool.name}</span>
<span class="profile-tools__bar" aria-hidden="true">
<span
class="profile-tools__bar-fill"
style="width: ${maxToolCount > 0
? Math.max(4, Math.round((tool.count / maxToolCount) * 100))
: 0}%"
></span>
</span>
<span class="profile-tools__count">
${t(tool.count === 1 ? "profilePage.toolRun" : "profilePage.toolRuns", {
count: integerFormat().format(tool.count),
})}
</span>
</div>
`,
)}
</div>
`,
);
return html`${insightsSection} ${toolsSection}`;
}
+2 -137
View File
@@ -3,8 +3,8 @@
* Quick Settings now renders through the shared settings design language
* (ui/src/styles/settings.css). This file keeps only genuinely page-specific
* rules: the logs-page fill layout, the Simple/Advanced view toggle, the
* advanced-view accordion nav (used by view.ts), and the two documented
* escape hatches (identity blocks, gateway-host stats grid).
* advanced-view accordion nav (used by view.ts), and the documented
* gateway-host stats-grid escape hatch.
*/
.content:has(openclaw-logs-page) {
@@ -147,141 +147,6 @@ openclaw-logs-page {
height: 14px;
}
/* Escape hatch: Personal identity blocks (avatar-led rows)
The standard settings row (text left, one control right) cannot lead with
an avatar preview; these blocks stay inside one .settings-group and match
the row paddings. */
.config-identity {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
min-width: 0;
}
.config-identity + .config-identity {
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.config-identity__avatar {
width: 48px;
height: 48px;
flex: 0 0 auto;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
object-fit: cover;
object-position: center;
}
.config-identity__avatar--text,
.config-identity__avatar--default {
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent);
font-size: var(--control-ui-text-md);
font-weight: 650;
}
.config-identity__avatar--fallback {
padding: var(--space-2);
background: color-mix(in srgb, var(--bg) 84%, var(--bg-elevated) 16%);
object-fit: contain;
}
.config-identity__avatar--default svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.config-identity__copy {
min-width: 0;
flex: 1 1 auto;
}
.config-identity__eyebrow {
font-size: var(--control-ui-text-xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.config-identity__title {
font-size: var(--control-ui-text-md);
font-weight: 500;
color: var(--text-strong);
line-height: 1.35;
overflow-wrap: anywhere;
}
.config-identity__sub {
margin-top: 2px;
font-size: var(--control-ui-text-sm);
color: var(--muted);
line-height: 1.45;
}
.config-identity__source {
display: grid;
gap: 2px;
margin-top: var(--space-2);
font-size: var(--control-ui-text-xs);
color: var(--muted);
}
.config-identity__source code {
color: var(--text);
font-family: var(--mono);
font-size: var(--control-ui-text-xs);
overflow-wrap: anywhere;
}
.config-identity__issue {
margin-top: var(--space-2);
}
.config-identity__error {
margin-top: var(--space-2);
color: var(--danger);
font-size: var(--control-ui-text-sm);
line-height: 1.35;
}
.config-identity__repair {
display: grid;
gap: var(--space-2);
margin-top: var(--space-2);
}
.config-identity__field {
display: grid;
gap: var(--space-1);
max-width: 320px;
}
.config-identity__field-label {
font-size: var(--control-ui-text-sm);
color: var(--muted);
}
.config-identity__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.config-identity__actions .btn {
width: fit-content;
}
.config-identity__hint {
font-size: var(--control-ui-text-sm);
line-height: 1.45;
}
/* Escape hatch: Gateway host identity + metered stats grid
A genuine two-column stat grid with usage meters; stays flat (no nested
borders/surfaces) inside one .settings-group with row-matched paddings. */
+39 -1
View File
@@ -1940,12 +1940,20 @@ html.openclaw-native-macos
margin-left: 1px;
}
.viewer-facepile--footer .viewer-avatar {
.viewer-avatar--footer,
.viewer-facepile--footer .viewer-avatar--overflow {
width: 20px;
height: 20px;
font-size: 8px;
}
.viewer-avatar--profile {
width: 40px;
height: 40px;
border-color: color-mix(in srgb, var(--border) 72%, transparent);
font-size: var(--control-ui-text-sm);
}
.sidebar-recent-session__name {
flex: 1 1 auto;
min-width: 0;
@@ -2925,6 +2933,36 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) {
margin-left: 1px;
}
.sidebar-footer-bar__identity {
display: inline-flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
max-width: 132px;
padding: 2px 5px 2px 2px;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: var(--text);
font: inherit;
cursor: pointer;
transition: background var(--duration-fast) ease;
}
.sidebar-footer-bar__identity:hover,
.sidebar-footer-bar__identity:focus-visible {
background: var(--bg-hover);
}
.sidebar-footer-bar__identity-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: 550;
}
.sidebar-footer-bar .sidebar-footer-build {
flex: 1 1 auto;
min-width: 0;
+18
View File
@@ -719,6 +719,24 @@ textarea.settings-input {
font-size: var(--control-ui-text-xs);
}
/* Identity composes existing row controls; these wrappers only keep the
avatar/file action and name/save action aligned as one control cluster. */
.identity-avatar-control,
.identity-name-control {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.identity-name-control .settings-input {
width: min(260px, 38vw);
}
.identity-error .settings-row__desc {
color: var(--danger);
}
/* ── Mobile ── */
@media (max-width: 640px) {
@@ -52,12 +52,19 @@ describe("AppSidebar viewer presence", () => {
gatewayHarness.gateway,
createSessions("main", ["agent:main:main", "agent:main:work"]),
);
sidebar.connected = true;
const onNavigate = vi.fn();
sidebar.onNavigate = onNavigate;
gatewayHarness.publishEvent("presence", {
presence: [
{
instanceId: "self-instance",
user: { id: "00-self", name: "Self User" },
user: {
id: "00-self",
name: "Self User",
avatarUrl: "/api/users/00-self/avatar?v=1",
},
watchedSessions: ["agent:main:work"],
},
{
@@ -75,7 +82,7 @@ describe("AppSidebar viewer presence", () => {
user: { id: "bob", email: "bob@example.test" },
watchedSessions: ["agent:main:work"],
},
...["carol", "dave", "erin"].map((id) => ({
...["carol", "dave", "erin", "frank"].map((id) => ({
instanceId: `${id}-1`,
user: { id, name: id[0]?.toUpperCase() + id.slice(1) },
watchedSessions: ["agent:main:work"],
@@ -93,6 +100,14 @@ describe("AppSidebar viewer presence", () => {
],
});
await sidebar.updateComplete;
gatewayHarness.publish({
selfUser: {
id: "00-self",
name: "Self User",
avatarUrl: "/api/users/00-self/avatar?v=1",
},
});
await sidebar.updateComplete;
const sessionFacepile = sidebar.querySelector<HTMLElement>(
'[data-session-key="agent:main:work"] openclaw-viewer-facepile',
@@ -104,28 +119,75 @@ describe("AppSidebar viewer presence", () => {
(sessionFacepile as { updateComplete?: Promise<unknown> } | null)?.updateComplete,
(footerFacepile as { updateComplete?: Promise<unknown> } | null)?.updateComplete,
]);
expect(
sessionFacepile?.querySelector(".viewer-facepile")?.getAttribute("data-viewer-count"),
).toBe("5");
).toBe("6");
expect(
[...(sessionFacepile?.querySelectorAll<HTMLElement>("[data-viewer-id]") ?? [])].map(
(avatar) => avatar.dataset.viewerId,
),
).toEqual(["alice", "bob", "carol"]);
expect(sessionFacepile?.querySelector(".viewer-avatar--overflow")?.textContent).toContain("+2");
expect(sessionFacepile?.querySelector(".viewer-avatar--overflow")?.textContent).toContain("+3");
expect(sessionFacepile?.querySelector('[data-viewer-id="alice"] img')).not.toBeNull();
expect(
[...(sessionFacepile?.querySelectorAll("openclaw-tooltip") ?? [])].map(
(tooltip) => (tooltip as HTMLElement & { content?: string }).content,
),
).toEqual(["Alice", "bob@example.test", "Carol", "Dave\nErin"]);
).toEqual(["Alice", "bob@example.test", "Carol", "Dave\nErin\nFrank"]);
expect(
footerFacepile?.querySelector(".viewer-facepile")?.getAttribute("data-viewer-count"),
).toBe("6");
expect(footerFacepile?.querySelector('[data-viewer-id="00-self"]')).not.toBeNull();
expect(footerFacepile?.querySelector('[data-viewer-id="00-self"]')).toBeNull();
expect(footerFacepile?.querySelector(".viewer-avatar--overflow")?.textContent).toContain("+1");
const identityChip = sidebar.querySelector<HTMLButtonElement>(".sidebar-footer-bar__identity");
expect(identityChip?.querySelector(".sidebar-footer-bar__identity-name")?.textContent).toBe(
"Self User",
);
expect(identityChip?.querySelector('[data-viewer-id="00-self"]')).not.toBeNull();
identityChip?.click();
expect(onNavigate).toHaveBeenCalledWith("profile", {
hash: "#settings-profile-identity",
});
const avatar = identityChip?.querySelector<HTMLImageElement>("openclaw-viewer-avatar img");
expect(avatar?.getAttribute("src")).toBe("/api/users/00-self/avatar?v=1");
gatewayHarness.gateway.updateSelfUser?.({
name: "Augusta Ada",
avatarUrl: "/api/users/00-self/avatar?v=4",
});
await sidebar.updateComplete;
// Profile mutations update gateway state directly; no presence event follows them.
expect(identityChip?.querySelector(".sidebar-footer-bar__identity-name")?.textContent).toBe(
"Augusta Ada",
);
expect(avatar?.getAttribute("src")).toBe("/api/users/00-self/avatar?v=4");
sidebar.connected = false;
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-footer-bar__identity")).toBeNull();
});
it("leaves the footer identity chip absent for an unidentified connection", async () => {
const client = { instanceId: "anonymous-self" } as GatewayBrowserClient;
const gatewayHarness = createGatewayHarness(client);
const { sidebar } = await mountSidebar(
gatewayHarness.gateway,
createSessions("main", ["agent:main:main"]),
);
gatewayHarness.publishEvent("presence", {
presence: [
{ instanceId: "anonymous-self", watchedSessions: ["agent:main:main"] },
{ instanceId: "alice", user: { id: "alice", name: "Alice" } },
],
});
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-footer-bar__identity")).toBeNull();
expect(sidebar.querySelector(".sidebar-footer-bar")?.textContent).not.toContain("Sign in");
});
});
+12 -1
View File
@@ -36,7 +36,7 @@ export type SidebarLifecycleState = HTMLElement & {
onUpdateSidebarEntries?: (entries: string[]) => void;
pinnedAgentIds: readonly string[];
sessionKey: string;
onNavigate: (routeId: string, options?: { search?: string }) => void;
onNavigate: (routeId: string, options?: { search?: string; hash?: string }) => void;
sessionCatalogs: SessionCatalog[];
sessionRowsByAgent: Record<string, SessionsListResult["sessions"]>;
sessionCreatedOrder: Map<string, number>;
@@ -87,6 +87,17 @@ export function createGatewayHarness(client: GatewayBrowserClient) {
eventListeners.add(listener);
return () => eventListeners.delete(listener);
},
updateSelfUser(
patch: Partial<Omit<NonNullable<ApplicationGatewaySnapshot["selfUser"]>, "id">>,
) {
if (!snapshot.selfUser) {
return;
}
snapshot = { ...snapshot, selfUser: { ...snapshot.selfUser, ...patch } };
for (const listener of listeners) {
listener(snapshot);
}
},
} as unknown as ApplicationGateway;
return {
gateway,