Skip to content

Commit 9b87d11

Browse files
committed
Simplify agents and teams cards
1 parent ce994df commit 9b87d11

7 files changed

Lines changed: 1089 additions & 447 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { ReactNode } from "react";
2+
3+
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
4+
import { cn } from "@/shared/lib/cn";
5+
import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar";
6+
7+
type AgentIdentityCardProps = {
8+
actions?: ReactNode;
9+
ariaLabel: string;
10+
avatarUrl?: string | null;
11+
dataTestId: string;
12+
label: string;
13+
modelLabel: string;
14+
onClick: () => void;
15+
};
16+
17+
export function AgentIdentityCard({
18+
actions,
19+
ariaLabel,
20+
avatarUrl,
21+
dataTestId,
22+
label,
23+
modelLabel,
24+
onClick,
25+
}: AgentIdentityCardProps) {
26+
const trimmedAvatarUrl = avatarUrl?.trim() || null;
27+
28+
return (
29+
<div
30+
className={cn(
31+
"group relative aspect-[4/5] w-full min-w-0 overflow-hidden rounded-xl border border-border/70 bg-muted/50 text-left shadow-xs transition-colors hover:border-border hover:bg-muted/65",
32+
)}
33+
data-testid={dataTestId}
34+
>
35+
<button
36+
aria-label={ariaLabel}
37+
className="flex h-full w-full min-w-0 flex-col items-center justify-center gap-5 px-4 pb-12 text-center focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
38+
onClick={onClick}
39+
type="button"
40+
>
41+
<div className="flex h-24 w-24 items-center justify-center">
42+
{trimmedAvatarUrl ? (
43+
<ProfileAvatar
44+
avatarUrl={trimmedAvatarUrl}
45+
className="h-full w-full border-[3px] border-background bg-muted shadow-sm"
46+
iconClassName="h-8 w-8"
47+
label={label}
48+
/>
49+
) : (
50+
<IdentityInitialsAvatar label={label} size={96} />
51+
)}
52+
</div>
53+
</button>
54+
55+
{actions ? (
56+
<div className="absolute top-3 right-3 z-40">{actions}</div>
57+
) : null}
58+
59+
<div className="absolute right-3 bottom-3 left-3 z-30 flex min-w-0 flex-col gap-0.5 text-left text-sm leading-5">
60+
<span className="min-w-0 truncate font-semibold text-foreground tracking-normal">
61+
{label}
62+
</span>
63+
<span className="min-w-0 truncate font-normal text-secondary-foreground/75">
64+
{modelLabel}
65+
</span>
66+
</div>
67+
</div>
68+
);
69+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as React from "react";
2+
import { Plus } from "lucide-react";
3+
4+
import { cn } from "@/shared/lib/cn";
5+
6+
type CreateIdentityCardProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
7+
ariaLabel: string;
8+
dataTestId: string;
9+
label: string;
10+
};
11+
12+
export const CreateIdentityCard = React.forwardRef<
13+
HTMLButtonElement,
14+
CreateIdentityCardProps
15+
>(function CreateIdentityCard(
16+
{ ariaLabel, className, dataTestId, label, ...buttonProps },
17+
ref,
18+
) {
19+
return (
20+
<button
21+
aria-label={ariaLabel}
22+
className={cn(
23+
"group relative flex aspect-[4/5] w-full min-w-0 items-center justify-center overflow-hidden rounded-xl border border-dashed border-border/80 bg-transparent text-muted-foreground shadow-xs transition-colors hover:border-border hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
24+
className,
25+
)}
26+
data-testid={dataTestId}
27+
ref={ref}
28+
type="button"
29+
{...buttonProps}
30+
>
31+
<span className="flex flex-col items-center justify-center gap-2 text-center">
32+
<Plus className="h-7 w-7 transition-colors" />
33+
<span className="text-sm font-medium leading-5">{label}</span>
34+
</span>
35+
</button>
36+
);
37+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { UserRound } from "lucide-react";
2+
3+
import { getInitials } from "@/shared/lib/initials";
4+
import { cn } from "@/shared/lib/cn";
5+
6+
const IDENTITY_INITIAL_AVATAR_CLASS_NAMES = [
7+
"bg-muted text-foreground",
8+
"bg-secondary text-secondary-foreground",
9+
"bg-accent text-accent-foreground",
10+
"bg-card text-card-foreground",
11+
"bg-popover text-popover-foreground",
12+
"bg-background text-foreground",
13+
] as const;
14+
15+
type IdentityInitialsAvatarProps = {
16+
className?: string;
17+
colorIndex?: number;
18+
colorSeed?: string;
19+
label: string;
20+
size: number;
21+
};
22+
23+
export function IdentityInitialsAvatar({
24+
className,
25+
colorIndex,
26+
colorSeed,
27+
label,
28+
size,
29+
}: IdentityInitialsAvatarProps) {
30+
const initials = getInitials(label);
31+
const seed = colorSeed ?? (label || "agent");
32+
const paletteIndex = colorIndex ?? getStableColorIndex(seed);
33+
const colorClassName =
34+
IDENTITY_INITIAL_AVATAR_CLASS_NAMES[
35+
paletteIndex % IDENTITY_INITIAL_AVATAR_CLASS_NAMES.length
36+
];
37+
const fontSize = Math.round(Math.min(40, Math.max(22, size * 0.28)));
38+
39+
return (
40+
<span
41+
className={cn(
42+
"flex h-full w-full items-center justify-center rounded-full border-[3px] border-background font-semibold shadow-sm",
43+
colorClassName,
44+
className,
45+
)}
46+
style={{ fontSize }}
47+
>
48+
{initials.length > 0 ? initials : <UserRound className="h-8 w-8" />}
49+
</span>
50+
);
51+
}
52+
53+
function getStableColorIndex(seed: string) {
54+
let hash = 0;
55+
for (let index = 0; index < seed.length; index += 1) {
56+
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
57+
}
58+
return hash;
59+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import type { ReactNode } from "react";
2+
import { Link, Users } from "lucide-react";
3+
4+
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
5+
import type { AgentPersona } from "@/shared/api/types";
6+
import { Card } from "@/shared/ui/card";
7+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
8+
import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar";
9+
10+
type TeamIdentityCardProps = {
11+
actions: ReactNode;
12+
children?: ReactNode;
13+
dataTestId: string;
14+
description?: string | null;
15+
isSymlink?: boolean;
16+
memberCount: number;
17+
personas: AgentPersona[];
18+
sourceDir?: string | null;
19+
symlinkTarget?: string | null;
20+
teamId: string;
21+
teamName: string;
22+
version?: string | null;
23+
};
24+
25+
const MAX_VISIBLE_MEMBER_AVATARS = 4;
26+
27+
export function TeamIdentityCard({
28+
actions,
29+
children,
30+
dataTestId,
31+
isSymlink = false,
32+
memberCount,
33+
personas,
34+
sourceDir,
35+
symlinkTarget,
36+
teamName,
37+
version,
38+
}: TeamIdentityCardProps) {
39+
const footerModelLabel = getTeamFooterModelLabel(personas);
40+
41+
return (
42+
<Card
43+
className="min-w-0 overflow-hidden p-0 transition-colors hover:border-border hover:bg-muted/65"
44+
data-testid={dataTestId}
45+
>
46+
<div className="relative aspect-[4/5] min-w-0 overflow-hidden bg-muted/50">
47+
<div className="absolute top-3 left-3 z-30 flex max-w-[calc(100%-4rem)] flex-wrap items-center gap-1.5">
48+
{isSymlink ? (
49+
<Tooltip>
50+
<TooltipTrigger asChild>
51+
<span className="flex h-6 w-6 items-center justify-center rounded-full border border-border/65 bg-background/90 text-muted-foreground shadow-xs">
52+
<Link className="h-3.5 w-3.5" />
53+
</span>
54+
</TooltipTrigger>
55+
<TooltipContent side="bottom" className="max-w-xs">
56+
<p>Linked from {symlinkTarget ?? sourceDir}</p>
57+
</TooltipContent>
58+
</Tooltip>
59+
) : null}
60+
{version ? (
61+
<span className="rounded-full border border-border/65 bg-background/90 px-2 py-1 text-2xs font-medium leading-none text-muted-foreground shadow-xs">
62+
v{version}
63+
</span>
64+
) : null}
65+
</div>
66+
67+
<div className="absolute top-3 right-3 z-40">{actions}</div>
68+
69+
<TeamAvatarRow
70+
memberCount={memberCount}
71+
personas={personas}
72+
teamName={teamName}
73+
/>
74+
75+
<div className="absolute right-3 bottom-3 left-3 z-30 flex min-w-0 flex-col gap-0.5 text-left text-sm leading-5">
76+
<span className="min-w-0 truncate font-semibold tracking-normal text-foreground">
77+
{teamName}
78+
</span>
79+
<span className="min-w-0 truncate font-normal text-secondary-foreground/75">
80+
{footerModelLabel}
81+
</span>
82+
</div>
83+
</div>
84+
{children}
85+
</Card>
86+
);
87+
}
88+
89+
function TeamAvatarRow({
90+
memberCount,
91+
personas,
92+
teamName,
93+
}: {
94+
memberCount: number;
95+
personas: AgentPersona[];
96+
teamName: string;
97+
}) {
98+
const visiblePersonas = personas.slice(0, MAX_VISIBLE_MEMBER_AVATARS);
99+
const overflowCount = Math.max(0, memberCount - visiblePersonas.length);
100+
101+
if (visiblePersonas.length === 0 && overflowCount === 0) {
102+
return (
103+
<div className="absolute inset-x-4 top-0 bottom-12 flex items-center justify-center">
104+
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-border/65 bg-background/80 text-muted-foreground shadow-xs">
105+
<Users className="h-9 w-9" />
106+
</div>
107+
</div>
108+
);
109+
}
110+
111+
return (
112+
<div className="absolute inset-x-0 top-0 bottom-12 flex items-center justify-center">
113+
<div
114+
aria-label={`${teamName} member avatars`}
115+
className="flex max-w-full items-center justify-center gap-2 px-4"
116+
role="img"
117+
>
118+
{visiblePersonas.map((persona, index) => (
119+
<TeamAvatarItem index={index} key={persona.id} persona={persona} />
120+
))}
121+
{overflowCount > 0 ? (
122+
<span className="flex h-14 w-14 items-center justify-center rounded-full border-[3px] border-background bg-card text-sm font-semibold text-muted-foreground shadow-sm">
123+
+{overflowCount}
124+
</span>
125+
) : null}
126+
</div>
127+
</div>
128+
);
129+
}
130+
131+
function TeamAvatarItem({
132+
index,
133+
persona,
134+
}: {
135+
index: number;
136+
persona: AgentPersona;
137+
}) {
138+
const avatarUrl = persona.avatarUrl?.trim() ?? null;
139+
140+
return (
141+
<div className="h-14 w-14" data-team-member-avatar="avatar">
142+
{avatarUrl ? (
143+
<ProfileAvatar
144+
avatarUrl={avatarUrl}
145+
className="h-full w-full border-[3px] border-background bg-muted shadow-sm"
146+
iconClassName="h-6 w-6"
147+
label={persona.displayName}
148+
testId={`team-member-avatar-${persona.id}`}
149+
/>
150+
) : (
151+
<IdentityInitialsAvatar
152+
colorIndex={index}
153+
label={persona.displayName}
154+
size={56}
155+
/>
156+
)}
157+
</div>
158+
);
159+
}
160+
161+
function getTeamFooterModelLabel(personas: AgentPersona[]) {
162+
const modelLabels = personas
163+
.map((persona) => formatFooterModelLabel(persona.model))
164+
.filter((model): model is string => Boolean(model));
165+
166+
if (modelLabels.length === 0) return "Auto";
167+
168+
const uniqueModels = new Map(
169+
modelLabels.map((model) => [model.toLowerCase(), model]),
170+
);
171+
172+
return uniqueModels.size === 1
173+
? (uniqueModels.values().next().value ?? "Auto")
174+
: "Mixed models";
175+
}
176+
177+
function formatFooterModelLabel(model: string | null | undefined) {
178+
const trimmed = model?.trim();
179+
return trimmed && trimmed.length > 0 ? trimmed : "Auto";
180+
}

0 commit comments

Comments
 (0)