diff --git a/.gitignore b/.gitignore index 2aade7c2b05..d04f807eadf 100644 --- a/.gitignore +++ b/.gitignore @@ -146,6 +146,7 @@ changelog/fragments/ # Local scratch workspace .tmp/ +.vmux* .artifacts/ test/fixtures/openclaw-vitest-unit-report.json analysis/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d6898cbcb..d300394b66b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 7f886b4d375..cabf999a0da 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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.", diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 420f818e2a6..10a4d561e5c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), diff --git a/src/gateway/assistant-identity.test.ts b/src/gateway/assistant-identity.test.ts index f91619baf8d..3d2f34a302d 100644 --- a/src/gateway/assistant-identity.test.ts +++ b/src/gateway/assistant-identity.test.ts @@ -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); + }); }); diff --git a/src/gateway/assistant-identity.ts b/src/gateway/assistant-identity.ts index b5dcfca1614..519887554a5 100644 --- a/src/gateway/assistant-identity.ts +++ b/src/gateway/assistant-identity.ts @@ -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 = { diff --git a/ui/src/styles/config-quick.css b/ui/src/styles/config-quick.css index ea70b8358f4..d885cdf4c16 100644 --- a/ui/src/styles/config-quick.css +++ b/ui/src/styles/config-quick.css @@ -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; } } diff --git a/ui/src/styles/config-quick.test.ts b/ui/src/styles/config-quick.test.ts index 256b4aaebd1..12361451efa 100644 --- a/ui/src/styles/config-quick.test.ts +++ b/ui/src/styles/config-quick.test.ts @@ -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)"); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a6c1edeadaf..e5c559239a3 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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, diff --git a/ui/src/ui/assistant-identity.test.ts b/ui/src/ui/assistant-identity.test.ts new file mode 100644 index 00000000000..c37fffd3c42 --- /dev/null +++ b/ui/src/ui/assistant-identity.test.ts @@ -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(); + }); +}); diff --git a/ui/src/ui/assistant-identity.ts b/ui/src/ui/assistant-identity.ts index a529fba986e..a1f29c86628 100644 --- a/ui/src/ui/assistant-identity.ts +++ b/ui/src/ui/assistant-identity.ts @@ -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 | 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 }; diff --git a/ui/src/ui/controllers/assistant-identity.test.ts b/ui/src/ui/controllers/assistant-identity.test.ts index 0283696c915..79e7707ae23 100644 --- a/ui/src/ui/controllers/assistant-identity.test.ts +++ b/ui/src/ui/controllers/assistant-identity.test.ts @@ -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[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[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(); }); }); diff --git a/ui/src/ui/controllers/assistant-identity.ts b/ui/src/ui/controllers/assistant-identity.ts index 756718241e0..13471a67cff 100644 --- a/ui/src/ui/controllers/assistant-identity.ts +++ b/ui/src/ui/controllers/assistant-identity.ts @@ -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.", - }); } diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts index be7083f127f..17706472068 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -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") diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index bf862528b52..b13d3284bc9 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -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; + 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(); diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index be72b905414..c3aa07633ff 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -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); }); diff --git a/ui/src/ui/views/config-quick.ts b/ui/src/ui/views/config-quick.ts index e0a9ce59454..2d284557d7f 100644 --- a/ui/src/ui/views/config-quick.ts +++ b/ui/src/ui/views/config-quick.ts @@ -639,6 +639,44 @@ function renderPersonalCard(props: QuickSettingsProps) {
User
${LOCAL_USER_LABEL}
Avatar is browser-local
+
+ +
+ + +
+
Stored in this browser only.
+
- - ${assistantAvatarOverride - ? html` - - ` - : nothing} +
+ + ${assistantAvatarOverride + ? html` + + ` + : nothing} +
Stores a Control UI override. Clear it to return to IDENTITY.md.
@@ -709,43 +749,6 @@ function renderPersonalCard(props: QuickSettingsProps) {
-
- -
-
- - -
`; @@ -977,10 +980,6 @@ function renderStack(...cards: TemplateResult[]) { return html`
${cards}
`; } -function renderWideStack(...cards: TemplateResult[]) { - return html`
${cards}
`; -} - // ── Main render ── export function renderQuickSettings(props: QuickSettingsProps) { @@ -995,9 +994,9 @@ export function renderQuickSettings(props: QuickSettingsProps) {
${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)}
${renderConnectionFooter(props)}