import { invoke } from '@tauri-apps/api/core'; import { useEffect, useMemo, useState } from 'react'; import { DefaultIcon, SKILL_ICONS, SkillActionButton, type SkillListEntry, STATUS_DISPLAY, STATUS_PRIORITY, } from '../components/skills/shared'; import SkillSetupModal from '../components/skills/SkillSetupModal'; import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/hooks'; import { skillManager } from '../lib/skills/manager'; import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types'; import { useAppSelector } from '../store/hooks'; import { IS_DEV } from '../utils/config'; import { deriveSkillSyncUiState } from './skillsSyncUi'; /** Format large numbers: 1200 → "1.2K", 1200000 → "1.2M" */ function formatNumber(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; return String(n); } /** Status dot color for skill connection status */ function statusDotClass(status: SkillConnectionStatus): string { switch (status) { case 'connected': return 'bg-sage-400'; case 'connecting': return 'bg-amber-400 animate-pulse'; case 'error': return 'bg-coral-400'; default: return 'bg-stone-600'; } } // ─── Skill Card (used in the skills list) ─────────────────────────────────── interface SkillCardProps { skill: SkillListEntry; onSetup: () => void; } function SkillCard({ skill, onSetup }: SkillCardProps) { const connectionStatus = useSkillConnectionStatus(skill.id); const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline; const skillState = useAppSelector(state => state.skills.skillStates[skill.id]) as | (SkillHostConnectionState & Record) | undefined; const [manualSyncing, setManualSyncing] = useState(false); const syncUi = useMemo( () => deriveSkillSyncUiState(skill.id, skillState), [skill.id, skillState] ); const isSyncing = manualSyncing || syncUi.isSyncing; const handleSync = async (e: React.MouseEvent) => { e.stopPropagation(); setManualSyncing(true); try { await skillManager.triggerSync(skill.id); } catch (err) { console.error(`Sync failed for ${skill.id}:`, err); } finally { setManualSyncing(false); } }; // Build subtitle from skill state (sync time, chat/message counts) const subtitleParts: string[] = []; if (skillState) { const chatCount = skillState.chat_count as number | undefined; const msgCount = skillState.message_count as number | undefined; const lastSync = skillState.last_sync as string | undefined; if (chatCount != null) subtitleParts.push(`${formatNumber(chatCount)} chats`); if (msgCount != null) subtitleParts.push(`${formatNumber(msgCount)} msgs`); if (lastSync) subtitleParts.push(`Synced ${lastSync}`); } return (
{/* Icon */}
{skill.icon || }
{/* Info */}
{skill.name}
{statusDisplay.text}
{subtitleParts.length > 0 && (

{subtitleParts.join(' · ')}

)} {isSyncing && (
{syncUi.progressPercent != null ? (
) : (
)}
{syncUi.progressMessage && (

{syncUi.progressMessage}

)} {syncUi.metricsText && (

{syncUi.metricsText}

)}
)}
{/* Actions */}
{connectionStatus === 'connected' && ( <> {/* Sync */} {/* Settings */} )}
); } // ─── Main Skills Page ─────────────────────────────────────────────────────── export default function Skills() { // Skills state const [skillsList, setSkillsList] = useState([]); const [skillsLoading, setSkillsLoading] = useState(true); const skillsState = useAppSelector(state => state.skills.skills); const skillStates = useAppSelector(state => state.skills.skillStates); // Modal state const [setupModalOpen, setSetupModalOpen] = useState(false); const [activeSkillId, setActiveSkillId] = useState(null); const [activeSkillName, setActiveSkillName] = useState(''); const [activeSkillDescription, setActiveSkillDescription] = useState(''); const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false); // Load skills useEffect(() => { const loadSkills = async () => { try { const manifests = await invoke>>('runtime_discover_skills'); const ALLOWED_SKILLS = new Set(['gmail', 'notion']); const validManifests = manifests.filter(m => { const id = m.id as string; if (id.includes('_')) return false; return ALLOWED_SKILLS.has(id); }); const processed: SkillListEntry[] = validManifests .map(m => { const setup = m.setup as Record | undefined; return { id: m.id as string, name: (m.name as string) || (m.id as string).charAt(0).toUpperCase() + (m.id as string).slice(1), description: (m.description as string) || '', icon: SKILL_ICONS[m.id as string], ignoreInProduction: (m.ignoreInProduction as boolean) ?? false, hasSetup: !!(setup && setup.required), }; }) .filter(s => IS_DEV || !s.ignoreInProduction); setSkillsList(processed); } catch { // Skills unavailable } finally { setSkillsLoading(false); } }; loadSkills(); }, []); // Sort skills by connection status const sortedSkillsList = useMemo(() => { return [...skillsList] .sort((a, b) => { const skillA = skillsState[a.id]; const skillB = skillsState[b.id]; const stateA = skillStates[a.id]; const stateB = skillStates[b.id]; const statusA = deriveConnectionStatus(skillA?.status, skillA?.setupComplete, stateA); const statusB = deriveConnectionStatus(skillB?.status, skillB?.setupComplete, stateB); const priorityA = STATUS_PRIORITY[statusA] ?? 999; const priorityB = STATUS_PRIORITY[statusB] ?? 999; if (priorityA === priorityB) return a.name.localeCompare(b.name); return priorityA - priorityB; }) .filter(s => IS_DEV || !s.ignoreInProduction); }, [skillsList, skillsState, skillStates]); const openSkillSetup = (skill: SkillListEntry) => { setActiveSkillId(skill.id); setActiveSkillName(skill.name); setActiveSkillDescription(skill.description); setActiveSkillHasSetup(skill.hasSetup); setSetupModalOpen(true); }; return (
{/* Header */}

Skills

{/* Active Skills */}

Active Skills

{skillsLoading ? (

Loading skills...

) : sortedSkillsList.length === 0 ? (

No skills discovered

) : (
{sortedSkillsList.map(skill => ( openSkillSetup(skill)} /> ))}
)}
{/* Setup modal */} {setupModalOpen && activeSkillId && ( { setSetupModalOpen(false); setActiveSkillId(null); }} /> )}
); }