Skip to content

Commit 22e3237

Browse files
committed
feat(desktop): replace Zustand nav with wouter URL routing
Use browser history for page navigation in desktop and web admin, with deep-linkable search params for server filters and client selection. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 1cd6c86 commit 22e3237

27 files changed

Lines changed: 191 additions & 169 deletions

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"react": "^19.1.0",
3333
"react-dom": "^19.1.0",
3434
"react-i18next": "^17.0.8",
35+
"wouter": "^3.10.0",
3536
"zustand": "^5.0.9"
3637
},
3738
"devDependencies": {

apps/desktop/src/App.tsx

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useEffect, useCallback } from 'react';
22
import { useTranslation } from 'react-i18next';
3+
import { Route, Switch, Redirect, useLocation } from 'wouter';
34
import { Sun, Moon, Download, X } from 'lucide-react';
45
import { AppShell, Sidebar, SidebarItem, SidebarSection } from '@mcpmux/ui';
56
import { ThemeProvider } from '@/components/ThemeProvider';
@@ -19,12 +20,11 @@ import {
1920
useViewSpace,
2021
useTheme,
2122
useAnalyticsEnabled,
22-
useActiveNav,
23-
useNavigateTo,
2423
useSetPendingSettingsSection,
2524
useIsLoading,
2625
} from '@/stores';
27-
import { NAV_ZONES, NAV_SETTINGS } from '@/lib/navigation';
26+
import { NAV_ZONES, NAV_SETTINGS, navItemFromPath } from '@/lib/navigation';
27+
import { useNavigate } from '@/hooks/use-navigate.hook';
2828
import { spaceAccentColor } from '@/lib/spaceAccent';
2929
import { DashboardPage } from '@/features/dashboard';
3030
import { RegistryPage } from '@/features/registry';
@@ -101,8 +101,8 @@ function AppContent() {
101101

102102
useDataSync();
103103

104-
const activeNav = useActiveNav();
105-
const navigateTo = useNavigateTo();
104+
const [location] = useLocation();
105+
const navigate = useNavigate();
106106
const setPendingSettingsSection = useSetPendingSettingsSection();
107107
const [availableUpdate, setAvailableUpdate] = useState<{ version: string } | null>(null);
108108

@@ -183,8 +183,8 @@ function AppContent() {
183183
useAnalytics();
184184

185185
useEffect(() => {
186-
capture('page_viewed', { page: activeNav });
187-
}, [activeNav]);
186+
capture('page_viewed', { page: navItemFromPath(location) });
187+
}, [location]);
188188

189189
const [gatewayUrl, setGatewayUrl] = useState<string | null>(null);
190190
const loadGatewayUrl = useCallback(async () => {
@@ -234,8 +234,8 @@ function AppContent() {
234234
icon={<NAV_SETTINGS.icon className="h-4 w-4" />}
235235
label={t(NAV_SETTINGS.labelKey)}
236236
hint={t(NAV_SETTINGS.hintKey)}
237-
active={activeNav === NAV_SETTINGS.key}
238-
onClick={() => navigateTo(NAV_SETTINGS.key)}
237+
active={location === NAV_SETTINGS.path}
238+
onClick={() => navigate(NAV_SETTINGS.key)}
239239
data-testid={NAV_SETTINGS.testId}
240240
/>
241241
}
@@ -249,8 +249,8 @@ function AppContent() {
249249
label={t(entry.labelKey)}
250250
hint={t(entry.hintKey)}
251251
title={entry.labelTitleKey ? t(entry.labelTitleKey) : undefined}
252-
active={activeNav === entry.key}
253-
onClick={() => navigateTo(entry.key)}
252+
active={location === entry.path}
253+
onClick={() => navigate(entry.key)}
254254
data-testid={entry.testId}
255255
/>
256256
))}
@@ -264,7 +264,7 @@ function AppContent() {
264264
<div className="flex items-center gap-4">
265265
<button
266266
type="button"
267-
onClick={() => navigateTo('dashboard')}
267+
onClick={() => navigate('dashboard')}
268268
className="flex items-center gap-1.5 transition-colors hover:text-[rgb(var(--foreground))]"
269269
data-testid="statusbar-gateway"
270270
title={tDashboard('statusbar.gatewayTitle')}
@@ -353,9 +353,8 @@ function AppContent() {
353353
</span>
354354
<button
355355
onClick={() => {
356-
// Land on (and flash) the Updates section, not the top of Settings.
357356
setPendingSettingsSection('updates');
358-
navigateTo('settings');
357+
navigate('settings');
359358
setAvailableUpdate(null);
360359
}}
361360
className="font-medium text-blue-500 underline underline-offset-2 hover:text-blue-400"
@@ -374,15 +373,20 @@ function AppContent() {
374373
</div>
375374
)}
376375
<StaleBuildBanner />
377-
{activeNav === 'dashboard' && <DashboardPage />}
378-
{activeNav === 'registry' && <RegistryPage />}
379-
{activeNav === 'servers' && <ServersPage />}
380-
{activeNav === 'spaces' && <SpacesPage />}
381-
{activeNav === 'featuresets' && <FeatureSetsPage />}
382-
{activeNav === 'workspaces' && <WorkspacesPage />}
383-
{activeNav === 'clients' && <ClientsPage />}
384-
{activeNav === 'builtin-servers' && <BuiltinServersPage />}
385-
{activeNav === 'settings' && <SettingsPage />}
376+
<Switch>
377+
<Route path="/">
378+
<Redirect to="/dashboard" />
379+
</Route>
380+
<Route path="/dashboard" component={DashboardPage} />
381+
<Route path="/registry" component={RegistryPage} />
382+
<Route path="/servers" component={ServersPage} />
383+
<Route path="/spaces" component={SpacesPage} />
384+
<Route path="/featuresets" component={FeatureSetsPage} />
385+
<Route path="/workspaces" component={WorkspacesPage} />
386+
<Route path="/clients" component={ClientsPage} />
387+
<Route path="/builtin-servers" component={BuiltinServersPage} />
388+
<Route path="/settings" component={SettingsPage} />
389+
</Switch>
386390
</div>
387391
</AppShell>
388392
);

