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:
David Kaya
2026-03-28 17:59:31 +01:00
co-authored by Copilot
parent dd203ddde5
commit 5ad85db0f5
+534 -285
View File
@@ -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<ProjectSettingsSection>('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: <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 (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
{/* Header */}
@@ -56,105 +121,114 @@ export function ProjectSettingsPanel({
>
<ChevronLeft className="size-4" />
</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>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl px-8 py-6 space-y-8">
{/* Project info */}
<ProjectInfoSection project={project} />
{/* Sidebar + Content */}
<div className="flex min-h-0 flex-1">
{/* Navigation sidebar */}
<nav className="w-52 shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface-1)] p-3">
<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 */}
{hasDiscoveredServers ? (
<DiscoveredServersSection
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"
>
{/* Danger zone at the bottom */}
<div className="border-t border-zinc-800 pt-3">
<div className="space-y-0.5">
<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={onRescanConfigs}
title="Scan project config files for MCP servers"
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] transition ${
activeSection === 'danger-zone'
? '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"
>
<RefreshCw className="size-3.5" />
Scan
<Trash2 className="size-3.5" />
<span className="flex-1 truncate">Danger Zone</span>
</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>
</nav>
{/* Copilot Customization */}
{hasCustomization ? (
<CustomizationSection
customization={customization!}
onRescan={onRescanCustomization}
onSetAgentProfileEnabled={onSetAgentProfileEnabled}
/>
) : (
<div>
<SectionHeader
description="Instructions, custom agents, and prompt files discovered from .github/ and AGENTS.md."
title="Copilot Customization"
>
<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={onRescanCustomization}
title="Scan for Copilot customization files"
type="button"
>
<RefreshCw className="size-3.5" />
Scan
</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 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.
</div>
</div>
)}
{/* Remove project */}
<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">
Removing a project deletes all its sessions and discovered tooling from Aryx.
Your project files on disk are not affected.
</p>
<div className="mt-4 flex items-center gap-3">
<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>
{/* Content panel */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl px-8 py-6">
{activeSection === 'overview' && (
<OverviewContent project={project} />
)}
{activeSection === 'instructions' && (
<InstructionsContent
instructions={instructions}
onRescan={onRescanCustomization}
/>
)}
{activeSection === 'agents' && (
<AgentsContent
agents={agentProfiles}
onRescan={onRescanCustomization}
onSetEnabled={onSetAgentProfileEnabled}
/>
)}
{activeSection === 'prompts' && (
<PromptsContent
onRescan={onRescanCustomization}
promptFiles={promptFiles}
/>
)}
{activeSection === 'mcp-servers' && (
<McpServersContent
accepted={acceptedServers}
onRescan={onRescanConfigs}
onResolve={onResolveDiscoveredTooling}
pending={pendingServers}
/>
)}
{activeSection === 'danger-zone' && (
<DangerZoneContent
confirmingRemove={confirmingRemove}
onCancelRemove={() => setConfirmingRemove(false)}
onRemove={handleRemove}
/>
)}
</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 (
<div>
<SectionHeader
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="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,
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 (
<div>
<SectionHeader
description="MCP servers discovered from project config files (.vscode/mcp.json, .mcp.json, .copilot/mcp.json)."
title="Discovered MCP Servers"
title="MCP Servers"
>
<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={onRescan}
title="Re-scan project config files"
type="button"
>
<RefreshCw className="size-3.5" />
Re-scan
</button>
<RescanButton label={hasServers ? 'Re-scan' : 'Scan'} onClick={onRescan} />
</SectionHeader>
<div className="space-y-1">
{accepted.map((server) => (
<DiscoveredServerRow
key={server.id}
onDismiss={() => onResolve([server.id], 'dismiss')}
server={server}
status="accepted"
/>
))}
{pending.map((server) => (
<DiscoveredServerRow
key={server.id}
onAccept={() => onResolve([server.id], 'accept')}
onDismiss={() => onResolve([server.id], 'dismiss')}
server={server}
status="pending"
/>
))}
</div>
{!hasServers ? (
<EmptyState>
No MCP servers discovered. Click Scan to check project config files.
</EmptyState>
) : (
<>
<div className="space-y-1">
{accepted.map((server) => (
<DiscoveredServerRow
key={server.id}
onDismiss={() => onResolve([server.id], 'dismiss')}
server={server}
status="accepted"
/>
))}
{pending.map((server) => (
<DiscoveredServerRow
key={server.id}
onAccept={() => onResolve([server.id], 'accept')}
onDismiss={() => onResolve([server.id], 'dismiss')}
server={server}
status="pending"
/>
))}
</div>
{pending.length > 1 && (
<div className="mt-3 flex items-center gap-2">
<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"
onClick={() => onResolve(pending.map((s) => s.id), 'accept')}
type="button"
>
Accept all ({pending.length})
</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"
onClick={() => onResolve(pending.map((s) => s.id), 'dismiss')}
type="button"
>
Dismiss all
</button>
</div>
{pending.length > 1 && (
<div className="mt-3 flex items-center gap-2">
<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"
onClick={() => onResolve(pending.map((s) => s.id), 'accept')}
type="button"
>
Accept all ({pending.length})
</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"
onClick={() => onResolve(pending.map((s) => s.id), 'dismiss')}
type="button"
>
Dismiss all
</button>
</div>
)}
</>
)}
</div>
);
@@ -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 (
<div>
<SectionHeader
description="Instructions, custom agents, and prompt files discovered from .github/ and AGENTS.md."
title="Copilot Customization"
>
<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={onRescan}
title="Re-scan for Copilot customization files"
type="button"
>
<RefreshCw className="size-3.5" />
Re-scan
</button>
</SectionHeader>
<div className="space-y-4">
{customization.instructions.length > 0 && (
<CustomizationInstructionsList instructions={customization.instructions} />
)}
{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"
description="Irreversible actions for this project."
title="Danger Zone"
/>
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-5 py-5">
<h4 className="text-[13px] font-semibold text-zinc-200">Remove project</h4>
<p className="mt-1 text-[12px] text-zinc-500">
Removing a project deletes all its sessions and discovered tooling from Aryx.
Your project files on disk are not affected.
</p>
<div className="mt-4 flex items-center gap-3">
<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={onRemove}
type="button"
>
<div className="flex items-center gap-2">
<FileCode2 className="size-3.5 shrink-0 text-indigo-400" />
<span className="text-[12px] font-medium text-zinc-200">{instruction.sourcePath}</span>
</div>
<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>
<Trash2 className="size-3.5" />
{confirmingRemove ? 'Confirm removal' : 'Remove project'}
</button>
{confirmingRemove && (
<button
aria-label={agent.enabled ? `Disable ${agent.name}` : `Enable ${agent.name}`}
aria-pressed={agent.enabled}
className="shrink-0"
onClick={() => onSetEnabled(agent.id, !agent.enabled)}
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={onCancelRemove}
type="button"
>
<ToggleSwitch enabled={agent.enabled} size="sm" />
Cancel
</button>
</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>
);
@@ -523,7 +750,7 @@ function SectionHeader({
}: {
title: string;
description: string;
children?: React.ReactNode;
children?: ReactNode;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3">
@@ -535,3 +762,25 @@ function SectionHeader({
</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>
);
}