update agents skill design

This commit is contained in:
2026-08-20 18:18:10 +05:30
parent 161b237695
commit b2e6868824
75 changed files with 14586 additions and 98 deletions

View File

@@ -0,0 +1,267 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Check, Info, Save, Send } from 'lucide-react';
import {
Badge, Button, EmptyState, Tabs, toast,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { agentIconFor } from '@/components/agents/icons';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { usePreferences } from '@/lib/krowHooks';
import { useAgents } from '@/lib/agents/useAgents';
import { agentTemplate } from '@/lib/agents/customAgents';
import { agentFieldsFromSource, applyAgentFields } from '@/lib/agents/agentFields';
import { validateAgentSource } from '@/lib/agents/registry';
import { AgentConfigure } from '@/components/agents/AgentConfigure';
import { AgentTestPanel } from '@/components/agents/AgentTestPanel';
import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
/**
* One agent, configured.
*
* The screen holds *fields*, never Markdown. Editing composes a definition
* through `applyAgentFields` only at the moment of saving, which is what lets
* the storage format stay exactly what it was while the person configuring an
* agent never meets it.
*
* A draft lives in component state until saved, so a half-finished change does
* not reach Owliver. "Unsaved changes" is therefore a real state and is shown
* as one rather than being auto-saved into the switcher mid-edit.
*/
const STATUS_TONE = { published: 'success', draft: 'neutral', archived: 'warning' };
export default function AdminAgentDetail() {
const { id } = useParams();
const navigate = useNavigate();
const preferences = usePreferences();
const {
agents, saving, isShipped, isOverridden, sourceFor, save, publish,
} = useAgents();
const creating = !id;
/* The definition this screen started from: a stored or shipped one, or a
fresh template when creating. */
const baseSource = useMemo(
() => (creating
? agentTemplate({ pages: [], skills: [] })
: sourceFor(id)),
[creating, id, sourceFor]
);
const [fields, setFields] = useState(() => (baseSource ? agentFieldsFromSource(baseSource) : null));
const [dirty, setDirty] = useState(false);
const [view, setView] = useState('configure');
/* Reload when the address changes, but never overwrite an edit in progress. */
useEffect(() => {
if (dirty) return;
setFields(baseSource ? agentFieldsFromSource(baseSource) : null);
}, [baseSource, dirty]);
const onChange = useCallback((next) => {
setFields(next);
setDirty(true);
}, []);
const agent = agents.find((a) => a.id === id) || null;
/** Composes the definition from the fields and stores it. */
const persist = useCallback(async () => {
if (!fields) return null;
/* A new agent needs an id before it has an address. Derived from the name
so the author never has to invent one. */
const composed = applyAgentFields(baseSource, fields);
const problem = validateAgentSource(composed);
if (problem) { toast.error(problem); return null; }
const result = await save(composed);
if (!result.ok) { toast.error(result.error); return null; }
setDirty(false);
/* A newly created agent gains its own address once it exists. */
if (creating) navigate(`/admin/workspace/agents/${result.agent.id}`, { replace: true });
return result.agent;
}, [fields, baseSource, save, creating, navigate]);
const onPublish = useCallback(async () => {
const stored = dirty ? await persist() : agent;
if (!stored) return;
const result = await publish(stored.id);
if (result?.conflict) toast.error(result.conflict.message);
else if (result?.error) toast.error(result.error);
}, [dirty, persist, agent, publish]);
if (!fields) {
return (
<div className="mx-auto w-full max-w-container">
<EmptyState
title="No such agent"
description="It may have been removed, or the address may be wrong."
action={{ label: 'Back to agents', onClick: () => navigate('/admin/workspace/agents') }}
/>
</div>
);
}
const shipped = !creating && isShipped(id);
const Glyph = agentIconFor(fields.icon);
return (
/**
* An editor, not a dashboard.
*
* The width is deliberately not the console's 80rem measure: Owliver holds
* a ~380px track on the right of every Admin page, and capping the editor
* at the dashboard width leaves a strip of empty canvas between the two on
* a wide screen. The workspace takes what `main` gives it, and readable
* measure is enforced inside the document — on the fields and the
* instruction editor — where it actually belongs.
*/
<div className="w-full space-y-3">
{/**
* The editor header.
*
* One block: where you are, what you are editing, what state it is in,
* and the two things you can do to it — with the tabs docked to its
* baseline. It sits on the page background rather than in a card, so the
* workspace below is the only surface on the screen.
*
* Save and Publish sit beside the title rather than above it. On the
* previous layout the eye met a blue Publish button before it had read
* the name of the thing being published.
*/}
<header>
<button
type="button"
onClick={() => navigate('/admin/workspace/agents')}
className={`-ml-1 inline-flex items-center gap-1 rounded px-1 py-0.5 text-caption font-medium
text-ink-4 transition-colors duration-base hover:text-krow-blue
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" /> Agents
</button>
<div className="mt-1.5 mb-1 flex flex-wrap items-start gap-x-4 gap-y-2.5">
{/* Icon and title are one group, so a narrow column wraps the actions
below them rather than stranding the icon on a line of its own. */}
<div className="flex min-w-[13rem] flex-1 basis-[22rem] items-start gap-3">
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border
border-border bg-surface shadow-xs`}
aria-hidden="true"
>
{Glyph
? <Glyph className="h-[1.125rem] w-[1.125rem] text-krow-blue" />
: <OwliverAvatar className="h-5 w-5" rounded="rounded-lg" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<h1 className="min-w-0 truncate font-heading text-title-lg font-bold tracking-tight text-ink-1">
{fields.name || 'New agent'}
</h1>
{agent && (
<>
<span className="sr-only">v{agent.version}</span>
<Badge variant={STATUS_TONE[agent.status] || 'neutral'} className="capitalize">
{agent.status}
</Badge>
</>
)}
{!agent && <Badge variant="neutral">Draft</Badge>}
{shipped && isOverridden(id) && <Badge variant="info">Customized</Badge>}
</div>
<p className="mt-0.5 line-clamp-2 max-w-[80ch] text-body-sm text-ink-3">
{fields.description || 'Configure what this agent covers and what it can do.'}
</p>
</div>
</div>
{/* Save state as a word, not a permanent button: "Saved" is a fact
about the document, and a disabled button is a worse way to say it
than a word is. */}
<div className="flex shrink-0 items-center gap-2.5">
<span
className={cn(
'inline-flex items-center gap-1.5 text-caption transition-colors duration-base',
dirty ? 'font-medium text-warning' : 'text-ink-4'
)}
>
<span
aria-hidden="true"
className={cn(
'grid h-3.5 w-3.5 place-items-center rounded-full transition-colors duration-base',
dirty ? 'bg-warning/20' : 'bg-transparent'
)}
>
{dirty
? <span className="h-1.5 w-1.5 rounded-full bg-warning" />
: <Check className="h-3 w-3" />}
</span>
{dirty ? 'Unsaved changes' : 'Saved'}
</span>
<Button variant="outline" size="sm" onClick={persist} disabled={!dirty} loading={saving}>
<Save className="h-3.5 w-3.5" /> Save
</Button>
<Button size="sm" onClick={onPublish} loading={saving}>
<Send className="h-3.5 w-3.5" />
{agent?.status === 'published' ? 'Publish update' : 'Publish'}
</Button>
</div>
</div>
{/* A standing fact about this agent, not an alarm. It used to be a
full-width tinted banner — the loudest element on a page whose
loudest element should be the thing being written. */}
{shipped && (
<p className="mt-2 flex max-w-[86ch] items-start gap-2 text-caption leading-relaxed text-ink-3">
<Info className="mt-px h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
<span>
This agent ships with Krow. Your changes are saved as this workspace&apos;s own
version and take over from the shipped one — the shipped definition is never
altered, and reverting brings it back.
</span>
</p>
)}
<Tabs
className="mt-3 mb-1"
variant="underline"
size="sm"
ariaLabel="Agent views"
value={view}
onChange={setView}
tabs={[
{ value: 'configure', label: 'Configure' },
{ value: 'test', label: 'Test' },
{ value: 'insights', label: 'Insights' },
]}
/>
</header>
{view === 'configure' && (
<AgentConfigure
fields={fields}
agents={agents}
customSkills={preferences.customSkills || []}
onChange={onChange}
/>
)}
{view === 'test' && (
<AgentTestPanel
fields={fields}
dirty={dirty}
customSkills={preferences.customSkills || []}
/>
)}
{view === 'insights' && (
<AgentInsightsPanel agentId={id || null} agentName={fields.name} />
)}
</div>
);
}

View File

@@ -1100,8 +1100,8 @@ export default function AdminPositions() {
{ value: 'paused', label: 'Paused' }, { value: 'draft', label: 'Draft' },
{ value: 'closed', label: 'Closed' },
]} />
<FilterSelect value={department} onChange={setDepartment} label="Department"
options={[{ value: 'all', label: 'All departments' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={department} onChange={setDepartment} label="Role"
options={[{ value: 'all', label: 'All roles' }, ...departments.map((d) => ({ value: d, label: d }))]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"

View File

@@ -6,6 +6,7 @@ import {
} from 'lucide-react';
import { usePreferences } from '@/lib/krowHooks';
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { allAgents } from '@/lib/agents/registry';
import { AdminPage } from '@/pages/admin/_shell';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { cn } from '@/lib/utils';
@@ -29,6 +30,12 @@ import { cn } from '@/lib/utils';
export default function AdminWorkspace() {
const preferences = usePreferences();
/* Read through the agent registry, never by counting skills — one registry
answers "how many agents", exactly as the skill registry answers its own. */
const agentCount = useMemo(
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }).length,
[preferences.customAgents, preferences.customSkills]
);
/**
* AI agent skills only.
@@ -94,6 +101,35 @@ export default function AdminWorkspace() {
</div>
</div>
{/* ── Agents ─────────────────────────────────────────────────────
Above the two skill columns because it is the layer over them: an
agent decides which of these skills Owliver may use on a page. */}
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<OwliverAvatar className="w-6 h-6" rounded="rounded-md" />
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Agents</h2>
</div>
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
{agentCount} Registered
</span>
</div>
<p className="text-[13px] text-[#687386]">
An agent is how Owliver answers on a page: which skills it may use, what it knows, and
how much work an answer is worth. Agents narrow what a page offers — they never widen it.
</p>
<Link
to="/admin/workspace/agents"
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.01]"
>
<Sparkles className="w-4 h-4" />
Manage Agents
<ArrowRight className="w-4 h-4" />
</Link>
</div>
{/* ── 2. Full Width 2-Column Grid ───────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
{/* ── Left Column: Owliver AI Capabilities ───────────────────── */}

View File

@@ -0,0 +1,564 @@
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>
);
}