diff --git a/src/renderer/components/ProjectSettingsPanel.tsx b/src/renderer/components/ProjectSettingsPanel.tsx index 2ca30ad..86dd55f 100644 --- a/src/renderer/components/ProjectSettingsPanel.tsx +++ b/src/renderer/components/ProjectSettingsPanel.tsx @@ -1,11 +1,26 @@ -import { useCallback, useState } from 'react'; -import { ChevronLeft, FileCode2, FileText, FolderOpen, GitBranch, RefreshCw, Server, Sparkles, Trash2, AlertTriangle, Circle } from 'lucide-react'; +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, ProjectCustomizationState, ProjectInstructionFile, ProjectPromptFile } from '@shared/domain/projectCustomization'; +import type { ProjectAgentProfile, ProjectInstructionFile, ProjectPromptFile } from '@shared/domain/projectCustomization'; + +/* ── Types ────────────────────────────────────────────────── */ + +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; @@ -17,6 +32,8 @@ interface ProjectSettingsPanelProps { onRemoveProject: () => void; } +/* ── Main component ───────────────────────────────────────── */ + export function ProjectSettingsPanel({ project, onClose, @@ -26,16 +43,15 @@ export function ProjectSettingsPanel({ onSetAgentProfileEnabled, onRemoveProject, }: ProjectSettingsPanelProps) { + const [activeSection, setActiveSection] = useState('overview'); const [confirmingRemove, setConfirmingRemove] = useState(false); - const acceptedServers = listAcceptedDiscoveredMcpServers(project.discoveredTooling); - const pendingServers = listPendingDiscoveredMcpServers(project.discoveredTooling); - const hasDiscoveredServers = acceptedServers.length + pendingServers.length > 0; - const customization = project.customization; - const hasCustomization = - (customization?.instructions?.length ?? 0) > 0 || - (customization?.agentProfiles?.length ?? 0) > 0 || - (customization?.promptFiles?.length ?? 0) > 0; + 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) { @@ -45,6 +61,55 @@ export function ProjectSettingsPanel({ 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 */} @@ -56,105 +121,114 @@ export function ProjectSettingsPanel({ > -

Project Settings

+

+ Project Settings + · + {project.name} +

- {/* Content */} -
-
- {/* Project info */} - + {/* Sidebar + Content */} +
+ {/* Navigation sidebar */} + - {/* Copilot Customization */} - {hasCustomization ? ( - - ) : ( -
- - - -
- No customization files found. Add .github/copilot-instructions.md, AGENTS.md, or files - in .github/agents/ and .github/prompts/ to customize Copilot behavior. -
-
- )} - - {/* Remove project */} -
-

Danger zone

-

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

-
- - {confirmingRemove && ( - - )} -
+ {/* Content panel */} +
+
+ {activeSection === 'overview' && ( + + )} + {activeSection === 'instructions' && ( + + )} + {activeSection === 'agents' && ( + + )} + {activeSection === 'prompts' && ( + + )} + {activeSection === 'mcp-servers' && ( + + )} + {activeSection === 'danger-zone' && ( + setConfirmingRemove(false)} + onRemove={handleRemove} + /> + )}
@@ -162,14 +236,32 @@ export function ProjectSettingsPanel({ ); } -/* ── Project info ─────────────────────────────────────────── */ +/* ── Nav badges ───────────────────────────────────────────── */ -function ProjectInfoSection({ project }: { project: ProjectRecord }) { +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 (
@@ -231,9 +323,240 @@ function ProjectGitInfo({ git }: { git: ProjectGitContext }) { ); } -/* ── Discovered MCP servers ──────────────────────────────── */ +/* ── Instructions ─────────────────────────────────────────── */ -function DiscoveredServersSection({ +function InstructionsContent({ + instructions, + onRescan, +}: { + instructions: ProjectInstructionFile[]; + onRescan: () => void; +}) { + return ( +
+ + + + + {instructions.length === 0 ? ( + + No instruction files found. Add a .github/copilot-instructions.md or AGENTS.md file to your project root. + + ) : ( +
+ {instructions.map((instruction) => ( + + ))} +
+ )} +
+ ); +} + +function InstructionCard({ instruction }: { instruction: ProjectInstructionFile }) { + const [expanded, setExpanded] = useState(false); + const isLong = instruction.content.length > 300; + + return ( +
+ + {expanded && ( +
+
+            {instruction.content}
+          
+
+ )} + {!expanded && ( +
+

+ {instruction.content} +

+
+ )} +
+ ); +} + +/* ── 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 to .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 to .github/prompts/ in your project. + + ) : ( +
+ {promptFiles.map((prompt) => ( + + ))} +
+ )} +
+ ); +} + +function PromptCard({ prompt }: { prompt: ProjectPromptFile }) { + return ( +
+
+ +
+
+ {prompt.name} + {prompt.variables.length > 0 && ( + + {prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'} + + )} +
+ {prompt.description && ( +

{prompt.description}

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

{prompt.sourcePath}

+
+
+
+ ); +} + +/* ── MCP Servers ──────────────────────────────────────────── */ + +function McpServersContent({ accepted, pending, onRescan, @@ -244,60 +567,62 @@ function DiscoveredServersSection({ onRescan: () => void; onResolve: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; }) { + const hasServers = accepted.length + pending.length > 0; + return (
- + -
- {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" - /> - ))} -
+ {!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 && ( -
- - -
+ {pending.length > 1 && ( +
+ + +
+ )} + )}
); @@ -365,150 +690,52 @@ function DiscoveredServerRow({ ); } -/* ── Copilot Customization ────────────────────────────────── */ +/* ── Danger Zone ──────────────────────────────────────────── */ -function CustomizationSection({ - customization, - onRescan, - onSetAgentProfileEnabled, +function DangerZoneContent({ + confirmingRemove, + onRemove, + onCancelRemove, }: { - customization: ProjectCustomizationState; - onRescan: () => void; - onSetAgentProfileEnabled: (agentProfileId: string, enabled: boolean) => void; + confirmingRemove: boolean; + onRemove: () => void; + onCancelRemove: () => void; }) { return (
- - - -
- {customization.instructions.length > 0 && ( - - )} - {customization.agentProfiles.length > 0 && ( - - )} - {customization.promptFiles.length > 0 && ( - - )} -
-
- ); -} - -function CustomizationInstructionsList({ instructions }: { instructions: ProjectInstructionFile[] }) { - return ( -
-

Instructions

-
- {instructions.map((instruction) => ( -
+
+

Remove project

+

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

+
+
- ))} -
-
- ); -} - -function CustomizationAgentsList({ - agents, - onSetEnabled, -}: { - agents: ProjectAgentProfile[]; - onSetEnabled: (agentProfileId: string, enabled: boolean) => void; -}) { - return ( -
-

Custom Agents

-
- {agents.map((agent) => ( -
- -
-
- - {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}

-
+ + {confirmingRemove ? 'Confirm removal' : 'Remove project'} + + {confirmingRemove && ( -
- ))} -
-
- ); -} - -function CustomizationPromptsList({ promptFiles }: { promptFiles: ProjectPromptFile[] }) { - return ( -
-

Prompt Files

-
- {promptFiles.map((prompt) => ( -
-
- - {prompt.name} - {prompt.variables.length > 0 && ( - - {prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'} - - )} -
- {prompt.description && ( -

{prompt.description}

- )} -

{prompt.sourcePath}

-
- ))} + )} +
); @@ -523,7 +750,7 @@ function SectionHeader({ }: { title: string; description: string; - children?: React.ReactNode; + children?: ReactNode; }) { return (
@@ -535,3 +762,25 @@ function SectionHeader({
); } + +function RescanButton({ onClick, label = 'Re-scan' }: { onClick: () => void; label?: string }) { + return ( + + ); +} + +function EmptyState({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +}