From dd203ddde556879d9aaae920d2ebc5a16f4df411 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sat, 28 Mar 2026 17:40:21 +0100 Subject: [PATCH] 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> --- src/renderer/App.tsx | 6 + src/renderer/components/ChatPane.tsx | 13 + .../components/ProjectSettingsPanel.tsx | 192 ++++++++++++- .../components/chat/InlinePromptPill.tsx | 253 ++++++++++++++++++ website/src/pages/index.astro | 13 + 5 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 src/renderer/components/chat/InlinePromptPill.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2f5e3af..e49f3f3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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); diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 2d67568..117d79f 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -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([]); + 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({ )} + {/* Prompt files pill */} + {!isScratchpad && promptFiles.length > 0 && ( +
+ void onSend(content)} + promptFiles={promptFiles} + /> +
+ )} + {/* Attachment preview */} {pendingAttachments.length > 0 && (
diff --git a/src/renderer/components/ProjectSettingsPanel.tsx b/src/renderer/components/ProjectSettingsPanel.tsx index 8c89358..2ca30ad 100644 --- a/src/renderer/components/ProjectSettingsPanel.tsx +++ b/src/renderer/components/ProjectSettingsPanel.tsx @@ -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({
)} + {/* Copilot Customization */} + {hasCustomization ? ( + + ) : ( +
+ + + +
+ No customization files found. Add .github/copilot-instructions.md, AGENTS.md, or files + in .github/agents/ and .github/prompts/ to customize Copilot behavior. +
+
+ )} + {/* Remove project */}

Danger zone

@@ -324,6 +365,155 @@ function DiscoveredServerRow({ ); } +/* ── Copilot Customization ────────────────────────────────── */ + +function CustomizationSection({ + customization, + onRescan, + onSetAgentProfileEnabled, +}: { + customization: ProjectCustomizationState; + onRescan: () => void; + onSetAgentProfileEnabled: (agentProfileId: string, enabled: boolean) => void; +}) { + return ( +
+ + + + +
+ {customization.instructions.length > 0 && ( + + )} + {customization.agentProfiles.length > 0 && ( + + )} + {customization.promptFiles.length > 0 && ( + + )} +
+
+ ); +} + +function CustomizationInstructionsList({ instructions }: { instructions: ProjectInstructionFile[] }) { + return ( +
+

Instructions

+
+ {instructions.map((instruction) => ( +
+
+ + {instruction.sourcePath} +
+

+ {instruction.content} +

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

Custom Agents

+
+ {agents.map((agent) => ( +
+ +
+
+ + {agent.displayName ?? agent.name} + + {agent.tools && agent.tools.length > 0 && ( + + {agent.tools.length} tool{agent.tools.length === 1 ? '' : 's'} + + )} +
+ {agent.description && ( +

{agent.description}

+ )} +

{agent.sourcePath}

+
+ +
+ ))} +
+
+ ); +} + +function CustomizationPromptsList({ promptFiles }: { promptFiles: ProjectPromptFile[] }) { + return ( +
+

Prompt Files

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

{prompt.description}

+ )} +

{prompt.sourcePath}

+
+ ))} +
+
+ ); +} + /* ── Shared helpers ──────────────────────────────────────── */ function SectionHeader({ diff --git a/src/renderer/components/chat/InlinePromptPill.tsx b/src/renderer/components/chat/InlinePromptPill.tsx new file mode 100644 index 0000000..0eee617 --- /dev/null +++ b/src/renderer/components/chat/InlinePromptPill.tsx @@ -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 { + return template.replace(promptVariablePattern, (_match, name: string) => { + return values[name] ?? ''; + }); +} + +export function InlinePromptPill({ + promptFiles, + disabled, + onSubmit, +}: { + promptFiles: ReadonlyArray; + disabled: boolean; + onSubmit: (resolvedContent: string) => void; +}) { + const [open, setOpen] = useState(false); + const [selectedPrompt, setSelectedPrompt] = useState(null); + const [variableValues, setVariableValues] = useState>({}); + const ref = useClickOutside(() => 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 ( +
+ + + {open && !disabled && ( +
+ {selectedPrompt ? ( + { + setSelectedPrompt(null); + setVariableValues({}); + }} + onSubmit={handleSubmitWithVariables} + onVariableChange={handleVariableChange} + prompt={selectedPrompt} + submitDisabled={!allVariablesFilled} + values={variableValues} + /> + ) : ( + + )} +
+ )} +
+ ); +} + +function PromptList({ + promptFiles, + onSelect, +}: { + promptFiles: ReadonlyArray; + onSelect: (prompt: ProjectPromptFile) => void; +}) { + return ( +
+
+ Prompt files +
+ {promptFiles.map((prompt) => ( + + ))} +
+ ); +} + +function PromptVariableForm({ + prompt, + values, + submitDisabled, + onVariableChange, + onSubmit, + onBack, +}: { + prompt: ProjectPromptFile; + values: Record; + submitDisabled: boolean; + onVariableChange: (name: string, value: string) => void; + onSubmit: () => void; + onBack: () => void; +}) { + return ( +
+
+ +
+
+ {prompt.name} +
+ {prompt.description && ( +
{prompt.description}
+ )} +
+
+ +
+ {prompt.variables.map((variable) => ( + onVariableChange(variable.name, value)} + onSubmit={!submitDisabled ? onSubmit : undefined} + value={values[variable.name] ?? ''} + variable={variable} + /> + ))} +
+ + +
+ ); +} + +function PromptVariableInput({ + variable, + value, + onChange, + onSubmit, +}: { + variable: ProjectPromptVariable; + value: string; + onChange: (value: string) => void; + onSubmit?: () => void; +}) { + return ( +
+ + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && onSubmit) { + e.preventDefault(); + onSubmit(); + } + }} + placeholder={variable.placeholder} + type="text" + value={value} + /> +
+ ); +} diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 08d16a7..da23ef4 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -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.

+ + +
+
+ + + +
+

Copilot Customization

+

+ Automatically discovers .github/copilot-instructions.md, AGENTS.md, custom agent profiles, and prompt files from your repository. Zero configuration needed. +

+