import { GithubIcon } from "@/components/icons/github-icon"; import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { RefreshCw, Plus, ExternalLink, Check, Lock, GitBranch, FileText, MoreVertical } from 'lucide-react'; import { parseGitHubSkillRepositoryUrl, type SkillSource, type SkillSourceDiscovery, type SkillSourceRefreshResult, type SkillSourceScanProgress } from '@paperclipai/shared'; import { Link, useNavigate, useParams } from '@/lib/router'; import { useCompany } from '@/context/CompanyContext'; import { useBreadcrumbs } from '@/context/BreadcrumbContext'; import { queryKeys } from '@/lib/queryKeys'; import { skillSourcesApi } from '@/api/skillSources'; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '@/components/ui/command'; import { appSourceConnectHref } from './apps/app-connect-policy'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { SkillImportProgress, type FoundSkill } from './skills/SkillImportProgress'; import { SkillPackagePreview } from './skills/SkillPackagePreview'; import { SkillSourceTree, type SkillTreeCandidate } from './skills/SkillSourceTree'; import { timeAgo } from '@/lib/timeAgo'; import { skillRoute } from '@/lib/company-skill-routes'; import { consumeSkillSourceReturn, rememberSkillSourceReturn } from '@/lib/skill-source-connect-return'; const sourceKey = (companyId: string) => queryKeys.skillSources.all(companyId); export function SkillSources() { const { selectedCompanyId, selectedCompany } = useCompany(); const { sourceId } = useParams<{ sourceId: string }>(); const navigate = useNavigate(); const client = useQueryClient(); const { setBreadcrumbs } = useBreadcrumbs(); const [results, setResults] = useState>({}); const companyId = selectedCompanyId ?? ''; useEffect(() => { setBreadcrumbs([{ label: 'Skills', href: '/skills' }, { label: 'Sources' }]); }, [setBreadcrumbs]); const query = useQuery({ queryKey: sourceKey(companyId), queryFn: () => skillSourcesApi.list(companyId), enabled: Boolean(companyId) }); async function invalidate() { await Promise.all([client.invalidateQueries({ queryKey: sourceKey(companyId) }), client.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) })]); } const refresh = useMutation({ mutationFn: (id: string) => skillSourcesApi.refresh(companyId, id), onSuccess: async result => { setResults(prev => ({ ...prev, [result.source.id]: result.warnings.join(' · ') })); await invalidate(); }, onError: (error, id) => { setResults(prev => ({ ...prev, [id]: error.message })); void invalidate(); } }); const disconnect = useMutation({ mutationFn: (id: string) => skillSourcesApi.disconnect(companyId, id), onSuccess: invalidate }); const activeSource = query.data?.find(source => source.id === sourceId); if (!companyId) return

Select a company to manage skill sources.

; return

Skill sources

Import skills from GitHub and refresh them when you’re ready.

