fix(control-ui): persist assistant avatar override locally (#71639)

* fix(control-ui): rebalance quick settings into stable 3-col bento

Pair Appearance with Automations and let Channels stand alone in the
middle column so all three top-row columns reach similar heights.
Promote Personal to a full-width row with a horizontal body
(identity tiles | emoji + actions) so the avatar block stops fighting
for half-width space. Drops the unused .qs-stack--wide hook.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(control-ui): rebalance Personal card with symmetric User↔Assistant identity pair

Restructure Personal card layout to present User and Assistant as 2 balanced identity cards instead of separate User tile + form controls. Mirrors the visual hierarchy and UI pattern across both identities.

Changes:
- Move User avatar text input into User identity card's .__repair section (mirroring Assistant's structure)
- Inline "Choose image" and "Clear avatar" buttons as flex-wrapped action group
- Remove .qs-personal-body and .qs-personal-form wrapper divs
- Update Personal card's .qs-identity-grid to 2-column layout with balanced spacing
- Responsive collapse to 1-column at ≤760px

Tests:
- config-quick.test.ts updated to expect 2 stacks (no longer wrapping Personal in form)
- config-quick.test.ts validates identity card layout now has symmetric User↔Assistant structure
- All 10 quick settings view tests passing
- All 20 schema regression tests passing

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* chore: ignore .vmux worktree paths

* fix(control-ui): persist assistant avatar override locally instead of via gateway config

Mirrors the user-avatar pattern: assistant avatar uploads now go to
localStorage and overlay the gateway-resolved identity at bootstrap and on
agent.identity.get refreshes. Sidesteps the ui.assistant.avatar zod cap
that rejected uploaded data URLs as 'Too big: expected string to have
<=200 characters', removes one config.patch RPC from the avatar path, and
collapses the upload handler from a 44-line async/loadConfig dance into a
plain synchronous setter.

Also lifts the gateway-side ui.assistant.avatar schema cap from 200 to
2,000,000 to match the user-avatar size budget for non-UI clients writing
the field directly, and adds a content-aware text/image normalizer in
ui/src/ui/assistant-identity.ts so short-text avatars stay short while
data URLs survive round-tripping.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Val Alexander
2026-04-25 11:17:48 -05:00
committed by GitHub
parent 95b7a85f06
commit c65aa1d2a6
17 changed files with 290 additions and 188 deletions

1
.gitignore vendored
View File

@@ -146,6 +146,7 @@ changelog/fragments/
# Local scratch workspace
.tmp/
.vmux*
.artifacts/
test/fixtures/openclaw-vitest-unit-report.json
analysis/

View File

@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI/Quick Settings: persist the assistant avatar override to browser local storage (mirroring the user avatar) so uploaded image data URLs no longer fail config validation with "Too big: expected string to have <=200 characters". Also lift the gateway-side `ui.assistant.avatar` length cap to match the user avatar size budget for non-UI clients writing the field directly. Thanks @BunsDev.
- Browser/CDP: make readiness diagnostics use the same discovery-first fallback as reachability for bare `ws://` Browserless and Browserbase CDP URLs. Fixes #69532.
- ACP/OpenCode: update the bundled acpx runtime to 0.6.0 and cover the OpenCode ACP bind path in Docker live tests.
- Browser/existing-session: support per-profile Chrome MCP command/args, map `cdpUrl` to `--browserUrl` or `--wsEndpoint`, and avoid combining endpoint flags with `--userDataDir`. Fixes #47879, #48037, and #62706. Thanks @puneet1409, @zhehao, and @madkow1001.

View File

@@ -878,7 +878,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
avatar: {
type: "string",
maxLength: 200,
maxLength: 2000000,
title: "Assistant Avatar",
description:
"Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.",

View File

@@ -457,7 +457,7 @@ export const OpenClawSchema = z
assistant: z
.object({
name: z.string().max(50).optional(),
avatar: z.string().max(200).optional(),
avatar: z.string().max(2_000_000).optional(),
})
.strict()
.optional(),

View File

@@ -40,4 +40,17 @@ describe("resolveAssistantIdentity avatar normalization", () => {
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/openclaw.png");
});
it("preserves long image data URLs without truncating past 200 chars", () => {
const dataUrl = `data:image/png;base64,${"A".repeat(50_000)}`;
const cfg: OpenClawConfig = {
ui: {
assistant: {
avatar: dataUrl,
},
},
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(dataUrl);
});
});

