diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index 3c37d57b77bc..30343c43e5db 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -7,6 +7,7 @@ import { loadSettings, persistSessionToken, resolvePageGatewaySettings, + saveLocalUserIdentity, saveSettings, type UiSettings, } from "./settings.ts"; @@ -908,6 +909,20 @@ 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", diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index c410b3ce9a32..6db07e7b10d5 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -458,6 +458,22 @@ 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(); diff --git a/ui/src/pages/config/config-page.test.ts b/ui/src/pages/config/config-page.test.ts index 98f2bfd41399..8e11e87f334c 100644 --- a/ui/src/pages/config/config-page.test.ts +++ b/ui/src/pages/config/config-page.test.ts @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { render, type ReactiveController } from "lit"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { @@ -9,6 +9,8 @@ import type { ApplicationGateway, ApplicationGatewaySnapshot, } from "../../app/context.ts"; +import { loadLocalUserIdentity } from "../../app/settings.ts"; +import { createStorageMock } from "../../test-helpers/storage.ts"; import { ConfigPage, configSelectionFromSearch, supportsSystemInfo } from "./config-page.ts"; import type { ConfigViewState } from "./view.ts"; @@ -20,9 +22,36 @@ function deferred() { return { promise, resolve }; } +let localStorageMock: Storage; + +beforeEach(() => { + localStorageMock = createStorageMock(); + vi.stubGlobal("localStorage", localStorageMock); +}); + afterEach(() => { document.body.replaceChildren(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("ConfigPage local user identity", () => { + it("persists avatar selections while preserving the local display name", () => { + localStorageMock.setItem( + "openclaw.control.user.v1", + JSON.stringify({ name: "Buns", avatar: "old" }), + ); + const page = new ConfigPage(); + const state = page as unknown as { + userAvatar: string | null; + setLocalUserAvatar: (avatar: string | null) => void; + }; + + state.setLocalUserAvatar("🦞"); + + expect(state.userAvatar).toBe("🦞"); + expect(loadLocalUserIdentity()).toEqual({ name: "Buns", avatar: "🦞" }); + }); }); describe("configSelectionFromSearch", () => { diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index cf6d47fe6fd6..8dae6bc77ec2 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -14,12 +14,14 @@ import { import { importCustomThemeFromUrl } from "../../app/custom-theme.ts"; import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; import { + loadLocalUserIdentity, loadSettings, normalizeCatalogOpenTarget, normalizeTextScale, normalizeChatFollowUpMode, normalizeChatSendShortcut, patchSettings, + saveLocalUserIdentity, type UiSettings, } from "../../app/settings.ts"; import { startThemeTransition } from "../../app/theme-transition.ts"; @@ -240,6 +242,7 @@ export class ConfigPage extends OpenClawLightDomElement { @property({ attribute: false }) routeData: ConfigRouteData | null = null; @state() private settings = loadSettings(); + @state() private userAvatar: string | null = loadLocalUserIdentity().avatar; @state() private settingsMode: "quick" | "advanced" = "quick"; @state() private systemInfo: SystemInfoResult | null = null; @state() private systemInfoUnavailable = false; @@ -321,9 +324,18 @@ export class ConfigPage extends OpenClawLightDomElement { override connectedCallback() { super.connectedCallback(); this.settings = loadSettings(); + this.userAvatar = loadLocalUserIdentity().avatar; this.syncRouteData(); } + private setLocalUserAvatar(avatar: string | null) { + const identity = saveLocalUserIdentity({ + ...loadLocalUserIdentity(), + avatar, + }); + this.userAvatar = identity.avatar; + } + override disconnectedCallback() { this.systemInfoPolling.stop(); this.invalidateSystemInfoRequest(); @@ -927,6 +939,8 @@ export class ConfigPage extends OpenClawLightDomElement { assistantAvatarStatus: appConfig.assistantIdentity.avatarStatus, assistantAvatarReason: appConfig.assistantIdentity.avatarReason, assistantAvatarOverride: null, + userAvatar: this.userAvatar, + onUserAvatarChange: (avatar) => this.setLocalUserAvatar(avatar), basePath: this.context.basePath, }); }