import { ProjectSkillRepositoryRow } from "@/components/skill-studio/ProjectSkillRepositoryRow"; import { SkillSourceConnectionRepair } from "@/components/skill-studio/SkillSourceConnectionRepair"; import { SkillSourceBranchRepair } from "@/components/skill-studio/SkillSourceBranchRepair"; import { SkillSourceSyncControls } from "@/components/skill-studio/SkillSourceSyncControls"; import { SkillsPageNavigation } from "@/components/skill-studio/SkillsNavigationTabs"; import { GithubIcon } from "@/components/icons/github-icon"; import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { RefreshCw, Plus, ExternalLink, Check, Lock, GitBranch, FileText, MoreVertical, CircleAlert } from 'lucide-react'; import { parseGitHubSkillRepositoryUrl, type SkillSourceDisconnectRequest, 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 { ApiError } from '@/api/client'; 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 { RadioCardGroup } from '@/components/ui/radio-card'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, 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 { SkillImportWarnings, skillImportWarnings } from './skills/SkillImportWarnings'; import { timeAgo } from '@/lib/timeAgo'; import { skillRoute } from '@/lib/company-skill-routes'; import { consumeSkillSourceReturn, rememberSkillSourceReturn } from '@/lib/skill-source-connect-return'; import { groupProjectSkillRepositories } from '@/lib/project-skill-repositories'; const sourceKey = (companyId: string) => queryKeys.skillSources.all(companyId); export function SkillSources() { const sourceActionButtons = useRef>({}); const { selectedCompanyId, selectedCompany } = useCompany(); const { sourceId } = useParams<{ sourceId: string }>(); const navigate = useNavigate(); const client = useQueryClient(); const { setBreadcrumbs } = useBreadcrumbs(); const [results, setResults] = useState>({}); const [repairSourceId, setRepairSourceId] = useState(null); const [branchSourceId, setBranchSourceId] = useState(null); const [disconnectSource, setDisconnectSource] = useState(null); 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), refetchInterval: 5000 }); const repairSource = query.data?.find(source => source.id === repairSourceId); const branchSource = query.data?.find(source => source.id === branchSourceId); const projectRepositories = useQuery({ queryKey: queryKeys.skillSources.projects(companyId), queryFn: () => skillSourcesApi.projectRepositories(companyId), enabled: Boolean(companyId), refetchInterval: 5000 }); async function invalidate() { await Promise.all([client.invalidateQueries({ queryKey: queryKeys.skillSources.projects(companyId) }), 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]: null })); await invalidate(); }, onError: (error, id) => { setResults(prev => ({ ...prev, [id]: { message: error.message, failedAt: Date.now(), operation: 'refresh' } })); void invalidate(); } }); const adopt = useMutation({ mutationFn: async (group: ReturnType[number]) => { for (const workspace of group.importableWorkspaces) await skillSourcesApi.adoptProject(companyId, workspace.workspaceId); }, onSuccess: invalidate, onError: () => { void invalidate(); } }); const settings = useMutation({ mutationFn: ({ source, enabled }: { source: SkillSource; enabled: boolean }) => skillSourcesApi.settings(companyId, source.id, { revision: source.revision, automaticUpdates: enabled }), onSuccess: async (_result, variables) => { setResults(prev => ({ ...prev, [variables.source.id]: null })); await invalidate(); }, onError: (error, variables) => setResults(prev => ({ ...prev, [variables.source.id]: { message: error.message, failedAt: Date.now(), operation: 'settings' } })) }); const availableProjects = groupProjectSkillRepositories(Array.isArray(projectRepositories.data) ? projectRepositories.data.filter(repo => !repo.sourceId) : []); const groupSource = (group: typeof availableProjects[number]) => query.data?.find(source => source.repositoryUrl.toLowerCase() === group.repository.repositoryUrl.toLowerCase() && source.trackingRef === group.repository.trackingRef); const uninstalledProjects = availableProjects.filter(group => !groupSource(group)); const disconnect = useMutation({ mutationFn: ({ source, skills }: { source: SkillSource; skills: SkillSourceDisconnectRequest['skills'] }) => skillSourcesApi.disconnect(companyId, source.id, { revision: source.revision, skills }), onSuccess: async result => { client.setQueryData(sourceKey(companyId), previous => previous?.filter(source => source.id !== result.id)); await invalidate(); await client.invalidateQueries({ queryKey: queryKeys.folders.list(companyId, 'skill') }); setDisconnectSource(null); }, }); const reloadRemoval = useMutation({ mutationFn: async () => { const result = await query.refetch({ throwOnError: true }); return result.data?.find(source => source.id === disconnectSource?.id) ?? null; }, onSuccess: source => { setDisconnectSource(source); disconnect.reset(); }, }); const staleRemoval = disconnect.error instanceof ApiError && disconnect.error.status === 409; const activeSource = query.data?.find(source => source.id === sourceId); if (!companyId) return

