235 lines
8.6 KiB
JavaScript
235 lines
8.6 KiB
JavaScript
import * as React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Check, ChevronDown, Plus, Search, SquareArrowOutUpRight } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { searchAgents } from '@/lib/agents/registry';
|
|
import { agentIconFor } from '@/components/agents/icons';
|
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
|
import { useActiveAgent } from './AgentContext';
|
|
|
|
/**
|
|
* The agent switcher, inside Owliver's own header.
|
|
*
|
|
* Designed with high-density Krow Control Tower aesthetics: clean popover
|
|
* geometry, smooth row hover/active states, and legible status indicators.
|
|
*/
|
|
|
|
/** The avatar for the primary agent; a lucide glyph for the rest. */
|
|
function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
|
const Icon = agentIconFor(agent?.icon);
|
|
|
|
if (!Icon) return <OwliverAvatar className={className} rounded="rounded-lg" />;
|
|
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'inline-flex shrink-0 items-center justify-center rounded-lg border border-border/80 bg-surface-subtle text-krow-blue shadow-2xs',
|
|
className
|
|
)}
|
|
aria-hidden="true"
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** The label a constrained agent carries, in the switcher and in the header. */
|
|
function ConstrainedTag({ className = '' }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300',
|
|
className
|
|
)}
|
|
>
|
|
Constrained
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** One row in the list. */
|
|
function AgentRow({ agent, active, covers, onSelect }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
onClick={() => onSelect(agent.id)}
|
|
className={cn(
|
|
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
|
active
|
|
? 'bg-krow-blue-tint/70 text-ink-1'
|
|
: 'hover:bg-surface-subtle text-ink-2'
|
|
)}
|
|
>
|
|
<AgentGlyph agent={agent} className="h-7 w-7 shrink-0" />
|
|
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="min-w-0 truncate text-body-sm font-semibold text-ink-1">
|
|
{agent.name}
|
|
</span>
|
|
{!covers && <ConstrainedTag />}
|
|
</span>
|
|
{agent.description && (
|
|
<span className="mt-0.5 block truncate text-[11px] leading-tight text-ink-4">
|
|
{agent.description}
|
|
</span>
|
|
)}
|
|
</span>
|
|
|
|
{active && (
|
|
<Check className="h-4 w-4 text-krow-blue shrink-0 ml-1" aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function AgentSwitcher({ page }) {
|
|
const navigate = useNavigate();
|
|
const { agents, agent, covers, select, coversPage } = useActiveAgent();
|
|
const [open, setOpen] = React.useState(false);
|
|
const [query, setQuery] = React.useState('');
|
|
const listRef = React.useRef(null);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) setQuery('');
|
|
}, [open]);
|
|
|
|
const matches = React.useMemo(() => {
|
|
const found = searchAgents(agents, query);
|
|
return [...found].sort((a, b) => Number(coversPage(b)) - Number(coversPage(a)));
|
|
}, [agents, query, coversPage]);
|
|
|
|
const choose = (id) => {
|
|
select(id);
|
|
setOpen(false);
|
|
};
|
|
|
|
const onListKeyDown = (event) => {
|
|
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
|
|
if (!step) return;
|
|
const rows = [...(listRef.current?.querySelectorAll('[role="option"]') ?? [])];
|
|
if (!rows.length) return;
|
|
event.preventDefault();
|
|
const at = rows.indexOf(document.activeElement);
|
|
rows[Math.max(0, Math.min(rows.length - 1, (at === -1 ? 0 : at + step)))]?.focus();
|
|
};
|
|
|
|
if (!agent) {
|
|
return (
|
|
<>
|
|
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
|
|
<div className="min-w-0 flex-1">
|
|
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>
|
|
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={`Owliver agent: ${agent.name}. Switch to another agent`}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
|
>
|
|
<AgentGlyph agent={agent} />
|
|
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex items-center gap-1">
|
|
<span className="min-w-0 truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
|
Owliver
|
|
</span>
|
|
<ChevronDown
|
|
className="h-3 w-3 shrink-0 text-ink-4 transition-colors group-hover:text-krow-blue"
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="truncate text-caption leading-tight text-ink-3">{page}</span>
|
|
{!covers && <ConstrainedTag />}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent align="start" sideOffset={6} className="w-[21.5rem] p-0 shadow-lg rounded-xl border border-border/80 overflow-hidden">
|
|
<div className="border-b border-border/60 bg-surface-subtle/40 px-3.5 py-2.5">
|
|
<p className="font-heading text-body-sm font-semibold text-ink-1">Switch agent</p>
|
|
<p className="mt-0.5 text-[11px] text-ink-4">
|
|
Choose an agent to focus Owliver's scope on this page.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="px-3 pt-2.5 pb-1">
|
|
<label className="relative block">
|
|
<span className="sr-only">Search agents</span>
|
|
<Search
|
|
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
|
|
aria-hidden="true"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search agents..."
|
|
className="w-full h-8 rounded-lg border border-border/70 bg-surface-subtle/40 py-1 pl-8 pr-2.5 text-body-sm text-ink-1 placeholder:text-ink-4 transition-all focus:bg-surface focus:border-krow-blue/50 focus:outline-none focus:ring-1 focus:ring-krow-blue/30"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div
|
|
ref={listRef}
|
|
role="listbox"
|
|
aria-label="Agents"
|
|
onKeyDown={onListKeyDown}
|
|
className="max-h-[16rem] space-y-0.5 overflow-y-auto px-2 py-1.5"
|
|
>
|
|
{matches.map((candidate) => (
|
|
<AgentRow
|
|
key={candidate.id}
|
|
agent={candidate}
|
|
active={candidate.id === agent.id}
|
|
covers={coversPage(candidate)}
|
|
onSelect={choose}
|
|
/>
|
|
))}
|
|
|
|
{!matches.length && (
|
|
<p className="px-3 py-4 text-center text-body-sm text-ink-3">No agents match that.</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-t border-border/80 bg-surface-subtle/30 px-3 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setOpen(false); navigate('/admin/workspace/agents'); }}
|
|
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-caption font-medium text-ink-3 transition-colors hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
|
>
|
|
<SquareArrowOutUpRight className="h-3 w-3" aria-hidden="true" />
|
|
Browse all
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => { setOpen(false); navigate('/admin/workspace/agents/new'); }}
|
|
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-caption font-semibold text-krow-blue transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
|
Create agent
|
|
</button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
export default AgentSwitcher;
|