Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/desktop/src/features/featuresets/FeatureSetsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
AlertCircle,
CheckCircle2,
Zap,
Sparkles,
} from 'lucide-react';
import {
Card,
Expand Down Expand Up @@ -268,6 +269,32 @@ export function FeatureSetsPage() {
</div>
</div>

{/* @mux self-optimization hint — let the assistant curate the toolset from chat */}
<div className="flex-shrink-0 px-8 pt-6">
<div
className="mx-auto flex max-w-[2000px] items-start gap-3 rounded-xl border border-violet-200/70 bg-gradient-to-r from-violet-50/60 to-transparent p-4 dark:border-violet-800/40 dark:from-violet-900/15"
data-testid="featuresets-mux-hint"
>
<div className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 text-white shadow-[0_4px_10px_-2px_rgb(139_92_246/0.45)]">
<Sparkles className="h-4 w-4 fill-current" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[rgb(var(--foreground))]">
Let your AI build these for you
</p>
<p className="mt-0.5 text-xs leading-relaxed text-[rgb(var(--muted))]">
In any connected client, just say{' '}
<code className="rounded bg-[rgb(var(--surface))] px-1.5 py-0.5 font-mono text-[11px] text-violet-600 dark:text-violet-300">
@mux optimize
</code>{' '}
— your assistant can discover the available tools, compose a FeatureSet, and pin it
to the current folder. Every change is gated behind a one-click approval, so you
stay in control.
</p>
</div>
</div>
</div>

{/* Feature-set model explainer */}
<div className="flex-shrink-0 px-8 pt-6">
<div className="mx-auto flex max-w-[2000px] items-start gap-3 rounded-xl border border-emerald-200/70 bg-gradient-to-r from-emerald-50/60 to-transparent p-4 dark:border-emerald-800/40 dark:from-emerald-900/15">
Expand Down
33 changes: 28 additions & 5 deletions apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui';
import { useNavigateTo } from '@/stores';

/**
* Incoming approval request emitted by the gateway's ApprovalBroker.
Expand Down Expand Up @@ -53,6 +54,7 @@ type Decision = 'allow_once' | 'always_for_this_session_and_client' | 'deny';
export function MetaToolApprovalDialog() {
const [queue, setQueue] = useState<ApprovalRequest[]>([]);
const current = queue[0];
const navigateTo = useNavigateTo();

useEffect(() => {
const unlistenPromise = listen<ApprovalRequest>(
Expand Down Expand Up @@ -87,6 +89,16 @@ 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`).
Expand Down Expand Up @@ -206,11 +218,22 @@ export function MetaToolApprovalDialog() {
</Button>
</div>

{queue.length > 1 && (
<p className="text-[11px] text-[rgb(var(--muted))] text-right pt-1">
{queue.length - 1} more pending…
</p>
)}
<div className="flex items-center justify-between gap-3 pt-1">
<button
type="button"
onClick={manageApprovals}
className="text-[11px] text-[rgb(var(--muted))] underline-offset-2 hover:text-[rgb(var(--foreground))] hover:underline"
title="Deny this request and open the Built-in tab, where you can turn off approval prompts for tool changes"
data-testid="meta-tool-approval-manage-link"
>
Prefer not to be asked? Manage approval prompts →
</button>
{queue.length > 1 && (
<span className="text-[11px] text-[rgb(var(--muted))]">
{queue.length - 1} more pending…
</span>
)}
</div>
</CardContent>
</Card>
</div>
Expand Down
40 changes: 36 additions & 4 deletions tests/ts/components/MetaToolApprovalDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
/**
* Approval dialog wiring:
* - renders the target-Space chip (so a cross-Space write is obvious), and
* - renders the target-Space chip (so a cross-Space write is obvious),
* - survives a freeform `diff` shape (`{ added_tools }`) without crashing —
* a regression guard for the earlier "Cannot read properties of undefined
* (reading 'length')" bug.
* (reading 'length')" bug, and
* - the "Manage approval prompts" link denies the request (fail-closed) and
* routes to the Built-in tab where prompts can be disabled.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

const { handlers } = vi.hoisted(() => ({
const { handlers, navigateToSpy } = vi.hoisted(() => ({
handlers: new Map<string, (e: { payload: unknown }) => void>(),
navigateToSpy: vi.fn(),
}));

// Capture the dialog's `listen('meta-tool-approval-request', cb)` so the test
Expand All @@ -22,7 +26,9 @@ vi.mock('@tauri-apps/api/event', () => ({
}),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockResolvedValue(undefined) }));
vi.mock('@/stores', () => ({ useNavigateTo: () => navigateToSpy }));

import { invoke } from '@tauri-apps/api/core';
import { MetaToolApprovalDialog } from '@/features/metaTools/MetaToolApprovalDialog';

async function emitRequest(payload: Record<string, unknown>) {
Expand All @@ -40,7 +46,11 @@ async function emitRequest(payload: Record<string, unknown>) {
}

describe('MetaToolApprovalDialog', () => {
beforeEach(() => handlers.clear());
beforeEach(() => {
handlers.clear();
navigateToSpy.mockClear();
vi.mocked(invoke).mockClear();
});

it('names the target Space when present', async () => {
render(<MetaToolApprovalDialog />);
Expand Down Expand Up @@ -82,4 +92,26 @@ describe('MetaToolApprovalDialog', () => {
expect(screen.getByText(/github_create_issue/)).toBeInTheDocument();
expect(screen.getByText(/slack_send/)).toBeInTheDocument();
});

it('"Manage approval prompts" denies the request and routes to the Built-in tab', async () => {
const user = userEvent.setup();
render(<MetaToolApprovalDialog />);
await emitRequest({
tool_name: 'mcpmux_bind_current_workspace',
summary: 'Bind this folder',
diff: null,
raw_args: {},
affects_other_clients: false,
});

await user.click(await screen.findByTestId('meta-tool-approval-manage-link'));

// Fail-closed: the pending request is denied rather than left to time out.
expect(invoke).toHaveBeenCalledWith(
'respond_to_meta_tool_approval',
expect.objectContaining({ decision: 'deny' })
);
// ...and the user lands on the tab that hosts the approval toggle.
expect(navigateToSpy).toHaveBeenCalledWith('builtin-servers');
});
});
Loading