Select a company to manage skill sources.

; return

Skill sources

Skills from your repositories stay up to date automatically.

Manage GitHub connection
{query.isPending &&

Loading sources…

} {query.error &&

{query.error.message}

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

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

} {projectRepositories.error &&

{projectRepositories.error.message}

} {adopt.error &&

{adopt.error.message}

} {uninstalledProjects.length > 0 &&
{uninstalledProjects.map(group => adopt.mutate(group)} />)}
}
{query.data?.map(source => { const installed = source.entries.filter(entry => entry.skillId); const newCount = source.entries.filter(entry => entry.selection === 'new' && entry.present).length; const result = results[source.id]; const projectGroup = availableProjects.find(group => groupSource(group)?.id === source.id); const importingProjectSkills = projectGroup && (projectGroup.hasPending || (adopt.isPending && adopt.variables?.key === projectGroup.key)); const localError = result && result.message !== source.lastError && (result.operation === 'settings' || !source.lastSuccessAt || new Date(source.lastSuccessAt).getTime() < result.failedAt) ? result.message : null; return
{source.fullName}

{source.trackingRef === 'HEAD' ? 'Default branch' : source.trackingRef}{source.lastSuccessAt ? ` · Updated ${timeAgo(source.lastSuccessAt)}` : ''}{source.accessScope === 'projects' ? ' · Project access' : ''}{importingProjectSkills && · Importing skills…}

{source.enabled && source.automaticUpdates === false &&

Automatic updates paused

} {source.trackingActive === false &&

Repository tracking stopped · installed skills kept

} {!source.enabled &&

Disconnected

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

Refreshing…

} {newCount > 0 && {newCount} new {newCount === 1 ? 'skill' : 'skills'} available}
refresh.mutate(source.id)} onAutomaticUpdates={enabled => settings.mutate({ source, enabled })} /> settings.mutate({ source, enabled })}>Automatic updates navigate(`/skills/sources/${source.id}`)}>Select skills {projectGroup && projectGroup.importableWorkspaces.length > 0 && adopt.mutate(projectGroup)}> {projectGroup.repository.status === 'error' ? 'Retry project import' : projectGroup.hasPending ? 'Import remaining project skills' : 'Import project skills'} } setRepairSourceId(source.id)}>Choose connection {source.accessScope !== 'projects' && !source.projects?.some(project => project.active) && setBranchSourceId(source.id)}>Choose a new branch} { disconnect.reset(); reloadRemoval.reset(); setDisconnectSource(source); }}>{source.enabled ? 'Disconnect source' : 'Remove source'}
{projectGroup?.repository.lastError && } {installed.length > 0 &&
    {installed.map(entry =>
  • {entry.name} {entry.description && {entry.description}} {!entry.present && Removed from source · installed copy kept}
  • )}
} {installed.length === 0 && source.entries.length === 0 && source.syncState !== 'refreshing' && !source.lastError &&

No skills found yet.{source.enabled && source.automaticUpdates && source.importNewSkills ? ' New skills will be imported automatically.' : ''}

} {localError && } entry.selection === 'selected'), source.repositoryPackage)} /> {source.lastError && Review source} />} {source.lastError && (source.repairAction === 'connection' || (!source.repairAction && !source.connectionId)) && } {source.lastError && source.repairAction === 'branch' && (source.accessScope === 'projects' || source.projects?.some(project => project.active) ?
{source.projects?.filter(project => project.active).map(project => Update {project.name} repository)}
: )}
; })}
{branchSource && setBranchSourceId(null)} onReturnFocus={() => sourceActionButtons.current[branchSource.id]?.focus()} onSaved={async () => { setResults(prev => ({ ...prev, [branchSource.id]: null })); await invalidate(); }} />} {repairSource && setRepairSourceId(null)} onReturnFocus={() => sourceActionButtons.current[repairSource.id]?.focus()} onSaved={async () => { setResults(prev => ({ ...prev, [repairSource.id]: null })); await invalidate(); }} />} {disconnectSource && reloadRemoval.mutate()} onChoiceChange={() => { if (!staleRemoval) disconnect.reset(); }} onClose={() => setDisconnectSource(null)} onConfirm={skills => disconnect.mutate({ source: disconnectSource, skills })} />} {sourceId && (sourceId === 'new' || activeSource) && navigate('/skills/sources')} onSaved={async result => { setResults(prev => ({ ...prev, [result.source.id]: null })); await invalidate(); navigate('/skills/sources'); }} />} {sourceId && sourceId !== 'new' && query.isSuccess && !activeSource &&

Source not found.

}
; } function SourceErrorNotice({ message, detail, action }: { message: string; detail?: string; action?: ReactNode }) { return
; } function DisconnectSourceDialog({ source, pending, error, stale, onReload, onClose, onConfirm, onChoiceChange }: { source: SkillSource; pending: boolean; error?: string; stale: boolean; onReload: () => void; onClose: () => void; onConfirm: (skills: SkillSourceDisconnectRequest['skills']) => void; onChoiceChange: () => void; }) { const count = new Set(source.entries.flatMap(entry => entry.skillId ? [entry.skillId] : [])).size; const [choice, setChoice] = useState(''); const action = source.enabled ? 'Disconnect source' : 'Remove source'; return { if (!open && !pending) onClose(); }}> {source.enabled ? 'Disconnect' : 'Remove'} {source.fullName}? {count ? `Remove this repository from Sources. What should happen to its ${count} imported ${count === 1 ? 'skill' : 'skills'}?` : 'Remove this repository from Sources.'} {count > 0 && { setChoice(value as SkillSourceDisconnectRequest['skills']); onChoiceChange(); }} disabled={pending} options={[ { value: 'copy', title: 'Keep editable copies', description: source.accessScope === 'projects' ? 'Move them to My Skills, keeping project access restrictions. Assigned agents keep using them.' : 'Move them to My Skills. Assigned agents keep using them.' }, { value: 'delete', title: 'Delete skills', description: 'Remove them from the library. Detach skills used by agents first.' }, ]} />} {error &&
{error} {stale && }
}
; } 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; repository?: boolean } | null>(null); const [packageMode, setPackageMode] = useState<'skills' | 'repository'>(source ? draft.packageMode ?? source.packageMode : 'repository'); const [discovery, setDiscovery] = useState(!source && draft.packageMode === 'skills' ? null : 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, packageMode, repositoryUrl, showRepositoryUrl, connectionId, discovery, selectedPaths: [...selected], excludedFolders })); }, [draftKey, selectionRevision, packageMode, 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 (nextMode: 'skills' | 'repository' = packageMode) => { const controller = new AbortController(); scanController.current?.abort(); scanController.current = controller; setProgress(null); setFound([]); const discover = (mode: 'skills' | 'repository') => skillSourcesApi.discoverStream(companyId, { repositoryUrl, trackingRef: source?.trackingRef, packageMode: mode, connectionId: source ? sourceConnectionId : 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); let result = await discover(nextMode); // Shared dependencies need their original repository layout. Include that // layout automatically instead of asking the person to repair the package. if (nextMode === 'skills' && result.candidates.some(candidate => candidate.inspection?.references.some(reference => reference.kind === 'outside_package'))) { setFound([]); result = await discover('repository'); } 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); setPackageMode(result.discovery.packageMode ?? packageMode); setConnectionId(result.connectionId); if (!source) { setSelected(new Set(result.discovery.candidates.map(candidate => candidate.path))); setExcludedFolders([]); } } }); const save = useMutation({ mutationFn: () => source ? skillSourcesApi.select(companyId, source.id, { revision: selectionRevision!, packageMode, selectedPaths: [...selected], excludedFolders, connectionId: sourceConnectionId }) : skillSourcesApi.create(companyId, { repositoryUrl: discovery!.repositoryUrl, trackingRef: discovery!.trackingRef, commitSha: discovery!.commitSha, packageMode, connectionId, selectedPaths: [...selected], excludedFolders }), onSuccess: async result => { sessionStorage.removeItem(draftKey); await onSaved(result); }, }); const candidates: SkillTreeCandidate[] = discovery?.candidates ?? (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, })) : []); const ready = Boolean(source || discovery); const repositoryPackage = discovery?.repositoryPackage ?? source?.repositoryPackage; const warnings = skillImportWarnings(candidates.filter(candidate => selected.has(candidate.path)), packageMode === 'repository' ? repositoryPackage : null, discovery?.warnings); const selectionTree = setPreview({ skill, filePath })} candidates={candidates} selected={selected} excludedFolders={excludedFolders} onChange={(paths, folders) => { setSelected(paths); setExcludedFolders(folders); }} disabled={scan.isPending || save.isPending} />; const selectedCount = candidates.filter(candidate => selected.has(candidate.path)).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 && 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 && !scan.isPending &&
{packageMode === 'repository' ? <> {candidates[0] && } : null} {selectionTree}
} {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)} />}
; }