apps/desktop/src/components/ConnectionCard.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
Sliders,
1212
} from 'lucide-react';
1313
import { Card, Button } from '@mcpmux/ui';
14-
import { useViewSpace, useNavigateTo, useSetPendingSettingsSection } from '@/stores';
14+
import { useViewSpace, useSetPendingSettingsSection } from '@/stores';
15+
import { useNavigate } from '@/hooks/use-navigate.hook';
1516
import { useGatewayControl } from '@/features/gateway/useGatewayControl';
1617
import { useGatewayEvents } from '@/hooks/useDomainEvents';
1718
import {
@@ -41,7 +42,7 @@ function extractPort(url: string | null): string {
4142
export function ConnectionCard() {
4243
const { t } = useTranslation('dashboard');
4344
const viewSpace = useViewSpace();
44-
const navigateTo = useNavigateTo();
45+
const navigate = useNavigate();
4546
const setPendingSettingsSection = useSetPendingSettingsSection();
4647
const gatewayControl = useGatewayControl();
4748

@@ -192,9 +193,8 @@ export function ConnectionCard() {
192193
<button
193194
type="button"
194195
onClick={() => {
195-
// Land on (and flash) the Gateway section where the port lives.
196196
setPendingSettingsSection('gateway');
197-
navigateTo('settings');
197+
navigate('settings');
198198
}}
199199
className="group inline-flex items-center gap-1 text-xs text-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))] transition-colors"
200200
data-testid="connection-port-settings-link"
@@ -255,7 +255,7 @@ export function ConnectionCard() {
255255
{pendingApprovals > 0 && (
256256
<button
257257
type="button"
258-
onClick={() => navigateTo('clients')}
258+
onClick={() => navigate('clients')}
259259
className="w-full flex items-center justify-between gap-3 rounded-lg border border-amber-300/60 dark:border-amber-700/60 bg-amber-50 dark:bg-amber-900/20 px-4 py-2.5 text-left hover:bg-amber-100/80 dark:hover:bg-amber-900/30 transition-colors"
260260
data-testid="connection-pending-approvals"
261261
>

apps/desktop/src/components/StatTile.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ArrowUpRight } from 'lucide-react';
22
import type { LucideIcon } from 'lucide-react';
3-
import { useNavigateTo } from '@/stores';
3+
import { useNavigate } from '@/hooks/use-navigate.hook';
44
import type { NavItem } from '@/stores/types';
55

66
export interface StatTileProps {
@@ -30,11 +30,11 @@ export function StatTile({
3030
navTarget,
3131
navHint,
3232
}: StatTileProps) {
33-
const navigateTo = useNavigateTo();
33+
const navigate = useNavigate();
3434
return (
3535
<button
3636
type="button"
37-
onClick={() => navigateTo(navTarget)}
37+
onClick={() => navigate(navTarget)}
3838
title={navHint}
3939
data-testid={testId}
4040
className="group relative overflow-hidden rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--card))] p-4 text-left shadow transition-all duration-200 hover:-translate-y-0.5 hover:border-[rgb(var(--border))] hover:shadow-md"

apps/desktop/src/components/ViewerIdentity.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import { MachineProfileEditor } from '@/components/machine-profile-editor';
99
import { ServerIcon } from '@/components/ServerIcon';
1010
import { useViewerIdentity } from '@/hooks/use-viewer-identity.hook';
1111
import { NAV_SETTINGS } from '@/lib/navigation';
12-
import { useNavigateTo } from '@/stores';
12+
import { useNavigate } from '@/hooks/use-navigate.hook';
1313

1414
/**
1515
* Blocking modal for first-time viewer device naming.
1616
*/
1717
export function ViewerIdentityModal() {
1818
const { t } = useTranslation(['common', 'settings']);
19-
const navigateTo = useNavigateTo();
19+
const navigate = useNavigate();
2020
const {
2121
name,
2222
hints,
@@ -51,7 +51,7 @@ export function ViewerIdentityModal() {
5151
if (name) {
5252
closePrompt();
5353
}
54-
navigateTo(NAV_SETTINGS.key);
54+
navigate(NAV_SETTINGS.key);
5555
};
5656

5757
const errorMessage =

apps/desktop/src/features/clients/ClientsPage.tsx

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useMemo, useState, useCallback } from 'react';
2+
import { useSearch, useLocation } from 'wouter';
23
import { useTranslation } from 'react-i18next';
34
import type { TFunction } from 'i18next';
45
import { useClientEvents, useOAuthClientEventListener } from '@/lib/backend/events';
@@ -43,12 +44,9 @@ import {
4344
type FeatureSet,
4445
} from '@/lib/api/featureSets';
4546
import { Card, CardContent, Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui';
46-
import {
47-
useDefaultSpace,
48-
useNavigateTo,
49-
usePendingClientId,
50-
useSetPendingClientId,
51-
} from '@/stores';
47+
import { useDefaultSpace } from '@/stores';
48+
import { useNavigate } from '@/hooks/use-navigate.hook';
49+
import { NAV_PATH_MAP } from '@/lib/navigation';
5250

5351
// Bundled icons for well-known AI clients.
5452
const CLIENT_ICON_ASSETS: Record<string, string> = {
@@ -130,9 +128,10 @@ export default function ClientsPage() {
130128

131129
const { toasts, success, error: showError, info, dismiss } = useToast();
132130
const { confirm, ConfirmDialogElement } = useConfirm();
133-
const pendingClientId = usePendingClientId();
134-
const setPendingClientId = useSetPendingClientId();
135-
const navigateTo = useNavigateTo();
131+
const search = useSearch();
132+
const [, setLocation] = useLocation();
133+
const selectClientId = new URLSearchParams(search).get('select');
134+
const navigate = useNavigate();
136135
const defaultSpace = useDefaultSpace();
137136

138137
const loadClients = async () => {
@@ -167,14 +166,14 @@ export default function ClientsPage() {
167166
}, []);
168167

169168
useEffect(() => {
170-
if (!pendingClientId || isLoading) return;
171-
const client = clients.find((c) => c.client_id === pendingClientId);
169+
if (!selectClientId || isLoading) return;
170+
const client = clients.find((c) => c.client_id === selectClientId);
172171
if (client) {
173172
openPanel(client);
174-
setPendingClientId(null);
173+
setLocation(NAV_PATH_MAP.clients, { replace: true });
175174
}
176175
// eslint-disable-next-line react-hooks/exhaustive-deps
177-
}, [pendingClientId, isLoading, clients]);
176+
}, [selectClientId, isLoading, clients]);
178177

179178
useClientEvents(
180179
useCallback(
@@ -273,7 +272,7 @@ export default function ClientsPage() {
273272
<p className="mt-2 max-w-2xl text-base text-[rgb(var(--muted))]">
274273
{t('subtitlePrefix')}{' '}
275274
<button
276-
onClick={() => navigateTo('workspaces')}
275+
onClick={() => navigate('workspaces')}
277276
className="font-medium text-[rgb(var(--accent))] hover:underline"
278277
data-testid="clients-workspaces-link"
279278
>
@@ -405,7 +404,7 @@ export default function ClientsPage() {
405404
onRevoke={() => handleRevoke(selected)}
406405
onOpenWorkspaces={() => {
407406
setSelected(null);
408-
navigateTo('workspaces');
407+
navigate('workspaces');
409408
}}
410409
onToastError={showError}
411410
onToastSuccess={success}

apps/desktop/src/features/dashboard/DashboardQuickLinks.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { FolderOpen, Globe, Monitor, Search, Settings, ShoppingBasket } from 'lu
33
import { McpNavIcon } from '@/components/McpNavIcon';
44
import { useTranslation } from 'react-i18next';
55
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui';
6-
import { useNavigateTo } from '@/stores';
6+
import { useNavigate } from '@/hooks/use-navigate.hook';
77
import type { NavItem } from '@/stores/types';
88

99
type QuickLinkConfig = {
@@ -77,7 +77,7 @@ const QUICK_LINK_CONFIG: QuickLinkConfig[] = [
7777
* Compact navigation grid covering every sidebar destination except Dashboard.
7878
*/
7979
export function DashboardQuickLinks() {
80-
const navigateTo = useNavigateTo();
80+
const navigate = useNavigate();
8181
const { t: tNav } = useTranslation('nav');
8282
const { t: tDashboard } = useTranslation('dashboard');
8383

@@ -93,7 +93,7 @@ export function DashboardQuickLinks() {
9393
<button
9494
key={link.nav}
9595
type="button"
96-
onClick={() => navigateTo(link.nav)}
96+
onClick={() => navigate(link.nav)}
9797
data-testid={link.testId}
9898
className="flex items-start gap-3 rounded-lg border border-[rgb(var(--border-subtle))] px-3 py-2.5 text-left transition-colors hover:border-[rgb(var(--primary))/30] hover:bg-[rgb(var(--surface-hover))]"
9999
>

apps/desktop/src/features/dashboard/DashboardServerHealth.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { TFunction } from 'i18next';
44
import { AlertCircle, CheckCircle2, KeyRound, Loader2, Settings2 } from 'lucide-react';
55
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui';
66
import { ServerLogViewer } from '@/components/ServerLogViewer';
7-
import { useNavigateTo, useSetPendingServersFilter } from '@/stores';
7+
import { useNavigate } from '@/hooks/use-navigate.hook';
88
import type { AttentionKind, AttentionServer } from './dashboard.helpers';
99

1010
interface DashboardServerHealthProps {
@@ -53,8 +53,7 @@ export function DashboardServerHealth({
5353
isLoading,
5454
}: DashboardServerHealthProps) {
5555
const { t } = useTranslation('dashboard');
56-
const navigateTo = useNavigateTo();
57-
const setPendingServersFilter = useSetPendingServersFilter();
56+
const navigate = useNavigate();
5857
const hasIssues = attentionServers.length > 0;
5958
const [logServer, setLogServer] = useState<{ id: string; name: string } | null>(null);
6059

@@ -124,8 +123,11 @@ export function DashboardServerHealth({
124123
<button
125124
type="button"
126125
onClick={() => {
127-
if (hasIssues) setPendingServersFilter('error');
128-
navigateTo('servers');
126+
if (hasIssues) {
127+
navigate('servers', { filter: 'error' });
128+
return;
129+
}
130+
navigate('servers');
129131
}}
130132
className="mt-4 text-sm font-medium text-primary-500 hover:text-primary-400"
131133
data-testid="dashboard-view-all-servers"

apps/desktop/src/features/dashboard/GetStartedStrip.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Compass, Monitor, Server, ArrowRight } from 'lucide-react';
2-
import { useNavigateTo } from '@/stores';
2+
import { useNavigate } from '@/hooks/use-navigate.hook';
33
import type { NavItem } from '@/stores/types';
44

55
/**
66
* Three-step onboarding shown until the Space has its first installed server.
77
*/
88
export function GetStartedStrip() {
9-
const navigateTo = useNavigateTo();
9+
const navigate = useNavigate();
1010
const steps = [
1111
{
1212
n: 1,
@@ -43,7 +43,7 @@ export function GetStartedStrip() {
4343
<button
4444
key={s.n}
4545
type="button"
46-
onClick={() => navigateTo(s.nav)}
46+
onClick={() => navigate(s.nav)}
4747
className="group flex items-start gap-3 p-4 text-left transition-colors hover:bg-[rgb(var(--primary))]/10"
4848
>
4949
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-[rgb(var(--primary))]/15 text-sm font-bold text-[rgb(var(--primary))]">

apps/desktop/src/features/dashboard/SetUpFolderCard.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import { ArrowRight, FolderPlus } from 'lucide-react';
2-
import { useNavigateTo, useSetPendingWorkspaceNew } from '@/stores';
2+
import { useNavigate } from '@/hooks/use-navigate.hook';
3+
import { useSetPendingWorkspaceNew } from '@/stores';
34

45
/**
56
* Per-folder setup entry point. ConnectionCard connects an app to the
67
* gateway globally; this routes into the Workspaces walkthrough to map a
78
* specific project and write its per-folder config.
89
*/
910
export function SetUpFolderCard() {
10-
const navigateTo = useNavigateTo();
11+
const navigate = useNavigate();
1112
const openWizard = useSetPendingWorkspaceNew();
1213
return (
1314
<button
1415
type="button"
1516
onClick={() => {
1617
openWizard(true);
17-
navigateTo('workspaces');
18+
navigate('workspaces');
1819
}}
1920
data-testid="dashboard-setup-folder"
2021
className="group flex w-full items-center gap-3 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--card))] p-4 text-left shadow transition-all duration-200 hover:-translate-y-0.5 hover:border-[rgb(var(--border))] hover:shadow-md"

0 commit comments

Comments
 (0)