Skip to content
2 changes: 2 additions & 0 deletions renderer/src/common/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const buttonVariants = cva(
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
enable:
'bg-green-600 text-white shadow-xs hover:bg-green-700 focus-visible:ring-green-500/20 dark:bg-green-600/80',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { Suspense } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { server, recordRequests } from '@/common/mocks/node'
import { http, HttpResponse } from 'msw'
import { PromptProvider } from '@/common/contexts/prompt/provider'
import { EnableGroupButton } from '../enable-group-button'
import { mswEndpoint } from '@/common/mocks/customHandlers'

describe('EnableGroupButton – flows', () => {
let queryClient: QueryClient

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
})

const renderWithProviders = (props: { groupName: string }) =>
render(
<QueryClientProvider client={queryClient}>
<PromptProvider>
<Suspense fallback={null}>
<EnableGroupButton {...props} />
</Suspense>
</PromptProvider>
</QueryClientProvider>
)

it('enables multiple clients for a disabled group', async () => {
// Group disabled initially
server.use(
http.get(mswEndpoint('/api/v1beta/groups'), () =>
HttpResponse.json({
groups: [{ name: 'default', registered_clients: [] }],
})
),
http.get(mswEndpoint('/api/v1beta/clients'), () => HttpResponse.json([]))
)

const rec = recordRequests()

const user = userEvent.setup()
renderWithProviders({ groupName: 'default' })
await user.click(
await screen.findByRole('button', { name: /enable group/i })
)

await user.click(await screen.findByRole('switch', { name: 'vscode' }))
await user.click(await screen.findByRole('switch', { name: /cursor/i }))
await user.click(await screen.findByRole('button', { name: /enable/i }))

await waitFor(() =>
expect(
rec.recordedRequests.filter(
(r) =>
r.pathname.startsWith('/api/v1beta/clients') &&
(r.method === 'POST' || r.method === 'DELETE')
)
).toHaveLength(2)
)
const snapshot = rec.recordedRequests
.filter(
(r) =>
r.pathname.startsWith('/api/v1beta/clients') &&
(r.method === 'POST' || r.method === 'DELETE')
)
.map(({ method, pathname, payload }) => ({
method,
path: pathname,
body: payload,
}))
expect(snapshot).toEqual([
{
method: 'POST',
path: '/api/v1beta/clients',
body: { name: 'vscode', groups: ['default'] },
},
{
method: 'POST',
path: '/api/v1beta/clients',
body: { name: 'cursor', groups: ['default'] },
},
])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import { http, HttpResponse } from 'msw'
import { PromptProvider } from '@/common/contexts/prompt/provider'
import { mswEndpoint } from '@/common/mocks/customHandlers'

// Use the shared request recorder from mocks/node.ts for consistency

describe('ManageClientsButton – BDD flows', () => {
let queryClient: QueryClient

Expand Down Expand Up @@ -88,26 +86,22 @@ describe('ManageClientsButton – BDD flows', () => {
body: { name: 'cursor', groups: ['default'] },
},
])
// no-op: global recorder persists; we reset via recordRequests() per test
})

it('enables a single client when none are enabled (clients API returns null)', async () => {
// Given: no clients are registered in the group
server.use(
http.get(mswEndpoint('/api/v1beta/groups'), () =>
HttpResponse.json({
groups: [{ name: 'default', registered_clients: [] }],
})
),
// Simulate backend returning null for current clients list
http.get(mswEndpoint('/api/v1beta/clients'), () =>
HttpResponse.json(null)
)
)

const rec = recordRequests()

// When: the user enables only VS Code and saves
const user = userEvent.setup()
renderWithProviders({ groupName: 'default' })
await user.click(
Expand All @@ -116,7 +110,6 @@ describe('ManageClientsButton – BDD flows', () => {
await user.click(await screen.findByRole('switch', { name: 'vscode' }))
await user.click(await screen.findByRole('button', { name: /save/i }))

// Then: exactly one POST registration should be sent
await waitFor(() =>
expect(
rec.recordedRequests.filter(
Expand Down
43 changes: 43 additions & 0 deletions renderer/src/features/clients/components/enable-group-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Button } from '@/common/components/ui/button'
import { Power, Code } from 'lucide-react'
import { useManageClientsDialog } from '../hooks/use-manage-clients-dialog'
import { useFeatureFlag } from '@/common/hooks/use-feature-flag'
import { featureFlagKeys } from '../../../../../utils/feature-flags'

export function EnableGroupButton({
groupName,
className,
}: {
groupName: string
className?: string
}) {
const { openDialog } = useManageClientsDialog(groupName)
const groupsEnabled = useFeatureFlag(featureFlagKeys.GROUPS)

if (groupsEnabled) {
return (
<Button
variant="enable"
onClick={() =>
openDialog({ title: 'Enable Group', confirmText: 'Enable' })
}
className={className}
>
Enable group
<Power className="ml-2 h-4 w-4" />
</Button>
)
}

// Temporary behavior while feature flag is off: keep green look
return (
<Button
variant="enable"
onClick={() => openDialog({ title: 'Add a client', confirmText: 'Add' })}
className={className}
>
<Code className="mr-2 h-4 w-4" />
Add a client
</Button>
)
}
82 changes: 3 additions & 79 deletions renderer/src/features/clients/components/manage-clients-button.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import { usePrompt } from '@/common/hooks/use-prompt'
import type { UseFormReturn } from 'react-hook-form'
import { Label } from '@/common/components/ui/label'
import { Button } from '@/common/components/ui/button'
import { Switch } from '@/common/components/ui/switch'
import { Code } from 'lucide-react'
import { z } from 'zod/v4'
import { zodV4Resolver } from '@/common/lib/zod-v4-resolver'
import { useManageClients } from '../hooks/use-manage-clients'
import { useToastMutation } from '@/common/hooks/use-toast-mutation'
import { useManageClientsDialog } from '../hooks/use-manage-clients-dialog'

interface ManageClientsButtonProps {
groupName: string
Expand All @@ -26,81 +19,12 @@ export function ManageClientsButton({
variant = 'outline',
className,
}: ManageClientsButtonProps) {
const promptForm = usePrompt()

const {
installedClients,
defaultValues,
reconcileGroupClients,
getClientFieldName,
} = useManageClients(groupName)

const { mutateAsync: saveClients } = useToastMutation({
mutationFn: reconcileGroupClients,
loadingMsg: 'Saving client settings...',
successMsg: 'Client settings saved',
errorMsg: 'Failed to save client settings',
})

const handleManageClients = async () => {
const formSchema = z.object(
installedClients.reduce(
(acc, client) => {
const fieldName = getClientFieldName(client.client_type!)
acc[fieldName] = z.boolean()
return acc
},
{} as Record<string, z.ZodBoolean>
)
)

const result = await promptForm({
title: 'Manage Clients',
defaultValues,
resolver: zodV4Resolver(formSchema),
fields: (form: UseFormReturn<Record<string, boolean>>) => (
<div className="rounded-xl border">
{installedClients.map((client) => {
const fieldName = getClientFieldName(client.client_type!)
const displayName = client.client_type!

return (
<div
key={client.client_type}
className="flex items-start gap-2 border-b p-4 align-middle last:border-b-0"
>
<Switch
id={fieldName}
checked={form.watch(fieldName) as boolean}
onCheckedChange={(checked) => {
form.setValue(fieldName, checked)
form.trigger(fieldName)
}}
/>

<Label htmlFor={fieldName} className="text-sm font-medium">
{displayName}
</Label>
</div>
)
})}
</div>
),
buttons: {
confirm: 'Save',
cancel: 'Cancel',
},
})

if (result) {
await saveClients(result)
}
}
const { openDialog } = useManageClientsDialog(groupName)

return (
<Button
variant={variant}
onClick={handleManageClients}
onClick={() => openDialog()}
className={className}
>
<Code className="mr-2 h-4 w-4" />
Expand Down
85 changes: 85 additions & 0 deletions renderer/src/features/clients/hooks/use-manage-clients-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { usePrompt } from '@/common/hooks/use-prompt'
import type { UseFormReturn } from 'react-hook-form'
import { Label } from '@/common/components/ui/label'
import { Switch } from '@/common/components/ui/switch'
import { z } from 'zod/v4'
import { zodV4Resolver } from '@/common/lib/zod-v4-resolver'
import { useManageClients } from './use-manage-clients'
import { useToastMutation } from '@/common/hooks/use-toast-mutation'

export function useManageClientsDialog(groupName: string) {
const promptForm = usePrompt()
const {
installedClients,
defaultValues,
reconcileGroupClients,
getClientFieldName,
} = useManageClients(groupName)

const { mutateAsync: saveClients } = useToastMutation({
mutationFn: reconcileGroupClients,
loadingMsg: 'Saving client settings...',
successMsg: 'Client settings saved',
errorMsg: 'Failed to save client settings',
})

const openDialog = async (opts?: {
title?: string
confirmText?: string
}) => {
const formSchema = z.object(
installedClients.reduce(
(acc, client) => {
const fieldName = getClientFieldName(client.client_type!)
acc[fieldName] = z.boolean()
return acc
},
{} as Record<string, z.ZodBoolean>
)
)

const result = await promptForm({
title: opts?.title ?? 'Manage Clients',
defaultValues,
resolver: zodV4Resolver(formSchema),
fields: (form: UseFormReturn<Record<string, boolean>>) => (
<div className="rounded-xl border">
{installedClients.map((client) => {
const fieldName = getClientFieldName(client.client_type!)
const displayName = client.client_type!

return (
<div
key={client.client_type}
className="flex items-start gap-2 border-b p-4 align-middle last:border-b-0"
>
<Switch
id={fieldName}
checked={form.watch(fieldName) as boolean}
onCheckedChange={(checked) => {
form.setValue(fieldName, checked)
form.trigger(fieldName)
}}
/>

<Label htmlFor={fieldName} className="text-sm font-medium">
{displayName}
</Label>
</div>
)
})}
</div>
),
buttons: {
confirm: opts?.confirmText ?? 'Save',
cancel: 'Cancel',
},
})

if (result) {
await saveClients(result)
}
}

return { openDialog }
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,15 @@ export function CardMcpServer({
url,
remote,
transport,
isInDisabledGroup,
}: {
name: string
status: CoreWorkload['status']
statusContext: CoreWorkload['status_context']
remote?: CoreWorkload['remote']
url: string
transport: CoreWorkload['transport_type']
isInDisabledGroup?: boolean
}) {
const isRemoteMcpEnabled = useFeatureFlag(featureFlagKeys.REMOTE_MCP)
const nameRef = useRef<HTMLElement | null>(null)
Expand Down Expand Up @@ -152,6 +154,7 @@ export function CardMcpServer({
'transition-all duration-300 ease-in-out',
isNewServer ? 'ring-2' : undefined,
isDeleting ? 'pointer-events-none opacity-50' : undefined,
isInDisabledGroup ? 'opacity-50 grayscale' : undefined,
(isTransitioning || hadRecentStatusChange) && 'animate-diagonal-ring',
isStopped && 'bg-card/65'
)}
Expand Down
Loading
Loading