Simplify agents and teams cards

This commit is contained in:
klopez4212
2026-06-25 19:07:13 +01:00
parent 0379247eba
commit a9e808375a
7 changed files with 1089 additions and 447 deletions
@@ -0,0 +1,69 @@
import type { ReactNode } from "react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { cn } from "@/shared/lib/cn";
import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar";
type AgentIdentityCardProps = {
actions?: ReactNode;
ariaLabel: string;
avatarUrl?: string | null;
dataTestId: string;
label: string;
modelLabel: string;
onClick: () => void;
};
export function AgentIdentityCard({
actions,
ariaLabel,
avatarUrl,
dataTestId,
label,
modelLabel,
onClick,
}: AgentIdentityCardProps) {
const trimmedAvatarUrl = avatarUrl?.trim() || null;
return (
<div
className={cn(
"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",
)}
data-testid={dataTestId}
>
<button
aria-label={ariaLabel}
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"
onClick={onClick}
type="button"
>
<div className="flex h-24 w-24 items-center justify-center">
{trimmedAvatarUrl ? (
<ProfileAvatar
avatarUrl={trimmedAvatarUrl}
className="h-full w-full border-[3px] border-background bg-muted shadow-sm"
iconClassName="h-8 w-8"
label={label}
/>
) : (
<IdentityInitialsAvatar label={label} size={96} />
)}
</div>
</button>
{actions ? (
<div className="absolute top-3 right-3 z-40">{actions}</div>
) : null}
<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">
<span className="min-w-0 truncate font-semibold text-foreground tracking-normal">
{label}
</span>
<span className="min-w-0 truncate font-normal text-secondary-foreground/75">
{modelLabel}
</span>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import * as React from "react";
import { Plus } from "lucide-react";
import { cn } from "@/shared/lib/cn";
type CreateIdentityCardProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
ariaLabel: string;
dataTestId: string;
label: string;
};
export const CreateIdentityCard = React.forwardRef<
HTMLButtonElement,
CreateIdentityCardProps
>(function CreateIdentityCard(
{ ariaLabel, className, dataTestId, label, ...buttonProps },
ref,
) {
return (
<button
aria-label={ariaLabel}
className={cn(
"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",
className,
)}
data-testid={dataTestId}
ref={ref}
type="button"
{...buttonProps}
>
<span className="flex flex-col items-center justify-center gap-2 text-center">
<Plus className="h-7 w-7 transition-colors" />
<span className="text-sm font-medium leading-5">{label}</span>
</span>
</button>
);
});
@@ -0,0 +1,59 @@
import { UserRound } from "lucide-react";
import { getInitials } from "@/shared/lib/initials";
import { cn } from "@/shared/lib/cn";
const IDENTITY_INITIAL_AVATAR_CLASS_NAMES = [
"bg-muted text-foreground",
"bg-secondary text-secondary-foreground",
"bg-accent text-accent-foreground",
"bg-card text-card-foreground",
"bg-popover text-popover-foreground",
"bg-background text-foreground",
] as const;
type IdentityInitialsAvatarProps = {
className?: string;
colorIndex?: number;
colorSeed?: string;
label: string;
size: number;
};
export function IdentityInitialsAvatar({
className,
colorIndex,
colorSeed,
label,
size,
}: IdentityInitialsAvatarProps) {
const initials = getInitials(label);
const seed = colorSeed ?? (label || "agent");
const paletteIndex = colorIndex ?? getStableColorIndex(seed);
const colorClassName =
IDENTITY_INITIAL_AVATAR_CLASS_NAMES[
paletteIndex % IDENTITY_INITIAL_AVATAR_CLASS_NAMES.length
];
const fontSize = Math.round(Math.min(40, Math.max(22, size * 0.28)));
return (
<span
className={cn(
"flex h-full w-full items-center justify-center rounded-full border-[3px] border-background font-semibold shadow-sm",
colorClassName,
className,
)}
style={{ fontSize }}
>
{initials.length > 0 ? initials : <UserRound className="h-8 w-8" />}
</span>
);
}
function getStableColorIndex(seed: string) {
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}
return hash;
}
@@ -0,0 +1,180 @@
import type { ReactNode } from "react";
import { Link, Users } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { Card } from "@/shared/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar";
type TeamIdentityCardProps = {
actions: ReactNode;
children?: ReactNode;
dataTestId: string;
description?: string | null;
isSymlink?: boolean;
memberCount: number;
personas: AgentPersona[];
sourceDir?: string | null;
symlinkTarget?: string | null;
teamId: string;
teamName: string;
version?: string | null;
};
const MAX_VISIBLE_MEMBER_AVATARS = 4;
export function TeamIdentityCard({
actions,
children,
dataTestId,
isSymlink = false,
memberCount,
personas,
sourceDir,
symlinkTarget,
teamName,
version,
}: TeamIdentityCardProps) {
const footerModelLabel = getTeamFooterModelLabel(personas);
return (
<Card
className="min-w-0 overflow-hidden p-0 transition-colors hover:border-border hover:bg-muted/65"
data-testid={dataTestId}
>
<div className="relative aspect-[4/5] min-w-0 overflow-hidden bg-muted/50">
<div className="absolute top-3 left-3 z-30 flex max-w-[calc(100%-4rem)] flex-wrap items-center gap-1.5">
{isSymlink ? (
<Tooltip>
<TooltipTrigger asChild>
<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">
<Link className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>Linked from {symlinkTarget ?? sourceDir}</p>
</TooltipContent>
</Tooltip>
) : null}
{version ? (
<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">
v{version}
</span>
) : null}
</div>
<div className="absolute top-3 right-3 z-40">{actions}</div>
<TeamAvatarRow
memberCount={memberCount}
personas={personas}
teamName={teamName}
/>
<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">
<span className="min-w-0 truncate font-semibold tracking-normal text-foreground">
{teamName}
</span>
<span className="min-w-0 truncate font-normal text-secondary-foreground/75">
{footerModelLabel}
</span>
</div>
</div>
{children}
</Card>
);
}
function TeamAvatarRow({
memberCount,
personas,
teamName,
}: {
memberCount: number;
personas: AgentPersona[];
teamName: string;
}) {
const visiblePersonas = personas.slice(0, MAX_VISIBLE_MEMBER_AVATARS);
const overflowCount = Math.max(0, memberCount - visiblePersonas.length);
if (visiblePersonas.length === 0 && overflowCount === 0) {
return (
<div className="absolute inset-x-4 top-0 bottom-12 flex items-center justify-center">
<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">
<Users className="h-9 w-9" />
</div>
</div>
);
}
return (
<div className="absolute inset-x-0 top-0 bottom-12 flex items-center justify-center">
<div
aria-label={`${teamName} member avatars`}
className="flex max-w-full items-center justify-center gap-2 px-4"
role="img"
>
{visiblePersonas.map((persona, index) => (
<TeamAvatarItem index={index} key={persona.id} persona={persona} />
))}
{overflowCount > 0 ? (
<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">
+{overflowCount}
</span>
) : null}
</div>
</div>
);
}
function TeamAvatarItem({
index,
persona,
}: {
index: number;
persona: AgentPersona;
}) {
const avatarUrl = persona.avatarUrl?.trim() ?? null;
return (
<div className="h-14 w-14" data-team-member-avatar="avatar">
{avatarUrl ? (
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-full w-full border-[3px] border-background bg-muted shadow-sm"
iconClassName="h-6 w-6"
label={persona.displayName}
testId={`team-member-avatar-${persona.id}`}
/>
) : (
<IdentityInitialsAvatar
colorIndex={index}
label={persona.displayName}
size={56}
/>
)}
</div>
);
}
function getTeamFooterModelLabel(personas: AgentPersona[]) {
const modelLabels = personas
.map((persona) => formatFooterModelLabel(persona.model))
.filter((model): model is string => Boolean(model));
if (modelLabels.length === 0) return "Auto";
const uniqueModels = new Map(
modelLabels.map((model) => [model.toLowerCase(), model]),
);
return uniqueModels.size === 1
? (uniqueModels.values().next().value ?? "Auto")
: "Mixed models";
}
function formatFooterModelLabel(model: string | null | undefined) {
const trimmed = model?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "Auto";
}
+110 -158
View File
@@ -4,17 +4,12 @@ import {
Ellipsis,
FolderOpen,
FolderSync,
Info,
Link,
Pencil,
Rocket,
Trash2,
Upload,
Users,
} from "lucide-react";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import {
@@ -24,12 +19,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Card } from "@/shared/ui/card";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { CreateNewButton } from "./CreateNewButton";
import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton";
import { CreateIdentityCard } from "./CreateIdentityCard";
import { TeamIdentityCard } from "./TeamIdentityCard";
const MAX_VISIBLE_AVATARS = 4;
const TEAM_CARD_COLUMN_CLASS = "w-full";
const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-start gap-3`;
type TeamsSectionProps = {
teams: AgentTeam[];
@@ -43,10 +38,10 @@ type TeamsSectionProps = {
onExport: (team: AgentTeam) => void;
onDelete: (team: AgentTeam) => void;
onAddToChannel: (team: AgentTeam) => void;
onImportFile: (fileBytes: number[], fileName: string) => void;
onInstallFromDirectory: () => void;
onSync: (team: AgentTeam) => void;
onRevealInFinder: (team: AgentTeam) => void;
onImportFile: (fileBytes: number[], fileName: string) => void;
onInstallFromDirectory?: () => void;
};
export function TeamsSection({
@@ -61,10 +56,10 @@ export function TeamsSection({
onExport,
onDelete,
onAddToChannel,
onImportFile,
onInstallFromDirectory,
onSync,
onRevealInFinder,
onImportFile,
onInstallFromDirectory,
}: TeamsSectionProps) {
const {
fileInputRef,
@@ -83,144 +78,64 @@ export function TeamsSection({
{isDragOver ? (
<div className="pointer-events-none absolute -inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
Drop .team.json to import
Drop .team.json or .zip to import
</p>
</div>
) : null}
<input
accept=".json,.zip"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
<div className="flex items-center justify-between gap-3">
<div
className={`${TEAM_CARD_COLUMN_CLASS} flex items-center justify-between gap-3`}
>
<div>
<h3 className="text-sm font-semibold tracking-tight">My teams</h3>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-secondary-foreground/75">
Saved groups from My Agents that you can add to a channel together.
</p>
</div>
<input
accept=".json,.zip"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
<div className="flex items-center gap-2">
<button
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onInstallFromDirectory}
type="button"
>
<FolderOpen className="h-4 w-4" />
Install from directory
</button>
<CreateNewButton
ariaLabel="Create team"
label="Team"
onClick={onCreate}
/>
</div>
</div>
{isLoading ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{["first", "second", "third"].map((key) => (
<Card className="p-3" key={key}>
<div className="flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20 rounded-full" />
</div>
</div>
</Card>
))}
<div className={TEAM_CARD_GRID_CLASS}>
<IdentityCardSkeleton
footerSubtitleWidthClass="w-14"
footerTitleWidthClass="w-24"
showAction
/>
<IdentityCardSkeleton
footerSubtitleWidthClass="w-24"
footerTitleWidthClass="w-32"
showAction
/>
<IdentityCardSkeleton
footerSubtitleWidthClass="w-20"
footerTitleWidthClass="w-28"
showAction
/>
</div>
) : null}
{!isLoading && teams.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{!isLoading ? (
<div className={TEAM_CARD_GRID_CLASS}>
{teams.map((team) => {
const resolution = resolveTeamPersonas(team, personas);
const visible = resolution.resolvedPersonas.slice(
0,
MAX_VISIBLE_AVATARS,
);
const overflow =
resolution.resolvedPersonas.length - visible.length;
const missingPersonaCount = resolution.missingPersonaCount;
const hasMissingPersonas = resolution.hasMissingPersonas;
return (
<Card className="p-3" key={team.id}>
<div className="flex items-start justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-semibold tracking-tight">
{team.name}
</p>
{team.isSymlink ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
<Link className="h-4 w-4" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>
Linked from {team.symlinkTarget ?? team.sourceDir}
</p>
</TooltipContent>
</Tooltip>
) : null}
{team.version ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-2xs font-medium text-muted-foreground">
v{team.version}
</span>
) : null}
{team.description ? (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="View description"
className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
type="button"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>{team.description}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex -space-x-1.5">
{visible.map((persona) => (
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-6 w-6 border-2 border-card text-2xs"
key={persona.id}
label={persona.displayName}
/>
))}
{overflow > 0 ? (
<span className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-card bg-muted text-2xs font-medium text-muted-foreground">
+{overflow}
</span>
) : null}
</div>
<span className="text-xs text-muted-foreground">
{team.personaIds.length}{" "}
{team.personaIds.length === 1 ? "persona" : "personas"}
</span>
</div>
</div>
<TeamIdentityCard
actions={
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={`${team.name} team actions`}
className="flex h-7 w-7 items-center justify-center rounded-md bg-transparent text-muted-foreground/80 transition-colors hover:bg-background/85 hover:text-foreground data-[state=open]:bg-background/90 data-[state=open]:text-foreground"
type="button"
>
<Ellipsis className="h-4 w-4" />
@@ -288,10 +203,21 @@ export function TeamsSection({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
dataTestId={`team-card-${team.id}`}
description={team.description}
isSymlink={team.isSymlink}
key={team.id}
memberCount={team.personaIds.length}
personas={resolution.resolvedPersonas}
sourceDir={team.sourceDir}
symlinkTarget={team.symlinkTarget}
teamId={team.id}
teamName={team.name}
version={team.version}
>
{hasMissingPersonas ? (
<p className="mt-3 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<p className="border-t border-destructive/20 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{missingPersonaCount} persona
{missingPersonaCount === 1 ? "" : "s"} in this team{" "}
{missingPersonaCount === 1 ? "is" : "are"} no longer in your
@@ -299,42 +225,68 @@ export function TeamsSection({
exporting.
</p>
) : null}
</Card>
</TeamIdentityCard>
);
})}
<button
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-primary p-3 text-primary transition-colors hover:bg-primary/5"
onClick={openFilePicker}
type="button"
>
<Upload className="h-4 w-4" />
<span className="text-xs">Import</span>
</button>
<NewTeamCard
isPending={isPending}
onCreate={onCreate}
onImport={openFilePicker}
onInstallFromDirectory={onInstallFromDirectory}
/>
</div>
) : null}
{!isLoading && teams.length === 0 ? (
<button
className="w-full cursor-pointer rounded-xl border border-dashed border-primary/40 px-6 py-10 text-center transition-colors hover:border-primary hover:bg-primary/5"
onClick={openFilePicker}
type="button"
>
<p className="text-sm font-semibold tracking-tight">No teams yet</p>
<p className="mt-2 text-sm text-muted-foreground">
Create a team from the personas in My Agents for quick deployment to
channels.
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Or drop a .team.json file here to import.
</p>
</button>
) : null}
{error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<p
className={`${TEAM_CARD_COLUMN_CLASS} rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive`}
>
{error.message}
</p>
) : null}
</section>
);
}
function NewTeamCard({
isPending,
onCreate,
onImport,
onInstallFromDirectory,
}: {
isPending: boolean;
onCreate: () => void;
onImport: () => void;
onInstallFromDirectory?: () => void;
}) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<CreateIdentityCard
ariaLabel="New team"
dataTestId="new-team-card"
label="New team"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem disabled={isPending} onClick={onCreate}>
Create team
</DropdownMenuItem>
{onInstallFromDirectory ? (
<DropdownMenuItem
disabled={isPending}
onClick={onInstallFromDirectory}
>
Install from directory
</DropdownMenuItem>
) : null}
<DropdownMenuItem disabled={isPending} onClick={onImport}>
Import team file
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import { cn } from "@/shared/lib/cn";
import { Skeleton } from "@/shared/ui/skeleton";
type IdentityCardSkeletonProps = {
className?: string;
footerSubtitleWidthClass?: string;
footerTitleWidthClass?: string;
showAction?: boolean;
};
export function IdentityCardSkeleton({
className,
footerSubtitleWidthClass = "w-16",
footerTitleWidthClass = "w-28",
showAction = false,
}: IdentityCardSkeletonProps) {
return (
<div
className={cn(
"relative aspect-[4/5] w-full min-w-0 overflow-hidden rounded-xl border border-border/70 bg-muted/50 shadow-xs",
className,
)}
>
{showAction ? (
<Skeleton className="absolute top-3 right-3 z-30 h-7 w-7 rounded-md bg-background/70" />
) : null}
<SingleAvatarSkeleton />
<div className="absolute right-3 bottom-3 left-3 z-30 flex min-w-0 flex-col gap-1 text-left">
<Skeleton className={cn("h-4 max-w-full", footerTitleWidthClass)} />
<Skeleton className={cn("h-4 max-w-full", footerSubtitleWidthClass)} />
</div>
</div>
);
}
function SingleAvatarSkeleton() {
return (
<div className="absolute inset-x-0 top-0 bottom-12 flex items-center justify-center">
<Skeleton className="h-24 w-24 rounded-full" />
</div>
);
}