import { useCallback, useMemo, useState, type ReactNode } from 'react'; import { ChevronDown, ChevronLeft, FileCode2, FileText, FolderOpen, GitBranch, RefreshCw, Server, Sparkles, Trash2, AlertTriangle, Circle } from 'lucide-react'; import { ToggleSwitch } from '@renderer/components/ui'; import type { ProjectRecord, ProjectGitContext } from '@shared/domain/project'; import type { DiscoveredMcpServer } from '@shared/domain/discoveredTooling'; import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; import type { ProjectAgentProfile, ProjectInstructionApplicationMode, ProjectInstructionFile, ProjectPromptFile } from '@shared/domain/projectCustomization'; /* ── Types ────────────────────────────────────────────────── */ /** Shortens ancestor-relative source paths like `..\..\..\.github\prompts\x.md` to `↑ .github/prompts/x.md` */ function formatSettingsSourcePath(sourcePath: string): string { const normalized = sourcePath.replace(/\\/g, '/'); const segments = normalized.split('/').filter((s) => s !== '..'); return `↑ ${segments.join('/')}`; } type ProjectSettingsSection = 'overview' | 'instructions' | 'agents' | 'prompts' | 'mcp-servers' | 'danger-zone'; interface NavItem { id: ProjectSettingsSection; label: string; icon: ReactNode; } interface NavGroup { label: string; items: NavItem[]; } interface ProjectSettingsPanelProps { project: ProjectRecord; onClose: () => void; onRescanConfigs: () => void; onRescanCustomization: () => void; onResolveDiscoveredTooling: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; onSetAgentProfileEnabled: (agentProfileId: string, enabled: boolean) => void; onRemoveProject: () => void; } /* ── Main component ───────────────────────────────────────── */ export function ProjectSettingsPanel({ project, onClose, onRescanConfigs, onRescanCustomization, onResolveDiscoveredTooling, onSetAgentProfileEnabled, onRemoveProject, }: ProjectSettingsPanelProps) { const [activeSection, setActiveSection] = useState('overview'); const [confirmingRemove, setConfirmingRemove] = useState(false); const acceptedServers = useMemo(() => listAcceptedDiscoveredMcpServers(project.discoveredTooling), [project.discoveredTooling]); const pendingServers = useMemo(() => listPendingDiscoveredMcpServers(project.discoveredTooling), [project.discoveredTooling]); const instructions = project.customization?.instructions ?? []; const agentProfiles = project.customization?.agentProfiles ?? []; const promptFiles = project.customization?.promptFiles ?? []; const enabledAgentCount = agentProfiles.filter((a) => a.enabled).length; const handleRemove = useCallback(() => { if (!confirmingRemove) { setConfirmingRemove(true); return; } onRemoveProject(); }, [confirmingRemove, onRemoveProject]); const navGroups: NavGroup[] = [ { label: 'Project', items: [ { id: 'overview', label: 'Overview', icon: }, ], }, { label: 'Copilot', items: [ { id: 'instructions', label: 'Instructions', icon: }, { id: 'agents', label: 'Custom Agents', icon: }, { id: 'prompts', label: 'Prompt Files', icon: }, ], }, { label: 'Tooling', items: [ { id: 'mcp-servers', label: 'MCP Servers', icon: }, ], }, ]; function sectionBadge(section: ProjectSettingsSection): ReactNode { switch (section) { case 'instructions': return instructions.length > 0 ? : null; case 'agents': return agentProfiles.length > 0 ? : null; case 'prompts': return promptFiles.length > 0 ? : null; case 'mcp-servers': { if (pendingServers.length > 0) { return ; } const totalServers = acceptedServers.length + pendingServers.length; return totalServers > 0 ? : null; } default: return null; } } return (
{/* Header */}

Project Settings · {project.name}

{/* Sidebar + Content */}
{/* Navigation sidebar */} {/* Content panel */}
{activeSection === 'overview' && ( )} {activeSection === 'instructions' && ( )} {activeSection === 'agents' && ( )} {activeSection === 'prompts' && ( )} {activeSection === 'mcp-servers' && ( )} {activeSection === 'danger-zone' && ( setConfirmingRemove(false)} onRemove={handleRemove} /> )}
); } /* ── Nav badges ───────────────────────────────────────────── */ function CountBadge({ count, total }: { count: number; total?: number }) { return ( {total !== undefined ? `${count}/${total}` : count} ); } function PendingBadge({ count }: { count: number }) { return ( {count} new ); } /* ── Overview ─────────────────────────────────────────────── */ function OverviewContent({ project }: { project: ProjectRecord }) { return (
{project.name}
{project.path}
{project.git && }
); } function ProjectGitInfo({ git }: { git: ProjectGitContext }) { if (git.status === 'not-repository') { return (
Not a git repository
); } if (git.status === 'git-missing') { return (
Git is not installed
); } if (git.status === 'error') { return (
{git.errorMessage ?? 'Git error'}
); } const branchLabel = git.branch ?? git.head?.shortHash ?? 'HEAD'; const parts: string[] = []; if (git.isDirty && git.changedFileCount) parts.push(`${git.changedFileCount} changed`); if (git.ahead) parts.push(`${git.ahead} ahead`); if (git.behind) parts.push(`${git.behind} behind`); return (
{branchLabel} {git.isDirty && } {parts.length > 0 && ( · {parts.join(' · ')} )}
); } /* ── Instructions ─────────────────────────────────────────── */ function InstructionsContent({ instructions, onRescan, }: { instructions: ProjectInstructionFile[]; onRescan: () => void; }) { return (
{instructions.length === 0 ? ( No instruction files found. Add .github/copilot-instructions.md, AGENTS.md, CLAUDE.md, *.instructions.md files under .github/instructions/, or *.md files under .claude/rules/. ) : (
{instructions.map((instruction) => ( ))}
)}
); } function InstructionCard({ instruction }: { instruction: ProjectInstructionFile }) { const [expanded, setExpanded] = useState(false); const isLong = instruction.content.length > 300; const isAncestor = instruction.sourcePath.startsWith('..'); const displayName = instruction.name ?? (isAncestor ? formatSettingsSourcePath(instruction.sourcePath) : instruction.sourcePath); return (
{expanded && (
{instruction.description && (

{instruction.description}

)} {instruction.name && (

{instruction.sourcePath}

)}
            {instruction.content}
          
)} {!expanded && (
{instruction.description && (

{instruction.description}

)}

{instruction.content}

)}
); } function InstructionModeBadge({ mode }: { mode: ProjectInstructionApplicationMode }) { switch (mode) { case 'always': return ( always ); case 'file': return ( file ); case 'task': return ( task ); case 'manual': return ( manual ); } } /* ── Custom Agents ────────────────────────────────────────── */ function AgentsContent({ agents, onRescan, onSetEnabled, }: { agents: ProjectAgentProfile[]; onRescan: () => void; onSetEnabled: (agentProfileId: string, enabled: boolean) => void; }) { const enabledCount = agents.filter((a) => a.enabled).length; return (
{agents.length === 0 ? ( No custom agents found. Add .agent.md files under .github/agents/ in your project. ) : ( <> {agents.length > 1 && (
{enabledCount} of {agents.length} agent{agents.length === 1 ? '' : 's'} enabled
)}
{agents.map((agent) => ( onSetEnabled(agent.id, !agent.enabled)} /> ))}
)}
); } function AgentCard({ agent, onToggle, }: { agent: ProjectAgentProfile; onToggle: () => void; }) { return (
{agent.displayName ?? agent.name} {agent.tools && agent.tools.length > 0 && ( {agent.tools.length} tool{agent.tools.length === 1 ? '' : 's'} )}
{agent.description && (

{agent.description}

)}

{agent.sourcePath}

); } /* ── Prompt Files ─────────────────────────────────────────── */ function PromptsContent({ promptFiles, onRescan, }: { promptFiles: ProjectPromptFile[]; onRescan: () => void; }) { return (
{promptFiles.length === 0 ? ( No prompt files found. Add .prompt.md files under .github/prompts/ in your project. ) : (
{promptFiles.map((prompt) => ( ))}
)}
); } function PromptCard({ prompt }: { prompt: ProjectPromptFile }) { const isAncestor = prompt.sourcePath.startsWith('..'); return (
{prompt.name} {prompt.model && ( {prompt.model} )} {prompt.agent && ( {prompt.agent} )} {prompt.variables.length > 0 && ( {prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'} )} {isAncestor && ( parent repo )}
{prompt.description && (

{prompt.description}

)} {prompt.argumentHint && (

Hint: {prompt.argumentHint}

)} {prompt.tools && prompt.tools.length > 0 && (
{prompt.tools.map((tool) => ( {tool} ))}
)} {prompt.variables.length > 0 && (
{prompt.variables.map((v) => ( {v.name} ))}
)}

{isAncestor ? formatSettingsSourcePath(prompt.sourcePath) : prompt.sourcePath}

); } /* ── MCP Servers ──────────────────────────────────────────── */ function McpServersContent({ accepted, pending, onRescan, onResolve, }: { accepted: DiscoveredMcpServer[]; pending: DiscoveredMcpServer[]; onRescan: () => void; onResolve: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; }) { const hasServers = accepted.length + pending.length > 0; return (
{!hasServers ? ( No MCP servers discovered. Click Scan to check project config files. ) : ( <>
{accepted.map((server) => ( onResolve([server.id], 'dismiss')} server={server} status="accepted" /> ))} {pending.map((server) => ( onResolve([server.id], 'accept')} onDismiss={() => onResolve([server.id], 'dismiss')} server={server} status="pending" /> ))}
{pending.length > 1 && (
)} )}
); } function DiscoveredServerRow({ server, status, onAccept, onDismiss, }: { server: DiscoveredMcpServer; status: 'accepted' | 'pending'; onAccept?: () => void; onDismiss?: () => void; }) { const detail = server.transport === 'local' ? server.command || 'No command' : server.url || 'No URL'; const statusBadge = status === 'accepted' ? 'bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]' : 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'; return (
{server.name} {server.transport} {status}

{detail} · {server.sourceLabel}

{onAccept && ( )} {onDismiss && ( )}
); } /* ── Danger Zone ──────────────────────────────────────────── */ function DangerZoneContent({ confirmingRemove, onRemove, onCancelRemove, }: { confirmingRemove: boolean; onRemove: () => void; onCancelRemove: () => void; }) { return (

Remove project

Removing a project deletes all its sessions and discovered tooling from Aryx. Your project files on disk are not affected.

{confirmingRemove && ( )}
); } /* ── Shared helpers ──────────────────────────────────────── */ function SectionHeader({ title, description, children, }: { title: string; description: string; children?: ReactNode; }) { return (

{title}

{description}

{children}
); } function RescanButton({ onClick, label = 'Re-scan' }: { onClick: () => void; label?: string }) { return ( ); } function EmptyState({ children }: { children: ReactNode }) { return (
{children}
); }