- {isLoading ? (
-
-
-
- ) : filteredSets.length === 0 ? (
-
-
-
-
- {searchQuery ? 'No feature sets match your search' : 'No feature sets created'}
-
-
- {searchQuery
- ? 'Try adjusting your search terms'
- : 'Create a feature set to group tools and resources together for easy access control.'}
-
- {!searchQuery && (
-
- )}
-
-
- ) : (
-
- {filteredSets.map((fs) => {
- const isSelected = selectedFeatureSet?.id === fs.id;
- const isBuiltin = fs.is_builtin;
- const isStarter = isStarterFeatureSet(fs);
-
- return (
-
handleOpenPanel(fs)}
- data-testid={`featureset-card-${fs.id}`}
- >
- {isStarter && (
-
-
- Starter
-
- )}
-
-
-
-
- {getFeatureSetIcon(fs)}
-
-
-
{fs.name}
-
- {getFeatureSetTypeName(fs.feature_set_type)}
-
-
+ ) : filteredSets.length === 0 ? (
+
+
+
+
+ {searchQuery ? t('empty.noMatchTitle') : t('empty.noCreatedTitle')}
+
+
+ {searchQuery ? t('empty.noMatchDesc') : t('empty.noCreatedDesc')}
+
+ {!searchQuery && (
+
+ )}
+
+
+ ) : (
+
+ {filteredSets.map((fs) => {
+ const isSelected = selectedFeatureSet?.id === fs.id;
+ const isBuiltin = fs.is_builtin;
+ const isStarter = isStarterFeatureSet(fs);
+
+ return (
+
handleOpenPanel(fs)}
+ data-testid={`featureset-card-${fs.id}`}
+ >
+ {isStarter && (
+
+
+ {t('card.starterBadge')}
+
+ )}
+
+
+
+
+ {getFeatureSetIcon(fs)}
-
-
- {fs.description || 'No description provided.'}
-
-
-
-
{fs.members?.length || 0} members
-
- Configure
+
+
{fs.name}
+
+ {getFeatureSetTypeName(fs.feature_set_type, t)}
-
-
- );
- })}
-
- )}
-
+
+
+
+ {fs.description || t('card.noDescription')}
+
+
+
+ {t('card.members', { count: fs.members?.length || 0 })}
+
+ {t('card.configure')}
+
+
+
+
+ );
+ })}
+
+ )}
-
- {/* Overlay backdrop when panel is open */}
- {selectedFeatureSet && (
-
setSelectedFeatureSet(null)}
- />
- )}
-
- {/* Slide-out Panel */}
- {selectedFeatureSet && viewSpace && (
-
loadData(viewSpace.id)}
- />
- )}
-
- {/* Create Modal */}
- {showCreateModal && (
-
- )}
+
+ {/* Overlay backdrop when panel is open */}
+ {selectedFeatureSet && (
+
setSelectedFeatureSet(null)}
+ />
+ )}
+
+ {/* Slide-out Panel */}
+ {selectedFeatureSet && viewSpace && (
+
loadData(viewSpace.id)}
+ />
+ )}
+
+ {/* Create Modal */}
+ {showCreateModal && (
+
+ )}
+
>
);
}
diff --git a/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx b/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx
index 9795e476..333c62ae 100644
--- a/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx
+++ b/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx
@@ -1,22 +1,15 @@
import { useEffect } from 'react';
-import {
- takePendingPortConflict,
- getGatewayStatus,
-} from '@/lib/api/gateway';
+import { getGatewayStatus, isTauri, takePendingPortConflict } from '@/lib/backend';
+import { useIsLoading } from '@/stores';
import { useGatewayControl } from './useGatewayControl';
/**
* Polling schedule (ms after mount). Covers the realistic window for the
* Rust auto-start task to complete its port probe. Short early polls catch
* the common case; longer tails catch cold-start machines / slow disks.
- *
- * The tail must outlast the backend's port-conflict wait: on a self-update
- * restart the auto-start task retries a busy port for up to ~6s (riding out
- * the prior process's listener teardown) before it either starts the gateway
- * or records a conflict. If we stopped polling first, a conflict raised at the
- * end of that window would never reach the prompt. Total max wait: ~10s.
+ * Total max wait: ~4.75s before giving up silently.
*/
-const POLL_SCHEDULE_MS = [0, 200, 400, 800, 1500, 2400, 2400, 2400];
+const POLL_SCHEDULE_MS = [0, 150, 300, 600, 1200, 2400];
/**
* Mounts at the app root and resolves any auto-start port conflict the
@@ -47,8 +40,13 @@ const POLL_SCHEDULE_MS = [0, 200, 400, 800, 1500, 2400, 2400, 2400];
*/
export function AutoStartConflictResolver() {
const gatewayControl = useGatewayControl();
+ const isLoadingSpaces = useIsLoading('spaces');
useEffect(() => {
+ if (!isTauri() && isLoadingSpaces) {
+ return;
+ }
+
let cancelled = false;
(async () => {
@@ -102,10 +100,7 @@ export function AutoStartConflictResolver() {
return () => {
cancelled = true;
};
- // `gatewayControl` is stable for the lifetime of this component; we
- // deliberately run this once on mount.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [gatewayControl, isLoadingSpaces]);
return <>{gatewayControl.ConfirmDialogElement}>;
}
diff --git a/apps/desktop/src/features/gateway/useGatewayControl.tsx b/apps/desktop/src/features/gateway/useGatewayControl.tsx
index 2c6bd24f..0600c29d 100644
--- a/apps/desktop/src/features/gateway/useGatewayControl.tsx
+++ b/apps/desktop/src/features/gateway/useGatewayControl.tsx
@@ -1,3 +1,5 @@
+import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
import { useConfirm } from '@mcpmux/ui';
import {
probeGatewayStart,
@@ -16,15 +18,13 @@ export type GatewayStartOutcome =
| { status: 'started'; url: string; fellBackToDynamic: boolean; port: number }
| { status: 'cancelled' };
-function sourceLabel(source: 'override' | 'configured' | 'default'): string {
- switch (source) {
- case 'configured':
- return 'your configured gateway port';
- case 'default':
- return 'the default gateway port';
- case 'override':
- return 'the requested gateway port';
- }
+type PortSource = 'override' | 'configured' | 'default';
+
+/**
+ * Returns the localized label for which gateway port source is in conflict.
+ */
+function sourceLabel(t: TFunction<'clients'>, source: PortSource): string {
+ return t(`gatewayConfirm.source.${source}`);
}
/**
@@ -38,6 +38,8 @@ function sourceLabel(source: 'override' | 'configured' | 'default'): string {
* error is thrown — the caller can exit silently.
*/
export function useGatewayControl() {
+ const { t } = useTranslation('clients');
+ const { t: tCommon } = useTranslation('common');
const { confirm, ConfirmDialogElement } = useConfirm();
const runStart = async (
@@ -58,13 +60,13 @@ export function useGatewayControl() {
console.log('[Gateway] preferred port taken → prompting user');
const ok = await confirm({
- title: 'Gateway port is in use',
- message:
- `${capitalize(sourceLabel(probe.source))} (:${probe.preferredPort}) is already ` +
- `taken by another process. Start the gateway on a different port that the system ` +
- `picks automatically? Your IDE configs will need to be updated to point at the new ` +
- `port.`,
- confirmLabel: 'Use another port',
+ title: t('gatewayConfirm.title'),
+ message: t('gatewayConfirm.portTakenMessage', {
+ sourceLabel: sourceLabel(t, probe.source),
+ port: probe.preferredPort,
+ }),
+ confirmLabel: t('gatewayConfirm.confirmLabel'),
+ cancelLabel: tCommon('actions.cancel'),
variant: 'default',
});
@@ -123,11 +125,13 @@ export function useGatewayControl() {
const pie = parsePortInUseError(err);
if (!pie) throw err;
const ok = await confirm({
- title: 'Gateway port is in use',
- message:
- `${capitalize(sourceLabel(pie.source))} (:${pie.port}) is already in use. ` +
- `Start on a different port?`,
- confirmLabel: 'Use another port',
+ title: t('gatewayConfirm.title'),
+ message: t('gatewayConfirm.portTakenShortMessage', {
+ sourceLabel: sourceLabel(t, pie.source),
+ port: pie.port,
+ }),
+ confirmLabel: t('gatewayConfirm.confirmLabel'),
+ cancelLabel: tCommon('actions.cancel'),
});
if (!ok) return { status: 'cancelled' };
const url = await invoker(true);
@@ -142,11 +146,10 @@ export function useGatewayControl() {
return { start, restart, ConfirmDialogElement };
}
+/**
+ * Extracts the TCP port from a gateway URL string.
+ */
function parsePortFromUrl(url: string): number | null {
const match = /:(\d+)(?:\/|$)/.exec(url);
return match ? Number(match[1]) : null;
}
-
-function capitalize(s: string): string {
- return s.charAt(0).toUpperCase() + s.slice(1);
-}
diff --git a/apps/desktop/src/features/home/HomePage.tsx b/apps/desktop/src/features/home/HomePage.tsx
index 650a7874..a6a427c7 100644
--- a/apps/desktop/src/features/home/HomePage.tsx
+++ b/apps/desktop/src/features/home/HomePage.tsx
@@ -10,6 +10,7 @@
* the page that manages what it counts.
*/
import { useEffect, useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
import { Server, Wrench, Monitor, Globe, ArrowUpRight, Compass, ArrowRight } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { PageHeader } from '@mcpmux/ui';
@@ -153,6 +154,7 @@ function GetStartedStrip() {
}
export function HomePage() {
+ const { t } = useTranslation('home');
const [stats, setStats] = useState({
installedServers: 0,
connectedServers: 0,
@@ -222,8 +224,8 @@ export function HomePage() {
return (
{/* First-steps journey — only until the first server is installed. */}
diff --git a/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx
index 3ebfab03..129b58b5 100644
--- a/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx
+++ b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx
@@ -1,8 +1,9 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import { listen } from '@tauri-apps/api/event';
-import { invoke } from '@tauri-apps/api/core';
+import { useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { AlertTriangle, CheckCircle2, SlidersHorizontal, XCircle } from 'lucide-react';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui';
+import { useBackendEventSubscription } from '@/lib/backend/events';
+import { respondToMetaToolApproval } from '@/lib/api/metaTools';
import { useNavigateTo } from '@/stores';
/**
@@ -15,19 +16,11 @@ export interface ApprovalRequest {
payload: {
tool_name: string;
summary: string;
- /**
- * Name of the Space this write targets. Surfaced as a chip so a change
- * aimed at a Space other than the one the user expects is obvious — a
- * client may now pass any `space_id`. Absent for writes with no single
- * target Space.
- */
+ /** Target Space name for cross-Space write visibility. */
space_name?: string | null;
/**
- * Tool-list diff the dialog renders. Freeform by design — the backend's
- * `ApprovalPayload.diff` is an arbitrary JSON value and each write tool
- * sends a different shape (`mcpmux_create_feature_set` sends
- * `{ added_tools }`; others may send `{ before, after, added, removed }`).
- * Read it defensively (see `toStringArray`); never assume a field exists.
+ * Tool-list diff the dialog renders. Freeform by design — read defensively
+ * via `toStringArray`; never assume a field exists.
*/
diff: null | Record
;
raw_args: unknown;
@@ -47,40 +40,38 @@ type Decision = 'allow_once' | 'always_for_this_session_and_client' | 'deny';
* Global listener that renders an approval dialog whenever the gateway
* asks for permission to run an `mcpmux_*` write tool. Place once, near the
* root of the app.
- *
- * The dialog queues multiple concurrent requests — if two clients request
- * approval at the same time, the user sees them in order.
*/
export function MetaToolApprovalDialog() {
+ const { t } = useTranslation('metatools');
+ const navigateTo = useNavigateTo();
const [queue, setQueue] = useState([]);
const current = queue[0];
- const navigateTo = useNavigateTo();
- useEffect(() => {
- const unlistenPromise = listen(
- 'meta-tool-approval-request',
- (event) => {
- setQueue((prev) => [...prev, event.payload]);
- }
- );
- return () => {
- unlistenPromise.then((fn) => fn()).catch(() => {});
- };
+ const enqueueApproval = useCallback((payload: ApprovalRequest) => {
+ setQueue((prev) => [...prev, payload]);
}, []);
+ const handleResolved = useCallback((payload: { request_id: string }) => {
+ setQueue((prev) => prev.filter((r) => r.request_id !== payload.request_id));
+ }, []);
+
+ useBackendEventSubscription('meta-tool-approval-request', enqueueApproval);
+ useBackendEventSubscription<{ request_id: string; decision: string }>(
+ 'meta-tool-approval-resolved',
+ handleResolved
+ );
+
const respond = useCallback(
async (decision: Decision) => {
if (!current) return;
try {
- await invoke('respond_to_meta_tool_approval', {
- requestId: current.request_id,
- clientId: current.client_id,
- toolName: current.payload.tool_name,
- decision,
- });
+ await respondToMetaToolApproval(
+ current.request_id,
+ current.client_id,
+ current.payload.tool_name,
+ decision
+ );
} catch (e) {
- // Log but don't block UI — broker will time out and surface
- // `approval_timed_out` to the tool caller.
console.warn('respond_to_meta_tool_approval failed', e);
} finally {
setQueue((prev) => prev.slice(1));
@@ -89,19 +80,11 @@ export function MetaToolApprovalDialog() {
[current]
);
- // "Prefer not to be asked?" escape hatch. Deny the current request first —
- // fail-closed and immediate, so the calling client isn't left hanging for
- // the full 60s broker timeout — then jump to the Built-in tab, where the
- // "Require approval for tool changes" switch lets the user turn these
- // prompts off entirely.
const manageApprovals = useCallback(() => {
void respond('deny');
navigateTo('builtin-servers');
}, [respond, navigateTo]);
- // Normalize the freeform diff defensively — a missing field must never
- // throw (this previously crashed on `mcpmux_create_feature_set`, whose diff
- // is `{ added_tools }` and has no `after`).
const rawDiff = current?.payload.diff ?? null;
const added = useMemo(
() => [...toStringArray(rawDiff?.added), ...toStringArray(rawDiff?.added_tools)],
@@ -124,9 +107,7 @@ export function MetaToolApprovalDialog() {
-
- An MCP client wants to change your tools
-
+ {t('approval.title')}
@@ -137,11 +118,12 @@ export function MetaToolApprovalDialog() {
className="inline-flex items-center gap-1 rounded-full border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] px-2 py-0.5 text-xs"
data-testid="meta-tool-approval-space"
>
- Space: {current.payload.space_name}
+ {t('approval.spaceLabel')}
+ {current.payload.space_name}
)}
- tool: {current.payload.tool_name}
+ {t('approval.toolLabel')} {current.payload.tool_name}
@@ -153,9 +135,9 @@ export function MetaToolApprovalDialog() {
>
- This change affects every connection in this Space — not just
- the one requesting it. Other connected clients will see a new
- toolset on their next tools/list.
+ {t('approval.crossClientWarning.before')}
+ tools/list
+ {t('approval.crossClientWarning.after')}
)}
@@ -163,26 +145,20 @@ export function MetaToolApprovalDialog() {
{hasDiff && (
diff --git a/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx b/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx
index 76195d3c..a2109c32 100644
--- a/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx
+++ b/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx
@@ -1,37 +1,49 @@
+import { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { CheckCircle2, Eye, ShieldAlert, XCircle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui';
-import {
- MAX_META_TOOL_ROWS as MAX_ROWS,
- useMetaToolActivityStore,
-} from '@/stores/metaToolActivityStore';
+import type { MetaToolAuditEvent } from '@/lib/api/metaTools';
+import { useMetaToolEventListener } from '@/hooks/useMetaToolEvents';
+
+/** Ring-buffer size — keeps the most recent N audit rows in memory. */
+const MAX_ROWS = 50;
/**
- * Audit log of every `mcpmux_*` invocation (read or write, success or failure).
- *
- * Rows live in a global store fed by an app-level `meta-tool-invoked` listener
- * (see metaToolActivityStore) so they persist across tab changes and capture
- * calls that fired before this panel was opened. The persistent audit stream
- * lives in the gateway's tracing logs.
+ * In-memory audit log of every `mcpmux_*` invocation (read or write,
+ * success or failure). Subscribes to the gateway's `meta-tool-invoked`
+ * event channel; rows are kept only for the current UI session — the
+ * persistent audit stream lives in the gateway's tracing logs.
*/
export function MetaToolAuditLog() {
- const rows = useMetaToolActivityStore((s) => s.rows);
+ const { t } = useTranslation('metatools');
+ const [rows, setRows] = useState