mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-29 22:17:13 +02:00
refactor: redesign project settings with sidebar navigation
Replace flat scrolling layout with sidebar + content panel matching the global SettingsPanel pattern. Sections: Overview, Instructions (with expandable previews), Custom Agents (with enable/disable), Prompt Files, MCP Servers, Danger Zone. Add count badges, pending indicators, project name in header, section-level rescan buttons, and contextual empty states. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,11 +1,26 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { ChevronLeft, FileCode2, FileText, FolderOpen, GitBranch, RefreshCw, Server, Sparkles, Trash2, AlertTriangle, Circle } from 'lucide-react';
|
import { ChevronDown, ChevronLeft, FileCode2, FileText, FolderOpen, GitBranch, RefreshCw, Server, Sparkles, Trash2, AlertTriangle, Circle } from 'lucide-react';
|
||||||
|
|
||||||
import { ToggleSwitch } from '@renderer/components/ui';
|
import { ToggleSwitch } from '@renderer/components/ui';
|
||||||
import type { ProjectRecord, ProjectGitContext } from '@shared/domain/project';
|
import type { ProjectRecord, ProjectGitContext } from '@shared/domain/project';
|
||||||
import type { DiscoveredMcpServer } from '@shared/domain/discoveredTooling';
|
import type { DiscoveredMcpServer } from '@shared/domain/discoveredTooling';
|
||||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } 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 {
|
interface ProjectSettingsPanelProps {
|
||||||
project: ProjectRecord;
|
project: ProjectRecord;
|
||||||
@@ -17,6 +32,8 @@ interface ProjectSettingsPanelProps {
|
|||||||
onRemoveProject: () => void;
|
onRemoveProject: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Main component ───────────────────────────────────────── */
|
||||||
|
|
||||||
export function ProjectSettingsPanel({
|
export function ProjectSettingsPanel({
|
||||||
project,
|
project,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -26,16 +43,15 @@ export function ProjectSettingsPanel({
|
|||||||
onSetAgentProfileEnabled,
|
onSetAgentProfileEnabled,
|
||||||
onRemoveProject,
|
onRemoveProject,
|
||||||
}: ProjectSettingsPanelProps) {
|
}: ProjectSettingsPanelProps) {
|
||||||
|
const [activeSection, setActiveSection] = useState<ProjectSettingsSection>('overview');
|
||||||
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
||||||
|
|
||||||
const acceptedServers = listAcceptedDiscoveredMcpServers(project.discoveredTooling);
|
const acceptedServers = useMemo(() => listAcceptedDiscoveredMcpServers(project.discoveredTooling), [project.discoveredTooling]);
|
||||||
const pendingServers = listPendingDiscoveredMcpServers(project.discoveredTooling);
|
const pendingServers = useMemo(() => listPendingDiscoveredMcpServers(project.discoveredTooling), [project.discoveredTooling]);
|
||||||
const hasDiscoveredServers = acceptedServers.length + pendingServers.length > 0;
|
const instructions = project.customization?.instructions ?? [];
|
||||||
const customization = project.customization;
|
const agentProfiles = project.customization?.agentProfiles ?? [];
|
||||||
const hasCustomization =
|
const promptFiles = project.customization?.promptFiles ?? [];
|
||||||
(customization?.instructions?.length ?? 0) > 0 ||
|
const enabledAgentCount = agentProfiles.filter((a) => a.enabled).length;
|
||||||
(customization?.agentProfiles?.length ?? 0) > 0 ||
|
|
||||||
(customization?.promptFiles?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
const handleRemove = useCallback(() => {
|
const handleRemove = useCallback(() => {
|
||||||
if (!confirmingRemove) {
|
if (!confirmingRemove) {
|
||||||
@@ -45,6 +61,55 @@ export function ProjectSettingsPanel({
|
|||||||
onRemoveProject();
|
onRemoveProject();
|
||||||
}, [confirmingRemove, onRemoveProject]);
|
}, [confirmingRemove, onRemoveProject]);
|
||||||
|
|
||||||
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: 'Project',
|
||||||
|
items: [
|
||||||
|
{ id: 'overview', label: 'Overview', icon: <FolderOpen className="size-3.5" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copilot',
|
||||||
|
items: [
|
||||||
|
{ id: 'instructions', label: 'Instructions', icon: <FileCode2 className="size-3.5" /> },
|
||||||
|
{ id: 'agents', label: 'Custom Agents', icon: <Sparkles className="size-3.5" /> },
|
||||||
|
{ id: 'prompts', label: 'Prompt Files', icon: <FileText className="size-3.5" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tooling',
|
||||||
|
items: [
|
||||||
|
{ id: 'mcp-servers', label: 'MCP Servers', icon: <Server className="size-3.5" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function sectionBadge(section: ProjectSettingsSection): ReactNode {
|
||||||
|
switch (section) {
|
||||||
|
case 'instructions':
|
||||||
|
return instructions.length > 0
|
||||||
|
? <CountBadge count={instructions.length} />
|
||||||
|
: null;
|
||||||
|
case 'agents':
|
||||||
|
return agentProfiles.length > 0
|
||||||
|
? <CountBadge count={enabledAgentCount} total={agentProfiles.length} />
|
||||||
|
: null;
|
||||||
|
case 'prompts':
|
||||||
|
return promptFiles.length > 0
|
||||||
|
? <CountBadge count={promptFiles.length} />
|
||||||
|
: null;
|
||||||
|
case 'mcp-servers': {
|
||||||
|
if (pendingServers.length > 0) {
|
||||||
|
return <PendingBadge count={pendingServers.length} />;
|
||||||
|
}
|
||||||
|
const totalServers = acceptedServers.length + pendingServers.length;
|
||||||
|
return totalServers > 0 ? <CountBadge count={totalServers} /> : null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -56,105 +121,114 @@ export function ProjectSettingsPanel({
|
|||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-[13px] font-semibold text-zinc-100">Project Settings</h2>
|
<h2 className="text-[13px] font-semibold text-zinc-100">
|
||||||
|
Project Settings
|
||||||
|
<span className="ml-2 font-normal text-zinc-500">·</span>
|
||||||
|
<span className="ml-2 font-normal text-zinc-400">{project.name}</span>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Sidebar + Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex min-h-0 flex-1">
|
||||||
<div className="mx-auto max-w-2xl px-8 py-6 space-y-8">
|
{/* Navigation sidebar */}
|
||||||
{/* Project info */}
|
<nav className="w-52 shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface-1)] p-3">
|
||||||
<ProjectInfoSection project={project} />
|
<div className="space-y-4">
|
||||||
|
{navGroups.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<span className="mb-1 block px-3 text-[10px] font-semibold uppercase tracking-wider text-zinc-600">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const isActive = item.id === activeSection;
|
||||||
|
const badge = sectionBadge(item.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-zinc-800 font-medium text-zinc-100'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSection(item.id);
|
||||||
|
setConfirmingRemove(false);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={isActive ? 'text-zinc-300' : 'text-zinc-500'}>{item.icon}</span>
|
||||||
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
|
{badge}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Discovered MCP Servers */}
|
{/* Danger zone at the bottom */}
|
||||||
{hasDiscoveredServers ? (
|
<div className="border-t border-zinc-800 pt-3">
|
||||||
<DiscoveredServersSection
|
<div className="space-y-0.5">
|
||||||
accepted={acceptedServers}
|
|
||||||
onRescan={onRescanConfigs}
|
|
||||||
onResolve={onResolveDiscoveredTooling}
|
|
||||||
pending={pendingServers}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<SectionHeader
|
|
||||||
description="MCP servers discovered from project config files (.vscode/mcp.json, .mcp.json, .copilot/mcp.json)."
|
|
||||||
title="Discovered MCP Servers"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
|
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] transition ${
|
||||||
onClick={onRescanConfigs}
|
activeSection === 'danger-zone'
|
||||||
title="Scan project config files for MCP servers"
|
? 'bg-zinc-800 font-medium text-red-400'
|
||||||
|
: 'text-zinc-500 hover:bg-zinc-800/50 hover:text-red-400'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSection('danger-zone');
|
||||||
|
setConfirmingRemove(false);
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
Scan
|
<span className="flex-1 truncate">Danger Zone</span>
|
||||||
</button>
|
</button>
|
||||||
</SectionHeader>
|
|
||||||
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/20 px-5 py-8 text-center text-[12px] leading-relaxed text-zinc-500">
|
|
||||||
No MCP servers discovered in this project yet. Click Scan to check project config files.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Copilot Customization */}
|
{/* Content panel */}
|
||||||
{hasCustomization ? (
|
<div className="flex-1 overflow-y-auto">
|
||||||
<CustomizationSection
|
<div className="mx-auto max-w-2xl px-8 py-6">
|
||||||
customization={customization!}
|
{activeSection === 'overview' && (
|
||||||
onRescan={onRescanCustomization}
|
<OverviewContent project={project} />
|
||||||
onSetAgentProfileEnabled={onSetAgentProfileEnabled}
|
)}
|
||||||
/>
|
{activeSection === 'instructions' && (
|
||||||
) : (
|
<InstructionsContent
|
||||||
<div>
|
instructions={instructions}
|
||||||
<SectionHeader
|
onRescan={onRescanCustomization}
|
||||||
description="Instructions, custom agents, and prompt files discovered from .github/ and AGENTS.md."
|
/>
|
||||||
title="Copilot Customization"
|
)}
|
||||||
>
|
{activeSection === 'agents' && (
|
||||||
<button
|
<AgentsContent
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
|
agents={agentProfiles}
|
||||||
onClick={onRescanCustomization}
|
onRescan={onRescanCustomization}
|
||||||
title="Scan for Copilot customization files"
|
onSetEnabled={onSetAgentProfileEnabled}
|
||||||
type="button"
|
/>
|
||||||
>
|
)}
|
||||||
<RefreshCw className="size-3.5" />
|
{activeSection === 'prompts' && (
|
||||||
Scan
|
<PromptsContent
|
||||||
</button>
|
onRescan={onRescanCustomization}
|
||||||
</SectionHeader>
|
promptFiles={promptFiles}
|
||||||
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/20 px-5 py-8 text-center text-[12px] leading-relaxed text-zinc-500">
|
/>
|
||||||
No customization files found. Add <code className="text-zinc-400">.github/copilot-instructions.md</code>, <code className="text-zinc-400">AGENTS.md</code>, or files
|
)}
|
||||||
in <code className="text-zinc-400">.github/agents/</code> and <code className="text-zinc-400">.github/prompts/</code> to customize Copilot behavior.
|
{activeSection === 'mcp-servers' && (
|
||||||
</div>
|
<McpServersContent
|
||||||
</div>
|
accepted={acceptedServers}
|
||||||
)}
|
onRescan={onRescanConfigs}
|
||||||
|
onResolve={onResolveDiscoveredTooling}
|
||||||
{/* Remove project */}
|
pending={pendingServers}
|
||||||
<div className="border-t border-zinc-800 pt-6">
|
/>
|
||||||
<h3 className="text-[13px] font-semibold text-zinc-200">Danger zone</h3>
|
)}
|
||||||
<p className="mt-1 text-[12px] text-zinc-500">
|
{activeSection === 'danger-zone' && (
|
||||||
Removing a project deletes all its sessions and discovered tooling from Aryx.
|
<DangerZoneContent
|
||||||
Your project files on disk are not affected.
|
confirmingRemove={confirmingRemove}
|
||||||
</p>
|
onCancelRemove={() => setConfirmingRemove(false)}
|
||||||
<div className="mt-4 flex items-center gap-3">
|
onRemove={handleRemove}
|
||||||
<button
|
/>
|
||||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] font-medium transition ${
|
)}
|
||||||
confirmingRemove
|
|
||||||
? 'bg-red-600 text-white hover:bg-red-500'
|
|
||||||
: 'bg-red-500/10 text-red-400 hover:bg-red-500/20'
|
|
||||||
}`}
|
|
||||||
onClick={handleRemove}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
{confirmingRemove ? 'Confirm removal' : 'Remove project'}
|
|
||||||
</button>
|
|
||||||
{confirmingRemove && (
|
|
||||||
<button
|
|
||||||
className="rounded-lg px-3 py-1.5 text-[13px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
|
||||||
onClick={() => setConfirmingRemove(false)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
||||||
|
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">
|
||||||
|
{total !== undefined ? `${count}/${total}` : count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingBadge({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">
|
||||||
|
{count} new
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Overview ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function OverviewContent({ project }: { project: ProjectRecord }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
description="Project details and git status."
|
description="Project details and git status."
|
||||||
title="Project Info"
|
title="Overview"
|
||||||
/>
|
/>
|
||||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-5 py-4 space-y-3">
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-5 py-4 space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -231,9 +323,240 @@ function ProjectGitInfo({ git }: { git: ProjectGitContext }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Discovered MCP servers ──────────────────────────────── */
|
/* ── Instructions ─────────────────────────────────────────── */
|
||||||
|
|
||||||
function DiscoveredServersSection({
|
function InstructionsContent({
|
||||||
|
instructions,
|
||||||
|
onRescan,
|
||||||
|
}: {
|
||||||
|
instructions: ProjectInstructionFile[];
|
||||||
|
onRescan: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
description="Repository instructions automatically included in every session. Discovered from .github/copilot-instructions.md and AGENTS.md."
|
||||||
|
title="Instructions"
|
||||||
|
>
|
||||||
|
<RescanButton onClick={onRescan} />
|
||||||
|
</SectionHeader>
|
||||||
|
|
||||||
|
{instructions.length === 0 ? (
|
||||||
|
<EmptyState>
|
||||||
|
No instruction files found. Add a <code className="text-zinc-400">.github/copilot-instructions.md</code> or <code className="text-zinc-400">AGENTS.md</code> file to your project root.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{instructions.map((instruction) => (
|
||||||
|
<InstructionCard key={instruction.id} instruction={instruction} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstructionCard({ instruction }: { instruction: ProjectInstructionFile }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const isLong = instruction.content.length > 300;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40">
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-3 px-5 py-3.5 text-left transition hover:bg-zinc-900/60"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<FileCode2 className="size-4 shrink-0 text-indigo-400" />
|
||||||
|
<span className="flex-1 text-[13px] font-medium text-zinc-200">{instruction.sourcePath}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`size-3.5 shrink-0 text-zinc-500 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-zinc-800 px-5 py-4">
|
||||||
|
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-zinc-400">
|
||||||
|
{instruction.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!expanded && (
|
||||||
|
<div className="px-5 pb-3">
|
||||||
|
<p className={`text-[11px] leading-relaxed text-zinc-500 ${isLong ? 'line-clamp-2' : ''}`}>
|
||||||
|
{instruction.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 (
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
description="Custom agent profiles discovered from .github/agents/*.agent.md. Enable or disable individual agents."
|
||||||
|
title="Custom Agents"
|
||||||
|
>
|
||||||
|
<RescanButton onClick={onRescan} />
|
||||||
|
</SectionHeader>
|
||||||
|
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<EmptyState>
|
||||||
|
No custom agents found. Add <code className="text-zinc-400">.agent.md</code> files to <code className="text-zinc-400">.github/agents/</code> in your project.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{agents.length > 1 && (
|
||||||
|
<div className="mb-3 text-[11px] text-zinc-500">
|
||||||
|
{enabledCount} of {agents.length} agent{agents.length === 1 ? '' : 's'} enabled
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<AgentCard
|
||||||
|
key={agent.id}
|
||||||
|
agent={agent}
|
||||||
|
onToggle={() => onSetEnabled(agent.id, !agent.enabled)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentCard({
|
||||||
|
agent,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
agent: ProjectAgentProfile;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border px-5 py-4 transition ${
|
||||||
|
agent.enabled
|
||||||
|
? 'border-zinc-800 bg-zinc-900/40'
|
||||||
|
: 'border-zinc-800/50 bg-zinc-900/20 opacity-60'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Sparkles className={`mt-0.5 size-4 shrink-0 ${agent.enabled ? 'text-amber-400' : 'text-zinc-600'}`} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-zinc-200">
|
||||||
|
{agent.displayName ?? agent.name}
|
||||||
|
</span>
|
||||||
|
{agent.tools && agent.tools.length > 0 && (
|
||||||
|
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium text-zinc-500">
|
||||||
|
{agent.tools.length} tool{agent.tools.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{agent.description && (
|
||||||
|
<p className="mt-1 text-[12px] leading-relaxed text-zinc-500">{agent.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-[11px] text-zinc-600">{agent.sourcePath}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label={agent.enabled ? `Disable ${agent.name}` : `Enable ${agent.name}`}
|
||||||
|
aria-pressed={agent.enabled}
|
||||||
|
className="mt-0.5 shrink-0"
|
||||||
|
onClick={onToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ToggleSwitch enabled={agent.enabled} size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Prompt Files ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function PromptsContent({
|
||||||
|
promptFiles,
|
||||||
|
onRescan,
|
||||||
|
}: {
|
||||||
|
promptFiles: ProjectPromptFile[];
|
||||||
|
onRescan: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
description="Reusable prompt templates discovered from .github/prompts/*.prompt.md. Use them from the Prompts pill in the chat input."
|
||||||
|
title="Prompt Files"
|
||||||
|
>
|
||||||
|
<RescanButton onClick={onRescan} />
|
||||||
|
</SectionHeader>
|
||||||
|
|
||||||
|
{promptFiles.length === 0 ? (
|
||||||
|
<EmptyState>
|
||||||
|
No prompt files found. Add <code className="text-zinc-400">.prompt.md</code> files to <code className="text-zinc-400">.github/prompts/</code> in your project.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{promptFiles.map((prompt) => (
|
||||||
|
<PromptCard key={prompt.id} prompt={prompt} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-5 py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText className="mt-0.5 size-4 shrink-0 text-emerald-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-zinc-200">{prompt.name}</span>
|
||||||
|
{prompt.variables.length > 0 && (
|
||||||
|
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium text-zinc-500">
|
||||||
|
{prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{prompt.description && (
|
||||||
|
<p className="mt-1 text-[12px] leading-relaxed text-zinc-500">{prompt.description}</p>
|
||||||
|
)}
|
||||||
|
{prompt.variables.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{prompt.variables.map((v) => (
|
||||||
|
<span
|
||||||
|
key={v.name}
|
||||||
|
className="rounded-md bg-zinc-800 px-2 py-0.5 text-[10px] font-medium text-zinc-400"
|
||||||
|
title={v.placeholder}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-1.5 text-[11px] text-zinc-600">{prompt.sourcePath}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MCP Servers ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function McpServersContent({
|
||||||
accepted,
|
accepted,
|
||||||
pending,
|
pending,
|
||||||
onRescan,
|
onRescan,
|
||||||
@@ -244,60 +567,62 @@ function DiscoveredServersSection({
|
|||||||
onRescan: () => void;
|
onRescan: () => void;
|
||||||
onResolve: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
onResolve: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
||||||
}) {
|
}) {
|
||||||
|
const hasServers = accepted.length + pending.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
description="MCP servers discovered from project config files (.vscode/mcp.json, .mcp.json, .copilot/mcp.json)."
|
description="MCP servers discovered from project config files (.vscode/mcp.json, .mcp.json, .copilot/mcp.json)."
|
||||||
title="Discovered MCP Servers"
|
title="MCP Servers"
|
||||||
>
|
>
|
||||||
<button
|
<RescanButton label={hasServers ? 'Re-scan' : 'Scan'} onClick={onRescan} />
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
|
|
||||||
onClick={onRescan}
|
|
||||||
title="Re-scan project config files"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3.5" />
|
|
||||||
Re-scan
|
|
||||||
</button>
|
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
|
||||||
<div className="space-y-1">
|
{!hasServers ? (
|
||||||
{accepted.map((server) => (
|
<EmptyState>
|
||||||
<DiscoveredServerRow
|
No MCP servers discovered. Click Scan to check project config files.
|
||||||
key={server.id}
|
</EmptyState>
|
||||||
onDismiss={() => onResolve([server.id], 'dismiss')}
|
) : (
|
||||||
server={server}
|
<>
|
||||||
status="accepted"
|
<div className="space-y-1">
|
||||||
/>
|
{accepted.map((server) => (
|
||||||
))}
|
<DiscoveredServerRow
|
||||||
{pending.map((server) => (
|
key={server.id}
|
||||||
<DiscoveredServerRow
|
onDismiss={() => onResolve([server.id], 'dismiss')}
|
||||||
key={server.id}
|
server={server}
|
||||||
onAccept={() => onResolve([server.id], 'accept')}
|
status="accepted"
|
||||||
onDismiss={() => onResolve([server.id], 'dismiss')}
|
/>
|
||||||
server={server}
|
))}
|
||||||
status="pending"
|
{pending.map((server) => (
|
||||||
/>
|
<DiscoveredServerRow
|
||||||
))}
|
key={server.id}
|
||||||
</div>
|
onAccept={() => onResolve([server.id], 'accept')}
|
||||||
|
onDismiss={() => onResolve([server.id], 'dismiss')}
|
||||||
|
server={server}
|
||||||
|
status="pending"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{pending.length > 1 && (
|
{pending.length > 1 && (
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
className="rounded-lg bg-emerald-500/10 px-3 py-1.5 text-[12px] font-medium text-emerald-400 transition hover:bg-emerald-500/20"
|
className="rounded-lg bg-emerald-500/10 px-3 py-1.5 text-[12px] font-medium text-emerald-400 transition hover:bg-emerald-500/20"
|
||||||
onClick={() => onResolve(pending.map((s) => s.id), 'accept')}
|
onClick={() => onResolve(pending.map((s) => s.id), 'accept')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Accept all ({pending.length})
|
Accept all ({pending.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded-lg px-3 py-1.5 text-[12px] font-medium text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
|
className="rounded-lg px-3 py-1.5 text-[12px] font-medium text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
|
||||||
onClick={() => onResolve(pending.map((s) => s.id), 'dismiss')}
|
onClick={() => onResolve(pending.map((s) => s.id), 'dismiss')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Dismiss all
|
Dismiss all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -365,150 +690,52 @@ function DiscoveredServerRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Copilot Customization ────────────────────────────────── */
|
/* ── Danger Zone ──────────────────────────────────────────── */
|
||||||
|
|
||||||
function CustomizationSection({
|
function DangerZoneContent({
|
||||||
customization,
|
confirmingRemove,
|
||||||
onRescan,
|
onRemove,
|
||||||
onSetAgentProfileEnabled,
|
onCancelRemove,
|
||||||
}: {
|
}: {
|
||||||
customization: ProjectCustomizationState;
|
confirmingRemove: boolean;
|
||||||
onRescan: () => void;
|
onRemove: () => void;
|
||||||
onSetAgentProfileEnabled: (agentProfileId: string, enabled: boolean) => void;
|
onCancelRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
description="Instructions, custom agents, and prompt files discovered from .github/ and AGENTS.md."
|
description="Irreversible actions for this project."
|
||||||
title="Copilot Customization"
|
title="Danger Zone"
|
||||||
>
|
/>
|
||||||
<button
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-5 py-5">
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
|
<h4 className="text-[13px] font-semibold text-zinc-200">Remove project</h4>
|
||||||
onClick={onRescan}
|
<p className="mt-1 text-[12px] text-zinc-500">
|
||||||
title="Re-scan for Copilot customization files"
|
Removing a project deletes all its sessions and discovered tooling from Aryx.
|
||||||
type="button"
|
Your project files on disk are not affected.
|
||||||
>
|
</p>
|
||||||
<RefreshCw className="size-3.5" />
|
<div className="mt-4 flex items-center gap-3">
|
||||||
Re-scan
|
<button
|
||||||
</button>
|
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] font-medium transition ${
|
||||||
</SectionHeader>
|
confirmingRemove
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-500'
|
||||||
<div className="space-y-4">
|
: 'bg-red-500/10 text-red-400 hover:bg-red-500/20'
|
||||||
{customization.instructions.length > 0 && (
|
}`}
|
||||||
<CustomizationInstructionsList instructions={customization.instructions} />
|
onClick={onRemove}
|
||||||
)}
|
type="button"
|
||||||
{customization.agentProfiles.length > 0 && (
|
|
||||||
<CustomizationAgentsList
|
|
||||||
agents={customization.agentProfiles}
|
|
||||||
onSetEnabled={onSetAgentProfileEnabled}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{customization.promptFiles.length > 0 && (
|
|
||||||
<CustomizationPromptsList promptFiles={customization.promptFiles} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomizationInstructionsList({ instructions }: { instructions: ProjectInstructionFile[] }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-[12px] font-medium text-zinc-400">Instructions</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{instructions.map((instruction) => (
|
|
||||||
<div
|
|
||||||
key={instruction.id}
|
|
||||||
className="rounded-xl border border-zinc-800 bg-zinc-900/40 px-4 py-3"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Trash2 className="size-3.5" />
|
||||||
<FileCode2 className="size-3.5 shrink-0 text-indigo-400" />
|
{confirmingRemove ? 'Confirm removal' : 'Remove project'}
|
||||||
<span className="text-[12px] font-medium text-zinc-200">{instruction.sourcePath}</span>
|
</button>
|
||||||
</div>
|
{confirmingRemove && (
|
||||||
<p className="mt-1.5 line-clamp-3 text-[11px] leading-relaxed text-zinc-500">
|
|
||||||
{instruction.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomizationAgentsList({
|
|
||||||
agents,
|
|
||||||
onSetEnabled,
|
|
||||||
}: {
|
|
||||||
agents: ProjectAgentProfile[];
|
|
||||||
onSetEnabled: (agentProfileId: string, enabled: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-[12px] font-medium text-zinc-400">Custom Agents</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{agents.map((agent) => (
|
|
||||||
<div
|
|
||||||
key={agent.id}
|
|
||||||
className="flex items-center gap-3 rounded-xl border border-transparent px-4 py-3 hover:border-zinc-800 hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
<Sparkles className="size-4 shrink-0 text-amber-400" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="truncate text-[13px] font-medium text-zinc-200">
|
|
||||||
{agent.displayName ?? agent.name}
|
|
||||||
</span>
|
|
||||||
{agent.tools && agent.tools.length > 0 && (
|
|
||||||
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium text-zinc-400">
|
|
||||||
{agent.tools.length} tool{agent.tools.length === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{agent.description && (
|
|
||||||
<p className="mt-0.5 truncate text-[12px] text-zinc-500">{agent.description}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-0.5 truncate text-[11px] text-zinc-600">{agent.sourcePath}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
aria-label={agent.enabled ? `Disable ${agent.name}` : `Enable ${agent.name}`}
|
className="rounded-lg px-3 py-1.5 text-[13px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
aria-pressed={agent.enabled}
|
onClick={onCancelRemove}
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => onSetEnabled(agent.id, !agent.enabled)}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ToggleSwitch enabled={agent.enabled} size="sm" />
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CustomizationPromptsList({ promptFiles }: { promptFiles: ProjectPromptFile[] }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-[12px] font-medium text-zinc-400">Prompt Files</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{promptFiles.map((prompt) => (
|
|
||||||
<div
|
|
||||||
key={prompt.id}
|
|
||||||
className="rounded-xl border border-transparent px-4 py-3 hover:border-zinc-800 hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="size-3.5 shrink-0 text-emerald-400" />
|
|
||||||
<span className="text-[13px] font-medium text-zinc-200">{prompt.name}</span>
|
|
||||||
{prompt.variables.length > 0 && (
|
|
||||||
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium text-zinc-400">
|
|
||||||
{prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{prompt.description && (
|
|
||||||
<p className="mt-0.5 text-[12px] text-zinc-500">{prompt.description}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-0.5 truncate text-[11px] text-zinc-600">{prompt.sourcePath}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -523,7 +750,7 @@ function SectionHeader({
|
|||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
children?: React.ReactNode;
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
@@ -535,3 +762,25 @@ function SectionHeader({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RescanButton({ onClick, label = 'Re-scan' }: { onClick: () => void; label?: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
|
||||||
|
onClick={onClick}
|
||||||
|
title={label === 'Scan' ? 'Scan for files' : 'Re-scan for changes'}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/20 px-5 py-8 text-center text-[12px] leading-relaxed text-zinc-500">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user