Skip to content

Commit 70cf07e

Browse files
committed
fix(ui): scope local assistant avatar overrides per agent (#90890)
The Control UI stored the assistant avatar override under a single global localStorage key (openclaw.control.assistant.v1). As a result, every agent shared the same avatar: setting one agent's avatar overwrote all others. - Change loadLocalAssistantIdentity/saveLocalAssistantIdentity to accept an optional agentId and persist overrides under an agents map - Migrate legacy flat avatar entries on save (the old avatar is preserved as a fallback until the user saves a per-agent override) - Update loadAssistantIdentity and setAssistantAvatarOverride to pass the current agent id through to storage - Add tests proving main and worker agents can have independent avatars Test plan: - pnpm test ui/src/ui/controllers/assistant-identity.test.ts (5/5 pass) Closes #90890
1 parent ab7c922 commit 70cf07e

3 files changed

Lines changed: 117 additions & 8 deletions

File tree

ui/src/ui/controllers/assistant-identity.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,63 @@ describe("setAssistantAvatarOverride", () => {
9696
expect(state.assistantAvatarReason).toBeNull();
9797
expect(loadLocalAssistantIdentity().avatar).toBeNull();
9898
});
99+
100+
it("scopes local avatar overrides per agent", () => {
101+
const mainState: Parameters<typeof setAssistantAvatarOverride>[0] = {
102+
assistantAgentId: "main",
103+
};
104+
const workerState: Parameters<typeof setAssistantAvatarOverride>[0] = {
105+
assistantAgentId: "worker",
106+
};
107+
108+
setAssistantAvatarOverride(mainState, "data:image/png;base64,main");
109+
setAssistantAvatarOverride(workerState, "data:image/png;base64,worker");
110+
111+
expect(loadLocalAssistantIdentity("main").avatar).toBe("data:image/png;base64,main");
112+
expect(loadLocalAssistantIdentity("worker").avatar).toBe("data:image/png;base64,worker");
113+
114+
setAssistantAvatarOverride(mainState, null);
115+
116+
expect(loadLocalAssistantIdentity("main").avatar).toBeNull();
117+
expect(loadLocalAssistantIdentity("worker").avatar).toBe("data:image/png;base64,worker");
118+
});
119+
120+
it("applies the local override only for the matching agent on load", async () => {
121+
const mainState: Parameters<typeof setAssistantAvatarOverride>[0] = {
122+
assistantAgentId: "main",
123+
};
124+
const workerState: Parameters<typeof setAssistantAvatarOverride>[0] = {
125+
assistantAgentId: "worker",
126+
};
127+
setAssistantAvatarOverride(mainState, "data:image/png;base64,main");
128+
setAssistantAvatarOverride(workerState, "data:image/png;base64,worker");
129+
130+
const request = vi.fn().mockResolvedValue({
131+
agentId: "main",
132+
name: "Main",
133+
avatar: "server-main.png",
134+
});
135+
const state: Parameters<typeof loadAssistantIdentity>[0] = {
136+
client: { request } as never,
137+
connected: true,
138+
sessionKey: "agent:main:main",
139+
assistantName: "Main",
140+
assistantAvatar: null,
141+
assistantAgentId: "main",
142+
};
143+
144+
await loadAssistantIdentity(state);
145+
expect(state.assistantAvatar).toBe("data:image/png;base64,main");
146+
147+
request.mockResolvedValue({
148+
agentId: "worker",
149+
name: "Worker",
150+
avatar: "server-worker.png",
151+
});
152+
state.sessionKey = "agent:worker:main";
153+
state.assistantAgentId = "worker";
154+
155+
await loadAssistantIdentity(state);
156+
expect(state.assistantAvatar).toBe("data:image/png;base64,worker");
157+
});
99158
});

