Files
doormilxpress_astryx/src/pages/admin/WorkspaceAgents.jsx
2026-08-20 18:18:10 +05:30

565 lines
24 KiB
JavaScript

import React, { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
ArchiveRestore, Archive, Copy, LayoutGrid, List, MoreHorizontal,
Plus, RotateCcw, Send, Settings2, Trash2,
} from 'lucide-react';
import {
Alert, Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, EmptyState, SearchInput, SegmentedToggle, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { useAgents } from '@/lib/agents/useAgents';
import { searchAgents } from '@/lib/agents/registry';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { AdminPage } from '@/pages/admin/_shell';
/**
* Agents — the workspace management list.
*
* Modern, clean, and high-density agent directory with streamlined
* status filter tabs, no redundant versioning labels, and full-width layout.
*/
const STATUS_TONE = { published: 'success', draft: 'neutral', archived: 'warning' };
function AgentGlyph({ agent, className = 'h-10 w-10' }) {
const Icon = agentIconFor(agent.icon);
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-xl" />;
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-xl border border-border/80 bg-surface-subtle shadow-2xs transition-colors group-hover:border-krow-blue/40 group-hover:bg-krow-blue-tint/30',
className
)}
aria-hidden="true"
>
<Icon className="h-5 w-5 text-krow-blue" />
</span>
);
}
/** Card representation for Grid view */
function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
return (
<div
className={cn(
'group flex flex-col justify-between rounded-2xl border border-border bg-surface p-5 shadow-xs',
'transition-all duration-200 ease-out hover:border-krow-blue/40 hover:shadow-md'
)}
>
<div className="space-y-3">
{/* Top row: Glyph + Title/Badges + Menu */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0 flex-1">
<AgentGlyph agent={agent} className="h-10 w-10" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={onOpen}
className="font-heading text-body font-semibold text-ink-1 hover:text-krow-blue transition-colors
text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded truncate"
>
{agent.name}
</button>
<Badge variant={STATUS_TONE[agent.status] || 'neutral'} size="sm" className="shrink-0 capitalize">
{agent.status}
</Badge>
{shipped && overridden && (
<Badge variant="info" size="sm" className="shrink-0">Customized</Badge>
)}
{!shipped && <Badge variant="neutral" size="sm" className="shrink-0">Custom</Badge>}
</div>
{agent.description && (
<p className="mt-1 text-body-sm leading-relaxed text-ink-3 line-clamp-2">
{agent.description}
</p>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-ink-4 hover:text-ink-1 hover:bg-surface-sunken rounded-lg"
aria-label={`Actions for ${agent.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen} className="cursor-pointer">
<Settings2 className="mr-2 h-3.5 w-3.5" /> Configure
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction('duplicate', agent)} className="cursor-pointer">
<Copy className="mr-2 h-3.5 w-3.5" /> Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
{agent.status !== 'published' && (
<DropdownMenuItem onClick={() => onAction('publish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish
</DropdownMenuItem>
)}
{agent.status === 'published' && (
<DropdownMenuItem onClick={() => onAction('republish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish update
</DropdownMenuItem>
)}
{agent.status !== 'archived' ? (
<DropdownMenuItem onClick={() => onAction('archive', agent)} className="cursor-pointer">
<Archive className="mr-2 h-3.5 w-3.5" /> Archive
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onAction('restore', agent)} className="cursor-pointer">
<ArchiveRestore className="mr-2 h-3.5 w-3.5" /> Restore as draft
</DropdownMenuItem>
)}
{(overridden || !shipped) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* When to use context */}
{agent.trigger && (
<div className="rounded-xl border border-border/60 bg-surface-subtle/60 px-3 py-2 text-[12px] text-ink-3">
<span className="font-semibold text-ink-2">Trigger: </span>
<span>{agent.trigger}</span>
</div>
)}
</div>
{/* Card bottom */}
<div className="mt-4 pt-3.5 border-t border-border/60 space-y-2.5">
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Skills</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.skills.length}</strong>
</span>
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Pages</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.pages.length}</strong>
</span>
{agent.subagents.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Subagents</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.subagents.length}</strong>
</span>
)}
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Knowledge</span>
<strong className="font-bold text-ink-1 tabular-nums">{agent.knowledge.length}</strong>
</span>
<span className="inline-flex items-center gap-1 rounded-md bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-3">
<span className="text-ink-4">Reasoning</span>
<strong className="font-bold capitalize text-ink-1">{agent.reasoning}</strong>
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{agent.pages.map((page) => (
<span
key={page}
className="rounded-md border border-border/80 bg-surface-subtle px-2 py-0.5 text-[11px] font-medium text-ink-3"
>
{page}
</span>
))}
</div>
</div>
</div>
);
}
/** Directory Table Row for List view */
function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
return (
<tr className="group border-b border-border/60 transition-colors hover:bg-surface-subtle/50">
{/* Agent Identity */}
<td className="py-3.5 pl-4 pr-3 align-middle">
<div className="flex items-center gap-3">
<AgentGlyph agent={agent} className="h-9 w-9" />
<div className="min-w-0">
<button
type="button"
onClick={onOpen}
className="font-heading text-body-sm font-semibold text-ink-1 hover:text-krow-blue transition-colors text-left focus-visible:outline-none"
>
{agent.name}
</button>
{agent.description && (
<p className="mt-0.5 text-caption text-ink-3 line-clamp-1 max-w-md">
{agent.description}
</p>
)}
</div>
</div>
</td>
{/* Status */}
<td className="px-3 py-3.5 align-middle whitespace-nowrap">
<div className="flex items-center gap-1.5">
<Badge variant={STATUS_TONE[agent.status] || 'neutral'} size="sm" className="capitalize">
{agent.status}
</Badge>
{shipped && overridden && (
<Badge variant="info" size="sm">Customized</Badge>
)}
{!shipped && <Badge variant="neutral" size="sm">Custom</Badge>}
</div>
</td>
{/* Surfaces / Pages */}
<td className="px-3 py-3.5 align-middle">
<div className="flex flex-wrap gap-1 max-w-xs">
{agent.pages.slice(0, 3).map((page) => (
<span
key={page}
className="rounded-md border border-border/80 bg-surface-subtle px-2 py-0.5 text-[11px] font-medium text-ink-3"
>
{page}
</span>
))}
{agent.pages.length > 3 && (
<span className="rounded-md border border-border/60 bg-surface-subtle px-1.5 py-0.5 text-[11px] font-medium text-ink-4">
+{agent.pages.length - 3}
</span>
)}
</div>
</td>
{/* Capabilities */}
<td className="px-3 py-3.5 align-middle whitespace-nowrap">
<div className="flex items-center gap-2 text-caption text-ink-3">
<span className="rounded bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium text-ink-2">
<strong className="font-semibold tabular-nums text-ink-1">{agent.skills.length}</strong> skills
</span>
<span className="rounded bg-surface-subtle border border-border/60 px-2 py-0.5 font-medium capitalize text-ink-2">
{agent.reasoning}
</span>
</div>
</td>
{/* Actions */}
<td className="py-3.5 pl-3 pr-4 align-middle text-right whitespace-nowrap">
<div className="inline-flex items-center gap-1.5">
<Button variant="outline" size="sm" onClick={onOpen} className="h-8 text-body-sm font-medium">
<Settings2 className="mr-1.5 h-3.5 w-3.5" /> Configure
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-ink-4 hover:text-ink-1 rounded-lg"
aria-label={`Actions for ${agent.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen} className="cursor-pointer">
<Settings2 className="mr-2 h-3.5 w-3.5" /> Configure
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction('duplicate', agent)} className="cursor-pointer">
<Copy className="mr-2 h-3.5 w-3.5" /> Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
{agent.status !== 'published' && (
<DropdownMenuItem onClick={() => onAction('publish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish
</DropdownMenuItem>
)}
{agent.status === 'published' && (
<DropdownMenuItem onClick={() => onAction('republish', agent)} className="cursor-pointer">
<Send className="mr-2 h-3.5 w-3.5" /> Publish update
</DropdownMenuItem>
)}
{agent.status !== 'archived' ? (
<DropdownMenuItem onClick={() => onAction('archive', agent)} className="cursor-pointer">
<Archive className="mr-2 h-3.5 w-3.5" /> Archive
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onAction('restore', agent)} className="cursor-pointer">
<ArchiveRestore className="mr-2 h-3.5 w-3.5" /> Restore as draft
</DropdownMenuItem>
)}
{(overridden || !shipped) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
);
}
export default function AdminWorkspaceAgents() {
const navigate = useNavigate();
const {
agents, diagnostics, saving, isShipped, isOverridden,
publish, archive, restore, duplicate, remove,
} = useAgents();
const [query, setQuery] = useState('');
const [status, setStatus] = useState('all');
const [view, setView] = useState(() => {
try {
return localStorage.getItem('krow_agents_view') || 'list';
} catch {
return 'list';
}
});
const [confirming, setConfirming] = useState(null);
const handleViewChange = (newView) => {
setView(newView);
try {
localStorage.setItem('krow_agents_view', newView);
} catch {
/* ignore */
}
};
const visible = useMemo(() => {
const found = searchAgents(agents, query);
return status === 'all' ? found : found.filter((a) => a.status === status);
}, [agents, query, status]);
const publishedCount = agents.filter((a) => a.status === 'published').length;
const draftCount = agents.filter((a) => a.status === 'draft').length;
const archivedCount = agents.filter((a) => a.status === 'archived').length;
/** Runs a lifecycle action and reports honestly when it is refused. */
const run = async (action, agent) => {
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[action];
const result = await fn(agent.id);
if (result?.conflict) toast.error(result.conflict.message);
else if (result?.error) toast.error(result.error);
else if (action === 'duplicate' && result?.id) navigate(`/admin/workspace/agents/${result.id}`);
};
const onAction = (action, agent) => {
/* Destructive and irreversible-looking actions confirm; the rest run. */
if (action === 'remove' || action === 'archive') {
setConfirming({ action, agent });
return;
}
run(action, agent);
};
return (
<div className="w-full">
<AdminPage
title="Agents"
subtitle="The agents Owliver can answer as, what each one covers, and what it may do."
meta={`${agents.length} registered · ${publishedCount} published`}
actions={
<Button onClick={() => navigate('/admin/workspace/agents/new')}>
<Plus className="h-4 w-4" /> Create agent
</Button>
}
>
<div className="space-y-4">
{diagnostics.length > 0 && (
<Alert tone="warning" title={`${diagnostics.length} definition problem${diagnostics.length === 1 ? '' : 's'}`} className="py-2.5 px-3.5">
<ul className="mt-1 space-y-0.5 text-body-sm">
{diagnostics.slice(0, 5).map((d) => (
<li key={`${d.kind}-${d.agentId}-${d.message}`}>{d.message}</li>
))}
</ul>
</Alert>
)}
{/* Unified horizontal management toolbar with clean filter pills */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-3">
<div className="w-full sm:w-72">
<SearchInput value={query} onChange={setQuery} placeholder="Search agents..." />
</div>
{/* Modern status filter tabs */}
<div className="inline-flex items-center rounded-xl border border-border/80 bg-surface-subtle/80 p-1 text-caption">
<button
type="button"
onClick={() => setStatus('all')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'all'
? 'bg-surface text-ink-1 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
All <span className="text-ink-4 font-normal">({agents.length})</span>
</button>
<button
type="button"
onClick={() => setStatus('published')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'published'
? 'bg-surface text-emerald-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Published <span className="text-ink-4 font-normal">({publishedCount})</span>
</button>
{draftCount > 0 && (
<button
type="button"
onClick={() => setStatus('draft')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'draft'
? 'bg-surface text-ink-1 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Drafts <span className="text-ink-4 font-normal">({draftCount})</span>
</button>
)}
{archivedCount > 0 && (
<button
type="button"
onClick={() => setStatus('archived')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'archived'
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
</button>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Link
to="/admin/workspace/skills"
className="hidden md:inline-flex items-center gap-1.5 rounded-lg border border-border/70 bg-surface px-3 py-1.5 text-body-sm text-ink-3 transition-colors hover:border-krow-blue/40 hover:text-krow-blue hover:bg-surface-subtle"
>
<span>Skills managed in</span>
<span className="font-semibold text-krow-blue">Workspace &rarr; Skills</span>
</Link>
<SegmentedToggle
value={view}
onChange={handleViewChange}
variant="surface"
size="sm"
options={[
{ value: 'list', icon: List, ariaLabel: 'Directory table view' },
{ value: 'grid', icon: LayoutGrid, ariaLabel: 'Grid cards view' },
]}
/>
</div>
</div>
{visible.length === 0 ? (
<EmptyState
title={agents.length ? 'No agents match that' : 'No agents yet'}
description={
agents.length
? 'Try a different search, or clear the status filter.'
: 'An agent decides which skills Owliver can use on a page. Create one to get started.'
}
/>
) : view === 'list' ? (
/* Clean Modern Directory Table View */
<div className="overflow-hidden rounded-2xl border border-border bg-surface shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border bg-surface-subtle/60 text-[11px] font-semibold uppercase tracking-wider text-ink-4">
<th className="py-3 pl-4 pr-3 font-semibold">Agent</th>
<th className="px-3 py-3 font-semibold">Status</th>
<th className="px-3 py-3 font-semibold">Surfaces Covered</th>
<th className="px-3 py-3 font-semibold">Capabilities</th>
<th className="py-3 pl-3 pr-4 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{visible.map((agent) => (
<AgentTableRow
key={agent.id}
agent={agent}
shipped={isShipped(agent.id)}
overridden={isOverridden(agent.id)}
onOpen={() => navigate(`/admin/workspace/agents/${agent.id}`)}
onAction={onAction}
/>
))}
</tbody>
</table>
</div>
</div>
) : (
/* Modern Grid View */
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{visible.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
shipped={isShipped(agent.id)}
overridden={isOverridden(agent.id)}
onOpen={() => navigate(`/admin/workspace/agents/${agent.id}`)}
onAction={onAction}
/>
))}
</div>
)}
</div>
<ConfirmModal
open={Boolean(confirming)}
onOpenChange={(open) => !open && setConfirming(null)}
title={
confirming?.action === 'archive'
? `Archive ${confirming?.agent.name}?`
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : `Delete ${confirming?.agent.name}?`
}
description={
confirming?.action === 'archive'
? 'It stops answering and disappears from the switcher. Its definition is kept, and it can be restored as a draft.'
: isShipped(confirming?.agent?.id)
? 'Your changes to this agent are discarded and the shipped definition takes over again.'
: 'This definition is removed. It cannot be recovered.'
}
confirmLabel={confirming?.action === 'archive' ? 'Archive' : 'Confirm'}
busy={saving}
onConfirm={async () => {
const pending = confirming;
setConfirming(null);
if (pending) await run(pending.action, pending.agent);
}}
/>
</AdminPage>
</div>
);
}