View File

@@ -11,7 +11,10 @@ import {
} from "../shared/avatar-policy.js";
const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200;
// Image-bearing avatars (data: URLs, paths) need to round-trip through
// coerceIdentityValue without truncation. Sized to match
// MAX_LOCAL_USER_IMAGE_AVATAR / AVATAR_MAX_BYTES expansion.
const MAX_ASSISTANT_AVATAR = 2_000_000;
const MAX_ASSISTANT_EMOJI = 16;
export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = {

View File

@@ -56,10 +56,6 @@
min-width: 0;
}
.qs-stack--wide {
grid-column: span 2;
}
/* ── Card ── */
.qs-card {
@@ -83,7 +79,12 @@
}
.qs-card--personal {
grid-column: span 2;
grid-column: 1 / -1;
}
.qs-card--personal .qs-identity-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 14px 16px 16px;
}
.qs-card__header {
@@ -325,7 +326,7 @@
.qs-identity-card__repair {
display: grid;
gap: 6px;
gap: 8px;
margin-top: 10px;
}
@@ -333,6 +334,29 @@
width: fit-content;
}
.qs-identity-card__repair .qs-field {
gap: 4px;
}
.qs-identity-card__repair .qs-row__label {
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.qs-identity-card__repair .qs-field__input {
font-size: 0.78rem;
padding: 6px 9px;
}
.qs-identity-card__actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.qs-identity-card__repair .muted {
font-size: 0.68rem;
line-height: 1.35;
@@ -1054,11 +1078,6 @@
.qs-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.qs-card--personal,
.qs-stack--wide {
grid-column: span 1;
}
}
@media (max-width: 760px) {
@@ -1066,9 +1085,8 @@
grid-template-columns: 1fr;
}
.qs-card--personal,
.qs-stack--wide {
grid-column: 1 / -1;
.qs-card--personal .qs-identity-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -18,7 +18,7 @@ describe("config-quick styles", () => {
it("includes the stacked quick-settings density layout", () => {
expect(css).toContain(".qs-stack");
expect(css).toContain(".qs-stack--wide");
expect(css).toContain(".qs-identity-card__actions");
expect(css).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));");
expect(css).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
expect(css).toContain("@media (max-width: 760px)");

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChat, refreshChatAvatar } from "./app-chat.ts";
import { refreshChat } from "./app-chat.ts";
import { DEFAULT_CRON_FORM } from "./app-defaults.ts";
import { renderUsageTab } from "./app-render-usage-tab.ts";
import {
@@ -1038,49 +1038,23 @@ export function renderApp(state: AppViewState) {
assistantAvatarOverride,
assistantAvatarUploadBusy: state.assistantAvatarUploadBusy,
assistantAvatarUploadError: state.assistantAvatarUploadError,
onAssistantAvatarOverrideChange: async (dataUrl) => {
state.assistantAvatarUploadBusy = true;
onAssistantAvatarOverrideChange: (dataUrl) => {
setAssistantAvatarOverride(state, dataUrl);
state.chatAvatarUrl = dataUrl;
state.chatAvatarSource = dataUrl;
state.chatAvatarStatus = "data";
state.chatAvatarReason = null;
state.assistantAvatarUploadError = null;
requestHostUpdate?.();
try {
await setAssistantAvatarOverride(state, dataUrl);
state.assistantAvatar = dataUrl;
state.assistantAvatarSource = dataUrl;
state.assistantAvatarStatus = "data";
state.assistantAvatarReason = null;
state.chatAvatarUrl = dataUrl;
state.chatAvatarSource = dataUrl;
state.chatAvatarStatus = "data";
state.chatAvatarReason = null;
await loadConfig(state);
await state.loadAssistantIdentity();
await refreshChatAvatar(state);
} catch (err) {
state.assistantAvatarUploadError = err instanceof Error ? err.message : String(err);
} finally {
state.assistantAvatarUploadBusy = false;
requestHostUpdate?.();
}
},
onAssistantAvatarClearOverride: async () => {
state.assistantAvatarUploadBusy = true;
onAssistantAvatarClearOverride: () => {
setAssistantAvatarOverride(state, null);
state.chatAvatarUrl = null;
state.chatAvatarSource = null;
state.chatAvatarStatus = null;
state.chatAvatarReason = null;
state.assistantAvatarUploadError = null;
requestHostUpdate?.();
try {
await setAssistantAvatarOverride(state, null);
state.chatAvatarUrl = null;
state.chatAvatarSource = null;
state.chatAvatarStatus = null;
state.chatAvatarReason = null;
await loadConfig(state);
await state.loadAssistantIdentity();
await refreshChatAvatar(state);
} catch (err) {
state.assistantAvatarUploadError = err instanceof Error ? err.message : String(err);
} finally {
state.assistantAvatarUploadBusy = false;
requestHostUpdate?.();
}
},
basePath: state.basePath ?? "",
configObject: configObj,

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
describe("normalizeAssistantIdentity", () => {
it("preserves long image data URLs without truncating past 200 chars", () => {
const dataUrl = `data:image/png;base64,${"A".repeat(50_000)}`;
expect(normalizeAssistantIdentity({ avatar: dataUrl }).avatar).toBe(dataUrl);
});
it("preserves same-origin Control UI avatar routes", () => {
expect(normalizeAssistantIdentity({ avatar: "/avatar/main" }).avatar).toBe("/avatar/main");
});
it("keeps short text avatars", () => {
expect(normalizeAssistantIdentity({ avatar: "PS" }).avatar).toBe("PS");
expect(normalizeAssistantIdentity({ avatar: "🦞" }).avatar).toBe("🦞");
});
it("drops sentence-like text that exceeds the text-avatar limit", () => {
const longText = "this is a description, not an emoji or url ".repeat(4);
expect(normalizeAssistantIdentity({ avatar: longText }).avatar).toBeNull();
});
it("drops avatars containing newlines", () => {
expect(normalizeAssistantIdentity({ avatar: "line1\nline2" }).avatar).toBeNull();
});
});

View File

@@ -1,8 +1,18 @@
import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js";
const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200;
// Short text/emoji avatars (e.g. "A", "PS", "🦞"). Anything longer that is not
// a renderable image URL is dropped during normalization.
const MAX_ASSISTANT_TEXT_AVATAR = 64;
// Image-bearing avatars (data: URLs, same-origin Control UI routes). Sized to
// match MAX_LOCAL_USER_IMAGE_AVATAR so an uploaded image data URL survives
// round-tripping through config without truncation.
const MAX_ASSISTANT_IMAGE_AVATAR = 2_000_000;
const MAX_ASSISTANT_AVATAR_SOURCE = 500;
const MAX_ASSISTANT_AVATAR_REASON = 200;
// Mirrors agents-utils.CONTROL_UI_AVATAR_URL_RE — duplicated locally to keep
// this module free of UI view imports (avoids an import cycle).
const RENDERABLE_AVATAR_URL_RE = /^(data:image\/|\/(?!\/))/i;
export const DEFAULT_ASSISTANT_NAME = "Assistant";
export const DEFAULT_ASSISTANT_AVATAR = "A";
@@ -16,11 +26,25 @@ export type AssistantIdentity = {
avatarReason?: string | null;
};
function normalizeAssistantAvatar(value: string | null | undefined): string | null {
const trimmed = coerceIdentityValue(value ?? undefined, MAX_ASSISTANT_IMAGE_AVATAR);
if (!trimmed) {
return null;
}
if (RENDERABLE_AVATAR_URL_RE.test(trimmed)) {
return trimmed;
}
if (/[\r\n]/.test(trimmed)) {
return null;
}
return trimmed.length <= MAX_ASSISTANT_TEXT_AVATAR ? trimmed : null;
}
export function normalizeAssistantIdentity(
input?: Partial<AssistantIdentity> | null,
): AssistantIdentity {
const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
const avatar = normalizeAssistantAvatar(input?.avatar);
const avatarSource =
coerceIdentityValue(input?.avatarSource ?? undefined, MAX_ASSISTANT_AVATAR_SOURCE) ?? null;
const avatarStatus =
@@ -31,7 +55,7 @@ export function normalizeAssistantIdentity(
? input.avatarStatus
: null;
const avatarReason =
coerceIdentityValue(input?.avatarReason ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
coerceIdentityValue(input?.avatarReason ?? undefined, MAX_ASSISTANT_AVATAR_REASON) ?? null;
const agentId =
typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null;
return { agentId, name, avatar, avatarSource, avatarStatus, avatarReason };

View File

@@ -1,45 +1,42 @@
import { describe, expect, it, vi } from "vitest";
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { loadLocalAssistantIdentity } from "../storage.ts";
import { setAssistantAvatarOverride } from "./assistant-identity.ts";
describe("setAssistantAvatarOverride", () => {
it("writes the assistant avatar override through config.patch", async () => {
const request = vi.fn().mockResolvedValue({});
await setAssistantAvatarOverride(
{
client: { request } as never,
connected: true,
applySessionKey: "agent:main",
configSnapshot: { hash: "config-hash" },
},
"data:image/png;base64,YXZhdGFy",
);
expect(request).toHaveBeenCalledWith("config.patch", {
baseHash: "config-hash",
raw: JSON.stringify({ ui: { assistant: { avatar: "data:image/png;base64,YXZhdGFy" } } }),
sessionKey: "agent:main",
note: "Assistant avatar override updated from Control UI.",
});
beforeEach(() => {
vi.stubGlobal("localStorage", createStorageMock());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("clears the assistant avatar override through config.patch", async () => {
const request = vi.fn().mockResolvedValue({});
it("persists the assistant avatar locally and mirrors the user avatar pattern", () => {
const state: Parameters<typeof setAssistantAvatarOverride>[0] = {};
await setAssistantAvatarOverride(
{
client: { request } as never,
connected: true,
configSnapshot: { hash: "config-hash" },
},
null,
);
setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy");
expect(request).toHaveBeenCalledWith("config.patch", {
baseHash: "config-hash",
raw: JSON.stringify({ ui: { assistant: { avatar: null } } }),
sessionKey: undefined,
note: "Assistant avatar override cleared from Control UI.",
});
expect(state.assistantAvatar).toBe("data:image/png;base64,YXZhdGFy");
expect(state.assistantAvatarSource).toBe("data:image/png;base64,YXZhdGFy");
expect(state.assistantAvatarStatus).toBe("data");
expect(state.assistantAvatarReason).toBeNull();
expect(loadLocalAssistantIdentity().avatar).toBe("data:image/png;base64,YXZhdGFy");
});
it("clears the local override", () => {
const state: Parameters<typeof setAssistantAvatarOverride>[0] = {
assistantAvatar: "data:image/png;base64,YXZhdGFy",
assistantAvatarSource: "data:image/png;base64,YXZhdGFy",
assistantAvatarStatus: "data",
};
setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy");
setAssistantAvatarOverride(state, null);
expect(state.assistantAvatarSource).toBeNull();
expect(state.assistantAvatarStatus).toBeNull();
expect(state.assistantAvatarReason).toBeNull();
expect(loadLocalAssistantIdentity().avatar).toBeNull();
});
});

View File

@@ -1,5 +1,6 @@
import { normalizeAssistantIdentity } from "../assistant-identity.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import { loadLocalAssistantIdentity, saveLocalAssistantIdentity } from "../storage.ts";
export type AssistantIdentityState = {
client: GatewayBrowserClient | null;
@@ -14,10 +15,10 @@ export type AssistantIdentityState = {
};
export type AssistantAvatarOverrideState = {
client: GatewayBrowserClient | null;
connected: boolean;
applySessionKey?: string;
configSnapshot?: { hash?: string | null } | null;
assistantAvatar?: string | null;
assistantAvatarSource?: string | null;
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
assistantAvatarReason?: string | null;
};
export async function loadAssistantIdentity(
@@ -41,28 +42,32 @@ export async function loadAssistantIdentity(
state.assistantAvatarStatus = normalized.avatarStatus ?? null;
state.assistantAvatarReason = normalized.avatarReason ?? null;
state.assistantAgentId = normalized.agentId ?? null;
// Local override always wins — same pattern as the user avatar.
const localAvatar = loadLocalAssistantIdentity().avatar;
if (localAvatar) {
state.assistantAvatar = localAvatar;
state.assistantAvatarSource = localAvatar;
state.assistantAvatarStatus = "data";
state.assistantAvatarReason = null;
}
} catch {
// Ignore errors; keep last known identity.
}
}
export async function setAssistantAvatarOverride(
export function setAssistantAvatarOverride(
state: AssistantAvatarOverrideState,
avatar: string | null,
) {
if (!state.client || !state.connected) {
throw new Error("Gateway is not connected.");
saveLocalAssistantIdentity({ avatar });
if (avatar) {
state.assistantAvatar = avatar;
state.assistantAvatarSource = avatar;
state.assistantAvatarStatus = "data";
state.assistantAvatarReason = null;
} else {
state.assistantAvatarSource = null;
state.assistantAvatarStatus = null;
state.assistantAvatarReason = null;
}
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
throw new Error("Config hash missing; refresh and retry.");
}
await state.client.request("config.patch", {
baseHash,
raw: JSON.stringify({ ui: { assistant: { avatar } } }),
sessionKey: state.applySessionKey,
note: avatar
? "Assistant avatar override updated from Control UI."
: "Assistant avatar override cleared from Control UI.",
});
}

View File

@@ -6,6 +6,7 @@ import {
import { normalizeAssistantIdentity } from "../assistant-identity.ts";
import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts";
import { normalizeBasePath } from "../navigation.ts";
import { loadLocalAssistantIdentity } from "../storage.ts";
export type ControlUiBootstrapState = {
basePath: string;
@@ -79,6 +80,14 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat
state.assistantAvatarStatus = normalized.avatarStatus ?? null;
state.assistantAvatarReason = normalized.avatarReason ?? null;
state.assistantAgentId = normalized.agentId ?? null;
// Local override always wins — same pattern as the user avatar.
const localAvatar = loadLocalAssistantIdentity().avatar;
if (localAvatar) {
state.assistantAvatar = localAvatar;
state.assistantAvatarSource = localAvatar;
state.assistantAvatarStatus = "data";
state.assistantAvatarReason = null;
}
state.serverVersion = parsed.serverVersion ?? null;
state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots)
? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string")

View File

@@ -1,6 +1,7 @@
const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:";
const LEGACY_SETTINGS_KEY = "openclaw.control.settings.v1";
const LOCAL_USER_IDENTITY_KEY = "openclaw.control.user.v1";
const LOCAL_ASSISTANT_IDENTITY_KEY = "openclaw.control.assistant.v1";
const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1";
const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:";
const MAX_SCOPED_SESSION_ENTRIES = 10;
@@ -310,6 +311,36 @@ export function saveLocalUserIdentity(next: LocalUserIdentity) {
}
}
export type LocalAssistantIdentity = { avatar: string | null };
export function loadLocalAssistantIdentity(): LocalAssistantIdentity {
const storage = getSafeLocalStorage();
try {
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
if (!raw) {
return { avatar: null };
}
const parsed = JSON.parse(raw) as Partial<LocalAssistantIdentity>;
return { avatar: typeof parsed.avatar === "string" ? parsed.avatar : null };
} catch {
return { avatar: null };
}
}
export function saveLocalAssistantIdentity(next: LocalAssistantIdentity) {
const storage = getSafeLocalStorage();
try {
if (!next.avatar) {
storage?.removeItem(LOCAL_ASSISTANT_IDENTITY_KEY);
return;
}
storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify({ avatar: next.avatar }));
} catch {
// best-effort — quota exceeded or security restrictions should not
// prevent in-memory identity updates from being applied
}
}
function persistSettings(next: UiSettings) {
persistSessionToken(next.gatewayUrl, next.token);
const storage = getSafeLocalStorage();

View File

@@ -67,7 +67,7 @@ describe("renderQuickSettings", () => {
render(renderQuickSettings(createProps()), container);
expect(container.querySelectorAll(".qs-stack")).toHaveLength(3);
expect(container.querySelectorAll(".qs-stack")).toHaveLength(2);
expect(container.querySelector(".qs-card--personal")).not.toBeNull();
expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1);
});

View File

@@ -639,6 +639,44 @@ function renderPersonalCard(props: QuickSettingsProps) {
<div class="qs-identity-card__eyebrow">User</div>
<div class="qs-identity-card__title">${LOCAL_USER_LABEL}</div>
<div class="qs-identity-card__sub">Avatar is browser-local</div>
<div class="qs-identity-card__repair">
<label class="qs-field">
<span class="qs-row__label">Avatar text / emoji</span>
<input
class="qs-field__input"
type="text"
maxlength="16"
.value=${avatarText}
placeholder="JD or 🦞"
@input=${(e: Event) => {
const value = (e.target as HTMLInputElement).value;
props.onUserAvatarChange?.(value.trim() ? value : null);
}}
/>
</label>
<div class="qs-identity-card__actions">
<label class="btn btn--sm">
Choose image
<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);
}}
>
Clear avatar
</button>
</div>
<div class="muted">Stored in this browser only.</div>
</div>
</div>
</section>
<section
@@ -667,34 +705,36 @@ function renderPersonalCard(props: QuickSettingsProps) {
${canOverrideAssistantAvatar
? html`
<div class="qs-identity-card__repair">
<label class="btn btn--sm">
${props.assistantAvatarUploadBusy
? "Saving..."
: assistantAvatarOverride
? "Replace image"
: "Choose image"}
<input
type="file"
accept="image/*"
hidden
?disabled=${props.assistantAvatarUploadBusy === true}
@change=${(e: Event) => handleAssistantAvatarFileSelect(e, props)}
/>
</label>
${assistantAvatarOverride
? html`
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${props.assistantAvatarUploadBusy === true}
@click=${() => {
void props.onAssistantAvatarClearOverride?.();
}}
>
Clear override
</button>
`
: nothing}
<div class="qs-identity-card__actions">
<label class="btn btn--sm">
${props.assistantAvatarUploadBusy
? "Saving..."
: assistantAvatarOverride
? "Replace image"
: "Choose image"}
<input
type="file"
accept="image/*"
hidden
?disabled=${props.assistantAvatarUploadBusy === true}
@change=${(e: Event) => handleAssistantAvatarFileSelect(e, props)}
/>
</label>
${assistantAvatarOverride
? html`
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${props.assistantAvatarUploadBusy === true}
@click=${() => {
void props.onAssistantAvatarClearOverride?.();
}}
>
Clear override
</button>
`
: nothing}
</div>
<div class="muted">
Stores a Control UI override. Clear it to return to IDENTITY.md.
</div>
@@ -709,43 +749,6 @@ function renderPersonalCard(props: QuickSettingsProps) {
</div>
</section>
</div>
<div class="qs-row">
<label class="qs-field">
<span class="qs-row__label">Avatar text / emoji</span>
<input
class="qs-field__input"
type="text"
maxlength="16"
.value=${avatarText}
placeholder="JD or 🦞"
@input=${(e: Event) => {
const value = (e.target as HTMLInputElement).value;
props.onUserAvatarChange?.(value.trim() ? value : null);
}}
/>
</label>
</div>
<div class="qs-personal-actions">
<label class="btn btn--sm">
Choose image
<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);
}}
>
Clear avatar
</button>
</div>
</div>
</div>
`;
@@ -977,10 +980,6 @@ function renderStack(...cards: TemplateResult[]) {
return html`<div class="qs-stack">${cards}</div>`;
}
function renderWideStack(...cards: TemplateResult[]) {
return html`<div class="qs-stack qs-stack--wide">${cards}</div>`;
}
// ── Main render ──
export function renderQuickSettings(props: QuickSettingsProps) {
@@ -995,9 +994,9 @@ export function renderQuickSettings(props: QuickSettingsProps) {
<div class="qs-grid">
${renderStack(renderModelCard(props), renderSecurityCard(props))}
${renderPersonalCard(props)}
${renderStack(renderChannelsCard(props), renderAutomationsCard(props))}
${renderWideStack(renderAppearanceCard(props))} ${renderPresetsCard(props)}
${renderChannelsCard(props)}
${renderStack(renderAppearanceCard(props), renderAutomationsCard(props))}
${renderPersonalCard(props)} ${renderPresetsCard(props)}
</div>
${renderConnectionFooter(props)}