🐛 fix: use custom avatar for group chat in sidebar (#12208)

* 🐛 fix: use custom avatar for group chat in sidebar

When a group chat has a custom avatar set, the sidebar was always showing
the member composition avatar instead. This fix:

- Queries chatGroups.avatar and chatGroups.backgroundColor in HomeRepository
- Prioritizes custom avatar (string) over member avatars (array) in data layer
- Replaces GroupAvatar with AgentGroupAvatar in AgentGroupItem for proper
  avatar type detection (custom vs member composition)

Closes LOBE-4883

*  test: add DB tests for group chat custom avatar in sidebar

Add 6 test cases covering the custom avatar fix for chat groups:

getSidebarAgentList:
- should return custom avatar when chat group has one set
- should return member avatars when chat group has no custom avatar
- should prioritize custom avatar over member avatars

searchAgents:
- should return custom avatar for chat groups with custom avatar in search
- should return member avatars for chat groups without custom avatar in search
- should prioritize custom avatar over member avatars in search
This commit is contained in:
Arvin Xu
2026-02-09 10:04:40 +08:00
committed by GitHub
parent ce8c0c3eaf
commit 31145c9a1f
4 changed files with 251 additions and 9 deletions

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { NewAgent, agents } from '../../schemas/agent';
import { NewChatGroup, chatGroups } from '../../schemas/chatGroup';
import { NewChatGroup, chatGroups, chatGroupsAgents } from '../../schemas/chatGroup';
import { agentsToSessions } from '../../schemas/relations';
import { NewSession, NewSessionGroup, sessionGroups, sessions } from '../../schemas/session';
import { users } from '../../schemas/user';
@@ -158,6 +158,126 @@ describe('HomeRepository', () => {
type: 'group',
});
});
it('should return custom avatar when chat group has one set', async () => {
const [group] = await serverDB
.insert(chatGroups)
.values({
avatar: '🚀',
backgroundColor: '#ff5500',
pinned: false,
title: 'Custom Avatar Group',
userId,
})
.returning();
const result = await homeRepo.getSidebarAgentList();
expect(result.ungrouped).toHaveLength(1);
expect(result.ungrouped[0]).toMatchObject({
avatar: '🚀',
backgroundColor: '#ff5500',
title: 'Custom Avatar Group',
type: 'group',
});
});
it('should return member avatars when chat group has no custom avatar', async () => {
// Create chat group without custom avatar
const [group] = await serverDB
.insert(chatGroups)
.values({
pinned: false,
title: 'No Custom Avatar Group',
userId,
})
.returning();
// Create member agents
const [agent1] = await serverDB
.insert(agents)
.values({
avatar: '🤖',
backgroundColor: '#0000ff',
title: 'Agent 1',
userId,
virtual: true,
})
.returning();
const [agent2] = await serverDB
.insert(agents)
.values({
avatar: '🧑‍💻',
backgroundColor: '#00ff00',
title: 'Agent 2',
userId,
virtual: true,
})
.returning();
// Link agents to group
await serverDB.insert(chatGroupsAgents).values([
{ agentId: agent1.id, chatGroupId: group.id, order: 0, userId },
{ agentId: agent2.id, chatGroupId: group.id, order: 1, userId },
]);
const result = await homeRepo.getSidebarAgentList();
expect(result.ungrouped).toHaveLength(1);
const groupItem = result.ungrouped[0];
expect(groupItem.type).toBe('group');
// Avatar should be an array of member avatars
expect(Array.isArray(groupItem.avatar)).toBe(true);
const avatarArray = groupItem.avatar as Array<{ avatar: string; background?: string }>;
expect(avatarArray).toHaveLength(2);
expect(avatarArray[0]).toMatchObject({ avatar: '🤖', background: '#0000ff' });
expect(avatarArray[1]).toMatchObject({ avatar: '🧑‍💻', background: '#00ff00' });
// backgroundColor should not be set when using member avatars
expect(groupItem.backgroundColor).toBeUndefined();
});
it('should prioritize custom avatar over member avatars', async () => {
// Create chat group WITH custom avatar
const [group] = await serverDB
.insert(chatGroups)
.values({
avatar: '🎯',
backgroundColor: '#ff0000',
pinned: false,
title: 'Group With Both',
userId,
})
.returning();
// Create member agent
const [agent] = await serverDB
.insert(agents)
.values({
avatar: '🤖',
backgroundColor: '#0000ff',
title: 'Member Agent',
userId,
virtual: true,
})
.returning();
// Link agent to group
await serverDB.insert(chatGroupsAgents).values({
agentId: agent.id,
chatGroupId: group.id,
order: 0,
userId,
});
const result = await homeRepo.getSidebarAgentList();
expect(result.ungrouped).toHaveLength(1);
const groupItem = result.ungrouped[0];
// Should use custom avatar (string), not member avatars (array)
expect(groupItem.avatar).toBe('🎯');
expect(groupItem.backgroundColor).toBe('#ff0000');
});
});
describe('getSidebarAgentList - pinned items', () => {
@@ -883,5 +1003,101 @@ describe('HomeRepository', () => {
expect(result[0].title).toBe('New Search Agent');
expect(result[1].title).toBe('Old Search Agent');
});
it('should return custom avatar for chat groups with custom avatar in search', async () => {
await serverDB.insert(chatGroups).values({
avatar: '🎨',
backgroundColor: '#abcdef',
title: 'Searchable Custom Avatar Group',
userId,
});
const result = await homeRepo.searchAgents('Searchable Custom');
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
avatar: '🎨',
backgroundColor: '#abcdef',
title: 'Searchable Custom Avatar Group',
type: 'group',
});
});
it('should return member avatars for chat groups without custom avatar in search', async () => {
// Create chat group without custom avatar
const [group] = await serverDB
.insert(chatGroups)
.values({
title: 'Searchable Member Avatar Group',
userId,
})
.returning();
// Create member agent
const [agent] = await serverDB
.insert(agents)
.values({
avatar: '🤖',
backgroundColor: '#112233',
title: 'Search Member',
userId,
virtual: true,
})
.returning();
await serverDB.insert(chatGroupsAgents).values({
agentId: agent.id,
chatGroupId: group.id,
order: 0,
userId,
});
const result = await homeRepo.searchAgents('Searchable Member Avatar');
expect(result).toHaveLength(1);
const groupItem = result[0];
expect(groupItem.type).toBe('group');
expect(Array.isArray(groupItem.avatar)).toBe(true);
const avatarArray = groupItem.avatar as Array<{ avatar: string; background?: string }>;
expect(avatarArray).toHaveLength(1);
expect(avatarArray[0]).toMatchObject({ avatar: '🤖', background: '#112233' });
expect(groupItem.backgroundColor).toBeUndefined();
});
it('should prioritize custom avatar over member avatars in search', async () => {
// Create chat group WITH custom avatar and members
const [group] = await serverDB
.insert(chatGroups)
.values({
avatar: '🏆',
backgroundColor: '#gold00',
title: 'Searchable Priority Group',
userId,
})
.returning();
const [agent] = await serverDB
.insert(agents)
.values({
avatar: '🤖',
title: 'Priority Member',
userId,
virtual: true,
})
.returning();
await serverDB.insert(chatGroupsAgents).values({
agentId: agent.id,
chatGroupId: group.id,
order: 0,
userId,
});
const result = await homeRepo.searchAgents('Searchable Priority');
expect(result).toHaveLength(1);
expect(result[0].avatar).toBe('🏆');
expect(result[0].backgroundColor).toBe('#gold00');
});
});
});

