feat: add frontend customization UI and prompt picker

Add Copilot Customization section to ProjectSettingsPanel with instruction file
previews, agent profile enable/disable toggles, and prompt file listing. Add
InlinePromptPill to chat input for selecting and sending prompt files with
variable substitution. Wire rescan and agent profile IPC in App.tsx. Update
website with Copilot Customization feature card.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 17:40:21 +01:00
co-authored by Copilot
parent 75b9ff667a
commit dd203ddde5
5 changed files with 476 additions and 1 deletions
+6
View File
@@ -499,9 +499,15 @@ export default function App() {
onRescanConfigs={() => {
void api.rescanProjectConfigs({ projectId: projectForSettings.id });
}}
onRescanCustomization={() => {
void api.rescanProjectCustomization({ projectId: projectForSettings.id });
}}
onResolveDiscoveredTooling={(serverIds, resolution) => {
void api.resolveProjectDiscoveredTooling({ projectId: projectForSettings.id, serverIds, resolution });
}}
onSetAgentProfileEnabled={(agentProfileId, enabled) => {
void api.setProjectAgentProfileEnabled({ projectId: projectForSettings.id, agentProfileId, enabled });
}}
onRemoveProject={() => {
void api.removeProject(projectForSettings.id);
setProjectSettingsId(undefined);
+13
View File
@@ -8,6 +8,7 @@ import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
import type { ApprovalDecision } from '@shared/domain/approval';
@@ -107,6 +108,7 @@ export function ChatPane({
const isComposerDisabled = isUpdatingSessionModelConfig;
const canSubmitInput = hasComposerContent && !isComposerDisabled;
const [pendingAttachments, setPendingAttachments] = useState<ChatMessageAttachment[]>([]);
const promptFiles = useMemo(() => project.customization?.promptFiles ?? [], [project.customization?.promptFiles]);
const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]);
const mcpServers = toolingSettings.mcpServers;
@@ -542,6 +544,17 @@ export function ChatPane({
</div>
)}
{/* Prompt files pill */}
{!isScratchpad && promptFiles.length > 0 && (
<div className="mb-2 flex items-center gap-2">
<InlinePromptPill
disabled={isComposerDisabled}
onSubmit={(content) => void onSend(content)}
promptFiles={promptFiles}
/>
</div>
)}
{/* Attachment preview */}
{pendingAttachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-1 pb-2">
@@ -1,15 +1,19 @@
import { useCallback, useState } from 'react';
import { ChevronLeft, FolderOpen, GitBranch, RefreshCw, Server, Trash2, AlertTriangle, Circle } from 'lucide-react';
import { 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';
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;
}
@@ -17,7 +21,9 @@ export function ProjectSettingsPanel({
project,
onClose,
onRescanConfigs,
onRescanCustomization,
onResolveDiscoveredTooling,
onSetAgentProfileEnabled,
onRemoveProject,
}: ProjectSettingsPanelProps) {
const [confirmingRemove, setConfirmingRemove] = useState(false);
@@ -25,6 +31,11 @@ export function ProjectSettingsPanel({
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 handleRemove = useCallback(() => {
if (!confirmingRemove) {
@@ -84,6 +95,36 @@ export function ProjectSettingsPanel({
</div>
)}
{/* 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>
@@ -324,6 +365,155 @@ function DiscoveredServerRow({
);
}
/* ── Copilot Customization ────────────────────────────────── */
function CustomizationSection({
customization,
onRescan,
onSetAgentProfileEnabled,
}: {
customization: ProjectCustomizationState;
onRescan: () => void;
onSetAgentProfileEnabled: (agentProfileId: string, enabled: boolean) => 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"
>
<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>
<button
aria-label={agent.enabled ? `Disable ${agent.name}` : `Enable ${agent.name}`}
aria-pressed={agent.enabled}
className="shrink-0"
onClick={() => onSetEnabled(agent.id, !agent.enabled)}
type="button"
>
<ToggleSwitch enabled={agent.enabled} size="sm" />
</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>
);
}
/* ── Shared helpers ──────────────────────────────────────── */
function SectionHeader({
@@ -0,0 +1,253 @@
import { useCallback, useMemo, useState } from 'react';
import { ArrowUp, FileText, X } from 'lucide-react';
import { useClickOutside } from '@renderer/hooks/useClickOutside';
import type { ProjectPromptFile, ProjectPromptVariable } from '@shared/domain/projectCustomization';
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):[^}]+\}/g;
function resolvePromptTemplate(template: string, values: Record<string, string>): string {
return template.replace(promptVariablePattern, (_match, name: string) => {
return values[name] ?? '';
});
}
export function InlinePromptPill({
promptFiles,
disabled,
onSubmit,
}: {
promptFiles: ReadonlyArray<ProjectPromptFile>;
disabled: boolean;
onSubmit: (resolvedContent: string) => void;
}) {
const [open, setOpen] = useState(false);
const [selectedPrompt, setSelectedPrompt] = useState<ProjectPromptFile | null>(null);
const [variableValues, setVariableValues] = useState<Record<string, string>>({});
const ref = useClickOutside<HTMLDivElement>(() => handleClose(), open);
const handleClose = useCallback(() => {
setOpen(false);
setSelectedPrompt(null);
setVariableValues({});
}, []);
const handleSelectPrompt = useCallback((prompt: ProjectPromptFile) => {
if (prompt.variables.length === 0) {
onSubmit(prompt.template.trim());
handleClose();
} else {
setSelectedPrompt(prompt);
setVariableValues({});
}
}, [onSubmit, handleClose]);
const handleSubmitWithVariables = useCallback(() => {
if (!selectedPrompt) return;
const resolved = resolvePromptTemplate(selectedPrompt.template, variableValues).trim();
if (!resolved) return;
onSubmit(resolved);
handleClose();
}, [selectedPrompt, variableValues, onSubmit, handleClose]);
const handleVariableChange = useCallback((name: string, value: string) => {
setVariableValues((prev) => ({ ...prev, [name]: value }));
}, []);
const allVariablesFilled = useMemo(() => {
if (!selectedPrompt) return false;
return selectedPrompt.variables.every((v) => (variableValues[v.name] ?? '').trim().length > 0);
}, [selectedPrompt, variableValues]);
if (promptFiles.length === 0) return null;
return (
<div className="relative" ref={ref}>
<button
aria-expanded={open}
aria-haspopup="listbox"
className="inline-flex items-center gap-1 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-[11px] font-medium text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200"
disabled={disabled}
onClick={() => setOpen(!open)}
type="button"
>
<FileText className="size-3" />
Prompts
<span className="ml-0.5 text-zinc-600">({promptFiles.length})</span>
</button>
{open && !disabled && (
<div
className="absolute bottom-full left-0 z-40 mb-1.5 w-80 rounded-lg border border-zinc-700 bg-zinc-900 shadow-xl"
role="listbox"
>
{selectedPrompt ? (
<PromptVariableForm
onBack={() => {
setSelectedPrompt(null);
setVariableValues({});
}}
onSubmit={handleSubmitWithVariables}
onVariableChange={handleVariableChange}
prompt={selectedPrompt}
submitDisabled={!allVariablesFilled}
values={variableValues}
/>
) : (
<PromptList
onSelect={handleSelectPrompt}
promptFiles={promptFiles}
/>
)}
</div>
)}
</div>
);
}
function PromptList({
promptFiles,
onSelect,
}: {
promptFiles: ReadonlyArray<ProjectPromptFile>;
onSelect: (prompt: ProjectPromptFile) => void;
}) {
return (
<div className="max-h-64 overflow-y-auto py-1">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
Prompt files
</div>
{promptFiles.map((prompt) => (
<button
key={prompt.id}
className="flex w-full items-start gap-2.5 px-3 py-2 text-left transition hover:bg-zinc-800"
onClick={() => onSelect(prompt)}
role="option"
type="button"
>
<FileText className="mt-0.5 size-3.5 shrink-0 text-zinc-500" />
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-zinc-200">
{prompt.name}
</div>
{prompt.description && (
<div className="mt-0.5 truncate text-[11px] text-zinc-500">
{prompt.description}
</div>
)}
{prompt.variables.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{prompt.variables.map((v) => (
<span
key={v.name}
className="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500"
>
{v.name}
</span>
))}
</div>
)}
</div>
</button>
))}
</div>
);
}
function PromptVariableForm({
prompt,
values,
submitDisabled,
onVariableChange,
onSubmit,
onBack,
}: {
prompt: ProjectPromptFile;
values: Record<string, string>;
submitDisabled: boolean;
onVariableChange: (name: string, value: string) => void;
onSubmit: () => void;
onBack: () => void;
}) {
return (
<div className="p-3">
<div className="mb-3 flex items-center gap-2">
<button
className="flex size-5 items-center justify-center rounded text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
onClick={onBack}
type="button"
aria-label="Back to prompt list"
>
<X className="size-3" />
</button>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-zinc-200">
{prompt.name}
</div>
{prompt.description && (
<div className="truncate text-[10px] text-zinc-500">{prompt.description}</div>
)}
</div>
</div>
<div className="space-y-2.5">
{prompt.variables.map((variable) => (
<PromptVariableInput
key={variable.name}
onChange={(value) => onVariableChange(variable.name, value)}
onSubmit={!submitDisabled ? onSubmit : undefined}
value={values[variable.name] ?? ''}
variable={variable}
/>
))}
</div>
<button
className={`mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-[12px] font-medium transition ${
submitDisabled
? 'bg-zinc-800 text-zinc-600'
: 'bg-indigo-600 text-white hover:bg-indigo-500'
}`}
disabled={submitDisabled}
onClick={onSubmit}
type="button"
>
<ArrowUp className="size-3.5" />
Send prompt
</button>
</div>
);
}
function PromptVariableInput({
variable,
value,
onChange,
onSubmit,
}: {
variable: ProjectPromptVariable;
value: string;
onChange: (value: string) => void;
onSubmit?: () => void;
}) {
return (
<div>
<label className="mb-1 block text-[11px] font-medium text-zinc-400">
{variable.name}
</label>
<input
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-2.5 py-1.5 text-[12px] text-zinc-200 placeholder-zinc-600 transition focus:border-indigo-500/50 focus:outline-none"
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && onSubmit) {
e.preventDefault();
onSubmit();
}
}}
placeholder={variable.placeholder}
type="text"
value={value}
/>
</div>
);
}
+13
View File
@@ -262,6 +262,19 @@ import Base from '../layouts/Base.astro';
Every turn is recorded in a structured run timeline — see tool calls, agent delegation, hook execution, and context usage at a glance.
</p>
</div>
<!-- Feature 14: Copilot Customization Files -->
<div class="group rounded-2xl border border-zinc-800 bg-zinc-900/40 p-6 transition hover:border-zinc-700 hover:bg-zinc-900/60">
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-violet-500/10 text-violet-400">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<h3 class="text-base font-semibold">Copilot Customization</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
Automatically discovers <code class="text-zinc-300">.github/copilot-instructions.md</code>, <code class="text-zinc-300">AGENTS.md</code>, custom agent profiles, and prompt files from your repository. Zero configuration needed.
</p>
</div>
</div>
</div>
</section>