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
This commit is contained in:
Val Alexander
2026-04-29 07:02:01 -05:00
committed by GitHub
parent 49a6bfe601
commit 1424982792
6 changed files with 324 additions and 17 deletions

View File

@@ -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<string, string>());
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`<div data-testid="quick-settings"></div>`;
},
}));
vi.mock("./views/chat.ts", () => ({
renderChat: () => html`<div data-testid="chat"></div>`,
}));
vi.mock("./icons.ts", () => ({
icons: {},
}));
import { renderApp } from "./app-render.ts";
import { saveLocalAssistantIdentity } from "./storage.ts";
function createState(overrides: Partial<AppViewState> = {}): 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);
});
});

View File

@@ -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<string, unknown> | 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<string, unknown> | undefined)
?.defaults ?? {}) as Record<string, unknown>;
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 ?? "",

View File

@@ -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();

View File

@@ -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;

View File

@@ -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();

View File

@@ -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`<img class="qs-assistant-avatar" src=${assistantAvatarUrl} alt=${assistantName} />`;
}
const assistantAvatarText = resolveAssistantTextAvatar(props.assistantAvatar);
const assistantAvatarText = resolveAssistantTextAvatar(
assistantAvatarOverride ?? props.assistantAvatar,
);
if (assistantAvatarText) {
return html`<div
class="qs-assistant-avatar qs-assistant-avatar--text"
@@ -616,15 +632,19 @@ function renderPersonalCard(props: QuickSettingsProps) {
const assistantName = normalizeOptionalString(props.assistantName) ?? "Assistant";
const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props);
const assistantAvatarRendered = Boolean(
assistantAvatarUrl || resolveAssistantTextAvatar(props.assistantAvatar),
assistantAvatarUrl ||
resolveAssistantTextAvatar(props.assistantAvatarOverride ?? props.assistantAvatar),
);
const assistantAvatarOverride = normalizeOptionalString(props.assistantAvatarOverride);
const assistantAvatarSource = formatAssistantAvatarSource(
assistantAvatarOverride ?? props.assistantAvatarSource,
);
const assistantAvatarSource = formatAssistantAvatarSource(props.assistantAvatarSource);
const assistantAvatarIssue = formatAssistantAvatarIssue(
props.assistantAvatarStatus ?? null,
props.assistantAvatarReason,
assistantAvatarRendered,
Boolean(assistantAvatarOverride),
);
const assistantAvatarOverride = normalizeOptionalString(props.assistantAvatarOverride);
const assistantAvatarSourceLabel = assistantAvatarOverride ? "UI override" : "IDENTITY.md";
const canOverrideAssistantAvatar = Boolean(props.onAssistantAvatarOverrideChange);
const assistantAvatarSubtitle = assistantAvatarOverride