Installed skills {query.isPending &&

Loading sources…

} {query.error &&

{query.error.message}

} {disconnect.error &&

{disconnect.error.message}

} {query.data?.length === 0 &&

No repositories added yet. Import your skills to make them available in {selectedCompany?.name ?? 'this company'}.

}
{query.data?.map(source => { const installed = source.entries.filter(entry => entry.skillId); const newCount = source.entries.filter(entry => entry.selection === 'new' && entry.present).length; return
{source.fullName} {!source.enabled &&

Disconnected

} {refresh.isPending && refresh.variables === source.id &&

Refreshing…

} {newCount > 0 && {newCount} new {newCount === 1 ? 'skill' : 'skills'} available}
refresh.mutate(source.id)}>{refresh.isPending && refresh.variables === source.id ? 'Refreshing…' : 'Refresh'} navigate(`/skills/sources/${source.id}`)}>Select skills disconnect.mutate(source.id)}>Disconnect source
{installed.length > 0 &&
    {installed.map(entry =>
  • {entry.name} {entry.description && {entry.description}}
  • )}
} {results[source.id] &&

{results[source.id]}

} {source.lastError && source.lastAttemptAt &&

Last attempt {timeAgo(source.lastAttemptAt)}

} {source.lastError &&

{source.lastError} Review source

} {source.connectionId && Manage GitHub connection}
; })}
{sourceId && (sourceId === 'new' || activeSource) && navigate('/skills/sources')} onSaved={async result => { setResults(prev => ({ ...prev, [result.source.id]: result.warnings.join(' · ') })); await invalidate(); navigate('/skills/sources'); }} />} {sourceId && sourceId !== 'new' && query.isSuccess && !activeSource &&

Source not found.

}
; } function SourceDialog({ companyId, source, onClose, onSaved }: { companyId: string; source?: SkillSource; onClose: () => void; onSaved: (result: SkillSourceRefreshResult) => Promise; }) { const draftKey = `paperclip.skill-source-draft:${companyId}:${source?.id ?? 'new'}`; const [selectionRevision] = useState(source?.revision); const [draft] = useState(() => { try { const saved = JSON.parse(sessionStorage.getItem(draftKey) ?? '{}'); return !source || saved.revision === source.revision ? saved : {}; } catch { return {}; } }); const [repositoryUrl, setRepositoryUrl] = useState(draft.repositoryUrl ?? source?.repositoryUrl ?? ''); const [showRepositoryUrl, setShowRepositoryUrl] = useState(draft.showRepositoryUrl ?? Boolean(draft.repositoryUrl)); const [connectionId, setConnectionId] = useState('connectionId' in draft ? draft.connectionId : source?.connectionId ?? null); const [preview, setPreview] = useState<{ skill: SkillTreeCandidate; filePath?: string } | null>(null); const [discovery, setDiscovery] = useState(draft.discovery ?? null); const [selected, setSelected] = useState>(() => new Set(draft.selectedPaths ?? (source ? source.entries.filter(entry => entry.selection !== 'excluded').map(entry => entry.path) : []))); const [excludedFolders, setExcludedFolders] = useState(draft.excludedFolders ?? source?.excludedFolders ?? []); const repositories = useQuery({ queryKey: queryKeys.skillSources.repositories(companyId), queryFn: () => skillSourcesApi.repositories(companyId), refetchOnMount: 'always', retry: false }); const availableRepositories = repositories.data?.repositories ?? []; const parsedRepository = parseGitHubSkillRepositoryUrl(repositoryUrl); const matchingRepository = availableRepositories.find(repo => parseGitHubSkillRepositoryUrl(repo.url)?.repositoryUrl === parsedRepository?.repositoryUrl); // Only choose from the current caller's authorized repository inventory. The // server reauthorizes the chosen connection on every GitHub request. const matchingConnectionIds = matchingRepository?.connectionIds ?? []; const availableConnectionId = connectionId && matchingConnectionIds.includes(connectionId) ? connectionId : matchingConnectionIds[0] ?? null; const sourceConnectionId = availableConnectionId ?? source?.connectionId ?? null; const connectHref = appSourceConnectHref('github'); const rememberReturn = () => rememberSkillSourceReturn(companyId, source?.id ?? 'new'); useEffect(() => { consumeSkillSourceReturn(companyId); }, [companyId]); useEffect(() => { sessionStorage.setItem(draftKey, JSON.stringify({ revision: selectionRevision, repositoryUrl, showRepositoryUrl, connectionId, discovery, selectedPaths: [...selected], excludedFolders })); }, [draftKey, selectionRevision, repositoryUrl, showRepositoryUrl, connectionId, discovery, selected, excludedFolders]); const scanController = useRef(null); const [progress, setProgress] = useState(null); const [found, setFound] = useState([]); useEffect(() => () => { scanController.current?.abort(); }, []); const scan = useMutation({ mutationFn: async () => { const controller = new AbortController(); scanController.current?.abort(); scanController.current = controller; setProgress(null); setFound([]); const result = await skillSourcesApi.discoverStream(companyId, { repositoryUrl, connectionId: availableConnectionId }, event => { if (controller.signal.aborted || scanController.current !== controller) return; if (event.type === 'progress') setProgress(event); else setFound(previous => [...previous.filter(skill => skill.path !== event.candidate.path), event.candidate].slice(-5)); }, controller.signal); return { discovery: result, connectionId: result.connectionId === undefined ? availableConnectionId : result.connectionId, controller }; }, onSuccess: result => { if (result.controller.signal.aborted || scanController.current !== result.controller) return; setDiscovery(result.discovery); setConnectionId(result.connectionId); setSelected(new Set(result.discovery.candidates.map(candidate => candidate.path))); setExcludedFolders([]); } }); const save = useMutation({ mutationFn: () => source ? skillSourcesApi.select(companyId, source.id, { revision: selectionRevision!, selectedPaths: [...selected], excludedFolders, connectionId: sourceConnectionId }) : skillSourcesApi.create(companyId, { repositoryUrl: discovery!.repositoryUrl, trackingRef: discovery!.trackingRef, commitSha: discovery!.commitSha, connectionId, selectedPaths: [...selected], excludedFolders }), onSuccess: async result => { sessionStorage.removeItem(draftKey); await onSaved(result); }, }); const candidates: SkillTreeCandidate[] = source ? source.entries.map(entry => ({ ...entry, note: !entry.present ? 'Removed from source · installed copy retained' : entry.selection === 'new' ? 'New skill' : entry.skillId ? 'Already imported' : undefined, })) : discovery?.candidates ?? []; const ready = Boolean(source || discovery); const eligibleCount = candidates.filter(candidate => selected.has(candidate.path) && !candidate.error).length; const skippedCount = candidates.filter(candidate => selected.has(candidate.path) && candidate.error).length; const busy = scan.isPending || save.isPending; const error = (scan.error?.name === 'AbortError' ? null : scan.error) ?? save.error; function stopScan() { scanController.current?.abort(); scanController.current = null; scan.reset(); setProgress(null); setFound([]); } function clearScan() { setDiscovery(null); scan.reset(); save.reset(); } function dismiss() { stopScan(); sessionStorage.removeItem(draftKey); onClose(); } return { if (!open && !save.isPending) dismiss(); }}> {source ? source.fullName : 'Import from GitHub'}{source && Choose the skills to keep synced. Unchecked skills stay installed.} {!ready && !scan.isPending &&
{availableRepositories.length > 0 &&
{availableRepositories.length} {availableRepositories.length === 1 ? 'repository' : 'repositories'}
} {repositories.isPending &&

Loading your GitHub repositories…

} {availableRepositories.length > 0 && No matching repositories. {availableRepositories.map(repo => { setRepositoryUrl(repo.url); clearScan(); }}> {repo.fullName} {repo.connections.join(' · ')} {repo.private && } {matchingRepository?.id === repo.id && } )} } {(repositories.error || Boolean(repositories.data?.failedConnectionCount)) &&

{availableRepositories.length ? 'Some GitHub connections could not load repositories.' : 'Could not load GitHub repositories.'}{' '} Manage connections

} {!repositories.isPending && availableRepositories.length === 0 && }
{showRepositoryUrl && }
} {ready && !save.isPending &&
{!source && {discovery?.fullName}} {source?.trackingRef === 'HEAD' ? 'Default branch' : source?.trackingRef ?? discovery?.trackingRef} View on GitHub
} {source?.lastError &&

{source.lastError}{' '}Manage GitHub connection

} {ready && !save.isPending && setPreview({ skill, filePath })} candidates={candidates} selected={selected} excludedFolders={excludedFolders} onChange={(paths, folders) => { setSelected(paths); setExcludedFolders(folders); }} disabled={busy} />} {discovery?.warnings.map(warning =>

{warning}

)} {skippedCount > 0 &&

{skippedCount} selected {skippedCount === 1 ? 'skill has' : 'skills have'} validation errors and will be skipped.

} {error &&

{error.message}{' '}Connect a GitHub account

} {scan.isPending && } {save.isPending && selected.has(candidate.path) && !candidate.error).map(candidate => ({ ...candidate, fileCount: candidate.inspection?.files.length ?? 1 }))} />}
{ready ? : }
{preview && setPreview(null)} />}
; }