Files
aryx/src/renderer/components/ProjectSettingsPanel.tsx
T
David KayaandCopilot a18803758b feat: phase 3 frontend — model badges, argument-hint composer, ancestor path display
Surface prompt model and argumentHint metadata across the UI:

- InlinePromptPill: send model in promptInvocation, show model badge
  in dropdown, show argumentHint preview, support armed-prompt state
  for prompts with argumentHint (prompt stays pinned, hint becomes
  composer placeholder, user types additional context before sending)
- ChatPane: armed-prompt state management, argumentHint-driven
  placeholder, send button enabled when prompt is armed, clear armed
  state on session change
- PromptInvocationChrome: model badge in transcript, parent-repo badge
  and shortened source path display for ancestor-relative paths
- ProjectSettingsPanel: model and argumentHint on prompt cards,
  parent-repo badge on instructions and prompts with ancestor paths,
  .claude/rules mentioned in instructions description and empty state

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 12:53:38 +02:00

889 lines
34 KiB
TypeScript

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<ProjectSettingsSection>('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: <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="overlay-slide-enter fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
{/* Header */}
<div className="drag-region flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-3">
<button
className="no-drag flex size-8 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={onClose}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<h2 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
Project Settings
<span className="ml-2 font-normal text-[var(--color-text-muted)]">·</span>
<span className="ml-2 font-normal text-[var(--color-text-secondary)]">{project.name}</span>
</h2>
</div>
{/* 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-[var(--color-text-muted)]">
{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-all duration-200 ${
isActive
? 'bg-[var(--color-surface-3)] font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-3)]/50 hover:text-[var(--color-text-secondary)]'
}`}
key={item.id}
onClick={() => {
setActiveSection(item.id);
setConfirmingRemove(false);
}}
type="button"
>
<span className={isActive ? 'text-[var(--color-text-secondary)]' : 'text-[var(--color-text-muted)]'}>{item.icon}</span>
<span className="flex-1 truncate">{item.label}</span>
{badge}
</button>
);
})}
</div>
</div>
))}
{/* Danger zone at the bottom */}
<div className="border-t border-[var(--color-border)] pt-3">
<div className="space-y-0.5">
<button
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] transition-all duration-200 ${
activeSection === 'danger-zone'
? 'bg-[var(--color-surface-3)] font-medium text-[var(--color-status-error)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)]/50 hover:text-[var(--color-status-error)]'
}`}
onClick={() => {
setActiveSection('danger-zone');
setConfirmingRemove(false);
}}
type="button"
>
<Trash2 className="size-3.5" />
<span className="flex-1 truncate">Danger Zone</span>
</button>
</div>
</div>
</div>
</nav>
{/* 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>
</div>
);
}
/* ── Nav badges ───────────────────────────────────────────── */
function CountBadge({ count, total }: { count: number; total?: number }) {
return (
<span className="rounded-full bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{total !== undefined ? `${count}/${total}` : count}
</span>
);
}
function PendingBadge({ count }: { count: number }) {
return (
<span className="rounded-full bg-[var(--color-status-warning)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
{count} new
</span>
);
}
/* ── Overview ─────────────────────────────────────────────── */
function OverviewContent({ project }: { project: ProjectRecord }) {
return (
<div>
<SectionHeader
description="Project details and git status."
title="Overview"
/>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)] px-5 py-4 space-y-3">
<div className="flex items-center gap-3">
<FolderOpen className="size-5 shrink-0 text-[var(--color-text-accent)]" />
<div className="min-w-0">
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">{project.name}</div>
<div className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{project.path}</div>
</div>
</div>
{project.git && <ProjectGitInfo git={project.git} />}
</div>
</div>
);
}
function ProjectGitInfo({ git }: { git: ProjectGitContext }) {
if (git.status === 'not-repository') {
return (
<div className="flex items-center gap-2 text-[12px] text-[var(--color-text-muted)]">
<GitBranch className="size-3.5" />
Not a git repository
</div>
);
}
if (git.status === 'git-missing') {
return (
<div className="flex items-center gap-2 text-[12px] text-[var(--color-status-warning)]">
<AlertTriangle className="size-3.5" />
Git is not installed
</div>
);
}
if (git.status === 'error') {
return (
<div className="flex items-center gap-2 text-[12px] text-[var(--color-status-error)]">
<AlertTriangle className="size-3.5" />
{git.errorMessage ?? 'Git error'}
</div>
);
}
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 (
<div className="flex items-center gap-2 text-[12px] text-[var(--color-text-secondary)]">
<GitBranch className="size-3.5 shrink-0" />
<span>{branchLabel}</span>
{git.isDirty && <Circle className="size-1.5 shrink-0 fill-[var(--color-status-warning)] text-[var(--color-status-warning)]" />}
{parts.length > 0 && (
<span className="text-[var(--color-text-muted)]">· {parts.join(' · ')}</span>
)}
</div>
);
}
/* ── Instructions ─────────────────────────────────────────── */
function InstructionsContent({
instructions,
onRescan,
}: {
instructions: ProjectInstructionFile[];
onRescan: () => void;
}) {
return (
<div>
<SectionHeader
description="Repository instructions discovered from copilot-instructions.md, AGENTS.md, CLAUDE.md, .github/instructions/**/*.instructions.md, and .claude/rules/**/*.md."
title="Instructions"
>
<RescanButton onClick={onRescan} />
</SectionHeader>
{instructions.length === 0 ? (
<EmptyState>
No instruction files found. Add <code className="text-[var(--color-text-secondary)]">.github/copilot-instructions.md</code>, <code className="text-[var(--color-text-secondary)]">AGENTS.md</code>, <code className="text-[var(--color-text-secondary)]">CLAUDE.md</code>, <code className="text-[var(--color-text-secondary)]">*.instructions.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/instructions/</code>, or <code className="text-[var(--color-text-secondary)]">*.md</code> files under <code className="text-[var(--color-text-secondary)]">.claude/rules/</code>.
</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;
const isAncestor = instruction.sourcePath.startsWith('..');
const displayName = instruction.name ?? (isAncestor ? formatSettingsSourcePath(instruction.sourcePath) : instruction.sourcePath);
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)]">
<button
className="flex w-full items-center gap-3 px-5 py-3.5 text-left transition-all duration-200 hover:bg-[var(--color-glass)]"
onClick={() => setExpanded(!expanded)}
type="button"
>
<FileCode2 className="size-4 shrink-0 text-[var(--color-text-accent)]" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]" title={instruction.sourcePath}>{displayName}</span>
<InstructionModeBadge mode={instruction.applicationMode} />
{isAncestor && (
<span className="shrink-0 rounded bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
parent repo
</span>
)}
{instruction.applyTo && (
<span
className="truncate rounded bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"
title={`Applies to files matching: ${instruction.applyTo}`}
>
{instruction.applyTo}
</span>
)}
</div>
<ChevronDown
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</button>
{expanded && (
<div className="border-t border-[var(--color-border)] px-5 py-4">
{instruction.description && (
<p className="mb-2 text-[11px] italic text-[var(--color-text-muted)]">{instruction.description}</p>
)}
{instruction.name && (
<p className="mb-2 text-[11px] text-[var(--color-text-muted)]">{instruction.sourcePath}</p>
)}
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
{instruction.content}
</pre>
</div>
)}
{!expanded && (
<div className="px-5 pb-3">
{instruction.description && (
<p className="mb-1 text-[11px] italic text-[var(--color-text-muted)]">{instruction.description}</p>
)}
<p className={`text-[11px] leading-relaxed text-[var(--color-text-muted)] ${isLong ? 'line-clamp-2' : ''}`}>
{instruction.content}
</p>
</div>
)}
</div>
);
}
function InstructionModeBadge({ mode }: { mode: ProjectInstructionApplicationMode }) {
switch (mode) {
case 'always':
return (
<span className="shrink-0 rounded-full bg-[var(--color-status-success)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-success)]">
always
</span>
);
case 'file':
return (
<span className="shrink-0 rounded-full bg-[var(--color-text-accent)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-accent)]">
file
</span>
);
case 'task':
return (
<span className="shrink-0 rounded-full bg-[var(--color-status-warning)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
task
</span>
);
case 'manual':
return (
<span
className="shrink-0 rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"
title="Discovered but not auto-applied to sessions"
>
manual
</span>
);
}
}
/* ── 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-[var(--color-text-secondary)]">.agent.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/agents/</code> in your project.
</EmptyState>
) : (
<>
{agents.length > 1 && (
<div className="mb-3 text-[11px] text-[var(--color-text-muted)]">
{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-[var(--color-border)] bg-[var(--color-glass)]'
: 'border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]/20 opacity-60'
}`}>
<div className="flex items-start gap-3">
<Sparkles className={`mt-0.5 size-4 shrink-0 ${agent.enabled ? 'text-[var(--color-status-warning)]' : 'text-[var(--color-text-muted)]'}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
{agent.displayName ?? agent.name}
</span>
{agent.tools && agent.tools.length > 0 && (
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{agent.tools.length} tool{agent.tools.length === 1 ? '' : 's'}
</span>
)}
</div>
{agent.description && (
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-muted)]">{agent.description}</p>
)}
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">{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-[var(--color-text-secondary)]">.prompt.md</code> files under <code className="text-[var(--color-text-secondary)]">.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 }) {
const isAncestor = prompt.sourcePath.startsWith('..');
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)] px-5 py-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 size-4 shrink-0 text-[var(--color-status-success)]" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{prompt.name}</span>
{prompt.model && (
<span className="rounded-full bg-[var(--color-accent-purple)]/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
{prompt.model}
</span>
)}
{prompt.agent && (
<span className="rounded-full bg-[var(--color-accent-sky)]/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
{prompt.agent}
</span>
)}
{prompt.variables.length > 0 && (
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'}
</span>
)}
{isAncestor && (
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
parent repo
</span>
)}
</div>
{prompt.description && (
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-muted)]">{prompt.description}</p>
)}
{prompt.argumentHint && (
<p className="mt-1 text-[11px] italic text-[var(--color-text-muted)]">
Hint: {prompt.argumentHint}
</p>
)}
{prompt.tools && prompt.tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{prompt.tools.map((tool) => (
<span
key={tool}
className="rounded-md bg-[var(--color-status-warning)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]"
>
{tool}
</span>
))}
</div>
)}
{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-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-secondary)]"
title={v.placeholder}
>
{v.name}
</span>
))}
</div>
)}
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]" title={prompt.sourcePath}>
{isAncestor ? formatSettingsSourcePath(prompt.sourcePath) : prompt.sourcePath}
</p>
</div>
</div>
</div>
);
}
/* ── 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 (
<div>
<SectionHeader
description="MCP servers discovered from project config files (.vscode/mcp.json, .mcp.json, .copilot/mcp.json)."
title="MCP Servers"
>
<RescanButton label={hasServers ? 'Re-scan' : 'Scan'} onClick={onRescan} />
</SectionHeader>
{!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-[var(--color-status-success)]/10 px-3 py-1.5 text-[12px] font-medium text-[var(--color-status-success)] transition-all duration-200 hover:bg-[var(--color-status-success)]/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-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={() => onResolve(pending.map((s) => s.id), 'dismiss')}
type="button"
>
Dismiss all
</button>
</div>
)}
</>
)}
</div>
);
}
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 (
<div className="flex items-center gap-3 rounded-xl border border-transparent px-4 py-3 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]">
<Server className="size-4 shrink-0 text-[var(--color-text-muted)]" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">{server.name}</span>
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-secondary)]">
{server.transport}
</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${statusBadge}`}>
{status}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">
{detail}
<span className="ml-2 text-[var(--color-text-muted)]">· {server.sourceLabel}</span>
</p>
</div>
<div className="flex items-center gap-1">
{onAccept && (
<button
className="rounded-lg px-2.5 py-1 text-[12px] font-medium text-[var(--color-status-success)] transition-all duration-200 hover:bg-[var(--color-status-success)]/10"
onClick={onAccept}
type="button"
>
Accept
</button>
)}
{onDismiss && (
<button
className="rounded-lg px-2.5 py-1 text-[12px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onDismiss}
type="button"
>
{status === 'accepted' ? 'Remove' : 'Dismiss'}
</button>
)}
</div>
</div>
);
}
/* ── Danger Zone ──────────────────────────────────────────── */
function DangerZoneContent({
confirmingRemove,
onRemove,
onCancelRemove,
}: {
confirmingRemove: boolean;
onRemove: () => void;
onCancelRemove: () => void;
}) {
return (
<div>
<SectionHeader
description="Irreversible actions for this project."
title="Danger Zone"
/>
<div className="rounded-xl border border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 px-5 py-5">
<h4 className="text-[13px] font-semibold text-[var(--color-text-primary)]">Remove project</h4>
<p className="mt-1 text-[12px] text-[var(--color-text-muted)]">
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-all duration-200 ${
confirmingRemove
? 'bg-[var(--color-status-error)] text-white hover:bg-[var(--color-status-error)]'
: 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)] hover:bg-[var(--color-status-error)]/20'
}`}
onClick={onRemove}
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-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={onCancelRemove}
type="button"
>
Cancel
</button>
)}
</div>
</div>
</div>
);
}
/* ── Shared helpers ──────────────────────────────────────── */
function SectionHeader({
title,
description,
children,
}: {
title: string;
description: string;
children?: ReactNode;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">{title}</h3>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{description}</p>
</div>
{children}
</div>
);
}
function RescanButton({ onClick, label = 'Re-scan' }: { onClick: () => void; label?: string }) {
return (
<button
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-surface-3)] px-3 py-1.5 text-[13px] font-medium text-[var(--color-text-primary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
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-[var(--color-border)] bg-[var(--color-surface-1)]/20 px-5 py-8 text-center text-[12px] leading-relaxed text-[var(--color-text-muted)]">
{children}
</div>
);
}