UNPKG

@alavida/agentpack

Version:

Compiler-driven lifecycle CLI for source-backed agent skills

364 lines (340 loc) 10.7 kB
import { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react'; import { fetchWorkbenchModel, runWorkbenchAction } from './lib/api.js'; import { resolveWorkbenchNodeInteraction } from './lib/navigation.js'; import { getSkillFromHash, setSkillHash, onHashChange } from './lib/router.js'; import { SkillGraph } from './components/SkillGraph.jsx'; import { InspectorPanel } from './components/InspectorPanel.jsx'; import { Breadcrumbs } from './components/Breadcrumbs.jsx'; import { ControlStrip } from './components/ControlStrip.jsx'; import { Tooltip } from './components/Tooltip.jsx'; export function App() { const [model, setModel] = useState(null); const [selectedId, setSelectedId] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [busyAction, setBusyAction] = useState(null); const [labelsVisible, setLabelsVisible] = useState(true); const [knowledgeVisible, setKnowledgeVisible] = useState(true); const [resetZoomSignal, setResetZoomSignal] = useState(0); const [lightMode, setLightMode] = useState(false); const [trail, setTrail] = useState([]); const [tooltipNode, setTooltipNode] = useState(null); const [tooltipPos, setTooltipPos] = useState(null); const inspectedNode = model?.nodes.find((n) => n.id === selectedId) || null; useLayoutEffect(() => { document.documentElement.setAttribute('data-theme', lightMode ? 'light' : 'dark'); }, [lightMode]); const loadModel = useCallback(async (skillPackageName) => { try { setLoading(true); setError(null); const nextModel = await fetchWorkbenchModel(skillPackageName); setModel(nextModel); setSelectedId(null); return nextModel; } catch (err) { setError(err.message); return null; } finally { setLoading(false); } }, []); // Initial load useEffect(() => { const hashSkill = getSkillFromHash(); loadModel(hashSkill).then((m) => { if (m) { const entry = { packageName: m.selected.id, name: m.selected.name }; if (hashSkill) { setTrail([entry]); } else { setTrail([entry]); setSkillHash(m.selected.id); } } }); }, [loadModel]); // Hash change listener useEffect(() => { onHashChange((skillPackageName) => { if (skillPackageName) { loadModel(skillPackageName).then((m) => { if (m) { setTrail((prev) => { const existingIndex = prev.findIndex((e) => e.packageName === skillPackageName); if (existingIndex >= 0) { return prev.slice(0, existingIndex + 1); } return [...prev, { packageName: m.selected.id, name: m.selected.name }]; }); } }); } }); }, [loadModel]); function navigateToSkill(packageName) { setSkillHash(packageName); } function handleBreadcrumbNavigate(packageName, index) { setTrail((prev) => prev.slice(0, index + 1)); setSkillHash(packageName); } async function handleAction(action) { if (action === 'reset-zoom') { setResetZoomSignal((s) => s + 1); return; } if (action === 'refresh') { const hashSkill = getSkillFromHash(); setBusyAction('refresh'); await loadModel(hashSkill); setBusyAction(null); return; } try { setBusyAction(action); await runWorkbenchAction(action); } catch (err) { setError(err.message); } finally { setBusyAction(null); } } const handleHover = useCallback((node, pos) => { setTooltipNode(node); setTooltipPos(pos); }, []); const handleHoverEnd = useCallback(() => { setTooltipNode(null); setTooltipPos(null); }, []); const handleGraphClick = useCallback((node) => { const interaction = resolveWorkbenchNodeInteraction(node, selectedId); if (interaction.action === 'navigate') { navigateToSkill(interaction.target); return; } setSelectedId(interaction.target); }, [selectedId]); return ( <> {/* Header */} <header data-testid="workbench-header" style={{ padding: '20px 40px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexShrink: 0, }} > <div> <div style={{ fontFamily: 'var(--font-mono)', fontVariant: 'small-caps', textTransform: 'uppercase', letterSpacing: 3, fontSize: 9, color: 'var(--text-dim)', marginBottom: 4, }}> Agentpack </div> <div style={{ fontFamily: 'var(--font-body)', fontSize: 28, fontWeight: 400, fontStyle: 'italic', color: 'var(--text)', }}> Skill Graph </div> <hr style={{ width: 36, height: 1, background: 'var(--status-current)', border: 'none', marginTop: 10, }} /> </div> <div style={{ display: 'flex', gap: 20, alignItems: 'center', }}> <LegendItem color="var(--edge-provenance)" label="Source" shape="diamond" /> <LegendItem color="#c45454" label="Changed" shape="diamond" /> <LegendItem color="var(--status-current)" label="Current" shape="dot" /> <LegendItem color="var(--status-stale)" label="Stale" shape="dot" /> <LegendItem color="var(--status-affected)" label="Affected" shape="ring" /> <LegendItem color="#b3c28d" label="Internal" shape="ring" /> <LegendItem color="#8d9fc2" label="External" shape="ring" /> <LegendItem color="var(--edge-requires)" label="Requires" shape="line" /> <LegendItem color="var(--edge-provenance)" label="Provenance" shape="dashed" /> </div> </header> {/* Breadcrumbs */} <Breadcrumbs trail={trail} onNavigate={handleBreadcrumbNavigate} /> {/* Main area */} <div data-testid="workbench-main" style={{ flex: 1, position: 'relative', minHeight: 0 }}> {loading && !model && ( <div data-testid="workbench-loading" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-dim)', }} > Loading... </div> )} {error && ( <div data-testid="workbench-error" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16, }} > <div style={{ fontFamily: 'var(--font-body)', fontSize: 18, fontStyle: 'italic', color: 'var(--status-stale)', }}> {error} </div> <button type="button" onClick={() => handleAction('refresh')} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, background: 'var(--surface)', border: '1px solid var(--border-bright)', color: 'var(--text-dim)', padding: '8px 16px', cursor: 'pointer', letterSpacing: '0.04em', }} > Retry </button> </div> )} {model && !error && ( <SkillGraph model={model} selectedId={selectedId} onSelect={handleGraphClick} onHover={handleHover} onHoverEnd={handleHoverEnd} labelsVisible={labelsVisible} knowledgeVisible={knowledgeVisible} resetZoomSignal={resetZoomSignal} /> )} {model && model.nodes.length === 1 && model.nodes[0].type === 'skill' && ( <div data-testid="workbench-empty" style={{ position: 'absolute', bottom: 80, left: '50%', transform: 'translateX(-50%)', fontFamily: 'var(--font-body)', fontSize: 14, fontStyle: 'italic', color: 'var(--text-faint)', }} > No dependencies or sources found. </div> )} </div> {/* Tooltip */} <Tooltip node={tooltipNode} position={tooltipPos} /> {/* Inspector Panel */} <InspectorPanel node={inspectedNode} onClose={() => setSelectedId(null)} onNavigate={navigateToSkill} /> {/* Control Strip */} <ControlStrip onAction={handleAction} busyAction={busyAction} labelsVisible={labelsVisible} onToggleLabels={() => setLabelsVisible((v) => !v)} knowledgeVisible={knowledgeVisible} onToggleKnowledge={() => setKnowledgeVisible((v) => !v)} lightMode={lightMode} onToggleTheme={() => setLightMode((v) => !v)} /> </> ); } function LegendItem({ color, label, shape }) { return ( <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-dim)', letterSpacing: '0.04em', }}> {shape === 'dot' && ( <div style={{ width: 10, height: 10, borderRadius: '50%', background: color, }} /> )} {shape === 'ring' && ( <div style={{ width: 10, height: 10, borderRadius: '50%', background: 'transparent', border: `1.5px solid ${color}`, }} /> )} {shape === 'diamond' && ( <svg width="12" height="12" viewBox="-6 -6 12 12" style={{ display: 'block' }}> <path d="M0,-5 L5,0 L0,5 L-5,0 Z" fill={color} opacity={0.6} /> </svg> )} {shape === 'line' && ( <div style={{ width: 20, height: 2, borderRadius: 1, background: color, opacity: 0.6, }} /> )} {shape === 'dashed' && ( <div style={{ width: 20, height: 0, borderTop: `2px dashed ${color}`, opacity: 0.5, }} /> )} {label} </div> ); }