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