From f53907755aeb1a4fa822aaf75f0e0433d20d081c Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sat, 28 Mar 2026 16:51:57 +0100 Subject: [PATCH] feat: add dedicated project settings panel accessible from sidebar Move project-specific settings (discovered MCP servers) out of the global Settings panel and into a dedicated ProjectSettingsPanel overlay. Users can now access any project's settings via a gear icon on the sidebar project header, or by clicking the pending-discovery badge. - Add ProjectSettingsPanel component with project info, discovered MCP server management (accept/dismiss/rescan), and project removal - Add gear icon and clickable discovery badge to Sidebar ProjectGroup - Remove project-specific discovery props from SettingsPanel (now shows only user-level discovered servers) - Wire ProjectSettingsPanel overlay in App.tsx with auto-close on project removal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/App.tsx | 36 +- .../components/ProjectSettingsPanel.tsx | 347 ++++++++++++++++++ src/renderer/components/SettingsPanel.tsx | 72 +--- src/renderer/components/Sidebar.tsx | 27 +- 4 files changed, 414 insertions(+), 68 deletions(-) create mode 100644 src/renderer/components/ProjectSettingsPanel.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6911365..2f5e3af 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5,6 +5,7 @@ import { ActivityPanel } from '@renderer/components/ActivityPanel'; import { ChatPane } from '@renderer/components/ChatPane'; import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal'; import { NewSessionModal } from '@renderer/components/NewSessionModal'; +import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel'; import { SettingsPanel } from '@renderer/components/SettingsPanel'; import { Sidebar } from '@renderer/components/Sidebar'; import { resolveChatToolingSettings } from '@renderer/lib/chatTooling'; @@ -101,6 +102,7 @@ export default function App() { const [turnEventLogs, setTurnEventLogs] = useState({}); const [showSettings, setShowSettings] = useState(false); + const [projectSettingsId, setProjectSettingsId] = useState(); const [newSessionProjectId, setNewSessionProjectId] = useState(); const [showDiscoveryModal, setShowDiscoveryModal] = useState(false); @@ -251,6 +253,16 @@ export default function App() { } }, [api, workspace]); + const projectForSettings = useMemo( + () => workspace?.projects.find((p) => p.id === projectSettingsId), + [workspace?.projects, projectSettingsId], + ); + + // Close project settings if the project was removed + useEffect(() => { + if (projectSettingsId && !projectForSettings) setProjectSettingsId(undefined); + }, [projectSettingsId, projectForSettings]); + // Loading state if (!workspace) { return ( @@ -397,15 +409,9 @@ export default function App() { theme={workspace.settings.theme} toolingSettings={workspace.settings.tooling} discoveredUserTooling={workspace.settings.discoveredUserTooling} - discoveredProjectTooling={selectedProject?.discoveredTooling} - selectedProjectName={selectedProject?.name} - onRescanProjectConfigs={selectedProject ? () => void api.rescanProjectConfigs({ projectId: selectedProject.id }) : undefined} onResolveUserDiscoveredTooling={(serverIds, resolution) => { void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution }); }} - onResolveProjectDiscoveredTooling={selectedProject ? (serverIds, resolution) => { - void api.resolveProjectDiscoveredTooling({ projectId: selectedProject.id, serverIds, resolution }); - } : undefined} /> ) : null; @@ -423,6 +429,7 @@ export default function App() { setNewSessionProjectId(projectId); }} onOpenSettings={() => setShowSettings(true)} + onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)} onProjectSelect={(projectId) => { void api.selectProject(projectId); }} @@ -484,6 +491,23 @@ export default function App() { userDiscoveredTooling={workspace.settings.discoveredUserTooling} /> )} + + {projectForSettings && ( + setProjectSettingsId(undefined)} + onRescanConfigs={() => { + void api.rescanProjectConfigs({ projectId: projectForSettings.id }); + }} + onResolveDiscoveredTooling={(serverIds, resolution) => { + void api.resolveProjectDiscoveredTooling({ projectId: projectForSettings.id, serverIds, resolution }); + }} + onRemoveProject={() => { + void api.removeProject(projectForSettings.id); + setProjectSettingsId(undefined); + }} + /> + )} ); } diff --git a/src/renderer/components/ProjectSettingsPanel.tsx b/src/renderer/components/ProjectSettingsPanel.tsx new file mode 100644 index 0000000..8c89358 --- /dev/null +++ b/src/renderer/components/ProjectSettingsPanel.tsx @@ -0,0 +1,347 @@ +import { useCallback, useState } from 'react'; +import { ChevronLeft, FolderOpen, GitBranch, RefreshCw, Server, Trash2, AlertTriangle, Circle } from 'lucide-react'; + +import type { ProjectRecord, ProjectGitContext } from '@shared/domain/project'; +import type { DiscoveredMcpServer } from '@shared/domain/discoveredTooling'; +import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; + +interface ProjectSettingsPanelProps { + project: ProjectRecord; + onClose: () => void; + onRescanConfigs: () => void; + onResolveDiscoveredTooling: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; + onRemoveProject: () => void; +} + +export function ProjectSettingsPanel({ + project, + onClose, + onRescanConfigs, + onResolveDiscoveredTooling, + onRemoveProject, +}: ProjectSettingsPanelProps) { + const [confirmingRemove, setConfirmingRemove] = useState(false); + + const acceptedServers = listAcceptedDiscoveredMcpServers(project.discoveredTooling); + const pendingServers = listPendingDiscoveredMcpServers(project.discoveredTooling); + const hasDiscoveredServers = acceptedServers.length + pendingServers.length > 0; + + const handleRemove = useCallback(() => { + if (!confirmingRemove) { + setConfirmingRemove(true); + return; + } + onRemoveProject(); + }, [confirmingRemove, onRemoveProject]); + + return ( +
+ {/* Header */} +
+ +

