mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user