mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
- Fix ActivitySource name from 'Microsoft.Agents.AI' to 'Experimental.Microsoft.Agents.AI' matching the Agent Framework default - Wrap agents with UseOpenTelemetry() via AIAgentBuilder so agent invocation spans are emitted to the configured OTLP collector - Add proper disposal of OpenTelemetryAgent wrappers via SyncDisposableAdapter bridging IDisposable to IAsyncDisposable - Enable workflow-level telemetry on WorkflowRunner custom graph builds via WithOpenTelemetry() (HandoffWorkflowBuilder and GroupChatWorkflowBuilder do not expose this API — framework limitation) - Fix settings panel help text to be user-facing instead of dev jargon - Update test assertion for corrected ActivitySource name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1613 lines
64 KiB
TypeScript
1613 lines
64 KiB
TypeScript
import { useEffect, useState, type ReactNode } from 'react';
|
|
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench, Activity } from 'lucide-react';
|
|
|
|
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
|
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
|
import { HotkeyRecorder, TextInput, ToggleSwitch } from '@renderer/components/ui';
|
|
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
|
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
|
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
|
|
import { getElectronApi } from '@renderer/lib/electronApi';
|
|
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
|
|
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
|
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
|
import type { ModelDefinition } from '@shared/domain/models';
|
|
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
|
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
|
|
import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shared/domain/workflowTemplate';
|
|
import {
|
|
normalizeLspProfileDefinition,
|
|
normalizeMcpServerDefinition,
|
|
DEFAULT_OTEL_ENDPOINT,
|
|
type AppearanceTheme,
|
|
type LspProfileDefinition,
|
|
type McpServerDefinition,
|
|
type OpenTelemetrySettings,
|
|
type QuickPromptSettings,
|
|
type WorkspaceToolingSettings,
|
|
} from '@shared/domain/tooling';
|
|
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
|
|
|
interface SettingsPanelProps {
|
|
availableModels: ReadonlyArray<ModelDefinition>;
|
|
workflows: WorkflowDefinition[];
|
|
sidecarCapabilities?: SidecarCapabilities;
|
|
theme: AppearanceTheme;
|
|
toolingSettings: WorkspaceToolingSettings;
|
|
discoveredUserTooling: DiscoveredToolingState;
|
|
isRefreshingCapabilities: boolean;
|
|
initialSection?: SettingsSection;
|
|
onRefreshCapabilities: () => void;
|
|
onClose: () => void;
|
|
onSaveWorkflow: (workflow: WorkflowDefinition) => Promise<void>;
|
|
onDeleteWorkflow: (workflowId: string) => Promise<void>;
|
|
onNewWorkflow: () => WorkflowDefinition;
|
|
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
|
|
onDeleteMcpServer: (serverId: string) => Promise<void>;
|
|
onNewMcpServer: () => McpServerDefinition;
|
|
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
|
|
onDeleteLspProfile: (profileId: string) => Promise<void>;
|
|
onNewLspProfile: () => LspProfileDefinition;
|
|
onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise<void>;
|
|
onDeleteWorkspaceAgent: (agentId: string) => Promise<void>;
|
|
onNewWorkspaceAgent: () => WorkspaceAgentDefinition;
|
|
workspaceAgents: WorkspaceAgentDefinition[];
|
|
onSetTheme: (theme: AppearanceTheme) => void;
|
|
notificationsEnabled: boolean;
|
|
onSetNotificationsEnabled: (enabled: boolean) => void;
|
|
minimizeToTray: boolean;
|
|
onSetMinimizeToTray: (enabled: boolean) => void;
|
|
gitAutoRefreshEnabled: boolean;
|
|
onSetGitAutoRefreshEnabled: (enabled: boolean) => void;
|
|
onOpenAppDataFolder: () => void;
|
|
onResetLocalWorkspace: () => Promise<void>;
|
|
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
|
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
|
workflowTemplates?: WorkflowTemplateDefinition[];
|
|
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
|
|
quickPromptSettings?: QuickPromptSettings;
|
|
onSetQuickPromptSettings?: (patch: Partial<QuickPromptSettings>) => void;
|
|
openTelemetry?: OpenTelemetrySettings;
|
|
onSetOpenTelemetry?: (settings: OpenTelemetrySettings) => void;
|
|
}
|
|
|
|
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'telemetry' | 'troubleshooting';
|
|
|
|
interface NavItem {
|
|
id: SettingsSection;
|
|
label: string;
|
|
icon: ReactNode;
|
|
}
|
|
|
|
interface NavGroup {
|
|
label: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
const navGroups: NavGroup[] = [
|
|
{
|
|
label: 'General',
|
|
items: [
|
|
{ id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> },
|
|
{ id: 'quick-prompt', label: 'Quick Prompt', icon: <Sparkles className="size-3.5" /> },
|
|
{ id: 'telemetry', label: 'Telemetry', icon: <Activity className="size-3.5" /> },
|
|
],
|
|
},
|
|
{
|
|
label: 'AI Provider',
|
|
items: [
|
|
{ id: 'connection', label: 'Connection', icon: <Cpu className="size-3.5" /> },
|
|
],
|
|
},
|
|
{
|
|
label: 'Workflows',
|
|
items: [
|
|
{ id: 'workflows', label: 'Workflows', icon: <GitBranch className="size-3.5" /> },
|
|
{ id: 'agents', label: 'Agents', icon: <UserCircle className="size-3.5" /> },
|
|
],
|
|
},
|
|
{
|
|
label: 'Tooling',
|
|
items: [
|
|
{ id: 'mcp-servers', label: 'MCP Servers', icon: <Server className="size-3.5" /> },
|
|
{ id: 'lsp-profiles', label: 'LSP Profiles', icon: <Code className="size-3.5" /> },
|
|
],
|
|
},
|
|
{
|
|
label: 'Support',
|
|
items: [
|
|
{ id: 'troubleshooting', label: 'Troubleshooting', icon: <Wrench className="size-3.5" /> },
|
|
],
|
|
},
|
|
];
|
|
|
|
export function SettingsPanel({
|
|
availableModels,
|
|
workflows,
|
|
sidecarCapabilities,
|
|
theme,
|
|
toolingSettings,
|
|
discoveredUserTooling,
|
|
isRefreshingCapabilities,
|
|
initialSection,
|
|
onRefreshCapabilities,
|
|
onClose,
|
|
onSaveWorkflow,
|
|
onDeleteWorkflow,
|
|
onNewWorkflow,
|
|
onSaveMcpServer,
|
|
onDeleteMcpServer,
|
|
onNewMcpServer,
|
|
onSaveLspProfile,
|
|
onDeleteLspProfile,
|
|
onNewLspProfile,
|
|
onSaveWorkspaceAgent,
|
|
onDeleteWorkspaceAgent,
|
|
onNewWorkspaceAgent,
|
|
workspaceAgents,
|
|
onSetTheme,
|
|
notificationsEnabled,
|
|
onSetNotificationsEnabled,
|
|
minimizeToTray,
|
|
onSetMinimizeToTray,
|
|
gitAutoRefreshEnabled,
|
|
onSetGitAutoRefreshEnabled,
|
|
onOpenAppDataFolder,
|
|
onResetLocalWorkspace,
|
|
onResolveUserDiscoveredTooling,
|
|
onGetQuota,
|
|
workflowTemplates,
|
|
onCreateWorkflowFromTemplate,
|
|
quickPromptSettings,
|
|
onSetQuickPromptSettings,
|
|
openTelemetry,
|
|
onSetOpenTelemetry,
|
|
}: SettingsPanelProps) {
|
|
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
|
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
|
|
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
|
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
|
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | null>(null);
|
|
|
|
if (editingWorkflow) {
|
|
const api = getElectronApi();
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
|
<WorkflowEditor
|
|
availableModels={availableModels}
|
|
onBack={() => setEditingWorkflow(null)}
|
|
onChange={setEditingWorkflow}
|
|
onDelete={
|
|
async () => {
|
|
try {
|
|
await onDeleteWorkflow(editingWorkflow.id);
|
|
setEditingWorkflow(null);
|
|
} catch (err) {
|
|
window.alert(
|
|
err instanceof Error
|
|
? err.message
|
|
: 'Cannot delete this workflow. It may be referenced by other workflows.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
onSave={async () => {
|
|
await onSaveWorkflow(normalizeWorkflowDefinition(editingWorkflow));
|
|
setEditingWorkflow(null);
|
|
}}
|
|
onExportWorkflow={async (format) => {
|
|
const result = await api.exportWorkflow({ workflowId: editingWorkflow.id, format });
|
|
return result;
|
|
}}
|
|
onImportWorkflow={async (content, format) => {
|
|
const result = await api.importWorkflow({ content, format, options: { save: false } });
|
|
return result.workflow;
|
|
}}
|
|
workflow={editingWorkflow}
|
|
workflows={workflows}
|
|
workspaceAgents={workspaceAgents}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (editingMcpServer) {
|
|
const exists = toolingSettings.mcpServers.some((server) => server.id === editingMcpServer.id);
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
|
<McpServerEditor
|
|
onBack={() => setEditingMcpServer(null)}
|
|
onChange={setEditingMcpServer}
|
|
onDelete={
|
|
exists
|
|
? async () => {
|
|
await onDeleteMcpServer(editingMcpServer.id);
|
|
setEditingMcpServer(null);
|
|
}
|
|
: undefined
|
|
}
|
|
onSave={async () => {
|
|
await onSaveMcpServer(normalizeMcpServerDefinition(editingMcpServer));
|
|
setEditingMcpServer(null);
|
|
}}
|
|
server={editingMcpServer}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (editingLspProfile) {
|
|
const exists = toolingSettings.lspProfiles.some((profile) => profile.id === editingLspProfile.id);
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
|
<LspProfileEditor
|
|
onBack={() => setEditingLspProfile(null)}
|
|
onChange={setEditingLspProfile}
|
|
onDelete={
|
|
exists
|
|
? async () => {
|
|
await onDeleteLspProfile(editingLspProfile.id);
|
|
setEditingLspProfile(null);
|
|
}
|
|
: undefined
|
|
}
|
|
onSave={async () => {
|
|
await onSaveLspProfile(normalizeLspProfileDefinition(editingLspProfile));
|
|
setEditingLspProfile(null);
|
|
}}
|
|
profile={editingLspProfile}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (editingWorkspaceAgent) {
|
|
const exists = workspaceAgents.some((a) => a.id === editingWorkspaceAgent.id);
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
|
<WorkspaceAgentEditor
|
|
agent={editingWorkspaceAgent}
|
|
availableModels={availableModels}
|
|
onBack={() => setEditingWorkspaceAgent(null)}
|
|
onChange={setEditingWorkspaceAgent}
|
|
onDelete={
|
|
exists
|
|
? async () => {
|
|
await onDeleteWorkspaceAgent(editingWorkspaceAgent.id);
|
|
setEditingWorkspaceAgent(null);
|
|
}
|
|
: undefined
|
|
}
|
|
onSave={async () => {
|
|
await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent));
|
|
setEditingWorkspaceAgent(null);
|
|
}}
|
|
workflows={workflows}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="overlay-slide-enter fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
|
<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)]">Settings</h2>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1">
|
|
<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;
|
|
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)}
|
|
type="button"
|
|
>
|
|
<span className={isActive ? 'text-[var(--color-text-secondary)]' : 'text-[var(--color-text-muted)]'}>{item.icon}</span>
|
|
{item.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="mx-auto max-w-2xl px-8 py-6">
|
|
{activeSection === 'appearance' && (
|
|
<AppearanceSection
|
|
theme={theme}
|
|
onSetTheme={onSetTheme}
|
|
notificationsEnabled={notificationsEnabled}
|
|
onSetNotificationsEnabled={onSetNotificationsEnabled}
|
|
minimizeToTray={minimizeToTray}
|
|
onSetMinimizeToTray={onSetMinimizeToTray}
|
|
gitAutoRefreshEnabled={gitAutoRefreshEnabled}
|
|
onSetGitAutoRefreshEnabled={onSetGitAutoRefreshEnabled}
|
|
/>
|
|
)}
|
|
{activeSection === 'connection' && (
|
|
<ConnectionSection
|
|
connection={sidecarCapabilities?.connection}
|
|
isRefreshing={isRefreshingCapabilities}
|
|
modelCount={sidecarCapabilities?.models.length ?? 0}
|
|
onRefresh={onRefreshCapabilities}
|
|
onGetQuota={onGetQuota}
|
|
/>
|
|
)}
|
|
{activeSection === 'workflows' && (
|
|
<WorkflowsSection
|
|
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
|
|
onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())}
|
|
workflows={workflows}
|
|
workflowTemplates={workflowTemplates}
|
|
onCreateWorkflowFromTemplate={onCreateWorkflowFromTemplate}
|
|
/>
|
|
)}
|
|
{activeSection === 'agents' && (
|
|
<WorkspaceAgentsSection
|
|
agents={workspaceAgents}
|
|
workflows={workflows}
|
|
onEditAgent={(agent) => setEditingWorkspaceAgent(structuredClone(agent))}
|
|
onNewAgent={() => setEditingWorkspaceAgent(onNewWorkspaceAgent())}
|
|
/>
|
|
)}
|
|
{activeSection === 'mcp-servers' && (
|
|
<McpServersSection
|
|
onEditServer={(server) => setEditingMcpServer(structuredClone(server))}
|
|
onNewServer={() => setEditingMcpServer(onNewMcpServer())}
|
|
servers={toolingSettings.mcpServers}
|
|
/>
|
|
)}
|
|
{activeSection === 'mcp-servers' && (
|
|
<DiscoveredMcpSection
|
|
discoveredUserTooling={discoveredUserTooling}
|
|
onResolveUserDiscoveredTooling={onResolveUserDiscoveredTooling}
|
|
/>
|
|
)}
|
|
{activeSection === 'lsp-profiles' && (
|
|
<LspProfilesSection
|
|
onEditProfile={(profile) => setEditingLspProfile(structuredClone(profile))}
|
|
onNewProfile={() => setEditingLspProfile(onNewLspProfile())}
|
|
profiles={toolingSettings.lspProfiles}
|
|
/>
|
|
)}
|
|
{activeSection === 'quick-prompt' && (
|
|
<QuickPromptSettingsSection
|
|
settings={quickPromptSettings}
|
|
availableModels={availableModels}
|
|
onUpdate={onSetQuickPromptSettings}
|
|
/>
|
|
)}
|
|
{activeSection === 'telemetry' && (
|
|
<TelemetrySection
|
|
openTelemetry={openTelemetry}
|
|
onSetOpenTelemetry={onSetOpenTelemetry}
|
|
/>
|
|
)}
|
|
{activeSection === 'troubleshooting' && (
|
|
<TroubleshootingSection
|
|
onOpenAppDataFolder={onOpenAppDataFolder}
|
|
onResetLocalWorkspace={onResetLocalWorkspace}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const themeOptions: { value: AppearanceTheme; label: string; description: string }[] = [
|
|
{ value: 'dark', label: 'Dark', description: 'Dark background with light text' },
|
|
{ value: 'light', label: 'Light', description: 'Light background with dark text' },
|
|
{ value: 'system', label: 'System', description: 'Follow your operating system setting' },
|
|
];
|
|
|
|
function AppearanceSection({
|
|
theme,
|
|
onSetTheme,
|
|
notificationsEnabled,
|
|
onSetNotificationsEnabled,
|
|
minimizeToTray,
|
|
onSetMinimizeToTray,
|
|
gitAutoRefreshEnabled,
|
|
onSetGitAutoRefreshEnabled,
|
|
}: {
|
|
theme: AppearanceTheme;
|
|
onSetTheme: (theme: AppearanceTheme) => void;
|
|
notificationsEnabled: boolean;
|
|
onSetNotificationsEnabled: (enabled: boolean) => void;
|
|
minimizeToTray: boolean;
|
|
onSetMinimizeToTray: (enabled: boolean) => void;
|
|
gitAutoRefreshEnabled: boolean;
|
|
onSetGitAutoRefreshEnabled: (enabled: boolean) => void;
|
|
}){
|
|
return (
|
|
<div>
|
|
<div className="mb-1">
|
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Appearance</h3>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Choose how Aryx looks on your device
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-5 space-y-1.5">
|
|
{themeOptions.map((option) => {
|
|
const isSelected = option.value === theme;
|
|
return (
|
|
<button
|
|
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all duration-200 ${
|
|
isSelected
|
|
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)]'
|
|
: 'border-[var(--color-border)] hover:border-[var(--color-border)] hover:bg-[var(--color-surface-3)]/40'
|
|
}`}
|
|
key={option.value}
|
|
onClick={() => onSetTheme(option.value)}
|
|
type="button"
|
|
>
|
|
<div
|
|
className={`flex size-4 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
|
isSelected ? 'border-[var(--color-accent)]' : 'border-[var(--color-border)]'
|
|
}`}
|
|
>
|
|
{isSelected && <div className="size-2 rounded-full bg-[var(--color-accent)]" />}
|
|
</div>
|
|
<div>
|
|
<span className={`text-[13px] font-medium ${isSelected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
|
|
{option.label}
|
|
</span>
|
|
<p className="text-[12px] text-[var(--color-text-muted)]">{option.description}</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Notifications */}
|
|
<div className="mt-8 mb-1">
|
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Notifications</h3>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Control when Aryx sends desktop notifications
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
|
|
onClick={() => onSetNotificationsEnabled(!notificationsEnabled)}
|
|
type="button"
|
|
>
|
|
<div>
|
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
|
Run completion alerts
|
|
</span>
|
|
<p className="text-[12px] text-[var(--color-text-muted)]">
|
|
Notify when a session run completes, fails, or needs approval while the app is unfocused
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch enabled={notificationsEnabled} />
|
|
</button>
|
|
|
|
{/* System Tray */}
|
|
<div className="mt-8 mb-1">
|
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">System Tray</h3>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Control how Aryx behaves when you close the window
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
|
|
onClick={() => onSetMinimizeToTray(!minimizeToTray)}
|
|
type="button"
|
|
>
|
|
<div>
|
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
|
Minimize to tray on close
|
|
</span>
|
|
<p className="text-[12px] text-[var(--color-text-muted)]">
|
|
Keep Aryx running in the system tray when you close the window instead of quitting
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch enabled={minimizeToTray} />
|
|
</button>
|
|
|
|
{/* Git */}
|
|
<div className="mt-8 mb-1">
|
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Git</h3>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Control how Aryx monitors your repositories
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
|
|
onClick={() => onSetGitAutoRefreshEnabled(!gitAutoRefreshEnabled)}
|
|
type="button"
|
|
>
|
|
<div>
|
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
|
Auto-refresh git status
|
|
</span>
|
|
<p className="text-[12px] text-[var(--color-text-muted)]">
|
|
Periodically poll repository status in the background and refresh on window focus
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch enabled={gitAutoRefreshEnabled} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TelemetrySection({
|
|
openTelemetry,
|
|
onSetOpenTelemetry,
|
|
}: {
|
|
openTelemetry?: OpenTelemetrySettings;
|
|
onSetOpenTelemetry?: (settings: OpenTelemetrySettings) => void;
|
|
}) {
|
|
const enabled = openTelemetry?.enabled ?? false;
|
|
const endpoint = openTelemetry?.endpoint ?? DEFAULT_OTEL_ENDPOINT;
|
|
|
|
const handleToggle = () => {
|
|
onSetOpenTelemetry?.({ enabled: !enabled, endpoint });
|
|
};
|
|
|
|
const handleEndpointChange = (value: string) => {
|
|
onSetOpenTelemetry?.({ enabled, endpoint: value });
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-1">
|
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">OpenTelemetry</h3>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Export traces from the sidecar to an OTLP-compatible collector
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
|
|
onClick={handleToggle}
|
|
type="button"
|
|
>
|
|
<div>
|
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
|
Enable OTLP export
|
|
</span>
|
|
<p className="text-[12px] text-[var(--color-text-muted)]">
|
|
Send traces to the configured OTLP endpoint when a new sidecar session starts
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch enabled={enabled} />
|
|
</button>
|
|
|
|
<div className="mt-5">
|
|
<label className="mb-1.5 block text-[12px] font-medium text-[var(--color-text-secondary)]">
|
|
OTLP endpoint
|
|
</label>
|
|
<TextInput
|
|
value={endpoint}
|
|
onChange={handleEndpointChange}
|
|
placeholder={DEFAULT_OTEL_ENDPOINT}
|
|
/>
|
|
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]">
|
|
The URL of any OTLP-compatible collector (e.g. Jaeger, Aspire Dashboard, Grafana Alloy).
|
|
Changes take effect on the next session.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConnectionSection({
|
|
connection,
|
|
modelCount,
|
|
isRefreshing,
|
|
onRefresh,
|
|
onGetQuota,
|
|
}: {
|
|
connection?: SidecarCapabilities['connection'];
|
|
modelCount: number;
|
|
isRefreshing: boolean;
|
|
onRefresh: () => void;
|
|
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<div className="mb-1">
|
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">GitHub Copilot</h3>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Aryx uses your installed GitHub Copilot CLI for AI capabilities
|
|
</p>
|
|
</div>
|
|
<div className="mt-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)] p-4">
|
|
<CopilotStatusCard
|
|
connection={connection}
|
|
isRefreshing={isRefreshing}
|
|
modelCount={modelCount}
|
|
onGetQuota={onGetQuota}
|
|
onRefresh={onRefresh}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const categoryLabels: Record<WorkflowTemplateCategory, string> = {
|
|
'orchestration': 'Orchestration',
|
|
'data-pipeline': 'Data Pipeline',
|
|
'human-in-loop': 'Human-in-Loop',
|
|
};
|
|
|
|
function WorkflowsSection({
|
|
workflows,
|
|
onEditWorkflow,
|
|
onNewWorkflow,
|
|
workflowTemplates,
|
|
onCreateWorkflowFromTemplate,
|
|
}: {
|
|
workflows: WorkflowDefinition[];
|
|
onEditWorkflow: (workflow: WorkflowDefinition) => void;
|
|
onNewWorkflow: () => void;
|
|
workflowTemplates?: WorkflowTemplateDefinition[];
|
|
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
|
|
}) {
|
|
const [categoryFilter, setCategoryFilter] = useState<WorkflowTemplateCategory | 'all'>('all');
|
|
|
|
const filteredTemplates = (workflowTemplates ?? []).filter(
|
|
(t) => categoryFilter === 'all' || t.category === categoryFilter,
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
description="Design multi-step agent workflows with visual graphs"
|
|
title="Workflows"
|
|
>
|
|
<SectionAction label="New Workflow" onClick={onNewWorkflow} />
|
|
</SectionHeader>
|
|
|
|
<div className="space-y-1">
|
|
{workflows.map((wf) => {
|
|
const agentCount = wf.graph.nodes.filter((n) => n.kind === 'agent').length;
|
|
return (
|
|
<button
|
|
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
|
|
key={wf.id}
|
|
onClick={() => onEditWorkflow(wf)}
|
|
type="button"
|
|
>
|
|
<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)]">{wf.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)]">
|
|
{wf.settings.executionMode}
|
|
</span>
|
|
</div>
|
|
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{wf.description}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[12px] text-[var(--color-text-muted)]">
|
|
{agentCount} agent{agentCount === 1 ? '' : 's'}
|
|
</span>
|
|
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
{workflows.length === 0 && (
|
|
<p className="px-4 py-6 text-center text-[12px] text-[var(--color-text-muted)]">
|
|
No workflows yet. Create one to get started.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Template Gallery */}
|
|
{workflowTemplates && workflowTemplates.length > 0 && (
|
|
<div className="mt-8">
|
|
<div className="mb-3">
|
|
<h4 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Templates</h4>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Start from a pre-built workflow template
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mb-4 flex gap-1.5">
|
|
{(['all', 'orchestration', 'data-pipeline', 'human-in-loop'] as const).map((cat) => (
|
|
<button
|
|
key={cat}
|
|
className={`rounded-lg px-2.5 py-1 text-[11px] font-medium transition-all duration-200 ${
|
|
categoryFilter === cat
|
|
? 'bg-[var(--color-accent)] text-white'
|
|
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
|
}`}
|
|
onClick={() => setCategoryFilter(cat)}
|
|
type="button"
|
|
>
|
|
{cat === 'all' ? 'All' : categoryLabels[cat]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{filteredTemplates.map((template) => {
|
|
const nodeCount = template.workflow.graph.nodes.length;
|
|
const agentCount = template.workflow.graph.nodes.filter((n) => n.kind === 'agent').length;
|
|
return (
|
|
<div
|
|
key={template.id}
|
|
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)]/50 p-4 transition-all duration-200 hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-1)]"
|
|
>
|
|
<div className="mb-2 flex items-start justify-between gap-2">
|
|
<h5 className="text-[13px] font-medium text-[var(--color-text-primary)]">{template.name}</h5>
|
|
<div className="flex shrink-0 gap-1">
|
|
<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)]">
|
|
{categoryLabels[template.category]}
|
|
</span>
|
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${
|
|
template.source === 'builtin'
|
|
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
|
|
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]'
|
|
}`}>
|
|
{template.source}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p className="mb-3 line-clamp-2 text-[12px] leading-relaxed text-[var(--color-text-muted)]">
|
|
{template.description}
|
|
</p>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[11px] text-[var(--color-text-muted)]">
|
|
{nodeCount} node{nodeCount === 1 ? '' : 's'} · {agentCount} agent{agentCount === 1 ? '' : 's'}
|
|
</span>
|
|
{onCreateWorkflowFromTemplate && (
|
|
<button
|
|
className="rounded-lg bg-[var(--color-accent)] px-3 py-1 text-[11px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
|
|
onClick={() => void onCreateWorkflowFromTemplate(template.id)}
|
|
type="button"
|
|
>
|
|
Use Template
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{filteredTemplates.length === 0 && (
|
|
<p className="py-6 text-center text-[12px] text-[var(--color-text-muted)]">
|
|
No templates in this category.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkspaceAgentsSection({
|
|
agents,
|
|
workflows,
|
|
onEditAgent,
|
|
onNewAgent,
|
|
}: {
|
|
agents: WorkspaceAgentDefinition[];
|
|
workflows: WorkflowDefinition[];
|
|
onEditAgent: (agent: WorkspaceAgentDefinition) => void;
|
|
onNewAgent: () => void;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
description="Define reusable agents that can be shared across multiple workflows"
|
|
title="Workspace Agents"
|
|
>
|
|
<SectionAction label="New Agent" onClick={onNewAgent} />
|
|
</SectionHeader>
|
|
|
|
{agents.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-[var(--color-border)] px-6 py-10 text-center">
|
|
<UserCircle className="mx-auto size-8 text-[var(--color-text-muted)]" />
|
|
<p className="mt-3 text-[13px] font-medium text-[var(--color-text-secondary)]">
|
|
No workspace agents yet
|
|
</p>
|
|
<p className="mt-1 text-[12px] text-[var(--color-text-muted)]">
|
|
Create agents here and reference them in multiple workflows.
|
|
Changes to a workspace agent automatically propagate to all linked workflows.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{agents.map((agent) => {
|
|
const usageCount = findWorkspaceAgentUsages(agent.id, workflows).length;
|
|
return (
|
|
<button
|
|
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
|
|
key={agent.id}
|
|
onClick={() => onEditAgent(agent)}
|
|
type="button"
|
|
>
|
|
<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.name}</span>
|
|
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
|
{agent.model}
|
|
</span>
|
|
</div>
|
|
{agent.description && (
|
|
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{agent.description}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{usageCount > 0 && (
|
|
<span className="text-[12px] text-[var(--color-text-muted)]">
|
|
{usageCount} workflow{usageCount === 1 ? '' : 's'}
|
|
</span>
|
|
)}
|
|
<ChevronRight className="size-4 text-[var(--color-text-muted)]" />
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function McpServersSection({
|
|
servers,
|
|
onEditServer,
|
|
onNewServer,
|
|
}: {
|
|
servers: McpServerDefinition[];
|
|
onEditServer: (server: McpServerDefinition) => void;
|
|
onNewServer: () => void;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
description="Define machine-wide MCP servers that sessions can enable from the Activity panel."
|
|
title="MCP Servers"
|
|
>
|
|
<SectionAction label="New MCP Server" onClick={onNewServer} />
|
|
</SectionHeader>
|
|
|
|
<div className="space-y-1">
|
|
{servers.length === 0 && (
|
|
<EmptyState>
|
|
No MCP servers configured yet. Add one here, then enable it per session from the Activity panel.
|
|
</EmptyState>
|
|
)}
|
|
{servers.map((server) => (
|
|
<ToolingListButton
|
|
detail={
|
|
server.transport === 'local'
|
|
? server.command || 'No command set'
|
|
: server.url || 'No URL set'
|
|
}
|
|
key={server.id}
|
|
label={server.name}
|
|
meta={server.transport.toUpperCase()}
|
|
onClick={() => onEditServer(server)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LspProfilesSection({
|
|
profiles,
|
|
onEditProfile,
|
|
onNewProfile,
|
|
}: {
|
|
profiles: LspProfileDefinition[];
|
|
onEditProfile: (profile: LspProfileDefinition) => void;
|
|
onNewProfile: () => void;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
description="Define machine-wide LSP commands that sessions can enable from the Activity panel."
|
|
title="LSP Profiles"
|
|
>
|
|
<SectionAction label="New LSP Profile" onClick={onNewProfile} />
|
|
</SectionHeader>
|
|
|
|
<div className="space-y-1">
|
|
{profiles.length === 0 && (
|
|
<EmptyState>
|
|
No LSP profiles configured yet. Add one here, then enable it per session from the Activity panel.
|
|
</EmptyState>
|
|
)}
|
|
{profiles.map((profile) => (
|
|
<ToolingListButton
|
|
detail={profile.command || 'No command set'}
|
|
key={profile.id}
|
|
label={profile.name}
|
|
meta={profile.languageId}
|
|
onClick={() => onEditProfile(profile)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 SectionAction({ label, onClick }: { label: string; onClick: () => void }) {
|
|
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}
|
|
type="button"
|
|
>
|
|
<Plus className="size-3.5" />
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ToolingListButton({
|
|
label,
|
|
detail,
|
|
meta,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
detail: string;
|
|
meta: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
|
|
onClick={onClick}
|
|
type="button"
|
|
>
|
|
<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)]">{label}</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)]">
|
|
{meta}
|
|
</span>
|
|
</div>
|
|
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{detail}</p>
|
|
</div>
|
|
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
/* ── Discovered MCP section ────────────────────────────────── */
|
|
|
|
function DiscoveredMcpSection({
|
|
discoveredUserTooling,
|
|
onResolveUserDiscoveredTooling,
|
|
}: {
|
|
discoveredUserTooling: DiscoveredToolingState;
|
|
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
|
}) {
|
|
const acceptedUser = listAcceptedDiscoveredMcpServers(discoveredUserTooling);
|
|
const pendingUser = listPendingDiscoveredMcpServers(discoveredUserTooling);
|
|
|
|
const hasAny = acceptedUser.length + pendingUser.length > 0;
|
|
|
|
if (!hasAny) return null;
|
|
|
|
return (
|
|
<div className="mt-8">
|
|
<SectionHeader
|
|
description="MCP servers discovered from user config files. Accepted servers are available for session tooling."
|
|
title="Discovered MCP Servers"
|
|
/>
|
|
|
|
<DiscoveredSubSection
|
|
label="User-level"
|
|
description="From ~/.copilot/mcp.json"
|
|
accepted={acceptedUser}
|
|
pending={pendingUser}
|
|
onResolve={onResolveUserDiscoveredTooling}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiscoveredSubSection({
|
|
label,
|
|
description,
|
|
accepted,
|
|
pending,
|
|
onResolve,
|
|
}: {
|
|
label: string;
|
|
description: string;
|
|
accepted: DiscoveredMcpServer[];
|
|
pending: DiscoveredMcpServer[];
|
|
onResolve?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
|
|
}) {
|
|
return (
|
|
<div className="mb-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div>
|
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
|
<p className="text-[11px] text-[var(--color-text-muted)]">{description}</p>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{accepted.map((server) => (
|
|
<DiscoveredServerRow
|
|
key={server.id}
|
|
onDismiss={onResolve ? () => onResolve([server.id], 'dismiss') : undefined}
|
|
server={server}
|
|
status="accepted"
|
|
/>
|
|
))}
|
|
{pending.map((server) => (
|
|
<DiscoveredServerRow
|
|
key={server.id}
|
|
onAccept={onResolve ? () => onResolve([server.id], 'accept') : undefined}
|
|
onDismiss={onResolve ? () => onResolve([server.id], 'dismiss') : undefined}
|
|
server={server}
|
|
status="pending"
|
|
/>
|
|
))}
|
|
</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)]">
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function TroubleshootingSection({
|
|
onOpenAppDataFolder,
|
|
onResetLocalWorkspace,
|
|
}: {
|
|
onOpenAppDataFolder: () => void;
|
|
onResetLocalWorkspace: () => Promise<void>;
|
|
}) {
|
|
const [isResetting, setIsResetting] = useState(false);
|
|
const [confirmingReset, setConfirmingReset] = useState(false);
|
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
|
|
const [isCheckingManually, setIsCheckingManually] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = window.aryxApi.onUpdateStatus((status) => {
|
|
setUpdateStatus(status);
|
|
if (status.state !== 'checking') setIsCheckingManually(false);
|
|
});
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
async function handleCheckForUpdates() {
|
|
setIsCheckingManually(true);
|
|
try {
|
|
const status = await window.aryxApi.checkForUpdates();
|
|
setUpdateStatus(status);
|
|
} finally {
|
|
setIsCheckingManually(false);
|
|
}
|
|
}
|
|
|
|
async function handleInstallUpdate() {
|
|
await window.aryxApi.installUpdate();
|
|
}
|
|
|
|
async function handleReset() {
|
|
setIsResetting(true);
|
|
try {
|
|
await onResetLocalWorkspace();
|
|
} finally {
|
|
setIsResetting(false);
|
|
setConfirmingReset(false);
|
|
}
|
|
}
|
|
|
|
const isChecking = isCheckingManually || updateStatus.state === 'checking';
|
|
|
|
function getUpdateLabel(): string {
|
|
switch (updateStatus.state) {
|
|
case 'checking':
|
|
return 'Checking for updates…';
|
|
case 'up-to-date':
|
|
return 'Up to date';
|
|
case 'available':
|
|
return `Update available: v${updateStatus.version ?? 'unknown'}`;
|
|
case 'downloading':
|
|
return `Downloading update${updateStatus.downloadProgress ? ` (${Math.round(updateStatus.downloadProgress.percent)}%)` : '…'}`;
|
|
case 'downloaded':
|
|
return `Update ready: v${updateStatus.version ?? 'unknown'}`;
|
|
case 'error':
|
|
return 'Update check failed';
|
|
default:
|
|
return 'Check for updates';
|
|
}
|
|
}
|
|
|
|
function getUpdateDescription(): string {
|
|
switch (updateStatus.state) {
|
|
case 'checking':
|
|
return 'Contacting the update server…';
|
|
case 'up-to-date':
|
|
return 'You are running the latest version of Aryx.';
|
|
case 'available':
|
|
case 'downloading':
|
|
return 'A new version is being downloaded and will be installed automatically.';
|
|
case 'downloaded':
|
|
return 'Restart Aryx to apply the update.';
|
|
case 'error':
|
|
return updateStatus.error ?? 'Could not reach the update server. Try again later.';
|
|
default:
|
|
return 'Manually check whether a newer version of Aryx is available.';
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-full flex-col">
|
|
<div className="flex-1">
|
|
<SectionHeader
|
|
description="Diagnose issues and manage local application data"
|
|
title="Troubleshooting"
|
|
/>
|
|
|
|
<div className="space-y-2">
|
|
{/* Check for updates */}
|
|
{updateStatus.state === 'downloaded' ? (
|
|
<button
|
|
className="group flex w-full items-center gap-3 rounded-xl border border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/5 px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-status-success)]/40 hover:bg-[var(--color-status-success)]/10"
|
|
onClick={() => void handleInstallUpdate()}
|
|
type="button"
|
|
>
|
|
<span className="text-[var(--color-status-success)]">
|
|
<RefreshCw className="size-4" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-[13px] font-medium text-[var(--color-status-success)]">{getUpdateLabel()}</span>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{getUpdateDescription()}</p>
|
|
</div>
|
|
<span className="rounded-lg bg-[var(--color-status-success)]/15 px-2.5 py-1 text-[11px] font-semibold text-[var(--color-status-success)]">
|
|
Restart
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
className={`group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)] ${isChecking ? 'pointer-events-none opacity-70' : ''}`}
|
|
disabled={isChecking}
|
|
onClick={() => void handleCheckForUpdates()}
|
|
type="button"
|
|
>
|
|
<span className={`transition-all duration-200 ${updateStatus.state === 'up-to-date' ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]'}`}>
|
|
{updateStatus.state === 'up-to-date'
|
|
? <CircleCheck className="size-4" />
|
|
: <RefreshCw className={`size-4 ${isChecking ? 'animate-spin' : ''}`} />}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<span className={`text-[13px] font-medium ${updateStatus.state === 'error' ? 'text-[var(--color-status-error)]' : updateStatus.state === 'up-to-date' ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-primary)]'}`}>
|
|
{getUpdateLabel()}
|
|
</span>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{getUpdateDescription()}</p>
|
|
</div>
|
|
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
|
|
</button>
|
|
)}
|
|
|
|
<TroubleshootingAction
|
|
description="Reveal the folder where Aryx stores workspace data, scratchpad files, and configuration."
|
|
icon={<FolderOpen className="size-4" />}
|
|
label="Open App Data Folder"
|
|
onClick={onOpenAppDataFolder}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-8 rounded-xl border border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 p-5">
|
|
<div className="flex items-start gap-3">
|
|
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-[var(--color-status-error)]" />
|
|
<div className="min-w-0 flex-1">
|
|
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
|
|
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
|
|
Restore Aryx to its initial state. This permanently removes all sessions, custom workflows,
|
|
MCP server definitions, LSP profiles, and scratchpad contents. Your GitHub Copilot sign-in
|
|
is not affected.
|
|
</p>
|
|
|
|
{!confirmingReset ? (
|
|
<button
|
|
className="mt-3 rounded-lg border border-[var(--color-status-error)]/30 bg-[var(--color-status-error)]/10 px-3.5 py-1.5 text-[13px] font-medium text-[var(--color-status-error)] transition-all duration-200 hover:border-[var(--color-status-error)]/50 hover:bg-[var(--color-status-error)]/20"
|
|
onClick={() => setConfirmingReset(true)}
|
|
type="button"
|
|
>
|
|
Reset workspace…
|
|
</button>
|
|
) : (
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<button
|
|
className="rounded-lg bg-[var(--color-status-error)] px-3.5 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-status-error)] disabled:opacity-50"
|
|
disabled={isResetting}
|
|
onClick={() => void handleReset()}
|
|
type="button"
|
|
>
|
|
{isResetting ? 'Resetting…' : 'Confirm reset'}
|
|
</button>
|
|
<button
|
|
className="rounded-lg border border-[var(--color-border)] px-3.5 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)]"
|
|
disabled={isResetting}
|
|
onClick={() => setConfirmingReset(false)}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Attribution footer */}
|
|
<div className="mt-12 flex items-center justify-center gap-1.5 pb-2 text-[11px] text-[var(--color-text-muted)]">
|
|
<span>Built with</span>
|
|
<svg aria-hidden="true" className="size-3 text-[var(--color-status-error)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
|
</svg>
|
|
<span>by Dávid Kaya</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TroubleshootingAction({
|
|
icon,
|
|
label,
|
|
description,
|
|
onClick,
|
|
}: {
|
|
icon: ReactNode;
|
|
label: string;
|
|
description: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
|
|
onClick={onClick}
|
|
type="button"
|
|
>
|
|
<span className="text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-secondary)]">{icon}</span>
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{label}</span>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{description}</p>
|
|
</div>
|
|
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function QuickPromptSettingsSection({
|
|
settings,
|
|
availableModels,
|
|
onUpdate,
|
|
}: {
|
|
settings?: QuickPromptSettings;
|
|
availableModels: ReadonlyArray<ModelDefinition>;
|
|
onUpdate?: (patch: Partial<QuickPromptSettings>) => void;
|
|
}) {
|
|
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
|
|
|
|
const enabled = settings?.enabled ?? true;
|
|
const hotkey = settings?.hotkey ?? 'Alt+Shift+C';
|
|
const defaultModel = settings?.defaultModel;
|
|
const defaultReasoning = settings?.defaultReasoningEffort;
|
|
|
|
const resolvedModel = defaultModel ? availableModels.find((m) => m.id === defaultModel) : undefined;
|
|
const modelSupportsReasoning = resolvedModel?.supportedReasoningEfforts?.length;
|
|
|
|
// Group models by tier for the dropdown
|
|
const tierOrder = ['premium', 'standard', 'fast'] as const;
|
|
const tierLabels: Record<string, string> = { premium: 'Premium', standard: 'Standard', fast: 'Fast' };
|
|
const groupedModels = tierOrder
|
|
.map((tier) => ({
|
|
tier,
|
|
label: tierLabels[tier],
|
|
models: availableModels.filter((m) => m.tier === tier),
|
|
}))
|
|
.filter((g) => g.models.length > 0);
|
|
|
|
// Models without a tier
|
|
const untypedModels = availableModels.filter((m) => !m.tier);
|
|
if (untypedModels.length > 0) {
|
|
groupedModels.push({ tier: 'other' as never, label: 'Other', models: untypedModels });
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-1">
|
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Quick Prompt</h3>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Press a global hotkey to ask the AI a quick question from anywhere
|
|
</p>
|
|
</div>
|
|
|
|
{/* Enable / Disable toggle */}
|
|
<div className="mt-5 flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3">
|
|
<button
|
|
className="flex flex-1 items-start gap-0 text-left"
|
|
onClick={() => onUpdate?.({ enabled: !enabled })}
|
|
type="button"
|
|
>
|
|
<div className="flex-1">
|
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
|
Enable global hotkey
|
|
</span>
|
|
<p className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
|
|
Summon Quick Prompt from any app
|
|
</p>
|
|
</div>
|
|
</button>
|
|
<button onClick={() => onUpdate?.({ enabled: !enabled })} type="button">
|
|
<ToggleSwitch enabled={enabled} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Hotkey Recorder */}
|
|
<div className="mt-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
|
Keyboard shortcut
|
|
</span>
|
|
</div>
|
|
<HotkeyRecorder
|
|
value={hotkey}
|
|
disabled={!enabled}
|
|
onChange={(newHotkey) => onUpdate?.({ hotkey: newHotkey })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Default Model — compact dropdown selector */}
|
|
<div className="mt-6">
|
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Default Model</h3>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Override the workflow model for Quick Prompt sessions
|
|
</p>
|
|
|
|
<div className="relative mt-3">
|
|
{/* Trigger button */}
|
|
<button
|
|
onClick={() => setModelDropdownOpen((prev) => !prev)}
|
|
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition ${
|
|
modelDropdownOpen
|
|
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-muted)]'
|
|
: 'border-[var(--color-border)] hover:bg-[var(--color-surface-3)]/40'
|
|
}`}
|
|
type="button"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={modelDropdownOpen}
|
|
>
|
|
<div className="flex-1">
|
|
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
|
|
{resolvedModel?.name ?? 'Workflow default'}
|
|
</span>
|
|
{resolvedModel?.tier && (
|
|
<span className={`ml-2 inline-flex items-center gap-1 rounded-[4px] px-1.5 py-px text-[10px] font-medium ${
|
|
resolvedModel.tier === 'premium' ? 'bg-amber-400/10 text-amber-400' :
|
|
resolvedModel.tier === 'fast' ? 'bg-emerald-400/10 text-emerald-400' :
|
|
'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
|
|
}`}>
|
|
{resolvedModel.tier}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<ChevronRight className={`size-4 text-[var(--color-text-muted)] transition-transform ${modelDropdownOpen ? 'rotate-90' : ''}`} />
|
|
</button>
|
|
|
|
{/* Dropdown */}
|
|
{modelDropdownOpen && (
|
|
<div className="absolute top-full left-0 right-0 z-10 mt-1 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-xl shadow-black/40">
|
|
<div className="max-h-[280px] overflow-y-auto p-1.5">
|
|
{/* Workflow default option */}
|
|
<button
|
|
onClick={() => { onUpdate?.({ defaultModel: undefined }); setModelDropdownOpen(false); }}
|
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition ${
|
|
!defaultModel
|
|
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
|
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]'
|
|
}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={!defaultModel}
|
|
>
|
|
<span className="flex-1 text-[12px] font-medium">Workflow default</span>
|
|
{!defaultModel && <CircleCheck className="size-3.5 text-[var(--color-accent)]" />}
|
|
</button>
|
|
|
|
{/* Grouped models */}
|
|
{groupedModels.map((group) => (
|
|
<div key={group.tier}>
|
|
<div className="mx-2 mt-2 mb-1 border-t border-[var(--color-border-subtle)]/50" />
|
|
<div className="px-2.5 pt-1.5 pb-0.5 text-[10px] font-semibold tracking-wider text-[var(--color-text-muted)] uppercase">
|
|
{group.label}
|
|
</div>
|
|
{group.models.map((model) => {
|
|
const isSelected = defaultModel === model.id;
|
|
return (
|
|
<button
|
|
key={model.id}
|
|
onClick={() => { onUpdate?.({ defaultModel: model.id }); setModelDropdownOpen(false); }}
|
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-[7px] text-left transition ${
|
|
isSelected
|
|
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
|
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]'
|
|
}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
>
|
|
<span className="flex-1 truncate text-[12px] font-medium">{model.name}</span>
|
|
{model.supportedReasoningEfforts?.length ? (
|
|
<span className="text-[9px] font-semibold tracking-wide text-[var(--color-text-muted)] uppercase">reasoning</span>
|
|
) : null}
|
|
{isSelected && <CircleCheck className="size-3.5 flex-none text-[var(--color-accent)]" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reasoning Effort — only shown when selected model supports it */}
|
|
{modelSupportsReasoning ? (
|
|
<div className="mt-6">
|
|
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Reasoning Effort</h3>
|
|
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
|
|
Higher effort produces more thorough answers but takes longer
|
|
</p>
|
|
|
|
<div className="mt-3 flex gap-1.5">
|
|
{([undefined, 'low', 'medium', 'high'] as const).map((effort) => {
|
|
const isActive = defaultReasoning === effort;
|
|
const label = effort ?? 'Auto';
|
|
const displayLabel = label.charAt(0).toUpperCase() + label.slice(1);
|
|
|
|
// Only show efforts the model actually supports (plus "Auto")
|
|
if (effort && !resolvedModel?.supportedReasoningEfforts?.includes(effort)) return null;
|
|
|
|
return (
|
|
<button
|
|
key={displayLabel}
|
|
onClick={() => onUpdate?.({ defaultReasoningEffort: effort })}
|
|
className={`flex-1 rounded-lg border py-2 text-[12px] font-medium transition-all duration-150 ${
|
|
isActive
|
|
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent)]/12 text-[var(--color-text-accent)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-border-glow)] hover:text-[var(--color-text-secondary)]'
|
|
}`}
|
|
type="button"
|
|
>
|
|
{displayLabel}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : defaultModel ? (
|
|
<p className="mt-4 text-[11px] text-[var(--color-text-muted)]">
|
|
{resolvedModel?.name ?? 'Selected model'} does not support configurable reasoning effort.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|