mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Simplify agents and teams cards (#1199)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Bart Simpson <bart@buzz.local> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
Bart Simpson
Taylor Ho
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
e7d43dc225
commit
ccf1bfe3a0
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Returns a human-readable model label for an agent or persona, falling back to
|
||||
* "Auto" when no model is set (empty or whitespace-only).
|
||||
*/
|
||||
export function formatAgentModelLabel(model: string | null | undefined) {
|
||||
const trimmed = model?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : "Auto";
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
errorLabel?: string | null;
|
||||
modelControl?: ReactNode;
|
||||
modelLabel: string;
|
||||
onClick: () => void;
|
||||
status?: ReactNode;
|
||||
};
|
||||
|
||||
export function AgentIdentityCard({
|
||||
actions,
|
||||
ariaLabel,
|
||||
avatarUrl,
|
||||
dataTestId,
|
||||
errorLabel,
|
||||
label,
|
||||
modelControl,
|
||||
modelLabel,
|
||||
onClick,
|
||||
status,
|
||||
}: 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}
|
||||
|
||||
{status ? (
|
||||
<div className="absolute top-3 left-3 z-30 flex max-w-[calc(100%-4rem)] flex-wrap items-center gap-1.5">
|
||||
{status}
|
||||
</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>
|
||||
{modelControl ?? (
|
||||
<span className="min-w-0 truncate font-normal text-secondary-foreground/75">
|
||||
{modelLabel}
|
||||
</span>
|
||||
)}
|
||||
{errorLabel ? (
|
||||
<span
|
||||
className="min-w-0 truncate text-2xs font-medium text-destructive"
|
||||
title={errorLabel}
|
||||
>
|
||||
{errorLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,13 +75,6 @@ export function AgentsView() {
|
||||
}
|
||||
isActionPending={isActionPending}
|
||||
isAgentsLoading={agents.managedAgentsQuery.isLoading}
|
||||
logContent={agents.managedAgentLogQuery.data?.content ?? null}
|
||||
logError={
|
||||
agents.managedAgentLogQuery.error instanceof Error
|
||||
? agents.managedAgentLogQuery.error
|
||||
: null
|
||||
}
|
||||
logLoading={agents.managedAgentLogQuery.isLoading}
|
||||
personaLabelsById={personas.personaLabelsById}
|
||||
presenceLoaded={agents.managedPresenceQuery.isSuccess}
|
||||
presenceLookup={agents.managedPresenceQuery.data ?? {}}
|
||||
@@ -100,8 +93,6 @@ export function AgentsView() {
|
||||
onOpenPersonaProfile={(persona) => {
|
||||
openPersonaProfilePanel?.(persona);
|
||||
}}
|
||||
onSelectLogAgent={agents.setLogAgentPubkey}
|
||||
selectedLogAgentPubkey={agents.logAgentPubkey}
|
||||
// Persona props
|
||||
canChooseCatalog={personas.catalogPersonas.length > 0}
|
||||
personas={personas.libraryPersonas}
|
||||
|
||||
@@ -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 textSizeClassName = size >= 80 ? "text-3xl" : "text-xl";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full border-[3px] border-background font-semibold shadow-sm",
|
||||
colorClassName,
|
||||
textSizeClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{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,193 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Info, 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 { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
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;
|
||||
teamName: string;
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
const MAX_VISIBLE_MEMBER_AVATARS = 4;
|
||||
|
||||
export function TeamIdentityCard({
|
||||
actions,
|
||||
children,
|
||||
dataTestId,
|
||||
description,
|
||||
isSymlink = false,
|
||||
memberCount,
|
||||
personas,
|
||||
sourceDir,
|
||||
symlinkTarget,
|
||||
teamName,
|
||||
version,
|
||||
}: TeamIdentityCardProps) {
|
||||
const footerModelLabel = getTeamFooterModelLabel(personas);
|
||||
const trimmedDescription = description?.trim();
|
||||
|
||||
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}
|
||||
{trimmedDescription ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={`${teamName} description`}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-border/65 bg-background/90 text-muted-foreground shadow-xs"
|
||||
type="button"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p>{trimmedDescription}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : 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) => formatAgentModelLabel(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";
|
||||
}
|
||||
@@ -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,20 @@ 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}
|
||||
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 +224,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,20 +4,24 @@ import {
|
||||
ChevronRight,
|
||||
Ellipsis,
|
||||
OctagonX,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { isPersonaActive } from "@/features/agents/lib/catalog";
|
||||
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
|
||||
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
|
||||
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
|
||||
import { ModelPicker } from "@/features/agents/ui/ModelPicker";
|
||||
import { useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import type {
|
||||
AgentPersona,
|
||||
ManagedAgent,
|
||||
PresenceLookup,
|
||||
} from "@/shared/api/types";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
|
||||
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -26,10 +30,10 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { AgentGroupRows } from "./AgentGroupRows";
|
||||
import { PersonaIdentity } from "./PersonaIdentity";
|
||||
import { PersonaLibraryEntryPoints } from "./PersonaLibraryEntryPoints";
|
||||
import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton";
|
||||
import { AgentIdentityCard } from "./AgentIdentityCard";
|
||||
import { CreateIdentityCard } from "./CreateIdentityCard";
|
||||
import { buildUnifiedGroups, pickProfileAgent } from "./unifiedAgentGroups";
|
||||
|
||||
type UnifiedAgentsSectionProps = {
|
||||
actionErrorMessage: string | null;
|
||||
@@ -40,9 +44,6 @@ type UnifiedAgentsSectionProps = {
|
||||
agentsError: Error | null;
|
||||
isActionPending: boolean;
|
||||
isAgentsLoading: boolean;
|
||||
logContent: string | null;
|
||||
logError: Error | null;
|
||||
logLoading: boolean;
|
||||
personaLabelsById: Record<string, string>;
|
||||
presenceLoaded: boolean;
|
||||
presenceLookup: PresenceLookup;
|
||||
@@ -51,8 +52,6 @@ type UnifiedAgentsSectionProps = {
|
||||
onCreateAgent: () => void;
|
||||
onOpenAgentProfile: (pubkey: string) => void;
|
||||
onOpenPersonaProfile: (persona: AgentPersona) => void;
|
||||
onSelectLogAgent: (pubkey: string | null) => void;
|
||||
selectedLogAgentPubkey: string | null;
|
||||
canChooseCatalog: boolean;
|
||||
personas: AgentPersona[];
|
||||
personasError: Error | null;
|
||||
@@ -65,50 +64,17 @@ type UnifiedAgentsSectionProps = {
|
||||
onImportPersonaFile: (fileBytes: number[], fileName: string) => void;
|
||||
};
|
||||
|
||||
type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] };
|
||||
|
||||
function buildUnifiedGroups(personas: AgentPersona[], agents: ManagedAgent[]) {
|
||||
const byPersonaId = new Map<string, ManagedAgent[]>();
|
||||
const ungrouped: ManagedAgent[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
if (!agent.personaId) {
|
||||
ungrouped.push(agent);
|
||||
} else {
|
||||
const list = byPersonaId.get(agent.personaId) ?? [];
|
||||
list.push(agent);
|
||||
byPersonaId.set(agent.personaId, list);
|
||||
}
|
||||
}
|
||||
|
||||
const matched = new Set<string>();
|
||||
const groups: PersonaGroup[] = personas.map((p) => {
|
||||
matched.add(p.id);
|
||||
return { persona: p, agents: byPersonaId.get(p.id) ?? [] };
|
||||
});
|
||||
|
||||
const unknown: ManagedAgent[] = [];
|
||||
for (const [id, list] of byPersonaId) {
|
||||
if (!matched.has(id)) unknown.push(...list);
|
||||
}
|
||||
|
||||
return { groups, ungrouped, unknown };
|
||||
}
|
||||
const AGENT_CARD_COLUMN_CLASS = "w-full";
|
||||
const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-start gap-3`;
|
||||
|
||||
export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
const {
|
||||
actionErrorMessage,
|
||||
actionNoticeMessage,
|
||||
agents,
|
||||
channelIdToName,
|
||||
channelsByPubkey,
|
||||
agentsError,
|
||||
isActionPending,
|
||||
isAgentsLoading,
|
||||
logContent,
|
||||
logError,
|
||||
logLoading,
|
||||
personaLabelsById,
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
onBulkRemoveStopped,
|
||||
@@ -116,8 +82,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
onCreateAgent,
|
||||
onOpenAgentProfile,
|
||||
onOpenPersonaProfile,
|
||||
onSelectLogAgent,
|
||||
selectedLogAgentPubkey,
|
||||
canChooseCatalog,
|
||||
personas,
|
||||
personasError,
|
||||
@@ -130,14 +94,28 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
onImportPersonaFile,
|
||||
} = props;
|
||||
|
||||
const runningCount = agents.filter((a) => isManagedAgentActive(a)).length;
|
||||
const runningCount = agents.filter((agent) =>
|
||||
isManagedAgentActive(agent),
|
||||
).length;
|
||||
const stoppedCount = agents.filter(
|
||||
(a) => a.status === "stopped" || a.status === "not_deployed",
|
||||
(agent) => agent.status === "stopped" || agent.status === "not_deployed",
|
||||
).length;
|
||||
const { groups, ungrouped, unknown } = React.useMemo(
|
||||
() => buildUnifiedGroups(personas, agents),
|
||||
[personas, agents],
|
||||
);
|
||||
const additionalPersonaAgents = React.useMemo(() => {
|
||||
const additional: ManagedAgent[] = [];
|
||||
for (const group of groups) {
|
||||
const primary = pickProfileAgent(group.agents);
|
||||
for (const agent of group.agents) {
|
||||
if (primary?.pubkey !== agent.pubkey) {
|
||||
additional.push(agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return additional;
|
||||
}, [groups]);
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const {
|
||||
fileInputRef,
|
||||
@@ -160,21 +138,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
useFeedbackToasts(personaFeedbackNoticeMessage, personaFeedbackErrorMessage);
|
||||
const isLoading = isAgentsLoading || isPersonasLoading;
|
||||
|
||||
const rowProps = {
|
||||
channelIdToName,
|
||||
channelsByPubkey,
|
||||
isActionPending,
|
||||
logContent,
|
||||
logError,
|
||||
logLoading,
|
||||
personaLabelsById,
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
selectedLogAgentPubkey,
|
||||
onOpenProfile: onOpenAgentProfile,
|
||||
onSelectLogAgent,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative space-y-4"
|
||||
@@ -191,97 +154,66 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
|
||||
<SectionHeader
|
||||
agentCount={agents.length}
|
||||
canChooseCatalog={canChooseCatalog}
|
||||
fileInputRef={fileInputRef}
|
||||
handleFileChange={handleFileChange}
|
||||
isActionPending={isActionPending}
|
||||
isPersonasPending={isPersonasPending}
|
||||
openFilePicker={openFilePicker}
|
||||
runningCount={runningCount}
|
||||
stoppedCount={stoppedCount}
|
||||
onBulkRemoveStopped={onBulkRemoveStopped}
|
||||
onBulkStopRunning={onBulkStopRunning}
|
||||
onChooseCatalog={onChooseCatalog}
|
||||
onCreateAgent={onCreateAgent}
|
||||
onCreatePersona={onCreatePersona}
|
||||
/>
|
||||
|
||||
{isLoading ? <LoadingSkeleton /> : null}
|
||||
|
||||
{!isLoading && personas.length === 0 && agents.length === 0 ? (
|
||||
<EmptyState
|
||||
canChooseCatalog={canChooseCatalog}
|
||||
isPersonasPending={isPersonasPending}
|
||||
openFilePicker={openFilePicker}
|
||||
onChooseCatalog={onChooseCatalog}
|
||||
onCreatePersona={onCreatePersona}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && (personas.length > 0 || agents.length > 0) ? (
|
||||
{!isLoading ? (
|
||||
<div className="space-y-3" data-testid="unified-agents-groups">
|
||||
{groups.map((g) => {
|
||||
const isCollapsed = collapsed.has(g.persona.id);
|
||||
const hasAgents = g.agents.length > 0;
|
||||
const isDeactivated = !isPersonaActive(g.persona);
|
||||
return (
|
||||
<div
|
||||
key={g.persona.id}
|
||||
className={`overflow-hidden rounded-xl border border-border/70 bg-card/40${isDeactivated ? " opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 transition-colors hover:bg-muted/40">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
|
||||
onClick={() => toggle(g.persona.id)}
|
||||
type="button"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<PersonaIdentity
|
||||
persona={g.persona}
|
||||
showPromptTooltip={false}
|
||||
/>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{hasAgents
|
||||
? `${g.agents.length} instance${g.agents.length === 1 ? "" : "s"}`
|
||||
: "Not deployed"}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isDeactivated ? (
|
||||
<Badge variant="outline">Deactivated</Badge>
|
||||
) : !hasAgents ? (
|
||||
<Badge variant="outline">Inactive</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={isPersonasPending}
|
||||
onClick={() => onOpenPersonaProfile(g.persona)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && hasAgents ? (
|
||||
<AgentGroupRows agents={g.agents} {...rowProps} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={AGENT_CARD_GRID_CLASS}>
|
||||
{groups.map((group) => {
|
||||
const profileAgent = pickProfileAgent(group.agents);
|
||||
return (
|
||||
<AgentPersonaCard
|
||||
agent={profileAgent}
|
||||
key={group.persona.id}
|
||||
persona={group.persona}
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceLookup={presenceLookup}
|
||||
onOpenAgentProfile={onOpenAgentProfile}
|
||||
onOpenPersonaProfile={onOpenPersonaProfile}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<NewAgentCard
|
||||
canChooseCatalog={canChooseCatalog}
|
||||
isPersonasPending={isPersonasPending}
|
||||
openFilePicker={openFilePicker}
|
||||
onChooseCatalog={onChooseCatalog}
|
||||
onCreateAgent={onCreateAgent}
|
||||
onCreatePersona={onCreatePersona}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{additionalPersonaAgents.length > 0 ? (
|
||||
<CollapsibleAgentGroup
|
||||
agents={additionalPersonaAgents}
|
||||
collapsed={collapsed}
|
||||
groupKey="__additional_persona_agents__"
|
||||
label="Additional agent instances"
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceLookup={presenceLookup}
|
||||
onToggle={toggle}
|
||||
onOpenAgentProfile={onOpenAgentProfile}
|
||||
/>
|
||||
) : null}
|
||||
{unknown.length > 0 ? (
|
||||
<CollapsibleAgentGroup
|
||||
agents={unknown}
|
||||
collapsed={collapsed}
|
||||
groupKey="__unknown__"
|
||||
label="Unknown Persona"
|
||||
rowProps={rowProps}
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceLookup={presenceLookup}
|
||||
onToggle={toggle}
|
||||
onOpenAgentProfile={onOpenAgentProfile}
|
||||
/>
|
||||
) : null}
|
||||
{ungrouped.length > 0 ? (
|
||||
@@ -290,15 +222,19 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
collapsed={collapsed}
|
||||
groupKey="__ungrouped__"
|
||||
label="Custom Agents"
|
||||
rowProps={rowProps}
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceLookup={presenceLookup}
|
||||
onToggle={toggle}
|
||||
onOpenAgentProfile={onOpenAgentProfile}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && stoppedCount > 0 ? (
|
||||
<div className="flex items-center justify-between rounded-xl border border-border/60 bg-muted/30 px-4 py-2.5">
|
||||
<div
|
||||
className={`${AGENT_CARD_COLUMN_CLASS} flex items-center justify-between rounded-xl border border-border/60 bg-muted/30 px-4 py-2.5`}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stoppedCount} stopped {stoppedCount === 1 ? "agent" : "agents"}
|
||||
</p>
|
||||
@@ -316,12 +252,16 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
) : null}
|
||||
|
||||
{agentsError ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<p
|
||||
className={`${AGENT_CARD_COLUMN_CLASS} rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive`}
|
||||
>
|
||||
{agentsError.message}
|
||||
</p>
|
||||
) : null}
|
||||
{personasError ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<p
|
||||
className={`${AGENT_CARD_COLUMN_CLASS} rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive`}
|
||||
>
|
||||
{personasError.message}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -329,43 +269,157 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPersonaCard({
|
||||
agent,
|
||||
persona,
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
onOpenAgentProfile,
|
||||
onOpenPersonaProfile,
|
||||
}: {
|
||||
agent: ManagedAgent | undefined;
|
||||
persona: AgentPersona;
|
||||
presenceLoaded: boolean;
|
||||
presenceLookup: PresenceLookup;
|
||||
onOpenAgentProfile: (pubkey: string) => void;
|
||||
onOpenPersonaProfile: (persona: AgentPersona) => void;
|
||||
}) {
|
||||
const title = persona.displayName;
|
||||
const modelLabel = formatAgentModelLabel(agent?.model ?? persona.model);
|
||||
const profileQuery = useUserProfileQuery(agent?.pubkey);
|
||||
const avatarUrl = agent
|
||||
? firstAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl)
|
||||
: persona.avatarUrl;
|
||||
const friendlyError = agent
|
||||
? friendlyAgentLastError(agent.lastError)?.copy
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AgentIdentityCard
|
||||
ariaLabel={`${title} agent profile`}
|
||||
avatarUrl={avatarUrl}
|
||||
dataTestId={`persona-agent-row-${persona.id}`}
|
||||
errorLabel={friendlyError}
|
||||
label={title}
|
||||
modelControl={agent ? <ModelPicker agent={agent} /> : undefined}
|
||||
modelLabel={modelLabel}
|
||||
onClick={() => {
|
||||
if (agent) {
|
||||
onOpenAgentProfile(agent.pubkey);
|
||||
return;
|
||||
}
|
||||
onOpenPersonaProfile(persona);
|
||||
}}
|
||||
status={
|
||||
agent ? (
|
||||
<AgentCardStatus
|
||||
agent={agent}
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceLookup={presenceLookup}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StandaloneAgentCard({
|
||||
agent,
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
onOpenAgentProfile,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
presenceLoaded: boolean;
|
||||
presenceLookup: PresenceLookup;
|
||||
onOpenAgentProfile: (pubkey: string) => void;
|
||||
}) {
|
||||
const title = agent.name;
|
||||
const profileQuery = useUserProfileQuery(agent.pubkey);
|
||||
const friendlyError = friendlyAgentLastError(agent.lastError)?.copy;
|
||||
|
||||
return (
|
||||
<AgentIdentityCard
|
||||
ariaLabel={`${title} agent profile`}
|
||||
avatarUrl={profileQuery.data?.avatarUrl}
|
||||
dataTestId={`managed-agent-${agent.pubkey}`}
|
||||
errorLabel={friendlyError}
|
||||
label={title}
|
||||
modelControl={<ModelPicker agent={agent} />}
|
||||
modelLabel={formatAgentModelLabel(agent.model)}
|
||||
onClick={() => {
|
||||
onOpenAgentProfile(agent.pubkey);
|
||||
}}
|
||||
status={
|
||||
<AgentCardStatus
|
||||
agent={agent}
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceLookup={presenceLookup}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentCardStatus({
|
||||
agent,
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
presenceLoaded: boolean;
|
||||
presenceLookup: PresenceLookup;
|
||||
}) {
|
||||
const activeTurns = useActiveAgentTurns(agent.pubkey);
|
||||
const presenceStatus = presenceLookup[normalizePubkey(agent.pubkey)];
|
||||
|
||||
return (
|
||||
<AgentStatusBadge
|
||||
isWorking={activeTurns.length > 0}
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceStatus={presenceStatus}
|
||||
status={agent.status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function firstAvatarUrl(
|
||||
...candidates: Array<string | null | undefined>
|
||||
): string | null {
|
||||
for (const candidate of candidates) {
|
||||
const trimmed = candidate?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
agentCount,
|
||||
canChooseCatalog,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
isActionPending,
|
||||
isPersonasPending,
|
||||
openFilePicker,
|
||||
runningCount,
|
||||
stoppedCount,
|
||||
onBulkRemoveStopped,
|
||||
onBulkStopRunning,
|
||||
onChooseCatalog,
|
||||
onCreateAgent,
|
||||
onCreatePersona,
|
||||
}: {
|
||||
agentCount: number;
|
||||
canChooseCatalog: boolean;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
handleFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
isActionPending: boolean;
|
||||
isPersonasPending: boolean;
|
||||
openFilePicker: () => void;
|
||||
runningCount: number;
|
||||
stoppedCount: number;
|
||||
onBulkRemoveStopped: () => void;
|
||||
onBulkStopRunning: () => void;
|
||||
onChooseCatalog: () => void;
|
||||
onCreateAgent: () => void;
|
||||
onCreatePersona: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div
|
||||
className={`${AGENT_CARD_COLUMN_CLASS} flex items-center justify-between gap-3`}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">Your Agents</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Personas and their deployed agent instances.
|
||||
<p className="text-sm text-secondary-foreground/75">
|
||||
Agents in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
@@ -375,181 +429,113 @@ function SectionHeader({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{agentCount > 0 ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Bulk actions"
|
||||
className="h-7 w-7"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending || runningCount === 0}
|
||||
onClick={onBulkStopRunning}
|
||||
>
|
||||
<OctagonX className="h-4 w-4" />
|
||||
Stop all running ({runningCount})
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isActionPending || stoppedCount === 0}
|
||||
onClick={onBulkRemoveStopped}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Remove all stopped ({stoppedCount})
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{agentCount > 0 ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" type="button" variant="default">
|
||||
<Plus className="h-4 w-4" />
|
||||
New
|
||||
<Button
|
||||
aria-label="Bulk actions"
|
||||
className="h-7 w-7"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={isPersonasPending}
|
||||
onClick={onCreatePersona}
|
||||
disabled={isActionPending || runningCount === 0}
|
||||
onClick={onBulkStopRunning}
|
||||
>
|
||||
Persona
|
||||
<OctagonX className="h-4 w-4" />
|
||||
Stop all running ({runningCount})
|
||||
</DropdownMenuItem>
|
||||
{canChooseCatalog ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isPersonasPending}
|
||||
onClick={onChooseCatalog}
|
||||
>
|
||||
Choose from Catalog...
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onCreateAgent}>
|
||||
Custom Agent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openFilePicker}>
|
||||
Import persona file
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isActionPending || stoppedCount === 0}
|
||||
onClick={onBulkRemoveStopped}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Remove all stopped ({stoppedCount})
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{["a", "b", "c"].map((k, index) => (
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border border-border/70 bg-card/40"
|
||||
key={k}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
|
||||
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
|
||||
<Skeleton className="h-8 w-8 shrink-0 rounded-lg" />
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Skeleton className={index === 2 ? "h-4 w-36" : "h-4 w-32"} />
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="ml-1 h-3 w-20 shrink-0" />
|
||||
</div>
|
||||
{index === 1 ? (
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
) : null}
|
||||
<Skeleton className="h-8 w-8 shrink-0 rounded-lg" />
|
||||
</div>
|
||||
<div className="divide-y divide-border/50 border-t border-border/50">
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="mt-0.5 h-4 w-4 shrink-0 rounded-sm" />
|
||||
<Skeleton className="mt-1 h-2 w-2 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
{index === 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-2 lg:pt-0.5">
|
||||
<Skeleton className="h-7 w-24 rounded-md" />
|
||||
<Skeleton className="h-7 w-7 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
function NewAgentCard({
|
||||
canChooseCatalog,
|
||||
isPersonasPending,
|
||||
openFilePicker,
|
||||
onChooseCatalog,
|
||||
onCreateAgent,
|
||||
onCreatePersona,
|
||||
}: {
|
||||
canChooseCatalog: boolean;
|
||||
isPersonasPending: boolean;
|
||||
openFilePicker: () => void;
|
||||
onChooseCatalog: () => void;
|
||||
onCreateAgent: () => void;
|
||||
onCreatePersona: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-primary/40 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">No agents yet</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Create a persona or choose one from the catalog, then deploy it to a
|
||||
channel.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<PersonaLibraryEntryPoints
|
||||
canChooseCatalog={canChooseCatalog}
|
||||
isPending={isPersonasPending}
|
||||
layout="empty"
|
||||
onCreate={onCreatePersona}
|
||||
onChooseCatalog={onChooseCatalog}
|
||||
onImport={openFilePicker}
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<CreateIdentityCard
|
||||
ariaLabel="New agent"
|
||||
dataTestId="new-agent-card"
|
||||
label="New agent"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={isPersonasPending}
|
||||
onClick={onCreatePersona}
|
||||
>
|
||||
Persona
|
||||
</DropdownMenuItem>
|
||||
{canChooseCatalog ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isPersonasPending}
|
||||
onClick={onChooseCatalog}
|
||||
>
|
||||
Choose from Catalog...
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onCreateAgent}>
|
||||
Custom Agent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openFilePicker}>
|
||||
Import persona file
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className={AGENT_CARD_GRID_CLASS}>
|
||||
<IdentityCardSkeleton
|
||||
footerSubtitleWidthClass="w-14"
|
||||
footerTitleWidthClass="w-24"
|
||||
/>
|
||||
<IdentityCardSkeleton
|
||||
footerSubtitleWidthClass="w-20"
|
||||
footerTitleWidthClass="w-32"
|
||||
/>
|
||||
<IdentityCardSkeleton
|
||||
footerSubtitleWidthClass="w-16"
|
||||
footerTitleWidthClass="w-28"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -559,37 +545,49 @@ function CollapsibleAgentGroup({
|
||||
label,
|
||||
agents,
|
||||
collapsed,
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
onToggle,
|
||||
rowProps,
|
||||
onOpenAgentProfile,
|
||||
}: {
|
||||
groupKey: string;
|
||||
label: string;
|
||||
agents: ManagedAgent[];
|
||||
collapsed: ReadonlySet<string>;
|
||||
presenceLoaded: boolean;
|
||||
presenceLookup: PresenceLookup;
|
||||
onToggle: (key: string) => void;
|
||||
rowProps: Omit<React.ComponentProps<typeof AgentGroupRows>, "agents">;
|
||||
onOpenAgentProfile: (pubkey: string) => void;
|
||||
}) {
|
||||
const isCollapsed = collapsed.has(groupKey);
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/40">
|
||||
<div className="px-3 py-2 transition-colors hover:bg-muted/40">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 py-1 text-left"
|
||||
onClick={() => onToggle(groupKey)}
|
||||
type="button"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({agents.length})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed ? <AgentGroupRows agents={agents} {...rowProps} /> : null}
|
||||
<div className={`${AGENT_CARD_COLUMN_CLASS} space-y-2`}>
|
||||
<button
|
||||
className="group flex items-center gap-2 rounded-md px-1 py-1 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={() => onToggle(groupKey)}
|
||||
type="button"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs text-muted-foreground">({agents.length})</span>
|
||||
</button>
|
||||
{!isCollapsed ? (
|
||||
<div className={AGENT_CARD_GRID_CLASS}>
|
||||
{agents.map((agent) => (
|
||||
<StandaloneAgentCard
|
||||
agent={agent}
|
||||
key={agent.pubkey}
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceLookup={presenceLookup}
|
||||
onOpenAgentProfile={onOpenAgentProfile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] };
|
||||
|
||||
export function buildUnifiedGroups(
|
||||
personas: AgentPersona[],
|
||||
agents: ManagedAgent[],
|
||||
) {
|
||||
const byPersonaId = new Map<string, ManagedAgent[]>();
|
||||
const ungrouped: ManagedAgent[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
if (!agent.personaId) {
|
||||
ungrouped.push(agent);
|
||||
} else {
|
||||
const list = byPersonaId.get(agent.personaId) ?? [];
|
||||
list.push(agent);
|
||||
byPersonaId.set(agent.personaId, list);
|
||||
}
|
||||
}
|
||||
|
||||
const matched = new Set<string>();
|
||||
const groups: PersonaGroup[] = personas.map((persona) => {
|
||||
matched.add(persona.id);
|
||||
return { persona, agents: byPersonaId.get(persona.id) ?? [] };
|
||||
});
|
||||
|
||||
const unknown: ManagedAgent[] = [];
|
||||
for (const [id, list] of byPersonaId) {
|
||||
if (!matched.has(id)) unknown.push(...list);
|
||||
}
|
||||
|
||||
return { groups, ungrouped, unknown };
|
||||
}
|
||||
|
||||
export function pickProfileAgent(agents: ManagedAgent[]) {
|
||||
return [...agents].sort((left, right) => {
|
||||
const activeDiff =
|
||||
Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left));
|
||||
if (activeDiff !== 0) return activeDiff;
|
||||
return left.name.localeCompare(right.name);
|
||||
})[0];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -30,10 +30,7 @@ async function gotoApp(page: import("@playwright/test").Page) {
|
||||
}
|
||||
|
||||
async function openPersonaCatalog(page: import("@playwright/test").Page) {
|
||||
await page
|
||||
.getByTestId("agents-library-personas")
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
await page.getByTestId("new-agent-card").click();
|
||||
await page.getByText("Choose from Catalog...").click();
|
||||
}
|
||||
|
||||
|
||||
@@ -69,15 +69,16 @@ async function triggerManagedAgentPrimaryAction(
|
||||
pubkey: string,
|
||||
) {
|
||||
// Agent lifecycle actions moved from the old per-row dropdown into the
|
||||
// profile sidebar (PR #1200): the Agents-page row now exposes a "Manage"
|
||||
// button that opens the profile panel, where a single primary-action button
|
||||
// toggles Stop (when running/deployed) / Start (when stopped). Open the panel
|
||||
// for this agent if it isn't already showing it, then click that toggle.
|
||||
// profile sidebar (PR #1200): the Agents-page surfaces each agent as an
|
||||
// identity card that opens the profile panel on click, where a single
|
||||
// primary-action button toggles Stop (when running/deployed) / Start (when
|
||||
// stopped). Open the panel for this agent if it isn't already showing it,
|
||||
// then click that toggle.
|
||||
const panel = page.getByTestId("user-profile-panel");
|
||||
const primaryAction = panel.getByTestId("user-profile-agent-primary-action");
|
||||
if (!(await primaryAction.isVisible().catch(() => false))) {
|
||||
const row = page.getByTestId(`managed-agent-${pubkey}`);
|
||||
await row.getByRole("button", { name: "Manage" }).click();
|
||||
const card = page.getByTestId(`managed-agent-${pubkey}`);
|
||||
await card.getByRole("button", { name: /agent profile$/ }).click();
|
||||
await expect(panel).toBeVisible();
|
||||
}
|
||||
await expect(primaryAction).toBeEnabled();
|
||||
@@ -85,10 +86,7 @@ async function triggerManagedAgentPrimaryAction(
|
||||
}
|
||||
|
||||
async function openNewAgentMenu(page: import("@playwright/test").Page) {
|
||||
await page
|
||||
.getByTestId("agents-library-personas")
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
await page.getByTestId("new-agent-card").click();
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -223,10 +223,7 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({
|
||||
|
||||
// Open the Agents view, click New > Persona to open the persona dialog.
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page
|
||||
.getByTestId("agents-library-personas")
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
await page.getByTestId("new-agent-card").click();
|
||||
await page.getByRole("menuitem", { name: /^Persona$/ }).click();
|
||||
|
||||
// The env vars editor should be present.
|
||||
|
||||
@@ -115,10 +115,7 @@ test("create agent supports parallelism and system prompt overrides", async ({
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page
|
||||
.getByTestId("agents-library-personas")
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
await page.getByTestId("new-agent-card").click();
|
||||
await page.getByText("Custom Agent").click();
|
||||
|
||||
await page.getByTestId("agent-name-input").fill(agentName);
|
||||
@@ -137,12 +134,21 @@ test("create agent supports parallelism and system prompt overrides", async ({
|
||||
await expect(page.getByTestId("agents-library-personas")).toContainText(
|
||||
agentName,
|
||||
);
|
||||
const inlineLog = page
|
||||
.getByTestId("agents-library-personas")
|
||||
.getByTestId("managed-agent-log-content");
|
||||
|
||||
await expect(inlineLog).toContainText("parallelism=3");
|
||||
await expect(inlineLog).toContainText("system prompt override configured");
|
||||
// Logs now live in the profile sidebar (PR #1274), not an inline panel.
|
||||
// Open the new agent's card to reveal the profile panel, then read the
|
||||
// harness log from the diagnostics view.
|
||||
await page
|
||||
.getByRole("button", { name: `${agentName} agent profile` })
|
||||
.click();
|
||||
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-profile-tab-runtime").click();
|
||||
await page.getByTestId("user-profile-diagnostics-ingress").click();
|
||||
|
||||
const log = page.getByTestId("managed-agent-log-content");
|
||||
await expect(log).toContainText("parallelism=3");
|
||||
await expect(log).toContainText("system prompt override configured");
|
||||
});
|
||||
|
||||
test("opens a mocked channel from the inbox feed", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user