View File

@@ -59,6 +59,8 @@ export class HomeRepository {
// 2. Query all chatGroups (group chats)
const chatGroupList = await this.db
.select({
avatar: chatGroups.avatar,
backgroundColor: chatGroups.backgroundColor,
description: chatGroups.description,
groupId: chatGroups.groupId,
id: chatGroups.id,
@@ -102,6 +104,8 @@ export class HomeRepository {
updatedAt: Date;
}>,
chatGroupItems: Array<{
avatar: string | null;
backgroundColor: string | null;
description: string | null;
groupId: string | null;
id: string;
@@ -132,7 +136,9 @@ export class HomeRepository {
updatedAt: a.updatedAt,
})),
...chatGroupItems.map((g) => ({
avatar: memberAvatarsMap.get(g.id) ?? null,
// If group has custom avatar, use it (string); otherwise fallback to member avatars (array)
avatar: g.avatar ? g.avatar : (memberAvatarsMap.get(g.id) ?? null),
backgroundColor: g.avatar ? g.backgroundColor : null,
description: g.description,
groupId: g.groupId,
id: g.id,
@@ -214,6 +220,8 @@ export class HomeRepository {
// 2. Search chat groups by title or description
const chatGroupResults = await this.db
.select({
avatar: chatGroups.avatar,
backgroundColor: chatGroups.backgroundColor,
description: chatGroups.description,
id: chatGroups.id,
pinned: chatGroups.pinned,
@@ -250,7 +258,8 @@ export class HomeRepository {
),
...chatGroupResults.map((g) =>
cleanObject({
avatar: memberAvatarsMap.get(g.id),
avatar: g.avatar ? g.avatar : (memberAvatarsMap.get(g.id) ?? null),
backgroundColor: g.avatar ? g.backgroundColor : null,
description: g.description,
id: g.id,
pinned: g.pinned ?? false,

View File

@@ -17,11 +17,15 @@ export interface GroupMemberAvatar {
export interface SidebarAgentItem {
/**
* Avatar can be:
* - string: single avatar for agents
* - GroupMemberAvatar[]: array of member avatars for groups
* - string: single avatar for agents or custom group avatar
* - GroupMemberAvatar[]: array of member avatars for groups (when no custom avatar)
* - null: no avatar
*/
avatar?: GroupMemberAvatar[] | string | null;
/**
* Background color for the avatar (used for custom group avatars)
*/
backgroundColor?: string | null;
description?: string | null;
id: string;
pinned: boolean;

View File

@@ -7,7 +7,7 @@ import { type CSSProperties, type DragEvent, memo, useCallback, useMemo } from '
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import GroupAvatar from '@/features/GroupAvatar';
import AgentGroupAvatar from '@/features/AgentGroupAvatar';
import NavItem from '@/features/NavPanel/components/NavItem';
import { useGlobalStore } from '@/store/global';
import { useHomeStore } from '@/store/home';
@@ -23,7 +23,7 @@ interface GroupItemProps {
}
const GroupItem = memo<GroupItemProps>(({ item, style, className }) => {
const { id, avatar, title, pinned } = item;
const { id, avatar, backgroundColor, title, pinned } = item;
const { t } = useTranslation('chat');
const openAgentInNewWindow = useGlobalStore((s) => s.openAgentInNewWindow);
@@ -82,8 +82,21 @@ const GroupItem = memo<GroupItemProps>(({ item, style, className }) => {
if (isUpdating) {
return <Icon color={cssVar.colorTextDescription} icon={Loader2} size={18} spin />;
}
return <GroupAvatar avatars={(avatar as any) || []} size={22} />;
}, [isUpdating, avatar]);
// If avatar is a string, it's a custom group avatar
const customAvatar = typeof avatar === 'string' ? avatar : undefined;
// If avatar is an array, it's member avatars for composition
const memberAvatars = Array.isArray(avatar) ? avatar : [];
return (
<AgentGroupAvatar
avatar={customAvatar}
backgroundColor={backgroundColor || undefined}
memberAvatars={memberAvatars}
size={22}
/>
);
}, [isUpdating, avatar, backgroundColor]);
const dropdownMenu = useGroupDropdownMenu({
id,