updates on the agentstudio
This commit is contained in:
@@ -2,16 +2,13 @@ import React, { useState } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
Zap,
|
||||
Wrench,
|
||||
Pencil,
|
||||
Users,
|
||||
Package,
|
||||
Route,
|
||||
Layers,
|
||||
Check,
|
||||
Bot,
|
||||
LayoutGrid,
|
||||
Shield,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -23,15 +20,15 @@ const STATUS_TONE = {
|
||||
indigo: 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||
};
|
||||
|
||||
// The registry's word for what an agent actually does today. Shown beside
|
||||
// every agent, because a simulated agent that looks live is the failure the
|
||||
// registry exists to end.
|
||||
function StatusBadge({ agent }) {
|
||||
const meta = agent.statusMeta;
|
||||
const meta = agent.statusMeta || { label: agent.status || 'Live', tone: 'emerald', hint: '' };
|
||||
return (
|
||||
<span
|
||||
title={meta.hint}
|
||||
className={cn('shrink-0 rounded-full border px-1.5 py-px text-[9.5px] font-bold', STATUS_TONE[meta.tone] || STATUS_TONE.slate)}
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-bold tracking-wide',
|
||||
STATUS_TONE[meta.tone] || STATUS_TONE.slate
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
@@ -48,279 +45,175 @@ export default function AgentScopeNavigator({
|
||||
onSelectSkill,
|
||||
onSelectTool,
|
||||
}) {
|
||||
const [surfacesOpen, setSurfacesOpen] = useState(false);
|
||||
const [skillsOpen, setSkillsOpen] = useState(true);
|
||||
const [agentSkillsOpen, setAgentSkillsOpen] = useState(true);
|
||||
const [boardOpen, setBoardOpen] = useState(true);
|
||||
const [toolsOpen, setToolsOpen] = useState(true);
|
||||
const [agentMenuOpen, setAgentMenuOpen] = useState(false);
|
||||
|
||||
const currentAgent = agents.find((a) => a.id === activeAgentId) || agents[0];
|
||||
if (!currentAgent) return null;
|
||||
const agentSkills = skills.filter((s) => s.scope !== 'board');
|
||||
const boardSkills = skills.filter((s) => s.scope === 'board');
|
||||
|
||||
const activeSkillsCount = skills.filter((s) => s.enabled !== false).length;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-4 flex flex-col space-y-4">
|
||||
{/* 1. Agent Profile Header + Dropdown Switcher */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xs p-4 flex flex-col space-y-4">
|
||||
{/* 1. Agent Switcher */}
|
||||
<div className="relative">
|
||||
<label className="block text-[10.5px] font-bold uppercase tracking-wider text-slate-400 mb-1.5 px-0.5">
|
||||
Active Agent
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgentMenuOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between p-2 rounded-xl hover:bg-slate-50 transition-colors text-left cursor-pointer group"
|
||||
className="w-full flex items-center justify-between p-2.5 rounded-xl border border-slate-200 hover:border-slate-300 hover:bg-slate-50/80 transition-all text-left cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-full bg-brand-tint border border-brand/20 text-brand flex items-center justify-center shrink-0 shadow-2xs">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<div className="w-10 h-10 rounded-xl bg-brand/10 border border-brand/20 text-brand flex items-center justify-center shrink-0">
|
||||
<Bot className="w-5 h-5 text-brand" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs sm:text-sm font-bold text-slate-900 truncate font-heading group-hover:text-brand transition-colors">
|
||||
<div className="text-sm font-bold text-slate-900 truncate group-hover:text-brand transition-colors">
|
||||
{currentAgent.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<span>{currentAgent.badge || 'AGENT'}</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<StatusBadge agent={currentAgent} />
|
||||
<span className="text-[10px] font-medium text-slate-400 truncate">
|
||||
{currentAgent.runtime === 'engine' ? 'AI Engine' : 'Console'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0 ml-1 group-hover:text-slate-600 transition-colors" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-slate-400 shrink-0 ml-1 transition-transform',
|
||||
agentMenuOpen && 'rotate-180 text-brand'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Agent Switcher Menu */}
|
||||
{/* Dropdown Menu */}
|
||||
{agentMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-transparent"
|
||||
onClick={() => setAgentMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute left-0 right-0 top-full mt-1.5 bg-white border border-slate-200 rounded-xl shadow-lg p-1.5 z-50 animate-in fade-in zoom-in-95">
|
||||
<div className="absolute left-0 right-0 top-full mt-1.5 bg-white border border-slate-200 rounded-xl shadow-lg p-1.5 z-50 animate-in fade-in zoom-in-95 max-h-72 overflow-y-auto">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider px-3 py-1.5">
|
||||
Switch Agent Scope
|
||||
Select Agent
|
||||
</div>
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectAgent(agent.id);
|
||||
setAgentMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs font-semibold text-left transition-colors cursor-pointer',
|
||||
agent.id === activeAgentId
|
||||
? 'bg-brand-tint text-brand'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate">{agent.name}</span>
|
||||
<StatusBadge agent={agent} />
|
||||
</span>
|
||||
{agent.id === activeAgentId && <Check className="w-3.5 h-3.5 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
{agents.map((agent) => {
|
||||
const isSelected = agent.id === activeAgentId;
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectAgent(agent.id);
|
||||
setAgentMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs font-semibold text-left transition-colors cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-brand/10 text-brand font-bold'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate">{agent.name}</span>
|
||||
<StatusBadge agent={agent} />
|
||||
</div>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 shrink-0 text-brand ml-2" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-slate-100" />
|
||||
{/* 2. Agent Description */}
|
||||
{currentAgent.description && (
|
||||
<div className="p-3 rounded-xl bg-slate-50 border border-slate-100 text-xs text-slate-600 leading-relaxed">
|
||||
{currentAgent.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Hierarchical Tree (Clean, un-cluttered style) */}
|
||||
<div className="space-y-2 text-xs">
|
||||
{/* SURFACES TREE NODE */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSurfacesOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1.5 px-2 text-slate-700 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{surfacesOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
<LayoutGrid className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="font-semibold text-slate-800">Surfaces</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-slate-400">
|
||||
{surfaces.length || 1}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{surfacesOpen && (
|
||||
<div className="pl-7 pr-1 mt-1 space-y-0.5">
|
||||
{surfaces.map((surf) => (
|
||||
<div
|
||||
key={surf.id}
|
||||
className="py-1 px-2 rounded-md text-slate-600 hover:text-slate-900 hover:bg-slate-50 text-[11px] truncate cursor-pointer"
|
||||
title={surf.description}
|
||||
>
|
||||
{surf.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 3. Summary Stats */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-3 rounded-xl border border-slate-100 bg-slate-50/60 flex flex-col">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
Active Skills
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5 mt-1">
|
||||
<span className="text-xl font-bold text-slate-900">{activeSkillsCount}</span>
|
||||
<span className="text-xs text-slate-400">/ {skills.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SKILLS TREE NODE */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSkillsOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1.5 px-2 text-slate-700 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{skillsOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
<Zap className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="font-semibold text-slate-800">Skills</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-slate-400">
|
||||
{skills.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{skillsOpen && (
|
||||
<div className="pl-4 mt-1 space-y-1">
|
||||
{/* Agent Skills Sub-node */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgentSkillsOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1 px-2 text-[11px] font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-md cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{agentSkillsOpen ? (
|
||||
<ChevronDown className="w-3 h-3 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-slate-400" />
|
||||
)}
|
||||
<Zap className="w-3 h-3 text-brand" />
|
||||
<span className="font-medium text-slate-700">Agent Skills</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-semibold">
|
||||
{agentSkills.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{agentSkillsOpen && (
|
||||
<div className="pl-6 pr-1 mt-0.5 space-y-0.5">
|
||||
{agentSkills.map((skill) => {
|
||||
const getSkillIcon = (cat) => {
|
||||
switch ((cat || '').toUpperCase()) {
|
||||
case 'AUTHORING':
|
||||
return Pencil;
|
||||
case 'WORKFORCE':
|
||||
return Users;
|
||||
case 'LOGISTICS':
|
||||
return Package;
|
||||
case 'DISPATCH':
|
||||
return Route;
|
||||
default:
|
||||
return Zap;
|
||||
}
|
||||
};
|
||||
const SkillIcon = getSkillIcon(skill.category);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => onSelectSkill(skill)}
|
||||
className="w-full text-left py-1.5 px-2 rounded-md text-slate-600 hover:text-brand hover:bg-brand-tint/60 text-[11px] flex items-center gap-2 truncate cursor-pointer transition-colors group"
|
||||
>
|
||||
<SkillIcon className="w-3 h-3 shrink-0 text-slate-400 group-hover:text-brand transition-colors" />
|
||||
<span className="truncate">{skill.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Board Skills Sub-node */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBoardOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1 px-2 text-[11px] font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-md cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{boardOpen ? (
|
||||
<ChevronDown className="w-3 h-3 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-slate-400" />
|
||||
)}
|
||||
<Layers className="w-3 h-3 text-slate-400" />
|
||||
<span className="font-medium text-slate-700">Board Skills</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-semibold">
|
||||
{boardSkills.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{boardOpen && (
|
||||
<div className="pl-7 pr-2 mt-0.5">
|
||||
<p className="text-[10px] text-slate-400 leading-snug py-1">
|
||||
None drawing on this agent's surfaces.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TOOLS TREE NODE */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToolsOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between py-1.5 px-2 text-slate-700 hover:text-slate-900 hover:bg-slate-50 rounded-lg font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{toolsOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
<Wrench className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="font-semibold text-slate-800">Tools</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-slate-400">
|
||||
{tools.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{toolsOpen && (
|
||||
<div className="pl-6 pr-1 mt-1 space-y-0.5">
|
||||
{tools.map((tool) => (
|
||||
<button
|
||||
key={tool.name}
|
||||
type="button"
|
||||
onClick={onSelectTool}
|
||||
className="w-full text-left py-1 px-2 rounded-md hover:bg-slate-50 text-[11px] group cursor-pointer transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-slate-700 group-hover:text-brand truncate font-mono text-[10.5px]">
|
||||
<Wrench className="w-3 h-3 shrink-0 text-slate-400 group-hover:text-brand" />
|
||||
<span className="truncate">{tool.name}</span>
|
||||
</div>
|
||||
{tool.parentSkill && (
|
||||
<div className="text-[9.5px] text-slate-400 pl-4 truncate font-sans">
|
||||
{tool.parentSkill}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 rounded-xl border border-slate-100 bg-slate-50/60 flex flex-col">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
Linked Tools
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5 mt-1">
|
||||
<span className="text-xl font-bold text-slate-900">{tools.length}</span>
|
||||
<span className="text-xs text-slate-400">ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Quick Shortcuts */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-slate-100">
|
||||
<div className="text-[10.5px] font-bold uppercase tracking-wider text-slate-400 mb-1 px-1">
|
||||
Agent Capabilities
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectTool && onSelectTool()}
|
||||
className="w-full flex items-center justify-between p-2 rounded-xl text-xs font-medium text-slate-700 hover:text-slate-900 hover:bg-slate-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-3.5 h-3.5 text-brand" />
|
||||
<span>Configured Skills</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold bg-slate-100 px-2 py-0.5 rounded-full text-slate-600">
|
||||
{skills.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectTool && onSelectTool()}
|
||||
className="w-full flex items-center justify-between p-2 rounded-xl text-xs font-medium text-slate-700 hover:text-slate-900 hover:bg-slate-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span>Available Tools</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold bg-slate-100 px-2 py-0.5 rounded-full text-slate-600">
|
||||
{tools.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 5. Surfaces / Touchpoints */}
|
||||
{surfaces && surfaces.length > 0 && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-slate-100">
|
||||
<div className="flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-wider text-slate-400 px-1">
|
||||
<LayoutGrid className="w-3 h-3 text-slate-400" />
|
||||
<span>Active Surfaces</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{surfaces.map((surf) => (
|
||||
<span
|
||||
key={surf.id}
|
||||
className="text-[11px] font-medium text-slate-600 bg-slate-100 px-2 py-1 rounded-lg"
|
||||
title={surf.description}
|
||||
>
|
||||
{surf.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,13 +219,21 @@ export default function AgentStudio() {
|
||||
activeAgent={activeAgent}
|
||||
onTestSkill={handleTestSkill}
|
||||
onOpenDetails={(skill) => setDrawerSkillId(skill.id)}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
canEdit={canEdit}
|
||||
onNewSkill={() => setCreateModalOpen(true)}
|
||||
canCreate={canCreate}
|
||||
createDisabledReason={createDisabledReason}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'tools' && <ToolRegistryView tools={model.tools} />}
|
||||
{activeTab === 'tools' && (
|
||||
<ToolRegistryView
|
||||
tools={model.tools}
|
||||
agentTools={agentTools}
|
||||
activeAgent={activeAgent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'configure' && (
|
||||
<AgentConfigView
|
||||
|
||||
@@ -1,181 +1,142 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
Play,
|
||||
ArrowRight,
|
||||
Pencil,
|
||||
Users,
|
||||
Package,
|
||||
Route,
|
||||
Check,
|
||||
Settings2,
|
||||
Wrench,
|
||||
Layers,
|
||||
CircleDot,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const CATEGORY_META = {
|
||||
AUTHORING: {
|
||||
icon: Pencil,
|
||||
gradient: 'from-violet-500 to-purple-600',
|
||||
bgGlow: 'bg-violet-50',
|
||||
badgeBg: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||
accent: 'text-violet-600',
|
||||
glow: 'hover:shadow-violet-100',
|
||||
},
|
||||
WORKFORCE: {
|
||||
icon: Users,
|
||||
gradient: 'from-blue-500 to-indigo-600',
|
||||
bgGlow: 'bg-blue-50',
|
||||
badgeBg: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
accent: 'text-blue-600',
|
||||
glow: 'hover:shadow-blue-100',
|
||||
},
|
||||
LOGISTICS: {
|
||||
icon: Package,
|
||||
gradient: 'from-emerald-500 to-teal-600',
|
||||
bgGlow: 'bg-emerald-50',
|
||||
badgeBg: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
accent: 'text-emerald-600',
|
||||
glow: 'hover:shadow-emerald-100',
|
||||
},
|
||||
DISPATCH: {
|
||||
icon: Route,
|
||||
gradient: 'from-orange-500 to-rose-500',
|
||||
bgGlow: 'bg-orange-50',
|
||||
badgeBg: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
accent: 'text-orange-600',
|
||||
glow: 'hover:shadow-orange-100',
|
||||
},
|
||||
const CATEGORY_STYLES = {
|
||||
AUTHORING: 'bg-purple-50 text-purple-700 border-purple-200',
|
||||
WORKFORCE: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
LOGISTICS: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
DISPATCH: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
GENERAL: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
};
|
||||
|
||||
const DEFAULT_META = {
|
||||
icon: Layers,
|
||||
gradient: 'from-slate-500 to-slate-600',
|
||||
bgGlow: 'bg-slate-50',
|
||||
badgeBg: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
accent: 'text-slate-600',
|
||||
glow: 'hover:shadow-slate-100',
|
||||
};
|
||||
|
||||
export default function SkillCard({ skill, onTest, onDetails }) {
|
||||
export default function SkillCard({
|
||||
skill,
|
||||
onTest,
|
||||
onDetails,
|
||||
onToggleStatus,
|
||||
canEdit = true,
|
||||
}) {
|
||||
const isEnabled = skill.enabled !== false;
|
||||
const meta = CATEGORY_META[(skill.category || '').toUpperCase()] || DEFAULT_META;
|
||||
const CategoryIcon = meta.icon;
|
||||
const categoryKey = (skill.category || 'GENERAL').toUpperCase();
|
||||
const categoryBadgeStyle = CATEGORY_STYLES[categoryKey] || CATEGORY_STYLES.GENERAL;
|
||||
const toolsCount = Array.isArray(skill.tools) ? skill.tools.length : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative bg-white rounded-2xl border border-slate-200/80 shadow-sm transition-all duration-300 overflow-hidden cursor-pointer group',
|
||||
'hover:shadow-xl hover:shadow-slate-200/60 hover:-translate-y-0.5 hover:border-slate-300',
|
||||
meta.glow
|
||||
'bg-white rounded-2xl border transition-all duration-200 flex flex-col justify-between p-5 shadow-2xs hover:shadow-md hover:border-slate-300',
|
||||
isEnabled ? 'border-slate-200' : 'border-slate-200/60 bg-slate-50/40 opacity-80'
|
||||
)}
|
||||
>
|
||||
{/* Top gradient accent bar */}
|
||||
<div className={cn('h-1 w-full bg-gradient-to-r', meta.gradient)} />
|
||||
|
||||
<div className="p-5">
|
||||
{/* Header row: category badge + status */}
|
||||
<div className="flex items-center justify-between gap-2 mb-4">
|
||||
<div
|
||||
<div>
|
||||
{/* Top Header: Category Tag & Direct Status Toggle Switch */}
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-bold tracking-wider border',
|
||||
meta.badgeBg
|
||||
'px-2.5 py-0.5 rounded-lg text-[10px] font-bold uppercase tracking-wider border',
|
||||
categoryBadgeStyle
|
||||
)}
|
||||
>
|
||||
<CategoryIcon className="w-3 h-3" />
|
||||
<span>{(skill.category || 'SKILL').toUpperCase().replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
{categoryKey.replace(/_/g, ' ')}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-bold border transition-colors',
|
||||
isEnabled
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: 'bg-slate-100 text-slate-500 border-slate-200'
|
||||
)}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>Active</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CircleDot className="w-3 h-3" />
|
||||
<span>Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon + Title */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-gradient-to-br shadow-sm',
|
||||
meta.gradient
|
||||
)}
|
||||
>
|
||||
<CategoryIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-slate-900 font-heading leading-tight tracking-tight">
|
||||
{skill.title}
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5 leading-relaxed line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt pill */}
|
||||
{skill.prompt && (
|
||||
<div className="mb-3 px-3 py-2 rounded-xl bg-slate-50 border border-slate-200/80 flex items-start gap-2">
|
||||
<Sparkles className={cn('w-3 h-3 shrink-0 mt-0.5', meta.accent)} />
|
||||
<span className={cn('text-[10.5px] italic truncate font-medium', meta.accent)}>
|
||||
“{skill.prompt}”
|
||||
{/* User-friendly Direct On/Off Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] font-semibold transition-colors',
|
||||
isEnabled ? 'text-emerald-700' : 'text-slate-400'
|
||||
)}
|
||||
>
|
||||
{isEnabled ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
disabled={!canEdit}
|
||||
title={canEdit ? (isEnabled ? 'Click to disable' : 'Click to enable') : 'Admin access required'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onToggleStatus && canEdit) {
|
||||
onToggleStatus(skill.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isEnabled ? 'bg-emerald-500' : 'bg-slate-300'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out',
|
||||
isEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Integration tag */}
|
||||
{skill.integration && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-slate-50 border border-slate-200 text-[10px] font-semibold text-slate-500">
|
||||
{/* Skill Title & Plain English Description */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-bold text-slate-900 leading-snug">
|
||||
{skill.title}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mt-1 leading-relaxed line-clamp-2">
|
||||
{skill.description || 'No description provided for this skill.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Linked Tools Counter & Integration info */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-4">
|
||||
{toolsCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-slate-100 text-slate-600 text-[11px] font-medium">
|
||||
<Wrench className="w-3 h-3 text-slate-400" />
|
||||
<span>{toolsCount} {toolsCount === 1 ? 'tool linked' : 'tools linked'}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-slate-50 text-slate-400 text-[11px]">
|
||||
No external tools
|
||||
</span>
|
||||
)}
|
||||
|
||||
{skill.scope === 'board' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-indigo-50 border border-indigo-100 text-indigo-700 text-[10.5px] font-semibold">
|
||||
<Layers className="w-3 h-3" />
|
||||
{skill.integration}
|
||||
Shared Board Skill
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3.5 mt-1 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTest(skill)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-[11px] font-bold transition-all cursor-pointer',
|
||||
'px-3 py-1.5 rounded-lg bg-slate-50 hover:bg-slate-100 text-slate-600 hover:text-slate-900 border border-slate-200'
|
||||
)}
|
||||
>
|
||||
<Play className="w-3 h-3 fill-current" />
|
||||
<span>Test</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDetails(skill)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-[11px] font-semibold transition-all cursor-pointer group/btn',
|
||||
meta.accent, 'hover:opacity-70'
|
||||
)}
|
||||
>
|
||||
<span>Details</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 group-hover/btn:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Footer: Test and Configure */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-100 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTest && onTest(skill)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold text-slate-700 hover:text-slate-900 bg-slate-100 hover:bg-slate-200/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<Play className="w-3 h-3 fill-current text-slate-600" />
|
||||
<span>Test</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDetails && onDetails(skill)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-semibold text-brand hover:bg-brand/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5 text-brand" />
|
||||
<span>Configure</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Search, Plus, X, Layers, AlertCircle } from 'lucide-react';
|
||||
import { Search, Plus, X, AlertCircle, Filter, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import SkillCard from './SkillCard';
|
||||
|
||||
@@ -8,218 +8,190 @@ export default function SkillCardsGrid({
|
||||
activeAgent,
|
||||
onTestSkill,
|
||||
onOpenDetails,
|
||||
onToggleStatus,
|
||||
onNewSkill,
|
||||
canCreate = true,
|
||||
canEdit = true,
|
||||
createDisabledReason = '',
|
||||
}) {
|
||||
const [scopeTab, setScopeTab] = useState('agent'); // 'agent' or 'board'
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('ALL');
|
||||
const [statusFilter, setStatusFilter] = useState('ALL'); // 'ALL' | 'ACTIVE' | 'DISABLED'
|
||||
const [scopeFilter, setScopeFilter] = useState('ALL'); // 'ALL' | 'AGENT' | 'BOARD'
|
||||
|
||||
// Filter by active agent and scope
|
||||
const scopedSkills = useMemo(() => {
|
||||
return skills.filter((s) => {
|
||||
if (scopeTab === 'board') {
|
||||
return s.scope === 'board';
|
||||
}
|
||||
return s.scope !== 'board';
|
||||
});
|
||||
}, [skills, scopeTab]);
|
||||
const hasBoardSkills = skills.some((s) => s.scope === 'board');
|
||||
|
||||
// Categories present in the current scope
|
||||
// Categories present in the skills
|
||||
const categories = useMemo(() => {
|
||||
const counts = { ALL: scopedSkills.length };
|
||||
scopedSkills.forEach((s) => {
|
||||
const cat = (s.category || 'GENERAL').toUpperCase();
|
||||
counts[cat] = (counts[cat] || 0) + 1;
|
||||
const list = new Set();
|
||||
skills.forEach((s) => {
|
||||
if (s.category) list.add(s.category.toUpperCase());
|
||||
});
|
||||
return counts;
|
||||
}, [scopedSkills]);
|
||||
return Array.from(list);
|
||||
}, [skills]);
|
||||
|
||||
// Filter by category and search query
|
||||
// Filter skills
|
||||
const filteredSkills = useMemo(() => {
|
||||
return scopedSkills.filter((s) => {
|
||||
const matchesCat =
|
||||
categoryFilter === 'ALL' ||
|
||||
(s.category || '').toUpperCase() === categoryFilter;
|
||||
return skills.filter((s) => {
|
||||
// Category filter
|
||||
if (categoryFilter !== 'ALL' && (s.category || '').toUpperCase() !== categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
const isEnabled = s.enabled !== false;
|
||||
if (statusFilter === 'ACTIVE' && !isEnabled) return false;
|
||||
if (statusFilter === 'DISABLED' && isEnabled) return false;
|
||||
|
||||
// Scope filter
|
||||
if (scopeFilter === 'AGENT' && s.scope === 'board') return false;
|
||||
if (scopeFilter === 'BOARD' && s.scope !== 'board') return false;
|
||||
|
||||
// Search query
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
const matchesQuery =
|
||||
!q ||
|
||||
if (!q) return true;
|
||||
|
||||
return (
|
||||
(s.title || '').toLowerCase().includes(q) ||
|
||||
(s.description || '').toLowerCase().includes(q) ||
|
||||
(s.category || '').toLowerCase().includes(q) ||
|
||||
(s.integration || '').toLowerCase().includes(q) ||
|
||||
(s.prompt || '').toLowerCase().includes(q);
|
||||
|
||||
return matchesCat && matchesQuery;
|
||||
(s.prompt || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [scopedSkills, categoryFilter, searchQuery]);
|
||||
}, [skills, categoryFilter, statusFilter, scopeFilter, searchQuery]);
|
||||
|
||||
const enabledCount = filteredSkills.filter((s) => s.enabled !== false).length;
|
||||
const agentCount = skills.filter((s) => s.scope !== 'board').length;
|
||||
const boardCount = skills.filter((s) => s.scope === 'board').length;
|
||||
const activeCount = skills.filter((s) => s.enabled !== false).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 1. Scope Sub-tabs (Agent Skills / Board Skills) */}
|
||||
<div className="flex items-center gap-8 border-b border-slate-200/80 text-xs sm:text-sm font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScopeTab('agent');
|
||||
setCategoryFilter('ALL');
|
||||
}}
|
||||
className={cn(
|
||||
'pb-3 -mb-px transition-all cursor-pointer flex items-center gap-2 border-b-2 select-none',
|
||||
scopeTab === 'agent'
|
||||
? 'border-brand text-brand font-bold'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300'
|
||||
{/* 1. Header Toolbar: Search + Filters + New Skill Button */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search skills by name, description or role..."
|
||||
className="w-full h-10 bg-white border border-slate-200 rounded-xl pl-10 pr-9 text-xs sm:text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-2xs hover:border-slate-300 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-700 rounded-full hover:bg-slate-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<span>Agent Skills</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
|
||||
scopeTab === 'agent'
|
||||
? 'bg-brand text-white'
|
||||
: 'bg-slate-100 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{agentCount}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScopeTab('board');
|
||||
setCategoryFilter('ALL');
|
||||
}}
|
||||
className={cn(
|
||||
'pb-3 -mb-px transition-all cursor-pointer flex items-center gap-2 border-b-2 select-none',
|
||||
scopeTab === 'board'
|
||||
? 'border-brand text-brand font-bold'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-900 hover:border-slate-300'
|
||||
{/* Action Controls */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Category Dropdown Filter */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="h-10 px-3 pr-8 rounded-xl bg-white border border-slate-200 text-xs font-medium text-slate-700 hover:border-slate-300 focus:outline-none focus:border-brand shadow-2xs cursor-pointer appearance-none"
|
||||
>
|
||||
<option value="ALL">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat.charAt(0) + cat.slice(1).toLowerCase().replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Filter className="w-3.5 h-3.5 text-slate-400 absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Status Dropdown Filter */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-10 px-3 rounded-xl bg-white border border-slate-200 text-xs font-medium text-slate-700 hover:border-slate-300 focus:outline-none focus:border-brand shadow-2xs cursor-pointer"
|
||||
>
|
||||
<option value="ALL">All Status</option>
|
||||
<option value="ACTIVE">Active Only</option>
|
||||
<option value="DISABLED">Disabled Only</option>
|
||||
</select>
|
||||
|
||||
{/* Scope Filter (if board skills exist) */}
|
||||
{hasBoardSkills && (
|
||||
<select
|
||||
value={scopeFilter}
|
||||
onChange={(e) => setScopeFilter(e.target.value)}
|
||||
className="h-10 px-3 rounded-xl bg-white border border-slate-200 text-xs font-medium text-slate-700 hover:border-slate-300 focus:outline-none focus:border-brand shadow-2xs cursor-pointer"
|
||||
>
|
||||
<option value="ALL">All Scopes</option>
|
||||
<option value="AGENT">Agent Skills</option>
|
||||
<option value="BOARD">Board Skills</option>
|
||||
</select>
|
||||
)}
|
||||
>
|
||||
<span>Board Skills</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
|
||||
scopeTab === 'board'
|
||||
? 'bg-brand text-white'
|
||||
: 'bg-slate-100 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{boardCount}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. Global Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search skills, capabilities or integrations..."
|
||||
className="w-full h-11 bg-white border border-slate-200 rounded-2xl pl-11 pr-10 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-sm hover:border-slate-300 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
{/* New Skill Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-700 rounded-full hover:bg-slate-100 transition-colors cursor-pointer"
|
||||
onClick={onNewSkill}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : createDisabledReason}
|
||||
className="inline-flex items-center gap-1.5 h-10 px-4 rounded-xl bg-brand text-white font-semibold text-xs shadow-2xs hover:bg-brand/90 transition-all active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<Plus className="w-4 h-4 stroke-[2.5]" />
|
||||
<span>New Skill</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Summary & Result Stats */}
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 px-0.5">
|
||||
<div>
|
||||
<span>Showing </span>
|
||||
<span className="font-semibold text-slate-800">{filteredSkills.length}</span>
|
||||
<span> of {skills.length} skills (</span>
|
||||
<span className="font-semibold text-emerald-600">{activeCount} active</span>
|
||||
<span>)</span>
|
||||
</div>
|
||||
|
||||
{(categoryFilter !== 'ALL' || statusFilter !== 'ALL' || scopeFilter !== 'ALL' || searchQuery) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setCategoryFilter('ALL');
|
||||
setStatusFilter('ALL');
|
||||
setScopeFilter('ALL');
|
||||
}}
|
||||
className="text-xs text-brand font-medium hover:underline cursor-pointer"
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Category Filter Pills */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 no-scrollbar">
|
||||
{Object.entries(categories).map(([cat, count]) => {
|
||||
const isSelected = categoryFilter === cat;
|
||||
const displayLabel = cat === 'ALL' ? 'All' : (cat.charAt(0) + cat.slice(1).toLowerCase()).replace(/_/g, ' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all duration-200 whitespace-nowrap cursor-pointer flex items-center gap-1.5 border',
|
||||
isSelected
|
||||
? 'bg-slate-900 text-white border-slate-900 shadow-sm'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50 hover:border-slate-300 hover:text-slate-900'
|
||||
)}
|
||||
>
|
||||
<span>{displayLabel}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-bold px-1.5 py-0.5 rounded-md',
|
||||
isSelected ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 4. Stats + New Skill Row */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-50 border border-slate-200 text-xs">
|
||||
<span className="text-slate-500">Total</span>
|
||||
<span className="font-bold text-slate-900">{filteredSkills.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-50 border border-emerald-200 text-xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-emerald-700 font-semibold">{enabledCount} active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewSkill}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : createDisabledReason}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-semibold text-xs shadow-sm transition-all active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 stroke-[2.5]" />
|
||||
<span>New Skill</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 5. Responsive Skill Cards Grid */}
|
||||
{scopeTab === 'board' && filteredSkills.length === 0 ? (
|
||||
<div className="p-10 rounded-2xl border border-dashed border-slate-200 bg-white/60 text-center space-y-2">
|
||||
<Layers className="w-7 h-7 text-slate-400 mx-auto" />
|
||||
<div className="text-sm font-semibold text-slate-700">No Board Skills Connected</div>
|
||||
<p className="text-xs text-slate-400 max-w-sm mx-auto">
|
||||
None drawing on this agent's surfaces. Connect board skills to link live operational boards with this agent.
|
||||
</p>
|
||||
</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
{/* 3. Skill Cards Grid or Empty State */}
|
||||
{filteredSkills.length === 0 ? (
|
||||
<div className="p-10 rounded-2xl border border-slate-200 bg-white text-center space-y-3 shadow-2xs">
|
||||
<AlertCircle className="w-7 h-7 text-slate-400 mx-auto" />
|
||||
<div className="text-sm font-semibold text-slate-800">No matching capabilities found</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Try adjusting your search query or selecting a different category filter.
|
||||
<AlertCircle className="w-8 h-8 text-slate-400 mx-auto" />
|
||||
<div className="text-sm font-bold text-slate-800">No matching skills found</div>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
{searchQuery
|
||||
? `No skills matched "${searchQuery}". Try a different keyword or reset filters.`
|
||||
: 'There are no skills configured under the selected filter criteria.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setCategoryFilter('ALL');
|
||||
setStatusFilter('ALL');
|
||||
setScopeFilter('ALL');
|
||||
}}
|
||||
className="px-3.5 py-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 text-xs font-medium text-slate-700 transition-colors cursor-pointer"
|
||||
className="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-xs font-semibold text-slate-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Clear Filters
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -230,6 +202,8 @@ export default function SkillCardsGrid({
|
||||
skill={skill}
|
||||
onTest={onTestSkill}
|
||||
onDetails={onOpenDetails}
|
||||
onToggleStatus={onToggleStatus}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -11,207 +11,262 @@ import {
|
||||
Bell,
|
||||
Database,
|
||||
X,
|
||||
Hash,
|
||||
MapPin,
|
||||
Layers,
|
||||
Bot,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/* ─── Kind config ─────────────────────────────────────────────────────────── */
|
||||
const KIND = {
|
||||
/* ─── Friendly Tool Dictionary ────────────────────────────────────────────── */
|
||||
const TOOL_DICTIONARY = {
|
||||
alert_low_battery_rider: {
|
||||
title: 'Rider Battery Warning',
|
||||
desc: 'Sends an urgent notification to riders with low vehicle battery to recharge or report to a hub.',
|
||||
category: 'Fleet & Riders',
|
||||
},
|
||||
assign_express_batch: {
|
||||
title: 'Assign Express Batch',
|
||||
desc: 'Automatically assigns grouped high-priority express orders to optimal nearby riders.',
|
||||
category: 'Orders & Dispatch',
|
||||
},
|
||||
assign_riders: {
|
||||
title: 'Assign Orders to Riders',
|
||||
desc: 'Dispatches and matches unassigned customer orders with active, available delivery riders.',
|
||||
category: 'Orders & Dispatch',
|
||||
},
|
||||
create_single_order: {
|
||||
title: 'Create Express Booking',
|
||||
desc: 'Creates an on-demand express delivery booking upon operator review and confirmation.',
|
||||
category: 'Orders & Dispatch',
|
||||
},
|
||||
decide_assignment_failure: {
|
||||
title: 'Diagnose Assignment Failure',
|
||||
desc: 'Analyzes why an order failed assignment and recommends an immediate resolution.',
|
||||
category: 'Analysis & Diagnostics',
|
||||
},
|
||||
decide_stall_response: {
|
||||
title: 'Resolve Stalled Rider',
|
||||
desc: 'Detects riders stopped mid-route and initiates automatic reassignment or customer updates.',
|
||||
category: 'Analysis & Diagnostics',
|
||||
},
|
||||
dispatch_hub_idle_parcels: {
|
||||
title: 'Dispatch Idle Hub Parcels',
|
||||
desc: 'Coordinates idle riders to pick up parcels waiting at crowded hubs.',
|
||||
category: 'Orders & Dispatch',
|
||||
},
|
||||
enforce_cash_handoff: {
|
||||
title: 'Enforce Cash Hand-off',
|
||||
desc: 'Routes riders carrying high Cash-on-Delivery balances to deposit cash at the nearest hub.',
|
||||
category: 'Fleet & Riders',
|
||||
},
|
||||
enforce_otp_verification: {
|
||||
title: 'Require Delivery OTP',
|
||||
desc: 'Enforces one-time password verification before handing over high-value COD orders.',
|
||||
category: 'Fleet & Riders',
|
||||
},
|
||||
nearby_milers: {
|
||||
title: 'Locate Nearby Riders',
|
||||
desc: 'Searches real-time GPS locations to find active delivery riders within a specified radius.',
|
||||
category: 'Fleet & Riders',
|
||||
},
|
||||
notify_customer: {
|
||||
title: 'Send Customer Update',
|
||||
desc: 'Sends automated SMS or WhatsApp status notifications to the recipient about order delays or arrivals.',
|
||||
category: 'Customer Alerts',
|
||||
},
|
||||
publish_miler_stalled: {
|
||||
title: 'Broadcast Delay Event',
|
||||
desc: 'Broadcasts a stalled delivery alert to the operations team dashboard.',
|
||||
category: 'Customer Alerts',
|
||||
},
|
||||
reassign_booking: {
|
||||
title: 'Reassign to New Rider',
|
||||
desc: 'Transfers a stalled or delayed order to an alternate available rider to protect delivery SLA.',
|
||||
category: 'Orders & Dispatch',
|
||||
},
|
||||
rebalance_riders: {
|
||||
title: 'Rebalance Fleet Zones',
|
||||
desc: 'Directs idle riders from low-demand areas to high-volume delivery zones.',
|
||||
category: 'Fleet & Riders',
|
||||
},
|
||||
scan_bookings: {
|
||||
title: 'Scan Pending Orders',
|
||||
desc: 'Continuously monitors order queues for delayed or unassigned shipments.',
|
||||
category: 'Orders & Dispatch',
|
||||
},
|
||||
simulate_pricing_quote: {
|
||||
title: 'Calculate Delivery Quote',
|
||||
desc: 'Estimates accurate delivery fares and ETA based on routed distance and vehicle type.',
|
||||
category: 'Pricing & Route',
|
||||
},
|
||||
trigger_auto_dispatch: {
|
||||
title: 'Trigger Auto-Dispatch',
|
||||
desc: 'Automatically pairs waiting orders with the closest available riders during peak rush.',
|
||||
category: 'Orders & Dispatch',
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
'Orders & Dispatch',
|
||||
'Fleet & Riders',
|
||||
'Customer Alerts',
|
||||
'Analysis & Diagnostics',
|
||||
];
|
||||
|
||||
/** Convert snake_case code into clean Title Case if not in dictionary */
|
||||
function formatToolTitle(rawName) {
|
||||
if (TOOL_DICTIONARY[rawName]?.title) return TOOL_DICTIONARY[rawName].title;
|
||||
return rawName
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** Sanitize developer descriptions into clean operational text */
|
||||
function formatToolDescription(tool) {
|
||||
if (TOOL_DICTIONARY[tool.name]?.desc) return TOOL_DICTIONARY[tool.name].desc;
|
||||
let text = tool.description || 'No description provided.';
|
||||
text = text.replace(/REVIEW ONLY\.\s*/gi, '');
|
||||
text = text.replace(/No executor\.\s*/gi, '');
|
||||
text = text.replace(/\(structured output\)\s*/gi, '');
|
||||
text = text.replace(/POST\s+[^\s]+\s+accepts[^\.]+\.\s*/gi, '');
|
||||
text = text.replace(/NOT IMPLEMENTED:[^\.]+\.\s*/gi, '');
|
||||
return text.trim() || 'Executes this automated workflow action.';
|
||||
}
|
||||
|
||||
const KIND_META = {
|
||||
read: {
|
||||
icon: Eye,
|
||||
label: 'Read',
|
||||
card: 'border-sky-200 bg-sky-50/40',
|
||||
iconWrap: 'bg-sky-100 text-sky-600',
|
||||
badge: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||
dot: 'bg-sky-500',
|
||||
label: 'Lookup',
|
||||
badge: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
},
|
||||
write: {
|
||||
icon: Edit3,
|
||||
label: 'Write',
|
||||
card: 'border-violet-200 bg-violet-50/30',
|
||||
iconWrap: 'bg-violet-100 text-violet-600',
|
||||
badge: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||
dot: 'bg-violet-500',
|
||||
label: 'Action',
|
||||
badge: 'bg-violet-50 text-violet-700 border-violet-200',
|
||||
},
|
||||
notify: {
|
||||
icon: Bell,
|
||||
label: 'Notify',
|
||||
card: 'border-amber-200 bg-amber-50/30',
|
||||
iconWrap: 'bg-amber-100 text-amber-600',
|
||||
badge: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
dot: 'bg-amber-500',
|
||||
label: 'Alert',
|
||||
badge: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
},
|
||||
};
|
||||
const KIND_DEFAULT = {
|
||||
|
||||
const DEFAULT_KIND_META = {
|
||||
icon: Database,
|
||||
label: 'General',
|
||||
card: 'border-slate-200 bg-white',
|
||||
iconWrap: 'bg-slate-100 text-slate-500',
|
||||
badge: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
dot: 'bg-slate-400',
|
||||
label: 'Action',
|
||||
badge: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
};
|
||||
|
||||
function kindOf(tool) {
|
||||
return KIND[(tool.kind || '').toLowerCase()] || KIND_DEFAULT;
|
||||
}
|
||||
|
||||
/* ─── Stat chip ───────────────────────────────────────────────────────────── */
|
||||
function StatChip({ label, value, tone = 'slate' }) {
|
||||
const tones = {
|
||||
slate: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
amber: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
sky: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
};
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center px-5 py-3 rounded-2xl border', tones[tone])}>
|
||||
<span className="text-2xl font-black leading-none">{value}</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest mt-1 opacity-70">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Parameter pill ──────────────────────────────────────────────────────── */
|
||||
function ParamPill({ param }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-3 rounded-xl bg-white border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono font-bold text-[11px] text-brand">{param.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-600 border border-slate-200">
|
||||
{param.type}
|
||||
</span>
|
||||
{param.required && (
|
||||
<span className="text-[9px] font-bold text-rose-600 bg-rose-50 px-1.5 py-0.5 rounded-md border border-rose-200">
|
||||
req
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{param.description && (
|
||||
<p className="text-[10.5px] text-slate-500 leading-relaxed">{param.description}</p>
|
||||
)}
|
||||
{param.default !== undefined && (
|
||||
<div className="text-[9.5px] font-mono text-slate-400 bg-slate-50 px-2 py-0.5 rounded-md mt-0.5">
|
||||
default: {String(param.default)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Tool Card ───────────────────────────────────────────────────────────── */
|
||||
function ToolCard({ tool }) {
|
||||
function ToolRow({ tool }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const k = kindOf(tool);
|
||||
const KindIcon = k.icon;
|
||||
const meta = KIND_META[(tool.kind || '').toLowerCase()] || DEFAULT_KIND_META;
|
||||
const KindIcon = meta.icon;
|
||||
const friendlyTitle = formatToolTitle(tool.name);
|
||||
const friendlyDesc = formatToolDescription(tool);
|
||||
const hasParams = tool.parameters && tool.parameters.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border transition-all duration-200 overflow-hidden group',
|
||||
k.card,
|
||||
open ? 'shadow-md' : 'shadow-sm hover:shadow-md hover:-translate-y-px'
|
||||
'rounded-xl border bg-white transition-all duration-150 overflow-hidden shadow-2xs',
|
||||
open ? 'border-brand/40 shadow-xs' : 'border-slate-200 hover:border-slate-300'
|
||||
)}
|
||||
>
|
||||
{/* Card header — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full text-left p-4 flex items-start gap-3 cursor-pointer"
|
||||
className="w-full text-left p-3.5 flex items-center justify-between gap-3 cursor-pointer group"
|
||||
>
|
||||
{/* Kind icon */}
|
||||
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm', k.iconWrap)}>
|
||||
<KindIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-mono font-black text-xs text-slate-900 tracking-tight">{tool.name}</span>
|
||||
|
||||
{/* Kind badge */}
|
||||
<span className={cn('px-2 py-0.5 rounded-lg text-[9.5px] font-bold border capitalize', k.badge)}>
|
||||
{k.label}
|
||||
</span>
|
||||
|
||||
{/* Safety */}
|
||||
{tool.requiresConfirmation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[9.5px] font-bold bg-rose-50 text-rose-600 border border-rose-200">
|
||||
<ShieldAlert className="w-2.5 h-2.5" />
|
||||
Gated
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[9.5px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
<ShieldCheck className="w-2.5 h-2.5" />
|
||||
Open
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Params count */}
|
||||
{hasParams && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[9.5px] font-bold bg-slate-100 text-slate-600 border border-slate-200">
|
||||
<Hash className="w-2.5 h-2.5" />
|
||||
{tool.parameters.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-100 border border-slate-200 text-slate-600 flex items-center justify-center shrink-0 group-hover:text-brand transition-colors">
|
||||
<KindIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed line-clamp-1">{tool.description}</p>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs sm:text-sm font-bold text-slate-900 group-hover:text-brand transition-colors">
|
||||
{friendlyTitle}
|
||||
</span>
|
||||
|
||||
{/* Code pill */}
|
||||
<span className="font-mono text-[10px] text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
{tool.name}
|
||||
</span>
|
||||
|
||||
{/* Kind Badge */}
|
||||
<span className={cn('px-2 py-0.5 rounded-md text-[10px] font-semibold border', meta.badge)}>
|
||||
{meta.label}
|
||||
</span>
|
||||
|
||||
{/* Safety Badge */}
|
||||
{tool.requiresConfirmation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold bg-amber-50 text-amber-700 border border-amber-200">
|
||||
<ShieldAlert className="w-3 h-3 text-amber-600" />
|
||||
Needs Approval
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
<ShieldCheck className="w-3 h-3 text-emerald-600" />
|
||||
Automated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 mt-1 line-clamp-1 leading-normal">
|
||||
{friendlyDesc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand chevron */}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-slate-400 shrink-0 mt-1 transition-transform duration-200',
|
||||
open && 'rotate-180 text-brand'
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-slate-400 group-hover:text-slate-700 transition-transform duration-200',
|
||||
open && 'rotate-180 text-brand'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expandable detail panel */}
|
||||
{open && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-black/5 space-y-4">
|
||||
{/* Meta grid */}
|
||||
{(tool.target || tool.implementedAt || tool.parentSkill) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{tool.target && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-xl bg-white/70 border border-slate-200">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider">Touches</div>
|
||||
<div className="font-mono text-[11px] text-slate-800 mt-0.5">{tool.target}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tool.implementedAt && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-xl bg-white/70 border border-slate-200">
|
||||
<Layers className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider">Implemented at</div>
|
||||
<div className="font-mono text-[10px] text-slate-700 mt-0.5 break-all">{tool.implementedAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 pb-4 pt-2 border-t border-slate-100 bg-slate-50/70 space-y-3">
|
||||
<div className="text-xs text-slate-600 leading-relaxed">
|
||||
<span className="font-semibold text-slate-700">Detailed Action: </span>
|
||||
{friendlyDesc}
|
||||
</div>
|
||||
|
||||
{tool.parentSkill && (
|
||||
<div className="text-xs text-slate-600">
|
||||
<span className="font-semibold text-slate-700">Used by Skills: </span>
|
||||
<span className="text-brand font-medium">{tool.parentSkill}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameters */}
|
||||
{hasParams ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-600 uppercase tracking-wider">
|
||||
{hasParams && (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<div className="flex items-center gap-1.5 text-[10.5px] font-bold uppercase tracking-wider text-slate-500">
|
||||
<Code2 className="w-3.5 h-3.5 text-brand" />
|
||||
Parameters
|
||||
<span>Required Inputs ({tool.parameters.length})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{tool.parameters.map((p) => <ParamPill key={p.name} param={p} />)}
|
||||
{tool.parameters.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="p-2.5 rounded-lg bg-white border border-slate-200 flex flex-col gap-0.5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-xs font-bold text-brand">{p.name}</span>
|
||||
<span className="text-[9.5px] font-mono px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">
|
||||
{p.type || 'text'}
|
||||
</span>
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">{p.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-slate-400 italic">No parameters — input is open-ended.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -219,131 +274,201 @@ function ToolCard({ tool }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main view ───────────────────────────────────────────────────────────── */
|
||||
export default function ToolRegistryView({ tools = [] }) {
|
||||
export default function ToolRegistryView({
|
||||
tools = [],
|
||||
agentTools = [],
|
||||
activeAgent = null,
|
||||
}) {
|
||||
const [scope, setScope] = useState('AGENT'); // 'AGENT' | 'ALL'
|
||||
const [search, setSearch] = useState('');
|
||||
const [kindFilter, setKindFilter] = useState('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState('Orders & Dispatch');
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { all: tools.length };
|
||||
tools.forEach((t) => {
|
||||
const k = (t.kind || 'read').toLowerCase();
|
||||
c[k] = (c[k] || 0) + 1;
|
||||
});
|
||||
return c;
|
||||
}, [tools]);
|
||||
|
||||
const kindTabs = ['all', ...Object.keys(KIND).filter((k) => counts[k])];
|
||||
// If the agent has no linked tools, default to showing all tools
|
||||
const effectiveScope = scope === 'AGENT' && agentTools.length > 0 ? 'AGENT' : 'ALL';
|
||||
const baseTools = effectiveScope === 'AGENT' ? agentTools : tools;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return tools.filter((t) => {
|
||||
const matchesKind = kindFilter === 'all' || (t.kind || 'read').toLowerCase() === kindFilter;
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
(t.parentSkill || '').toLowerCase().includes(q) ||
|
||||
(t.target || '').toLowerCase().includes(q);
|
||||
return matchesKind && matchesSearch;
|
||||
});
|
||||
}, [tools, kindFilter, search]);
|
||||
return baseTools.filter((t) => {
|
||||
// In ALL tools mode, filter by selected category unless searching
|
||||
if (effectiveScope === 'ALL' && !q) {
|
||||
const cat = TOOL_DICTIONARY[t.name]?.category || 'Orders & Dispatch';
|
||||
if (cat !== categoryFilter) return false;
|
||||
}
|
||||
|
||||
const gatedCount = tools.filter((t) => t.requiresConfirmation).length;
|
||||
const openCount = tools.length - gatedCount;
|
||||
if (!q) return true;
|
||||
const friendlyTitle = formatToolTitle(t.name).toLowerCase();
|
||||
const friendlyDesc = formatToolDescription(t).toLowerCase();
|
||||
return (
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
friendlyTitle.includes(q) ||
|
||||
friendlyDesc.includes(q)
|
||||
);
|
||||
});
|
||||
}, [baseTools, effectiveScope, categoryFilter, search]);
|
||||
|
||||
const agentName = activeAgent?.name || 'Agent';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Stats row ─────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-stretch gap-3">
|
||||
<StatChip label="Total Tools" value={tools.length} tone="slate" />
|
||||
<StatChip label="Read" value={counts.read || 0} tone="sky" />
|
||||
<StatChip label="Write / Notify" value={(counts.write || 0) + (counts.notify || 0)} tone="amber" />
|
||||
<StatChip label="Gated" value={gatedCount} tone="amber" />
|
||||
<StatChip label="Open" value={openCount} tone="emerald" />
|
||||
<div className="space-y-4">
|
||||
{/* 1. Scope Selector Tabs: Agent Tools vs All Tools */}
|
||||
<div className="flex items-center justify-between gap-3 border-b border-slate-200 pb-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 bg-slate-100 p-1 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScope('AGENT');
|
||||
setSearch('');
|
||||
}}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5',
|
||||
effectiveScope === 'AGENT'
|
||||
? 'bg-white text-slate-900 shadow-2xs'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
)}
|
||||
>
|
||||
<Bot className="w-3.5 h-3.5 text-brand" />
|
||||
<span>{agentName}'s Tools</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md text-[10px]',
|
||||
effectiveScope === 'AGENT' ? 'bg-brand/10 text-brand' : 'bg-slate-200 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{agentTools.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Right-side header text */}
|
||||
<div className="flex-1 min-w-[200px] flex flex-col justify-center pl-2">
|
||||
<h3 className="text-sm font-bold text-slate-900 font-heading flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4 text-brand" />
|
||||
Agent Tool Registry
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5 leading-relaxed">
|
||||
Every capability a skill may call. Write & notify tools need operator confirmation unless the agent is autonomous.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScope('ALL');
|
||||
setSearch('');
|
||||
setCategoryFilter('Orders & Dispatch');
|
||||
}}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5',
|
||||
effectiveScope === 'ALL'
|
||||
? 'bg-white text-slate-900 shadow-2xs'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
)}
|
||||
>
|
||||
<Wrench className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>All System Tools</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md text-[10px]',
|
||||
effectiveScope === 'ALL' ? 'bg-slate-900 text-white' : 'bg-slate-200 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{tools.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Helper Note */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Info className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span>Tools are automated actions agents can execute in your system.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Search + Kind filter ───────────────────────────────────────── */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Search */}
|
||||
{/* 2. Search & Category Filters */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name, target system, or description…"
|
||||
className="w-full h-10 bg-white border border-slate-200 rounded-xl pl-10 pr-9 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-sm transition-all"
|
||||
placeholder="Search tools by action, rider, booking..."
|
||||
className="w-full h-10 bg-white border border-slate-200 rounded-xl pl-10 pr-9 text-xs sm:text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-2xs hover:border-slate-300 transition-all"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-slate-400 hover:text-slate-700 hover:bg-slate-100 cursor-pointer transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-700 rounded-full hover:bg-slate-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kind filter tabs */}
|
||||
<div className="flex items-center gap-1 bg-slate-100 rounded-xl p-1 shrink-0">
|
||||
{kindTabs.map((k) => {
|
||||
const meta = KIND[k];
|
||||
const isActive = kindFilter === k;
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setKindFilter(k)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer capitalize flex items-center gap-1.5',
|
||||
isActive ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
||||
)}
|
||||
>
|
||||
{meta && <span className={cn('w-1.5 h-1.5 rounded-full', meta.dot)} />}
|
||||
{k === 'all' ? 'All' : meta?.label || k}
|
||||
<span
|
||||
{/* Category Pills (Without 'All' filter) */}
|
||||
{effectiveScope === 'ALL' && (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-0.5">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const isSelected = categoryFilter === cat;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={cn(
|
||||
'text-[10px] font-bold',
|
||||
isActive ? 'text-slate-600' : 'text-slate-400'
|
||||
'px-3 py-1.5 rounded-lg text-xs font-semibold whitespace-nowrap cursor-pointer transition-all border',
|
||||
isSelected
|
||||
? 'bg-slate-900 text-white border-slate-900 shadow-2xs'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
|
||||
)}
|
||||
>
|
||||
{counts[k] || 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{cat}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Tool Cards ────────────────────────────────────────────────── */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-16 rounded-2xl border border-dashed border-slate-200 bg-slate-50 text-center">
|
||||
<Wrench className="w-8 h-8 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-semibold text-slate-500">No tools match your filter</p>
|
||||
{/* 3. Result Count */}
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 px-0.5">
|
||||
<div>
|
||||
<span>Showing </span>
|
||||
<span className="font-semibold text-slate-800">{filtered.length}</span>
|
||||
<span>
|
||||
{effectiveScope === 'ALL' && !search
|
||||
? ` tools in ${categoryFilter}`
|
||||
: ` of ${baseTools.length} ${effectiveScope === 'AGENT' ? `${agentName} tools` : 'tools'}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearch(''); setKindFilter('all'); }}
|
||||
className="mt-3 text-xs text-brand font-semibold hover:underline cursor-pointer"
|
||||
onClick={() => setSearch('')}
|
||||
className="text-xs text-brand font-medium hover:underline cursor-pointer"
|
||||
>
|
||||
Clear filters
|
||||
Clear search
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Tool List */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-8 rounded-2xl border border-slate-200 bg-white text-center space-y-2 shadow-2xs">
|
||||
<Wrench className="w-7 h-7 text-slate-300 mx-auto" />
|
||||
<div className="text-sm font-bold text-slate-800">No tools found</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{search
|
||||
? `No tools matched "${search}".`
|
||||
: `No tools configured under ${categoryFilter}.`}
|
||||
</p>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch('')}
|
||||
className="px-3 py-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 text-xs font-semibold text-slate-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Clear Search
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{filtered.map((tool) => <ToolCard key={tool.name} tool={tool} />)}
|
||||
<div className="space-y-2">
|
||||
{filtered.map((tool) => (
|
||||
<ToolRow key={tool.name} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Info, XCircle, ChevronDown, ChevronUp, Cpu } from 'lucide-react';
|
||||
import { useAiStatus } from '@/lib/doormileHooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* What this page's settings actually drive, from GET /admin/ai/status — not a
|
||||
* fixed note. It used to say "Half wired" forever, including after AI_engine
|
||||
* and the backend were deployed, because nothing checked.
|
||||
*
|
||||
* Green only when AI_engine has read the registry recently (the backend stamps
|
||||
* every poll) and the Test tab has a model. Re-checks every 30 seconds.
|
||||
* What this page's settings actually drive, from GET /admin/ai/status.
|
||||
* Designed with a clean, unobtrusive status bar that can be expanded
|
||||
* for detailed diagnostics when needed.
|
||||
*/
|
||||
|
||||
function ago(iso) {
|
||||
@@ -49,11 +47,13 @@ export function wiringLines(status) {
|
||||
|
||||
export default function WiringStatusBanner() {
|
||||
const { data, isLoading, isError, error } = useAiStatus();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div role="note" className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3.5 text-xs text-slate-500">
|
||||
Checking what is connected…
|
||||
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/70 px-3.5 py-2 text-xs text-slate-500">
|
||||
<Cpu className="w-3.5 h-3.5 animate-pulse text-slate-400" />
|
||||
<span>Checking system connectivity…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,17 +61,15 @@ export default function WiringStatusBanner() {
|
||||
if (isError) {
|
||||
const notDeployed = error?.response?.status === 404;
|
||||
return (
|
||||
<div
|
||||
role="note"
|
||||
className="flex items-start gap-3 rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-500" />
|
||||
<p className="leading-relaxed">
|
||||
<span className="font-bold text-amber-800">Connection status unavailable.</span>{' '}
|
||||
{notDeployed
|
||||
? 'This API server does not report agent status yet — deploy the current doormile_backend build. Changes here are still saved and audited.'
|
||||
: 'The status check failed; it will retry shortly. Changes here are still saved and audited.'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50/60 px-4 py-2.5 text-xs text-amber-900 shadow-2xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 text-amber-500" />
|
||||
<span className="font-medium truncate">
|
||||
{notDeployed
|
||||
? 'API status reporting is starting up. Changes made here are saved safely.'
|
||||
: 'Status check retrying shortly. Changes made here are saved safely.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,28 +79,63 @@ export default function WiringStatusBanner() {
|
||||
|
||||
return (
|
||||
<div
|
||||
role="note"
|
||||
className={
|
||||
className={cn(
|
||||
'rounded-xl border transition-all duration-200 shadow-2xs overflow-hidden',
|
||||
allOk
|
||||
? 'rounded-2xl border border-emerald-200/80 bg-gradient-to-r from-emerald-50 to-teal-50 px-4 py-3.5 text-xs text-emerald-900 shadow-sm'
|
||||
: 'rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm'
|
||||
}
|
||||
? 'border-emerald-200/90 bg-emerald-50/40 text-emerald-900'
|
||||
: 'border-slate-200 bg-white text-slate-700'
|
||||
)}
|
||||
>
|
||||
<p className={`font-bold mb-1.5 ${allOk ? 'text-emerald-800' : 'text-amber-800'}`}>
|
||||
{allOk ? 'All connected.' : 'Partly connected.'}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{lines.map((l) => {
|
||||
const Icon = l.info ? Info : l.ok ? CheckCircle2 : XCircle;
|
||||
const color = l.info ? 'text-slate-400' : l.ok ? 'text-emerald-600' : 'text-amber-600';
|
||||
return (
|
||||
<li key={l.text} className="flex items-start gap-2 leading-relaxed">
|
||||
<Icon className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${color}`} />
|
||||
<span>{l.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* Sleek top summary bar */}
|
||||
<div className="flex items-center justify-between px-3.5 py-2 gap-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full shrink-0',
|
||||
allOk ? 'bg-emerald-500 ring-2 ring-emerald-200' : 'bg-amber-400 ring-2 ring-amber-100'
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-slate-800">
|
||||
{allOk ? 'AI Engine Active' : 'Agent Registry Synced'}
|
||||
</span>
|
||||
<span className="text-slate-300 text-xs hidden sm:inline">•</span>
|
||||
<span className="text-[11px] text-slate-500 truncate hidden sm:inline">
|
||||
{allOk
|
||||
? 'All configured skills and tools are actively wired'
|
||||
: 'Settings are saved to registry; agent runtimes sync automatically'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-slate-500 hover:text-slate-900 px-2 py-1 rounded-md hover:bg-slate-100/80 transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<span>{expanded ? 'Hide details' : 'System details'}</span>
|
||||
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapsible diagnostic details */}
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-200/80 bg-slate-50/70 px-4 py-3 text-xs">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-2">
|
||||
Connection Diagnostics
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{lines.map((l) => {
|
||||
const Icon = l.info ? Info : l.ok ? CheckCircle2 : XCircle;
|
||||
const color = l.info ? 'text-slate-400' : l.ok ? 'text-emerald-600' : 'text-amber-500';
|
||||
return (
|
||||
<li key={l.text} className="flex items-start gap-2 leading-relaxed text-slate-600">
|
||||
<Icon className={cn('w-3.5 h-3.5 mt-0.5 shrink-0', color)} />
|
||||
<span className="text-[11px]">{l.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user