Skip to content
Merged
23 changes: 11 additions & 12 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { ConnectIDEs } from '@/components/ConnectIDEs';
import { useDataSync } from '@/hooks/useDataSync';
import { useAnalytics } from '@/hooks/useAnalytics';
import { initAnalytics, capture, optIn, optOut } from '@/lib/analytics';
import { useAppStore, useActiveSpace, useViewSpace, useTheme, useAnalyticsEnabled } from '@/stores';
import { useAppStore, useActiveSpace, useViewSpace, useTheme, useAnalyticsEnabled, useActiveNav, useNavigateTo } from '@/stores';
import { RegistryPage } from '@/features/registry';
import { FeatureSetsPage } from '@/features/featuresets';
import { ClientsPage } from '@/features/clients';
Expand Down Expand Up @@ -80,13 +80,12 @@ function McpMuxGlyph({ className }: { className?: string }) {
);
}

type NavItem = 'home' | 'registry' | 'servers' | 'spaces' | 'featuresets' | 'clients' | 'settings';

function AppContent() {
// Sync data from backend on mount
useDataSync();

const [activeNav, setActiveNav] = useState<NavItem>('home');
const activeNav = useActiveNav();
const navigateTo = useNavigateTo();
const [availableUpdate, setAvailableUpdate] = useState<{ version: string } | null>(null);

// Auto-check for updates on startup (silent check after 5 seconds)
Expand Down Expand Up @@ -199,21 +198,21 @@ function AppContent() {
icon={<Home className="h-4 w-4" />}
label="Dashboard"
active={activeNav === 'home'}
onClick={() => setActiveNav('home')}
onClick={() => navigateTo('home')}
data-testid="nav-dashboard"
/>
<SidebarItem
icon={<Server className="h-4 w-4" />}
label="My Servers"
active={activeNav === 'servers'}
onClick={() => setActiveNav('servers')}
onClick={() => navigateTo('servers')}
data-testid="nav-my-servers"
/>
<SidebarItem
icon={<Server className="h-4 w-4" />}
label="Discover"
active={activeNav === 'registry'}
onClick={() => setActiveNav('registry')}
onClick={() => navigateTo('registry')}
data-testid="nav-discover"
/>
</SidebarSection>
Expand All @@ -223,14 +222,14 @@ function AppContent() {
icon={<Globe className="h-4 w-4" />}
label="Spaces"
active={activeNav === 'spaces'}
onClick={() => setActiveNav('spaces')}
onClick={() => navigateTo('spaces')}
data-testid="nav-spaces"
/>
<SidebarItem
icon={<Wrench className="h-4 w-4" />}
label="FeatureSets"
active={activeNav === 'featuresets'}
onClick={() => setActiveNav('featuresets')}
onClick={() => navigateTo('featuresets')}
data-testid="nav-featuresets"
/>
</SidebarSection>
Expand All @@ -240,7 +239,7 @@ function AppContent() {
icon={<Monitor className="h-4 w-4" />}
label="Clients"
active={activeNav === 'clients'}
onClick={() => setActiveNav('clients')}
onClick={() => navigateTo('clients')}
data-testid="nav-clients"
/>
</SidebarSection>
Expand All @@ -250,7 +249,7 @@ function AppContent() {
icon={<Settings className="h-4 w-4" />}
label="Settings"
active={activeNav === 'settings'}
onClick={() => setActiveNav('settings')}
onClick={() => navigateTo('settings')}
data-testid="nav-settings"
/>
</SidebarSection>
Expand Down Expand Up @@ -316,7 +315,7 @@ function AppContent() {
</span>
<button
onClick={() => {
setActiveNav('settings');
navigateTo('settings');
setAvailableUpdate(null);
}}
className="text-blue-500 hover:text-blue-400 font-medium underline underline-offset-2"
Expand Down
37 changes: 23 additions & 14 deletions apps/desktop/src/components/ConfigEditorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,18 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf
<div className="bg-[rgb(var(--surface))] w-full max-w-4xl h-[80vh] rounded-xl shadow-2xl flex flex-col border border-[rgb(var(--border))]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[rgb(var(--border))]">
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
Add Custom Server
</h3>
<p className="text-sm text-[rgb(var(--muted))]">
Edit the JSON configuration for space: {spaceName}
</p>
<div className="flex items-center gap-3">
<div className="w-9 h-9 flex items-center justify-center rounded-lg bg-[rgb(var(--primary))]/10 border border-[rgb(var(--primary))]/20">
<Save className="h-4 w-4 text-[rgb(var(--primary))]" />
</div>
<div>
<h3 className="text-base font-semibold">
Custom Server Configuration
</h3>
<p className="text-xs text-[rgb(var(--muted))]">
{spaceName} &middot; JSON config
</p>
</div>
</div>
<button
onClick={onClose}
Expand All @@ -180,36 +185,40 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf
</div>

{/* Toolbar */}
<div className="flex items-center gap-2 p-2 border-b border-[rgb(var(--border))] bg-[rgb(var(--surface-dim))]">
<div className="flex items-center gap-2 px-3 py-2 border-b border-[rgb(var(--border))] bg-[rgb(var(--surface-dim))]">
<button
onClick={handleSave}
disabled={isSaving || isLoading || !isValidJson}
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium bg-[rgb(var(--primary))] text-[rgb(var(--primary-foreground))] rounded-md hover:bg-[rgb(var(--primary-hover))] disabled:opacity-50 transition-colors"
className="flex items-center gap-2 px-3.5 py-1.5 text-sm font-medium bg-[rgb(var(--primary))] text-[rgb(var(--primary-foreground))] rounded-lg hover:bg-[rgb(var(--primary-hover))] disabled:opacity-50 shadow-sm transition-colors"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save Changes
Save
</button>
<div className="h-4 w-px bg-[rgb(var(--border))]" />

<div className="h-5 w-px bg-[rgb(var(--border))]" />

<button
onClick={handleFormat}
disabled={isLoading || !isValidJson}
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] rounded-md transition-colors disabled:opacity-50"
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] rounded-lg transition-colors disabled:opacity-50"
title="Format JSON (Ctrl+Shift+F)"
>
<Wand2 className="h-4 w-4" />
Format
</button>

<div className="flex-1" />

{!isValidJson && (
<span className="flex items-center gap-1.5 text-xs text-[rgb(var(--error))] px-2 font-medium">
<AlertTriangle className="h-3 w-3" />
{validationErrors.length > 0 ? 'Schema Error' : 'Invalid JSON'}
</span>
)}

<span className="text-xs text-[rgb(var(--muted))]">
Ctrl+S save &middot; Ctrl+Shift+F format
</span>
</div>

{/* Editor Area */}
Expand Down
58 changes: 56 additions & 2 deletions apps/desktop/src/components/OAuthConsentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { listen } from '@tauri-apps/api/event';
import { Check, X, AlertCircle, Loader2, Globe, Lock } from 'lucide-react';
import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent } from '@mcpmux/ui';
import { listSpaces, type Space } from '@/lib/api/spaces';
import { useNavigateTo } from '@/stores';
import { resolveKnownClientKey } from '@/lib/clientIcons';
import cursorIcon from '@/assets/client-icons/cursor.svg';
import vscodeIcon from '@/assets/client-icons/vscode.png';
Expand Down Expand Up @@ -72,7 +73,8 @@ type ModalState =
| { type: 'hidden' }
| { type: 'loading'; requestId: string }
| { type: 'error'; requestId: string; error: ConsentError }
| { type: 'consent'; details: ConsentRequestDetails };
| { type: 'consent'; details: ConsentRequestDetails }
| { type: 'approved'; clientName: string };

/** Open a URL using the backend open command (handles custom protocols like cursor://) */
async function openRedirectUrl(url: string): Promise<void> {
Expand Down Expand Up @@ -121,6 +123,7 @@ export function OAuthConsentModal() {
const [processError, setProcessError] = useState<string | null>(null);
/** 2-second cooldown before the Approve button becomes active */
const [approveReady, setApproveReady] = useState(false);
const navigateTo = useNavigateTo();

// Load spaces when modal opens
useEffect(() => {
Expand Down Expand Up @@ -196,7 +199,7 @@ export function OAuthConsentModal() {
if (response.success && response.redirect_url) {
console.log('[OAuth] Approved, redirecting to:', response.redirect_url);
await openRedirectUrl(response.redirect_url);
setModalState({ type: 'hidden' });
setModalState({ type: 'approved', clientName: clientAlias || details.clientName });
} else {
setProcessError(response.error || 'Failed to approve consent');
}
Expand Down Expand Up @@ -289,6 +292,57 @@ export function OAuthConsentModal() {
);
}

// Approved state - show success with next-step guidance
if (modalState.type === 'approved') {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card className="animate-in fade-in zoom-in mx-4 w-full max-w-md shadow-xl duration-200">
<CardHeader>
<div className="flex items-center gap-3">
<div className="rounded-full bg-green-500/10 p-2">
<Check className="h-6 w-6 text-green-500" />
</div>
<div>
<CardTitle>Client Approved</CardTitle>
<CardDescription>
{modalState.clientName} is now connected
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] p-4 text-sm">
<p className="font-medium mb-1">Next step: Grant permissions</p>
<p className="text-[rgb(var(--muted))]">
Assign FeatureSets to control which tools, prompts, and resources this client can access.
</p>
</div>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={handleDismiss}
>
Later
</Button>
<Button
variant="primary"
className="flex-1 whitespace-nowrap"
onClick={() => {
navigateTo('clients');
handleDismiss();
}}
data-testid="go-to-clients-btn"
>
Manage Permissions
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

// Consent state - show approval modal
const { details } = modalState;
const scopes = details.scope?.split(' ').filter(Boolean) || ['mcp'];
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/components/ServerLogViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState, useRef } from 'react';
import { X, Download, Trash2, RefreshCw } from 'lucide-react';
import { useToast, ToastContainer } from '@mcpmux/ui';
import { useToast, ToastContainer, useConfirm } from '@mcpmux/ui';
import { getServerLogs, clearServerLogs, getServerLogFile, type ServerLogEntry } from '@/lib/api/logs';

interface ServerLogViewerProps {
Expand Down Expand Up @@ -41,6 +41,7 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(true);
const { toasts, success, error: showError, dismiss } = useToast();
const { confirm, ConfirmDialogElement } = useConfirm();

const loadLogs = async () => {
try {
Expand Down Expand Up @@ -93,7 +94,12 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView
};

const handleClearLogs = async () => {
if (!confirm('Clear all logs for this server? This cannot be undone.')) {
if (!await confirm({
title: 'Clear logs',
message: `Clear all logs for "${serverName}"? This cannot be undone.`,
confirmLabel: 'Clear',
variant: 'danger',
})) {
return;
}

Expand Down Expand Up @@ -135,6 +141,7 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<ToastContainer toasts={toasts} onClose={dismiss} />
{ConfirmDialogElement}
<div className="bg-[rgb(var(--card))] border border-[rgb(var(--border-subtle))] rounded-xl shadow-xl w-[90vw] h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[rgb(var(--border-subtle))]">
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/src/features/clients/ClientsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
Button,
useToast,
ToastContainer,
useConfirm,
} from '@mcpmux/ui';
import type { OAuthClient, UpdateClientRequest } from '@/lib/api/gateway';
import { listOAuthClients, updateOAuthClient, deleteOAuthClient } from '@/lib/api/gateway';
Expand Down Expand Up @@ -120,7 +121,8 @@ export default function ClientsPage() {
const [selectedClient, setSelectedClient] = useState<OAuthClient | null>(null);

const { toasts, success, error: showError, info, dismiss } = useToast();

const { confirm, ConfirmDialogElement } = useConfirm();

// Edit state
const [editAlias, setEditAlias] = useState('');
const [editMode, setEditMode] = useState('follow_active');
Expand Down Expand Up @@ -383,9 +385,14 @@ export default function ClientsPage() {
};

const handleDelete = async (clientId: string) => {
if (!confirm('Remove this client? All tokens will be revoked.')) return;

const deletedClient = oauthClients.find(c => c.client_id === clientId);
const name = deletedClient?.client_alias || deletedClient?.client_name || 'this client';
if (!await confirm({
title: 'Remove client',
message: `Remove "${name}"? All tokens will be revoked.`,
confirmLabel: 'Remove',
variant: 'danger',
})) return;
const clientName = deletedClient?.client_alias || deletedClient?.client_name || 'Client';

try {
Expand Down Expand Up @@ -1321,6 +1328,7 @@ export default function ClientsPage() {
)}

<ToastContainer toasts={toasts} onClose={dismiss} />
{ConfirmDialogElement}
</div>
);
}
13 changes: 10 additions & 3 deletions apps/desktop/src/features/featuresets/FeatureSetPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
Shield,
Save,
} from 'lucide-react';
import { Button, useToast, ToastContainer } from '@mcpmux/ui';
import { Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui';
import type { FeatureSet, AddMemberInput } from '@/lib/api/featureSets';
import { setFeatureSetMembers } from '@/lib/api/featureSets';
import type { ServerFeature } from '@/lib/api/serverFeatures';
Expand Down Expand Up @@ -49,6 +49,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
const [error, setError] = useState<string | null>(null);
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
const { toasts, success, error: showError, dismiss } = useToast();
const { confirm, ConfirmDialogElement } = useConfirm();

// Collapsible sections - only one expanded at a time, features by default
const [expandedSections, setExpandedSections] = useState({
Expand Down Expand Up @@ -279,6 +280,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
return (
<div className="fixed right-0 top-0 bottom-0 w-full max-w-[45%] min-w-[600px] bg-[rgb(var(--surface))] border-l border-[rgb(var(--border))] shadow-2xl flex flex-col animate-in slide-in-from-right duration-300 z-50">
<ToastContainer toasts={toasts} onClose={dismiss} />
{ConfirmDialogElement}
{/* Panel Header */}
<div className="flex-shrink-0 p-4 border-b border-[rgb(var(--border))] bg-[rgb(var(--surface-elevated))]">
<div className="flex items-start justify-between mb-3">
Expand Down Expand Up @@ -598,8 +600,13 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Delete this feature set?')) {
onClick={async () => {
if (await confirm({
title: 'Delete feature set',
message: `Delete "${featureSet.name}"? This cannot be undone.`,
confirmLabel: 'Delete',
variant: 'danger',
})) {
onDelete(featureSet.id);
}
}}
Expand Down
Loading
Loading