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:
klopez4212
2026-06-26 23:24:23 +00:00
committed by GitHub
co-authored by Bart Simpson Taylor Ho npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent e7d43dc225
commit ccf1bfe3a0
14 changed files with 931 additions and 517 deletions
@@ -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";
}
+109 -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,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>
);
}
+1 -4
View File
@@ -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();
}
+8 -10
View File
@@ -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 }) => {
+1 -4
View File
@@ -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.
+15 -9
View File
@@ -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 }) => {