Project Settings

+
+ + {/* Content */} +
+
+ {/* Project info */} + + + {/* Discovered MCP Servers */} + {hasDiscoveredServers ? ( + + ) : ( +
+ + + +
+ No MCP servers discovered in this project yet. Click Scan to check project config files. +
+
+ )} + + {/* Remove project */} +
+

Danger zone

+

+ Removing a project deletes all its sessions and discovered tooling from Aryx. + Your project files on disk are not affected. +

+
+ + {confirmingRemove && ( + + )} +
+
+
+
+
+ ); +} + +/* ── Project info ─────────────────────────────────────────── */ + +function ProjectInfoSection({ project }: { project: ProjectRecord }) { + return ( +
+ +
+
+ +
+
{project.name}
+
{project.path}
+
+
+ {project.git && } +
+
+ ); +} + +function ProjectGitInfo({ git }: { git: ProjectGitContext }) { + if (git.status === 'not-repository') { + return ( +
+ + Not a git repository +
+ ); + } + + if (git.status === 'git-missing') { + return ( +
+ + Git is not installed +
+ ); + } + + if (git.status === 'error') { + return ( +
+ + {git.errorMessage ?? 'Git error'} +
+ ); + } + + 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 ( +
+ + {branchLabel} + {git.isDirty && } + {parts.length > 0 && ( + · {parts.join(' · ')} + )} +
+ ); +} + +/* ── Discovered MCP servers ──────────────────────────────── */ + +function DiscoveredServersSection({ + accepted, + pending, + onRescan, + onResolve, +}: { + accepted: DiscoveredMcpServer[]; + pending: DiscoveredMcpServer[]; + onRescan: () => void; + onResolve: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; +}) { + return ( +
+ + + + +
+ {accepted.map((server) => ( + onResolve([server.id], 'dismiss')} + server={server} + status="accepted" + /> + ))} + {pending.map((server) => ( + onResolve([server.id], 'accept')} + onDismiss={() => onResolve([server.id], 'dismiss')} + server={server} + status="pending" + /> + ))} +
+ + {pending.length > 1 && ( +
+ + +
+ )} +
+ ); +} + +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-emerald-500/10 text-emerald-400' + : 'bg-amber-500/10 text-amber-400'; + + return ( +
+ +
+
+ {server.name} + + {server.transport} + + + {status} + +
+

+ {detail} + · {server.sourceLabel} +