ui/src/ui/controllers/assistant-identity.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ export async function loadAssistantIdentity(
6868
state.assistantAvatarReason = normalized.avatarReason ?? null;
6969
state.assistantAgentId = normalized.agentId ?? null;
7070
// Local override always wins — same pattern as the user avatar.
71-
const localAvatar = loadLocalAssistantIdentity().avatar;
71+
// Scope the override to the current agent so different agents can have
72+
// different avatars without clobbering each other.
73+
const localAvatar = loadLocalAssistantIdentity(normalized.agentId ?? null).avatar;
7274
if (localAvatar) {
7375
state.assistantAvatar = localAvatar;
7476
state.assistantAvatarSource = localAvatar;
@@ -81,10 +83,10 @@ export async function loadAssistantIdentity(
8183
}
8284

8385
export function setAssistantAvatarOverride(
84-
state: AssistantAvatarOverrideState,
86+
state: AssistantAvatarOverrideState & { assistantAgentId?: string | null },
8587
avatar: string | null,
8688
) {
87-
saveLocalAssistantIdentity({ avatar });
89+
saveLocalAssistantIdentity({ avatar }, state.assistantAgentId ?? null);
8890
if (avatar) {
8991
state.assistantAvatar = avatar;
9092
state.assistantAvatarSource = avatar;

ui/src/ui/storage.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -350,28 +350,76 @@ export function saveLocalUserIdentity(next: LocalUserIdentity) {
350350

351351
export type LocalAssistantIdentity = { avatar: string | null };
352352

353-
export function loadLocalAssistantIdentity(): LocalAssistantIdentity {
353+
type PersistedLocalAssistantIdentity = {
354+
// Legacy flat avatar stored before per-agent isolation.
355+
avatar?: string | null;
356+
// Per-agent avatar overrides. Key is the normalized agent id.
357+
agents?: Record<string, LocalAssistantIdentity>;
358+
};
359+
360+
export function loadLocalAssistantIdentity(agentId?: string | null): LocalAssistantIdentity {
354361
const storage = getSafeLocalStorage();
362+
const normalizedAgentId = normalizeOptionalString(agentId) ?? "main";
355363
try {
356364
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
357365
if (!raw) {
358366
return { avatar: null };
359367
}
360-
const parsed = JSON.parse(raw) as Partial<LocalAssistantIdentity>;
361-
return { avatar: typeof parsed.avatar === "string" ? parsed.avatar : null };
368+
const parsed = JSON.parse(raw) as Partial<PersistedLocalAssistantIdentity>;
369+
// Per-agent storage takes precedence.
370+
if (parsed.agents && typeof parsed.agents === "object") {
371+
const agentEntry = parsed.agents[normalizedAgentId];
372+
if (agentEntry && typeof agentEntry.avatar === "string") {
373+
return { avatar: agentEntry.avatar };
374+
}
375+
}
376+
// Legacy flat fallback: migrate on save, but for now treat the legacy
377+
// avatar as belonging to the requested agent so existing users do not
378+
// suddenly lose their override.
379+
if (typeof parsed.avatar === "string") {
380+
return { avatar: parsed.avatar };
381+
}
382+
return { avatar: null };
362383
} catch {
363384
return { avatar: null };
364385
}
365386
}
366387

367-
export function saveLocalAssistantIdentity(next: LocalAssistantIdentity) {
388+
export function saveLocalAssistantIdentity(next: LocalAssistantIdentity, agentId?: string | null) {
368389
const storage = getSafeLocalStorage();
390+
const normalizedAgentId = normalizeOptionalString(agentId) ?? "main";
369391
try {
392+
let parsed: PersistedLocalAssistantIdentity = {};
393+
try {
394+
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
395+
if (raw) {
396+
parsed = JSON.parse(raw) as PersistedLocalAssistantIdentity;
397+
}
398+
} catch {
399+
// ignore parse errors; start fresh
400+
}
401+
402+
const nextAgents: Record<string, LocalAssistantIdentity> =
403+
parsed.agents && typeof parsed.agents === "object" ? { ...parsed.agents } : {};
404+
370405
if (!next.avatar) {
406+
delete nextAgents[normalizedAgentId];
407+
} else {
408+
nextAgents[normalizedAgentId] = { avatar: next.avatar };
409+
}
410+
411+
const persisted: PersistedLocalAssistantIdentity = {
412+
...parsed,
413+
agents: nextAgents,
414+
};
415+
// Once migrated to per-agent storage, drop the legacy flat key.
416+
delete persisted.avatar;
417+
418+
if (Object.keys(persisted.agents ?? {}).length === 0) {
371419
storage?.removeItem(LOCAL_ASSISTANT_IDENTITY_KEY);
372420
return;
373421
}
374-
storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify({ avatar: next.avatar }));
422+
storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify(persisted));
375423
} catch {
376424
// best-effort — quota exceeded or security restrictions should not
377425
// prevent in-memory identity updates from being applied

0 commit comments

Comments
 (0)