Files
krow_talent_app/src/pages/admin/WorkspaceAgents.jsx
2026-08-24 13:16:15 +05:30

570 lines
25 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 '@/components/admin/PageShell';
/**
* 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}
/* On a phone the identity column claims the width of the name
rather than stacking "Control Center Agent" three words tall,
which it can afford because the table already scrolls inside
its own container there. Released at `sm`, so every wider
layout distributes its columns exactly as it did before. */
className="whitespace-nowrap sm:whitespace-normal 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 min-w-[48rem] 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>
);
}