feat: switch team member avatars to local assets
|
|
@ -11,8 +11,10 @@
|
|||
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
buildMemberLaunchPresentation,
|
||||
getMemberRuntimeAdvisoryLabel,
|
||||
resolveMemberAvatarUrl,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
|
||||
|
|
@ -143,6 +145,7 @@ export class TeamGraphAdapter {
|
|||
const leadId = `lead:${teamName}`;
|
||||
const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName);
|
||||
const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName);
|
||||
const avatarMap = buildMemberAvatarMap(teamData.members);
|
||||
const provisioningPresentation = buildTeamProvisioningPresentation({
|
||||
progress: provisioningProgress,
|
||||
members: teamData.members,
|
||||
|
|
@ -158,6 +161,7 @@ export class TeamGraphAdapter {
|
|||
teamData,
|
||||
teamName,
|
||||
leadName,
|
||||
avatarMap,
|
||||
pendingApprovalAgents,
|
||||
leadActivity,
|
||||
leadContext,
|
||||
|
|
@ -173,6 +177,7 @@ export class TeamGraphAdapter {
|
|||
teamData,
|
||||
teamName,
|
||||
memberNodeIdByAlias,
|
||||
avatarMap,
|
||||
spawnStatuses,
|
||||
pendingApprovalAgents,
|
||||
activeTools,
|
||||
|
|
@ -369,6 +374,7 @@ export class TeamGraphAdapter {
|
|||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
avatarMap: ReadonlyMap<string, string>,
|
||||
pendingApprovalAgents?: Set<string>,
|
||||
leadActivity?: LeadActivityState,
|
||||
leadContext?: LeadContextUsage,
|
||||
|
|
@ -428,7 +434,9 @@ export class TeamGraphAdapter {
|
|||
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
||||
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
||||
avatarUrl: agentAvatarUrl(leadName, 64),
|
||||
avatarUrl: leadMember
|
||||
? resolveMemberAvatarUrl(leadMember, avatarMap, 64)
|
||||
: agentAvatarUrl(leadName, 64),
|
||||
pendingApproval,
|
||||
activeTool: activeTool
|
||||
? {
|
||||
|
|
@ -465,6 +473,7 @@ export class TeamGraphAdapter {
|
|||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||
avatarMap: ReadonlyMap<string, string>,
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||
pendingApprovalAgents?: Set<string>,
|
||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||
|
|
@ -520,7 +529,7 @@ export class TeamGraphAdapter {
|
|||
spawnStatus: spawn?.status,
|
||||
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
||||
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
|
||||
avatarUrl: agentAvatarUrl(member.name, 64),
|
||||
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
|
||||
currentTaskId: member.currentTaskId ?? undefined,
|
||||
currentTaskSubject: member.currentTaskId
|
||||
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
||||
|
|
|
|||
|
|
@ -4,9 +4,15 @@
|
|||
* composes project-specific UI, selectors, and presentation helpers.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { agentAvatarUrl, buildMemberLaunchPresentation } from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
buildMemberLaunchPresentation,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
||||
|
||||
|
|
@ -291,7 +297,6 @@ const MemberPopoverContent = ({
|
|||
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
|
||||
? node.domainRef.teamName
|
||||
: '';
|
||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
||||
const {
|
||||
teamData,
|
||||
teamMembers,
|
||||
|
|
@ -301,6 +306,8 @@ const MemberPopoverContent = ({
|
|||
memberSpawnSnapshot,
|
||||
memberSpawnStatuses,
|
||||
} = useGraphMemberPopoverContext(teamName, memberName);
|
||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
|
||||
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
||||
const provisioningPresentation =
|
||||
teamData && teamName
|
||||
|
|
|
|||
BIN
src/renderer/assets/participant-avatars/01.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
src/renderer/assets/participant-avatars/02.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
src/renderer/assets/participant-avatars/03.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src/renderer/assets/participant-avatars/04.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
src/renderer/assets/participant-avatars/05.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
src/renderer/assets/participant-avatars/06.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/renderer/assets/participant-avatars/07.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/renderer/assets/participant-avatars/08.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/renderer/assets/participant-avatars/09.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
src/renderer/assets/participant-avatars/10.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src/renderer/assets/participant-avatars/11.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
src/renderer/assets/participant-avatars/12.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
src/renderer/assets/participant-avatars/13.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
|
|
@ -1,3 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
getTeamColorSet,
|
||||
getThemedBadge,
|
||||
|
|
@ -5,7 +7,13 @@ import {
|
|||
getThemedText,
|
||||
} from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { agentAvatarUrl, displayMemberName } from '@renderer/utils/memberHelpers';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
|
||||
import { MemberHoverCard } from './members/MemberHoverCard';
|
||||
|
||||
|
|
@ -40,6 +48,12 @@ export const MemberBadge = ({
|
|||
}: MemberBadgeProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(color ?? '');
|
||||
const { isLight } = useTheme();
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||
const teamMembers = useStore((s) =>
|
||||
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
|
||||
);
|
||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
|
||||
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
|
||||
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
|
||||
|
|
@ -53,7 +67,7 @@ export const MemberBadge = ({
|
|||
|
||||
const avatar = (
|
||||
<img
|
||||
src={agentAvatarUrl(name, avatarSize)}
|
||||
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
|
||||
alt=""
|
||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||
loading="lazy"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
buildMemberColorMap,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -42,6 +43,7 @@ export const ActiveTasksBlock = ({
|
|||
const { isLight } = useTheme();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
const avatarMap = buildMemberAvatarMap(members);
|
||||
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
||||
|
||||
const entries: ActivityEntry[] = [];
|
||||
|
|
@ -115,7 +117,7 @@ export const ActiveTasksBlock = ({
|
|||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="relative inline-flex shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name, 24)}
|
||||
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 24)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||
import { agentAvatarUrl, buildMemberAvatarMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
import { MemberBadge } from '../MemberBadge';
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ function formatTime(timestamp: string): string {
|
|||
|
||||
interface DialogThoughtsContentProps {
|
||||
group: LeadThoughtGroup;
|
||||
members?: ResolvedTeamMember[];
|
||||
memberColor?: string;
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
|
|
@ -39,6 +40,7 @@ interface DialogThoughtsContentProps {
|
|||
|
||||
const DialogThoughtsContent = ({
|
||||
group,
|
||||
members,
|
||||
memberColor,
|
||||
onTaskIdClick,
|
||||
onReply,
|
||||
|
|
@ -51,6 +53,7 @@ const DialogThoughtsContent = ({
|
|||
const newest = thoughts[0];
|
||||
const oldest = thoughts[thoughts.length - 1];
|
||||
const colors = getTeamColorSet(memberColor ?? '');
|
||||
const avatarMap = useMemo(() => buildMemberAvatarMap(members ?? []), [members]);
|
||||
const chronological = useMemo(() => [...thoughts].reverse(), [thoughts]);
|
||||
|
||||
return (
|
||||
|
|
@ -58,7 +61,7 @@ const DialogThoughtsContent = ({
|
|||
{/* Header */}
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
<img
|
||||
src={agentAvatarUrl(newest.from, 32)}
|
||||
src={avatarMap.get(newest.from) ?? agentAvatarUrl(newest.from, 32)}
|
||||
alt=""
|
||||
className="size-6 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
|
|
@ -193,6 +196,7 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
|
|||
) : displayItem?.type === 'lead-thoughts' ? (
|
||||
<DialogThoughtsContent
|
||||
group={displayItem.group}
|
||||
members={members}
|
||||
memberColor={thoughtMemberColor}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
onReply={onReplyToMessage}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useStore } from '@renderer/store';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
buildMemberColorMap,
|
||||
displayMemberName,
|
||||
getMemberRuntimeAdvisoryLabel,
|
||||
|
|
@ -41,6 +42,7 @@ export const PendingRepliesBlock = ({
|
|||
const { isLight } = useTheme();
|
||||
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
const avatarMap = buildMemberAvatarMap(members);
|
||||
const memberPending = Object.entries(pendingRepliesByMember)
|
||||
.map(([name, sentAtMs]) => ({
|
||||
kind: 'member' as const,
|
||||
|
|
@ -111,7 +113,7 @@ export const PendingRepliesBlock = ({
|
|||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="relative inline-flex shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name, 24)}
|
||||
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 24)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { useStore } from '@renderer/store';
|
|||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
buildMemberColorMap,
|
||||
displayMemberName,
|
||||
KANBAN_COLUMN_DISPLAY,
|
||||
|
|
@ -149,6 +150,7 @@ export const TaskDetailDialog = ({
|
|||
headerExtra,
|
||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const avatarMap = useMemo(() => buildMemberAvatarMap(members), [members]);
|
||||
const { isLight } = useTheme();
|
||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
||||
|
|
@ -697,7 +699,10 @@ export const TaskDetailDialog = ({
|
|||
style={reviewerBadgeStyle}
|
||||
>
|
||||
<img
|
||||
src={agentAvatarUrl(currentTask.reviewer, 18)}
|
||||
src={
|
||||
avatarMap.get(currentTask.reviewer) ??
|
||||
agentAvatarUrl(currentTask.reviewer, 18)
|
||||
}
|
||||
alt=""
|
||||
className="size-4 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
buildMemberLaunchPresentation,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -97,6 +102,11 @@ export const MemberCard = ({
|
|||
// const leadContext = useStore((s) =>
|
||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const teamMembers = useStore((s) =>
|
||||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||
);
|
||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||
const launchPresentation = buildMemberLaunchPresentation({
|
||||
member,
|
||||
spawnStatus,
|
||||
|
|
@ -173,7 +183,7 @@ export const MemberCard = ({
|
|||
}}
|
||||
>
|
||||
<img
|
||||
src={agentAvatarUrl(member.name)}
|
||||
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name)}
|
||||
alt={member.name}
|
||||
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
buildMemberLaunchPresentation,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -52,6 +55,11 @@ export const MemberDetailHeader = ({
|
|||
updatingRole,
|
||||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const teamMembers = useStore((s) =>
|
||||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||
);
|
||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||
|
||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||
// const teamName = useStore((s) => s.selectedTeamName);
|
||||
|
|
@ -84,7 +92,7 @@ export const MemberDetailHeader = ({
|
|||
<div className="flex items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name, 96)}
|
||||
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 96)}
|
||||
alt={member.name}
|
||||
className="size-12 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
buildMemberLaunchPresentation,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -92,6 +93,7 @@ export const MemberHoverCard = ({
|
|||
}))
|
||||
);
|
||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
||||
const avatarMap = buildMemberAvatarMap(teamMembers);
|
||||
|
||||
if (!member) {
|
||||
return <>{children}</>;
|
||||
|
|
@ -142,7 +144,7 @@ export const MemberHoverCard = ({
|
|||
<div className="flex items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name, 64)}
|
||||
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, 64)}
|
||||
alt={member.name}
|
||||
className="size-10 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
buildMemberColorMap,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
|
|
@ -43,6 +47,7 @@ export const MemberSelect = ({
|
|||
const { isLight } = useTheme();
|
||||
|
||||
const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const avatarMap = React.useMemo(() => buildMemberAvatarMap(members), [members]);
|
||||
const selectedMember = React.useMemo(
|
||||
() => (value ? members.find((m) => m.name === value) : null),
|
||||
[members, value]
|
||||
|
|
@ -60,7 +65,7 @@ export const MemberSelect = ({
|
|||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name, avatarSize)}
|
||||
src={avatarMap.get(member.name) ?? agentAvatarUrl(member.name, avatarSize)}
|
||||
alt=""
|
||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||
loading="lazy"
|
||||
|
|
@ -176,7 +181,7 @@ export const MemberSelect = ({
|
|||
className="relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
|
||||
>
|
||||
<img
|
||||
src={agentAvatarUrl(m.name, avatarSize)}
|
||||
src={avatarMap.get(m.name) ?? agentAvatarUrl(m.name, avatarSize)}
|
||||
alt=""
|
||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||
loading="lazy"
|
||||
|
|
|
|||
38
src/renderer/utils/memberAvatarCatalog.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import avatar01 from '@renderer/assets/participant-avatars/01.png';
|
||||
import avatar02 from '@renderer/assets/participant-avatars/02.png';
|
||||
import avatar03 from '@renderer/assets/participant-avatars/03.png';
|
||||
import avatar04 from '@renderer/assets/participant-avatars/04.png';
|
||||
import avatar05 from '@renderer/assets/participant-avatars/05.png';
|
||||
import avatar06 from '@renderer/assets/participant-avatars/06.png';
|
||||
import avatar07 from '@renderer/assets/participant-avatars/07.png';
|
||||
import avatar08 from '@renderer/assets/participant-avatars/08.png';
|
||||
import avatar09 from '@renderer/assets/participant-avatars/09.png';
|
||||
import avatar10 from '@renderer/assets/participant-avatars/10.png';
|
||||
import avatar11 from '@renderer/assets/participant-avatars/11.png';
|
||||
import avatar12 from '@renderer/assets/participant-avatars/12.png';
|
||||
import avatar13 from '@renderer/assets/participant-avatars/13.png';
|
||||
|
||||
export const PARTICIPANT_AVATAR_URLS = [
|
||||
avatar01,
|
||||
avatar02,
|
||||
avatar03,
|
||||
avatar04,
|
||||
avatar05,
|
||||
avatar06,
|
||||
avatar07,
|
||||
avatar08,
|
||||
avatar09,
|
||||
avatar10,
|
||||
avatar11,
|
||||
avatar12,
|
||||
avatar13,
|
||||
] as const;
|
||||
|
||||
export const LEAD_PARTICIPANT_AVATAR_URL = PARTICIPANT_AVATAR_URLS[0];
|
||||
|
||||
export function getParticipantAvatarUrlByIndex(index: number): string {
|
||||
const normalized =
|
||||
((Math.trunc(index) % PARTICIPANT_AVATAR_URLS.length) + PARTICIPANT_AVATAR_URLS.length) %
|
||||
PARTICIPANT_AVATAR_URLS.length;
|
||||
return PARTICIPANT_AVATAR_URLS[normalized];
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import {
|
||||
getParticipantAvatarUrlByIndex,
|
||||
LEAD_PARTICIPANT_AVATAR_URL,
|
||||
PARTICIPANT_AVATAR_URLS,
|
||||
} from './memberAvatarCatalog';
|
||||
|
||||
import type {
|
||||
LeadActivityState,
|
||||
MemberLaunchState,
|
||||
|
|
@ -23,8 +29,26 @@ export function displayMemberName(name: string): string {
|
|||
return name === 'team-lead' ? 'lead' : name;
|
||||
}
|
||||
|
||||
function hashStringToIndex(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function agentAvatarUrl(name: string, size = 64): string {
|
||||
return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
|
||||
void size;
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (normalized === 'team-lead' || normalized === 'lead') {
|
||||
return LEAD_PARTICIPANT_AVATAR_URL;
|
||||
}
|
||||
|
||||
// Temporarily disabled external avatar API.
|
||||
// return `https://robohash.org/${encodeURIComponent(name)}?size=${size}x${size}`;
|
||||
return getParticipantAvatarUrlByIndex(
|
||||
hashStringToIndex(normalized) % PARTICIPANT_AVATAR_URLS.length
|
||||
);
|
||||
}
|
||||
|
||||
export const STATUS_DOT_COLORS: Record<MemberStatus, string> = {
|
||||
|
|
@ -567,6 +591,12 @@ interface MemberColorInput {
|
|||
role?: string;
|
||||
}
|
||||
|
||||
interface MemberAvatarInput {
|
||||
name: string;
|
||||
removedAt?: number | string | null;
|
||||
agentType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a consistent name→colorName map for all members.
|
||||
* Active members receive colors sequentially from MEMBER_COLOR_PALETTE,
|
||||
|
|
@ -578,6 +608,49 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
|
|||
return buildTeamMemberColorMap(members, { preferProvidedColors: true });
|
||||
}
|
||||
|
||||
export function buildMemberAvatarMap(members: readonly MemberAvatarInput[]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
const activeMembers = members.filter((member) => !member.removedAt);
|
||||
const leadMembers = activeMembers.filter((member) => isLeadMember(member));
|
||||
const teammateMembers = activeMembers.filter((member) => !isLeadMember(member));
|
||||
|
||||
for (const [index, member] of leadMembers.entries()) {
|
||||
map.set(member.name, index === 0 ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name));
|
||||
}
|
||||
|
||||
for (const [index, member] of teammateMembers.entries()) {
|
||||
map.set(
|
||||
member.name,
|
||||
getParticipantAvatarUrlByIndex(1 + (index % (PARTICIPANT_AVATAR_URLS.length - 1)))
|
||||
);
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
if (!map.has(member.name)) {
|
||||
map.set(
|
||||
member.name,
|
||||
isLeadMember(member) ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
map.set('user', agentAvatarUrl('user'));
|
||||
map.set('system', agentAvatarUrl('system'));
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function resolveMemberAvatarUrl(
|
||||
member: MemberAvatarInput,
|
||||
avatarMap?: ReadonlyMap<string, string>,
|
||||
size = 64
|
||||
): string {
|
||||
return (
|
||||
avatarMap?.get(member.name) ??
|
||||
(isLeadMember(member) ? LEAD_PARTICIPANT_AVATAR_URL : agentAvatarUrl(member.name, size))
|
||||
);
|
||||
}
|
||||
|
||||
export const KANBAN_COLUMN_DISPLAY: Record<
|
||||
'review' | 'approved',
|
||||
{ label: string; bg: string; text: string }
|
||||
|
|
|
|||