update agents skill design
This commit is contained in:
267
src/pages/admin/AgentDetail.jsx
Normal file
267
src/pages/admin/AgentDetail.jsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ───────────────────── */}
|
||||
|
||||
564
src/pages/admin/WorkspaceAgents.jsx
Normal file
564
src/pages/admin/WorkspaceAgents.jsx
Normal 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 → 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user