mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Simplify agents and teams cards
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user