mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 08:51:58 +00:00
Add 5s in-memory TTL cache to loadRegistry() and getCrons() to eliminate duplicate execSync CLI calls on page load. Memoize dagre layout in OrgMap so agent selection changes only restyle nodes instead of recomputing positions. Includes agents-provider, useAgents hook, fingerprint API, and GlobalSearch refactors. Bumps to v0.8.4. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
263 lines
8.5 KiB
TypeScript
263 lines
8.5 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { usePathname } from 'next/navigation';
|
|
import { useEffect, useState } from 'react';
|
|
import { Map, MessageSquare, Clock, Activity, Brain, Columns3, BookOpen, Settings, DollarSign } from 'lucide-react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import type { CronJob } from '@/lib/types';
|
|
import { useSettings } from '@/app/settings-provider';
|
|
import { useAgentsContext } from '@/app/agents-provider';
|
|
|
|
function getInitials(name: string | null): string {
|
|
if (!name) return '??'
|
|
const parts = name.trim().split(/\s+/)
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Nav item definition
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface NavItem {
|
|
href: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
badge?: 'agents' | 'unread' | 'errors';
|
|
}
|
|
|
|
const NAV_ITEMS: NavItem[] = [
|
|
{ href: '/', label: 'Map', icon: Map, badge: 'agents' },
|
|
{ href: '/kanban', label: 'Kanban', icon: Columns3 },
|
|
{ href: '/chat', label: 'Messages', icon: MessageSquare, badge: 'unread' },
|
|
{ href: '/crons', label: 'Crons', icon: Clock, badge: 'errors' },
|
|
{ href: '/activity', label: 'Activity', icon: Activity },
|
|
{ href: '/costs', label: 'Costs', icon: DollarSign },
|
|
{ href: '/memory', label: 'Memory', icon: Brain },
|
|
{ href: '/docs', label: 'Docs', icon: BookOpen },
|
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NavLinks component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function NavLinks({ bottomSlot }: { bottomSlot?: React.ReactNode } = {}) {
|
|
const pathname = usePathname();
|
|
const { settings } = useSettings();
|
|
const { agents } = useAgentsContext();
|
|
const agentCount = agents.length > 0 ? agents.length : null;
|
|
const [cronCount, setCronCount] = useState<number | null>(null);
|
|
const [cronErrorCount, setCronErrorCount] = useState<number | null>(null);
|
|
|
|
// Fetch cron error count
|
|
useEffect(() => {
|
|
fetch('/api/crons')
|
|
.then((r) => {
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
return r.json();
|
|
})
|
|
.then((data: unknown) => {
|
|
const crons: CronJob[] = Array.isArray(data)
|
|
? data
|
|
: (data as { crons?: CronJob[] })?.crons ?? [];
|
|
setCronCount(crons.length);
|
|
setCronErrorCount(crons.filter((c) => c.status === 'error').length);
|
|
})
|
|
.catch(() => {
|
|
setCronErrorCount(null);
|
|
});
|
|
}, []);
|
|
|
|
// Resolve badge content per nav item
|
|
function getBadge(item: NavItem): React.ReactNode {
|
|
if (item.badge === 'agents' && agentCount !== null) {
|
|
return (
|
|
<span
|
|
className="nav-badge"
|
|
style={{
|
|
marginLeft: 'auto',
|
|
fontSize: '10px',
|
|
fontFamily: 'var(--font-mono)',
|
|
padding: '1px 6px',
|
|
borderRadius: 'var(--radius-sm)',
|
|
background: 'var(--fill-quaternary)',
|
|
color: 'var(--text-tertiary)',
|
|
lineHeight: '16px',
|
|
}}
|
|
>
|
|
{agentCount}
|
|
</span>
|
|
);
|
|
}
|
|
if (item.badge === 'errors' && cronCount !== null) {
|
|
const hasErrors = cronErrorCount !== null && cronErrorCount > 0;
|
|
return (
|
|
<span
|
|
style={{
|
|
marginLeft: 'auto',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
}}
|
|
>
|
|
<span
|
|
className="nav-badge"
|
|
style={{
|
|
fontSize: '10px',
|
|
fontFamily: 'var(--font-mono)',
|
|
padding: '1px 6px',
|
|
borderRadius: 'var(--radius-sm)',
|
|
background: hasErrors ? 'rgba(255,69,58,0.1)' : 'var(--fill-quaternary)',
|
|
color: hasErrors ? 'var(--system-red)' : 'var(--text-tertiary)',
|
|
lineHeight: '16px',
|
|
fontWeight: hasErrors ? 600 : undefined,
|
|
}}
|
|
>
|
|
{hasErrors ? `${cronErrorCount} err` : cronCount}
|
|
</span>
|
|
{hasErrors && (
|
|
<span
|
|
aria-label={`${cronErrorCount} cron error${cronErrorCount > 1 ? 's' : ''}`}
|
|
style={{
|
|
width: '6px',
|
|
height: '6px',
|
|
borderRadius: '50%',
|
|
background: 'var(--system-red)',
|
|
flexShrink: 0,
|
|
animation: 'pulse-red 1.5s ease-in-out infinite',
|
|
}}
|
|
/>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<nav className="flex-1 flex flex-col" style={{ minHeight: 0, overflow: 'hidden' }} aria-label="Main navigation">
|
|
{/* Scrollable nav items */}
|
|
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 12px 8px' }}>
|
|
{/* Section header */}
|
|
<div
|
|
style={{
|
|
fontSize: '11px',
|
|
fontWeight: 600,
|
|
letterSpacing: '0.06em',
|
|
color: 'var(--text-tertiary)',
|
|
textTransform: 'uppercase',
|
|
padding: '0 8px',
|
|
marginBottom: '2px',
|
|
}}
|
|
>
|
|
Workspace
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
{NAV_ITEMS.map((item) => {
|
|
const isActive =
|
|
item.href === '/'
|
|
? pathname === '/'
|
|
: pathname.startsWith(item.href);
|
|
|
|
const Icon = item.icon;
|
|
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={`nav-item focus-ring ${isActive ? 'nav-item-active' : ''}`}
|
|
aria-label={item.label}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '10px',
|
|
minHeight: '36px',
|
|
padding: '0 10px 0 12px',
|
|
borderRadius: '8px',
|
|
fontSize: '13px',
|
|
fontWeight: isActive ? 600 : 500,
|
|
color: isActive ? 'var(--accent)' : 'var(--text-secondary)',
|
|
background: isActive ? 'var(--accent-fill)' : 'transparent',
|
|
textDecoration: 'none',
|
|
transition: 'all 100ms var(--ease-smooth)',
|
|
}}
|
|
>
|
|
<Icon
|
|
size={16}
|
|
style={{
|
|
flexShrink: 0,
|
|
color: isActive ? 'var(--accent)' : 'var(--text-tertiary)',
|
|
transition: 'color 100ms var(--ease-smooth)',
|
|
}}
|
|
/>
|
|
<span style={{ flex: 1 }}>{item.label}</span>
|
|
{getBadge(item)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pinned bottom: usage widget + user footer */}
|
|
<div style={{ flexShrink: 0 }}>
|
|
{bottomSlot}
|
|
|
|
{/* User footer */}
|
|
<div
|
|
style={{
|
|
borderTop: '1px solid var(--separator)',
|
|
padding: '8px 16px',
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<div
|
|
style={{
|
|
width: '28px',
|
|
height: '28px',
|
|
borderRadius: '7px',
|
|
background: 'var(--accent-fill)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '11px',
|
|
fontWeight: 700,
|
|
color: 'var(--accent)',
|
|
flexShrink: 0,
|
|
letterSpacing: '-0.02em',
|
|
}}
|
|
>
|
|
{getInitials(settings.operatorName)}
|
|
</div>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
color: 'var(--text-primary)',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{settings.operatorName ?? 'Operator'}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: '11px',
|
|
color: 'var(--text-tertiary)',
|
|
}}
|
|
>
|
|
Owner
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|