import { useState } from 'react'; import { RotateCw } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useClient } from 'hooks/useClient'; import { usePermissions } from 'hooks/usePermissions'; import { useTranslation, type AppTranslationKey } from '@/lib/i18n'; type RescanResponse = { ok: boolean; counts: Record }; /** * One key per counted noun rather than one sentence with six numbers in it. * * Each noun pluralizes on its own count — English needs it for `1 task` vs `2 tasks`, and a locale with * more than two categories needs it for every value. Assembling the sentence in code and translating only * the frame would have hardcoded English plural rules into the frame. * * Spelled out as literal pairs rather than built from the count name: a key assembled at runtime defeats * the type checker and cannot be grepped (see the key naming convention in docs/localization.md). */ const RESCAN_COUNTS: readonly (readonly [string, AppTranslationKey])[] = [ ['tasks', 'rescan.counts.tasks'], ['agents', 'rescan.counts.agents'], ['tools', 'rescan.counts.tools'], ['skills', 'rescan.counts.skills'], ['processes', 'rescan.counts.processes'], ['affordances', 'rescan.counts.affordances'], ]; // Item query caches the header refreshes after a rescan (matches the queryKeys used by the // Automation and Capability pages). const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'processes', 'agents', 'agent-categories']; export function RescanButton() { const client = useClient(); const qc = useQueryClient(); const { can } = usePermissions(); const { t } = useTranslation(); const [loading, setLoading] = useState(false); // `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the // owner's disk, `kind: 'execution'`. A member pressing this got a 403 and a red toast about a feature // whose existence is not their business. if (!can('items')) return null; const rescan = async () => { if (loading) return; setLoading(true); try { const res = await client.post('/rescan', {}); // refetchType: 'all' so INACTIVE (unmounted) item lists refetch too — otherwise a rescan from // anywhere but the Tasks/Skills/… page only marks them stale, and the change isn't picked up until // you next open that page. This refreshes every item cache immediately, wherever you press it. await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key], refetchType: 'all' }))); const c = res.counts ?? {}; const summary = RESCAN_COUNTS.map(([noun, key]) => t(key, { count: c[noun] ?? 0 })).join(', '); toast.success(t('rescan.done', { summary }), { // Two things said here rather than left to be discovered. // // A rescan cannot reach a chat that is already open — its system prompt was assembled when it // started. And a SKILL.md with no `description` is COUNTED as a skill but never offered to an // agent, because the loader skips it: a line that does not say when to reach for something // costs context in every conversation and earns nothing. So a skill can be present, listed and // editable while being invisible to every agent, and the only symptom is two numbers // disagreeing where nobody is comparing them. description: [ c.affordances || c.skillsOffered ? t('rescan.newChatsOnly') : null, typeof c.skills === 'number' && typeof c.skillsOffered === 'number' && c.skillsOffered < c.skills ? t('rescan.noDescription', { count: c.skills - c.skillsOffered }) : null, ] .filter(Boolean) .join(' ') || undefined, }); } catch { toast.error(t('rescan.failed')); } finally { setLoading(false); } }; return ( ); }