From 14249827928ebefbb7ccf20ebadc4f030cd34167 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:02:01 -0500 Subject: [PATCH] fix(ui): keep assistant avatar overrides authoritative Summary: - Make browser-local assistant avatar overrides win over stale missing IDENTITY.md avatar metadata. - Show the selected assistant image in Personal settings and chat instead of a false File not found state. - Add focused Control UI coverage for assistant avatar override and clear behavior. Validation: - pnpm test ui/src/ui/app-render.assistant-avatar.test.ts ui/src/ui/views/config-quick.test.ts ui/src/ui/controllers/assistant-identity.test.ts -- --reporter=verbose - pnpm tsgo:core:test - pnpm deadcode:dependencies - pnpm deadcode:unused-files - CI green on PR #74260 --- ui/src/ui/app-render.assistant-avatar.test.ts | 230 ++++++++++++++++++ ui/src/ui/app-render.ts | 45 +++- .../ui/controllers/assistant-identity.test.ts | 1 + ui/src/ui/controllers/assistant-identity.ts | 1 + ui/src/ui/views/config-quick.test.ts | 36 +++ ui/src/ui/views/config-quick.ts | 28 ++- 6 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 ui/src/ui/app-render.assistant-avatar.test.ts diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts new file mode 100644 index 00000000000..01cbb365519 --- /dev/null +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -0,0 +1,230 @@ +/* @vitest-environment jsdom */ + +import { html } from "lit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AppViewState } from "./app-view-state.ts"; +import type { QuickSettingsProps } from "./views/config-quick.ts"; + +const quickSettingsProps = vi.hoisted(() => ({ + current: null as QuickSettingsProps | null, +})); +const localStorageValues = vi.hoisted(() => new Map()); + +vi.mock("../local-storage.ts", () => ({ + getSafeLocalStorage: () => ({ + getItem: (key: string) => localStorageValues.get(key) ?? null, + removeItem: (key: string) => localStorageValues.delete(key), + setItem: (key: string, value: string) => localStorageValues.set(key, value), + }), + getSafeSessionStorage: () => null, +})); + +vi.mock("./views/config-quick.ts", () => ({ + renderQuickSettings: (props: QuickSettingsProps) => { + quickSettingsProps.current = props; + return html`
`; + }, +})); + +vi.mock("./views/chat.ts", () => ({ + renderChat: () => html`
`, +})); + +vi.mock("./icons.ts", () => ({ + icons: {}, +})); + +import { renderApp } from "./app-render.ts"; +import { saveLocalAssistantIdentity } from "./storage.ts"; + +function createState(overrides: Partial = {}): AppViewState { + return { + settings: { + gatewayUrl: "ws://localhost:18789", + token: "", + locale: "en", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "dark", + splitRatio: 0.6, + navWidth: 280, + navCollapsed: false, + navGroupsCollapsed: {}, + borderRadius: 50, + chatFocusMode: false, + chatShowThinking: false, + chatShowToolCalls: true, + }, + password: "", + loginShowGatewayToken: false, + loginShowGatewayPassword: false, + tab: "config", + onboarding: false, + basePath: "", + connected: true, + theme: "claw", + themeMode: "dark", + themeResolved: "dark", + themeOrder: ["claw", "knot", "dash"], + customThemeImportUrl: "", + customThemeImportBusy: false, + customThemeImportMessage: null, + customThemeImportExpanded: false, + customThemeImportFocusToken: 0, + hello: null, + lastError: null, + lastErrorCode: null, + eventLog: [], + assistantName: "Nova", + assistantAvatar: "/avatar/main", + assistantAvatarSource: "avatars/missing.png", + assistantAvatarStatus: "none", + assistantAvatarReason: "missing", + assistantAvatarUploadBusy: false, + assistantAvatarUploadError: null, + assistantAgentId: "main", + userName: null, + userAvatar: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts", + allowExternalEmbedUrls: false, + sessionKey: "main", + chatLoading: false, + chatSending: false, + chatMessage: "", + chatAttachments: [], + chatMessages: [], + chatToolMessages: [], + chatStreamSegments: [], + chatStream: null, + chatStreamStartedAt: null, + chatRunId: null, + chatSideResult: null, + chatSideResultTerminalRuns: new Set(), + compactionStatus: null, + fallbackStatus: null, + chatAvatarUrl: null, + chatAvatarSource: null, + chatAvatarStatus: null, + chatAvatarReason: null, + chatThinkingLevel: null, + chatModelOverrides: {}, + chatModelsLoading: false, + chatModelCatalog: [], + chatQueue: [], + chatQueueBySession: {}, + chatLocalInputHistoryBySession: {}, + chatInputHistorySessionKey: null, + chatInputHistoryItems: null, + chatInputHistoryIndex: -1, + chatDraftBeforeHistory: null, + realtimeTalkActive: false, + realtimeTalkStatus: "idle", + realtimeTalkDetail: null, + realtimeTalkTranscript: null, + chatManualRefreshInFlight: false, + nodesLoading: false, + nodes: [], + chatNewMessagesBelow: false, + navDrawerOpen: false, + sidebarOpen: false, + sidebarContent: null, + sidebarError: null, + splitRatio: 0.6, + scrollToBottom: vi.fn(), + presenceEntries: [], + sessionsResult: null, + cronStatus: null, + configSettingsMode: "quick", + configForm: {}, + configSnapshot: { config: {}, hash: "hash" } as AppViewState["configSnapshot"], + configFormDirty: false, + configSaving: false, + configApplying: false, + cronJobs: [], + skillsReport: { + skills: [], + workspaceDir: "", + managedSkillsDir: "", + } as AppViewState["skillsReport"], + configActiveSection: null, + configActiveSubsection: null, + communicationsActiveSection: null, + communicationsActiveSubsection: null, + appearanceActiveSection: null, + appearanceActiveSubsection: null, + appearanceFormMode: "form", + appearanceSearchQuery: "", + automationActiveSection: null, + automationActiveSubsection: null, + infrastructureActiveSection: null, + infrastructureActiveSubsection: null, + aiAgentsActiveSection: null, + aiAgentsActiveSubsection: null, + configReady: true, + configRaw: "", + configRawOriginal: "", + configValid: true, + configIssues: [], + configLoading: false, + configSchema: null, + configSchemaLoading: false, + configUiHints: null, + configFormOriginal: {}, + updateRunning: false, + agentsList: null, + agentsSelectedId: null, + cronModelSuggestions: [], + cronForm: { deliveryChannel: "", deliveryMode: "last" }, + cronFieldErrors: {}, + cronError: null, + cronQuickCreateOpen: false, + cronQuickCreateStep: "what", + cronQuickCreateDraft: null, + cronEditingJobId: null, + channelsSnapshot: null, + execApprovalQueue: [], + dreamingRestartConfirmOpen: false, + dreamingRestartConfirmLoading: false, + dreamingStatusError: null, + client: null, + refreshSessionsAfterChat: new Set(), + connect: vi.fn(), + setTab: vi.fn(), + setTheme: vi.fn(), + setThemeMode: vi.fn(), + setCustomThemeImportUrl: vi.fn(), + openCustomThemeImport: vi.fn(), + importCustomTheme: vi.fn(), + clearCustomTheme: vi.fn(), + setBorderRadius: vi.fn(), + applySettings: vi.fn(), + applyLocalUserIdentity: vi.fn(), + loadOverview: vi.fn(), + loadAssistantIdentity: vi.fn(), + loadCron: vi.fn(), + ...overrides, + } as unknown as AppViewState; +} + +beforeEach(() => { + localStorageValues.clear(); + quickSettingsProps.current = null; +}); + +describe("renderApp assistant avatar routing", () => { + it("passes the browser-local assistant override to Quick Settings ahead of stale identity metadata", () => { + const dataUrl = "data:image/png;base64,bG9jYWwtYXNzaXN0YW50"; + saveLocalAssistantIdentity({ avatar: dataUrl }); + + renderApp(createState()); + + expect(quickSettingsProps.current?.assistantAvatar).toBe(dataUrl); + expect(quickSettingsProps.current?.assistantAvatarUrl).toBe(dataUrl); + expect(quickSettingsProps.current?.assistantAvatarSource).toBe(dataUrl); + expect(quickSettingsProps.current?.assistantAvatarStatus).toBe("data"); + expect(quickSettingsProps.current?.assistantAvatarReason).toBeNull(); + expect(quickSettingsProps.current?.assistantAvatarOverride).toBe(dataUrl); + }); +}); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 0b2ee0cc82c..2680bc202d6 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -118,13 +118,14 @@ import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { createLazyView, renderLazyView } from "./lazy-view.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; -import "./components/dashboard-header.ts"; import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts"; +import "./components/dashboard-header.ts"; import { buildAgentMainSessionKey, parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "./session-key.ts"; +import { loadLocalAssistantIdentity } from "./storage.ts"; import { normalizeOptionalString } from "./string-coerce.ts"; import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts"; import { agentLogoUrl } from "./views/agents-utils.ts"; @@ -637,28 +638,44 @@ export function renderApp(state: AppViewState) { const navCollapsed = state.settings.navCollapsed && !navDrawerOpen; const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls; + const localAssistantAvatarOverride = + normalizeOptionalString(loadLocalAssistantIdentity().avatar) ?? null; const assistantAvatarUrl = resolveAssistantAvatarUrl(state); - const chatAssistantAvatarStatus = state.chatAvatarStatus ?? state.assistantAvatarStatus ?? null; - const chatAssistantAvatarReason = state.chatAvatarReason ?? state.assistantAvatarReason ?? null; + const chatAssistantAvatarStatus = localAssistantAvatarOverride + ? "data" + : (state.chatAvatarStatus ?? state.assistantAvatarStatus ?? null); + const chatAssistantAvatarReason = localAssistantAvatarOverride + ? null + : (state.chatAvatarReason ?? state.assistantAvatarReason ?? null); const chatAssistantAvatarMissing = chatAssistantAvatarStatus === "none" && chatAssistantAvatarReason === "missing"; - const effectiveAssistantAvatar = chatAssistantAvatarMissing ? null : state.assistantAvatar; + const effectiveAssistantAvatar = + localAssistantAvatarOverride ?? (chatAssistantAvatarMissing ? null : state.assistantAvatar); const chatAvatarUrl = - state.chatAvatarUrl ?? (chatAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null)); - const configAssistantAvatarStatus = state.assistantAvatarStatus ?? state.chatAvatarStatus ?? null; - const configAssistantAvatarReason = state.assistantAvatarReason ?? state.chatAvatarReason ?? null; - const configAssistantAvatarSource = state.assistantAvatarSource ?? state.chatAvatarSource ?? null; + localAssistantAvatarOverride ?? + state.chatAvatarUrl ?? + (chatAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null)); + const configAssistantAvatarStatus = localAssistantAvatarOverride + ? "data" + : (state.assistantAvatarStatus ?? state.chatAvatarStatus ?? null); + const configAssistantAvatarReason = localAssistantAvatarOverride + ? null + : (state.assistantAvatarReason ?? state.chatAvatarReason ?? null); + const configAssistantAvatarSource = + localAssistantAvatarOverride ?? state.assistantAvatarSource ?? state.chatAvatarSource ?? null; const configAssistantAvatarMissing = configAssistantAvatarStatus === "none" && configAssistantAvatarReason === "missing"; const configAssistantAvatar = - configAssistantAvatarMissing || configAssistantAvatarStatus === "local" + localAssistantAvatarOverride ?? + (configAssistantAvatarMissing || configAssistantAvatarStatus === "local" ? null - : state.assistantAvatar; + : state.assistantAvatar); const configAssistantAvatarUrl = - configAssistantAvatarStatus === "local" && state.assistantAgentId + localAssistantAvatarOverride ?? + (configAssistantAvatarStatus === "local" && state.assistantAgentId ? buildAssistantAvatarRoute(state.basePath, state.assistantAgentId) : (state.chatAvatarUrl ?? - (configAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null))); + (configAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null)))); const configValue = state.configForm ?? (state.configSnapshot?.config as Record | null); const configuredDreaming = resolveConfiguredDreaming(configValue); @@ -956,7 +973,8 @@ export function renderApp(state: AppViewState) { // Quick Settings mode — opinionated card layout if (state.configSettingsMode === "quick") { const configObj = state.configForm ?? state.configSnapshot?.config ?? {}; - const assistantAvatarOverride = resolveAssistantAvatarOverride(configObj); + const assistantAvatarOverride = + localAssistantAvatarOverride ?? resolveAssistantAvatarOverride(configObj); const agentsDefaults = ((configObj.agents as Record | undefined) ?.defaults ?? {}) as Record; const activeSession = resolveQuickSettingsSessionRow(state); @@ -1069,6 +1087,7 @@ export function renderApp(state: AppViewState) { state.chatAvatarStatus = null; state.chatAvatarReason = null; state.assistantAvatarUploadError = null; + void state.loadAssistantIdentity?.().finally(() => requestHostUpdate?.()); requestHostUpdate?.(); }, basePath: state.basePath ?? "", diff --git a/ui/src/ui/controllers/assistant-identity.test.ts b/ui/src/ui/controllers/assistant-identity.test.ts index 33fa82ce9b9..2b18d927da4 100644 --- a/ui/src/ui/controllers/assistant-identity.test.ts +++ b/ui/src/ui/controllers/assistant-identity.test.ts @@ -87,6 +87,7 @@ describe("setAssistantAvatarOverride", () => { setAssistantAvatarOverride(state, null); + expect(state.assistantAvatar).toBeNull(); expect(state.assistantAvatarSource).toBeNull(); expect(state.assistantAvatarStatus).toBeNull(); expect(state.assistantAvatarReason).toBeNull(); diff --git a/ui/src/ui/controllers/assistant-identity.ts b/ui/src/ui/controllers/assistant-identity.ts index 161fbe73456..f8cad2cdfca 100644 --- a/ui/src/ui/controllers/assistant-identity.ts +++ b/ui/src/ui/controllers/assistant-identity.ts @@ -90,6 +90,7 @@ export function setAssistantAvatarOverride( state.assistantAvatarStatus = "data"; state.assistantAvatarReason = null; } else { + state.assistantAvatar = null; state.assistantAvatarSource = null; state.assistantAvatarStatus = null; state.assistantAvatarReason = null; diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index 9d6a847a9d3..17cf2f8cf0c 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -253,6 +253,42 @@ describe("renderQuickSettings", () => { expect(onAssistantAvatarClearOverride).toHaveBeenCalledTimes(1); }); + it("lets the browser-local assistant avatar override stale missing IDENTITY.md metadata", () => { + const dataUrl = "data:image/png;base64,bG9jYWwtYXNzaXN0YW50"; + const container = document.createElement("div"); + + render( + renderQuickSettings( + createProps({ + assistantName: "Nova", + assistantAvatar: "/avatar/main", + assistantAvatarUrl: null, + assistantAvatarSource: "avatars/missing.png", + assistantAvatarStatus: "none", + assistantAvatarReason: "missing", + assistantAvatarOverride: dataUrl, + }), + ), + container, + ); + + expect(container.querySelector(".qs-assistant-avatar")?.getAttribute("src")).toBe(dataUrl); + expect(container.querySelector(".qs-identity-card__source")?.textContent).toContain( + "UI override", + ); + expect(container.querySelector(".qs-identity-card__issue")).toBeNull(); + expect( + Array.from(container.querySelectorAll("label.btn")).some( + (label) => label.textContent?.trim() === "Replace image", + ), + ).toBe(true); + expect( + Array.from(container.querySelectorAll("button")).some( + (button) => button.textContent?.trim() === "Clear override", + ), + ).toBe(true); + }); + it("rejects oversized avatar uploads before reading them", () => { const onUserAvatarChange = vi.fn(); const fileReader = vi.fn(); diff --git a/ui/src/ui/views/config-quick.ts b/ui/src/ui/views/config-quick.ts index dc35c5d6a63..e1b4f1ad05d 100644 --- a/ui/src/ui/views/config-quick.ts +++ b/ui/src/ui/views/config-quick.ts @@ -171,6 +171,15 @@ function renderLocalUserAvatarPreview(avatar: string | null | undefined) { } function resolveAssistantPreviewAvatarUrl(props: QuickSettingsProps): string | null { + const override = normalizeOptionalString(props.assistantAvatarOverride); + if (override) { + return resolveChatAvatarRenderUrl(override, { + identity: { + avatar: override, + avatarUrl: override, + }, + }); + } if (props.assistantAvatarStatus === "none" && props.assistantAvatarReason === "missing") { return null; } @@ -198,7 +207,11 @@ function formatAssistantAvatarIssue( status: QuickSettingsProps["assistantAvatarStatus"], reason: string | null | undefined, _rendered: boolean, + hasOverride = false, ): string | null { + if (hasOverride) { + return null; + } if (status === "remote") { return "Remote URLs are blocked by Control UI image policy"; } @@ -219,11 +232,14 @@ function formatAssistantAvatarIssue( function renderAssistantAvatarPreview(props: QuickSettingsProps) { const assistantName = normalizeOptionalString(props.assistantName) ?? "Assistant"; + const assistantAvatarOverride = normalizeOptionalString(props.assistantAvatarOverride); const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props); if (assistantAvatarUrl) { return html`${assistantName}`; } - const assistantAvatarText = resolveAssistantTextAvatar(props.assistantAvatar); + const assistantAvatarText = resolveAssistantTextAvatar( + assistantAvatarOverride ?? props.assistantAvatar, + ); if (assistantAvatarText) { return html`