fix(control-ui): restore userAvatar wiring in Quick Settings (#107292) (#107327)

* fix(control-ui): restore userAvatar wiring in Quick Settings (#107292)

Between 2026.6.11 and 2026.7.1 the Quick Settings parent renderer stopped
passing userAvatar / onUserAvatarChange down to the avatar editor, while
the editor still calls onUserAvatarChange?.(...). As a result, choosing,
clearing, or entering an emoji/text avatar under Settings \u2192 Simple
silently no-ops and never persists.

Restore the wiring: ConfigPage now reads the browser-local user identity
via loadLocalUserIdentity(), tracks the avatar on a @state field, and
persists changes through applyLocalUserIdentity() (added to
ui/src/app/settings.ts). No behavior change vs 2026.6.11.

* chore: drop changelog edit (release-generation owns CHANGELOG)

* ci: pick up periphery workflow fix

* ci: pick up pinned xcodegen installer

* chore: restore merge-base CHANGELOG (release-owned file)

---------

Co-authored-by: SymbolStar <SymbolStar@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
SymbolStar
2026-07-16 21:28:10 +08:00
committed by GitHub
parent b8b064a948
commit 990d673083
4 changed files with 75 additions and 1 deletions
+15
View File
@@ -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",
+16
View File
@@ -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();
+30 -1
View File
@@ -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<T>() {
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", () => {
+14
View File
@@ -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,
});
}