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. +

+