+
+
+ {onAccept && ( + + )} + {onDismiss && ( + + )} +
+
+ ); +} + +/* ── Shared helpers ──────────────────────────────────────── */ + +function SectionHeader({ + title, + description, + children, +}: { + title: string; + description: string; + children?: React.ReactNode; +}) { + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ); +} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 06ae308..71b5b37 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -1,12 +1,12 @@ import { useState, type ReactNode } from 'react'; -import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react'; import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { PatternEditor } from '@renderer/components/PatternEditor'; import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor'; import { McpServerEditor } from '@renderer/components/settings/McpServerEditor'; import type { SidecarCapabilities } from '@shared/contracts/sidecar'; -import type { DiscoveredMcpServer, DiscoveredToolingState, ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling'; +import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling'; import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; import type { ModelDefinition } from '@shared/domain/models'; import type { PatternDefinition } from '@shared/domain/pattern'; @@ -26,8 +26,6 @@ interface SettingsPanelProps { theme: AppearanceTheme; toolingSettings: WorkspaceToolingSettings; discoveredUserTooling: DiscoveredToolingState; - discoveredProjectTooling?: ProjectDiscoveredTooling; - selectedProjectName?: string; isRefreshingCapabilities: boolean; onRefreshCapabilities: () => void; onClose: () => void; @@ -43,9 +41,7 @@ interface SettingsPanelProps { onSetTheme: (theme: AppearanceTheme) => void; onOpenAppDataFolder: () => void; onResetLocalWorkspace: () => Promise; - onRescanProjectConfigs?: () => void; onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; - onResolveProjectDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; } type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting'; @@ -107,8 +103,6 @@ export function SettingsPanel({ theme, toolingSettings, discoveredUserTooling, - discoveredProjectTooling, - selectedProjectName, isRefreshingCapabilities, onRefreshCapabilities, onClose, @@ -124,9 +118,7 @@ export function SettingsPanel({ onSetTheme, onOpenAppDataFolder, onResetLocalWorkspace, - onRescanProjectConfigs, onResolveUserDiscoveredTooling, - onResolveProjectDiscoveredTooling, }: SettingsPanelProps) { const [activeSection, setActiveSection] = useState('appearance'); const [editingPattern, setEditingPattern] = useState(null); @@ -287,12 +279,8 @@ export function SettingsPanel({ )} {activeSection === 'mcp-servers' && ( )} {activeSection === 'lsp-profiles' && ( @@ -607,68 +595,32 @@ function EmptyState({ children }: { children: ReactNode }) { function DiscoveredMcpSection({ discoveredUserTooling, - discoveredProjectTooling, - selectedProjectName, - onRescanProjectConfigs, onResolveUserDiscoveredTooling, - onResolveProjectDiscoveredTooling, }: { discoveredUserTooling: DiscoveredToolingState; - discoveredProjectTooling?: ProjectDiscoveredTooling; - selectedProjectName?: string; - onRescanProjectConfigs?: () => void; onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; - onResolveProjectDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; }) { const acceptedUser = listAcceptedDiscoveredMcpServers(discoveredUserTooling); const pendingUser = listPendingDiscoveredMcpServers(discoveredUserTooling); - const acceptedProject = listAcceptedDiscoveredMcpServers(discoveredProjectTooling); - const pendingProject = listPendingDiscoveredMcpServers(discoveredProjectTooling); - const hasAny = acceptedUser.length + pendingUser.length + acceptedProject.length + pendingProject.length > 0; + const hasAny = acceptedUser.length + pendingUser.length > 0; if (!hasAny) return null; return (
- {onRescanProjectConfigs && ( - - )} - + /> - {/* User-level discovered */} - {(acceptedUser.length > 0 || pendingUser.length > 0) && ( - - )} - - {/* Project-level discovered */} - {(acceptedProject.length > 0 || pendingProject.length > 0) && ( - - )} +
); } diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 47c1c1d..adba5a0 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -42,6 +42,7 @@ interface SidebarProps { onProjectSelect: (projectId?: string) => void; onSessionSelect: (sessionId: string) => void; onOpenSettings: () => void; + onOpenProjectSettings: (projectId: string) => void; onRenameSession: (sessionId: string, title: string) => void; onDuplicateSession: (sessionId: string) => void; onSetSessionPinned: (sessionId: string, isPinned: boolean) => void; @@ -328,6 +329,7 @@ function ProjectGroup({ onRenameSubmit, onRenameCancel, onRefreshGitContext, + onOpenProjectSettings, onNewSession, newSessionLabel, }: { @@ -341,6 +343,7 @@ function ProjectGroup({ onRenameSubmit: (sessionId: string, title: string) => void; onRenameCancel: () => void; onRefreshGitContext?: (projectId: string) => void; + onOpenProjectSettings?: (projectId: string) => void; onNewSession?: () => void; newSessionLabel?: string; }){ @@ -394,6 +397,19 @@ function ProjectGroup({ )}
+ {!isScratchpad && onOpenProjectSettings && ( + { + e.stopPropagation(); + onOpenProjectSettings(project.id); + }} + role="button" + title="Project settings" + > + + + )} {!isScratchpad && onRefreshGitContext && ( 0 && ( { + e.stopPropagation(); + onOpenProjectSettings?.(project.id); + }} + role="button" + title={`${pendingDiscoveryCount} MCP server${pendingDiscoveryCount === 1 ? '' : 's'} discovered — click to review`} > {pendingDiscoveryCount} new @@ -475,6 +496,7 @@ export function Sidebar({ onProjectSelect, onSessionSelect, onOpenSettings, + onOpenProjectSettings, onRenameSession, onDuplicateSession, onSetSessionPinned, @@ -678,6 +700,7 @@ export function Sidebar({ onRenameSubmit={handleRenameSubmit} onRenameCancel={() => setRenamingSessionId(undefined)} onRefreshGitContext={onRefreshGitContext} + onOpenProjectSettings={onOpenProjectSettings} renamingSessionId={renamingSessionId} patterns={workspace.patterns} project={project}