feat(desktop): add i18n infrastructure with react-intl (#8105)

This commit is contained in:
jh-block
2026-03-27 19:39:50 +01:00
committed by GitHub
parent d596113e72
commit a6765fbcd9
172 changed files with 13793 additions and 2054 deletions
@@ -3,6 +3,7 @@ import { Button } from '../../ui/button';
import { Plus } from 'lucide-react';
import { GPSIcon } from '../../ui/icons';
import { useConfig, FixedExtensionEntry } from '../../ConfigContext';
import { defineMessages, useIntl } from '../../../i18n';
import ExtensionList from './subcomponents/ExtensionList';
import ExtensionModal from './modal/ExtensionModal';
import {
@@ -15,6 +16,29 @@ import {
import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index';
import { ExtensionConfig } from '../../../api/types.gen';
const i18n = defineMessages({
addCustomExtension: {
id: 'extensionsSection.addCustomExtension',
defaultMessage: 'Add custom extension',
},
browseExtensions: {
id: 'extensionsSection.browseExtensions',
defaultMessage: 'Browse extensions',
},
updateExtension: {
id: 'extensionsSection.updateExtension',
defaultMessage: 'Update Extension',
},
saveChanges: {
id: 'extensionsSection.saveChanges',
defaultMessage: 'Save Changes',
},
addExtension: {
id: 'extensionsSection.addExtension',
defaultMessage: 'Add Extension',
},
});
interface ExtensionSectionProps {
deepLinkConfig?: ExtensionConfig;
showEnvVars?: boolean;
@@ -36,6 +60,7 @@ export default function ExtensionsSection({
onModalClose,
searchTerm = '',
}: ExtensionSectionProps) {
const intl = useIntl();
const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig();
const [selectedExtension, setSelectedExtension] = useState<FixedExtensionEntry | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -198,7 +223,7 @@ export default function ExtensionsSection({
onClick={() => setIsAddModalOpen(true)}
>
<Plus className="h-4 w-4" />
Add custom extension
{intl.formatMessage(i18n.addCustomExtension)}
</Button>
<Button
className="flex items-center gap-2 justify-center"
@@ -206,7 +231,7 @@ export default function ExtensionsSection({
onClick={() => window.open('https://block.github.io/goose/v1/extensions/', '_blank')}
>
<GPSIcon size={12} />
Browse extensions
{intl.formatMessage(i18n.browseExtensions)}
</Button>
</div>
)}
@@ -214,12 +239,12 @@ export default function ExtensionsSection({
{/* Modal for updating an existing extension */}
{isModalOpen && selectedExtension && (
<ExtensionModal
title="Update Extension"
title={intl.formatMessage(i18n.updateExtension)}
initialData={extensionToFormData(selectedExtension)}
onClose={handleModalClose}
onSubmit={handleUpdateExtension}
onDelete={handleDeleteExtension}
submitLabel="Save Changes"
submitLabel={intl.formatMessage(i18n.saveChanges)}
modalType={'edit'}
/>
)}
@@ -227,11 +252,11 @@ export default function ExtensionsSection({
{/* Modal for adding a new extension */}
{isAddModalOpen && (
<ExtensionModal
title="Add custom extension"
title={intl.formatMessage(i18n.addCustomExtension)}
initialData={getDefaultFormData()}
onClose={handleModalClose}
onSubmit={handleAddExtension}
submitLabel="Add Extension"
submitLabel={intl.formatMessage(i18n.addExtension)}
modalType={'add'}
/>
)}
@@ -239,14 +264,14 @@ export default function ExtensionsSection({
{/* Modal for adding extension from deeplink*/}
{deepLinkConfigStateVar && showEnvVarsStateVar && (
<ExtensionModal
title="Add custom extension"
title={intl.formatMessage(i18n.addCustomExtension)}
initialData={extensionToFormData({
...deepLinkConfig,
enabled: true,
} as FixedExtensionEntry)}
onClose={handleModalClose}
onSubmit={handleAddExtension}
submitLabel="Add Extension"
submitLabel={intl.formatMessage(i18n.addExtension)}
modalType={'add'}
/>
)}
@@ -3,6 +3,38 @@ import { Button } from '../../../ui/button';
import { Plus, X, Edit } from 'lucide-react';
import { Input } from '../../../ui/input';
import { cn } from '../../../../utils';
import { defineMessages, useIntl } from '../../../../i18n';
const i18n = defineMessages({
environmentVariables: {
id: 'envVarsSection.environmentVariables',
defaultMessage: 'Environment Variables',
},
envVarsDescription: {
id: 'envVarsSection.envVarsDescription',
defaultMessage: 'Add key-value pairs for environment variables. Click the "+" button to add after filling both fields. For existing secret values, click the edit button to modify.',
},
variableName: {
id: 'envVarsSection.variableName',
defaultMessage: 'Variable name',
},
value: {
id: 'envVarsSection.value',
defaultMessage: 'Value',
},
bothRequired: {
id: 'envVarsSection.bothRequired',
defaultMessage: 'Both variable name and value must be entered',
},
noSpaces: {
id: 'envVarsSection.noSpaces',
defaultMessage: 'Variable name cannot contain spaces',
},
add: {
id: 'envVarsSection.add',
defaultMessage: 'Add',
},
});
interface EnvVarsSectionProps {
envVars: { key: string; value: string; isEdited?: boolean }[];
@@ -21,6 +53,7 @@ export default function EnvVarsSection({
submitAttempted,
onPendingInputChange,
}: EnvVarsSectionProps) {
const intl = useIntl();
const [newKey, setNewKey] = React.useState('');
const [newValue, setNewValue] = React.useState('');
const [validationError, setValidationError] = React.useState<string | null>(null);
@@ -45,7 +78,7 @@ export default function EnvVarsSection({
key: keyEmpty,
value: valueEmpty,
});
setValidationError('Both variable name and value must be entered');
setValidationError(intl.formatMessage(i18n.bothRequired));
return;
}
@@ -54,7 +87,7 @@ export default function EnvVarsSection({
key: true,
value: false,
});
setValidationError('Variable name cannot contain spaces');
setValidationError(intl.formatMessage(i18n.noSpaces));
return;
}
@@ -95,11 +128,10 @@ export default function EnvVarsSection({
<div>
<div className="relative mb-2">
<label className="text-sm font-medium text-text-primary mb-2 block">
Environment Variables
{intl.formatMessage(i18n.environmentVariables)}
</label>
<p className="text-xs text-text-secondary mb-4">
Add key-value pairs for environment variables. Click the "+" button to add after filling
both fields. For existing secret values, click the edit button to modify.
{intl.formatMessage(i18n.envVarsDescription)}
</p>
</div>
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
@@ -110,7 +142,7 @@ export default function EnvVarsSection({
<Input
value={envVar.key}
onChange={(e) => onChange(index, 'key', e.target.value)}
placeholder="Variable name"
placeholder={intl.formatMessage(i18n.variableName)}
className={cn(
'w-full text-text-primary border-border-primary hover:border-border-primary',
isFieldInvalid(index, 'key') && 'border-red-500 focus:border-red-500'
@@ -127,7 +159,7 @@ export default function EnvVarsSection({
envVar.value === '••••••••' && !envVar.isEdited ? '' : e.target.value;
onChange(index, 'value', newValue);
}}
placeholder="Value"
placeholder={intl.formatMessage(i18n.value)}
className={cn(
'w-full border-border-primary',
envVar.value === '••••••••' && !envVar.isEdited
@@ -166,7 +198,7 @@ export default function EnvVarsSection({
setNewKey(e.target.value);
clearValidation();
}}
placeholder="Variable name"
placeholder={intl.formatMessage(i18n.variableName)}
className={cn(
'w-full text-text-primary border-border-primary hover:border-border-primary',
invalidFields.key && 'border-red-500 focus:border-red-500'
@@ -178,7 +210,7 @@ export default function EnvVarsSection({
setNewValue(e.target.value);
clearValidation();
}}
placeholder="Value"
placeholder={intl.formatMessage(i18n.value)}
className={cn(
'w-full text-text-primary border-border-primary hover:border-border-primary',
invalidFields.value && 'border-red-500 focus:border-red-500'
@@ -190,7 +222,7 @@ export default function EnvVarsSection({
variant="ghost"
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-text-primary bg-background-primary border border-border-primary hover:border-border-primary transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
>
<Plus /> Add
<Plus /> {intl.formatMessage(i18n.add)}
</Button>
</div>
</div>
@@ -1,4 +1,32 @@
import { Input } from '../../../ui/input';
import { defineMessages, useIntl } from '../../../../i18n';
const i18n = defineMessages({
commandLabel: {
id: 'extensionConfigFields.commandLabel',
defaultMessage: 'Command',
},
commandPlaceholder: {
id: 'extensionConfigFields.commandPlaceholder',
defaultMessage: 'e.g. npx -y @modelcontextprotocol/my-extension [filepath]',
},
commandRequired: {
id: 'extensionConfigFields.commandRequired',
defaultMessage: 'Command is required',
},
endpointLabel: {
id: 'extensionConfigFields.endpointLabel',
defaultMessage: 'Endpoint',
},
endpointPlaceholder: {
id: 'extensionConfigFields.endpointPlaceholder',
defaultMessage: 'Enter endpoint URL...',
},
endpointRequired: {
id: 'extensionConfigFields.endpointRequired',
defaultMessage: 'Endpoint URL is required',
},
});
interface ExtensionConfigFieldsProps {
type: 'stdio' | 'sse' | 'streamable_http' | 'builtin';
@@ -17,20 +45,22 @@ export default function ExtensionConfigFields({
submitAttempted = false,
isValid,
}: ExtensionConfigFieldsProps) {
const intl = useIntl();
if (type === 'stdio') {
return (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block text-text-primary">Command</label>
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.commandLabel)}</label>
<div className="relative">
<Input
value={full_cmd}
onChange={(e) => onChange('cmd', e.target.value)}
placeholder="e.g. npx -y @modelcontextprotocol/my-extension <filepath>"
placeholder={intl.formatMessage(i18n.commandPlaceholder)}
className={`w-full ${!submitAttempted || isValid ? 'border-border-primary' : 'border-red-500'} text-text-primary`}
/>
{submitAttempted && !isValid && (
<div className="absolute text-xs text-red-500 mt-1">Command is required</div>
<div className="absolute text-xs text-red-500 mt-1">{intl.formatMessage(i18n.commandRequired)}</div>
)}
</div>
</div>
@@ -39,16 +69,16 @@ export default function ExtensionConfigFields({
} else {
return (
<div>
<label className="text-sm font-medium mb-2 block text-text-primary">Endpoint</label>
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.endpointLabel)}</label>
<div className="relative">
<Input
value={endpoint}
onChange={(e) => onChange('endpoint', e.target.value)}
placeholder="Enter endpoint URL..."
placeholder={intl.formatMessage(i18n.endpointPlaceholder)}
className={`w-full ${!submitAttempted || isValid ? 'border-border-primary' : 'border-red-500'} text-text-primary`}
/>
{submitAttempted && !isValid && (
<div className="absolute text-xs text-red-500 mt-1">Endpoint URL is required</div>
<div className="absolute text-xs text-red-500 mt-1">{intl.formatMessage(i18n.endpointRequired)}</div>
)}
</div>
</div>
@@ -1,5 +1,53 @@
import { Input } from '../../../ui/input';
import { Select } from '../../../ui/Select';
import { defineMessages, useIntl } from '../../../../i18n';
const i18n = defineMessages({
extensionName: {
id: 'extensionInfoFields.extensionName',
defaultMessage: 'Extension Name',
},
extensionNamePlaceholder: {
id: 'extensionInfoFields.extensionNamePlaceholder',
defaultMessage: 'Enter extension name...',
},
nameRequired: {
id: 'extensionInfoFields.nameRequired',
defaultMessage: 'Name is required',
},
typeLabel: {
id: 'extensionInfoFields.typeLabel',
defaultMessage: 'Type',
},
typeStdio: {
id: 'extensionInfoFields.typeStdio',
defaultMessage: 'STDIO',
},
typeHttp: {
id: 'extensionInfoFields.typeHttp',
defaultMessage: 'HTTP',
},
typeSseUnsupported: {
id: 'extensionInfoFields.typeSseUnsupported',
defaultMessage: 'SSE (unsupported)',
},
typeStandardIo: {
id: 'extensionInfoFields.typeStandardIo',
defaultMessage: 'Standard IO (STDIO)',
},
typeStreamableHttp: {
id: 'extensionInfoFields.typeStreamableHttp',
defaultMessage: 'Streamable HTTP',
},
descriptionLabel: {
id: 'extensionInfoFields.descriptionLabel',
defaultMessage: 'Description',
},
descriptionPlaceholder: {
id: 'extensionInfoFields.descriptionPlaceholder',
defaultMessage: 'Optional description...',
},
});
interface ExtensionInfoFieldsProps {
name: string;
@@ -16,6 +64,8 @@ export default function ExtensionInfoFields({
onChange,
submitAttempted,
}: ExtensionInfoFieldsProps) {
const intl = useIntl();
const isNameValid = () => {
return name.trim() !== '';
};
@@ -25,33 +75,33 @@ export default function ExtensionInfoFields({
{/* Top row with Name and Type side by side */}
<div className="flex justify-between gap-4">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block text-text-primary">Extension Name</label>
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.extensionName)}</label>
<div className="relative">
<Input
value={name}
onChange={(e) => onChange('name', e.target.value)}
placeholder="Enter extension name..."
placeholder={intl.formatMessage(i18n.extensionNamePlaceholder)}
className={`${!submitAttempted || isNameValid() ? 'border-border-primary' : 'border-red-500'} text-text-primary focus:border-border-primary`}
/>
{submitAttempted && !isNameValid() && (
<div className="absolute text-xs text-red-500 mt-1">Name is required</div>
<div className="absolute text-xs text-red-500 mt-1">{intl.formatMessage(i18n.nameRequired)}</div>
)}
</div>
</div>
{/* Type Dropdown */}
<div className="w-[200px]">
<label className="text-sm font-medium mb-2 block text-text-primary">Type</label>
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.typeLabel)}</label>
<Select
value={{
value: type,
label:
type === 'stdio'
? 'STDIO'
? intl.formatMessage(i18n.typeStdio)
: type === 'streamable_http'
? 'HTTP'
? intl.formatMessage(i18n.typeHttp)
: type === 'sse'
? 'SSE (unsupported)'
? intl.formatMessage(i18n.typeSseUnsupported)
: type.toUpperCase(),
}}
onChange={(newValue: unknown) => {
@@ -61,8 +111,8 @@ export default function ExtensionInfoFields({
}
}}
options={[
{ value: 'stdio', label: 'Standard IO (STDIO)' },
{ value: 'streamable_http', label: 'Streamable HTTP' },
{ value: 'stdio', label: intl.formatMessage(i18n.typeStandardIo) },
{ value: 'streamable_http', label: intl.formatMessage(i18n.typeStreamableHttp) },
]}
isSearchable={false}
/>
@@ -71,12 +121,12 @@ export default function ExtensionInfoFields({
{/* Bottom row with Description spanning full width */}
<div className="w-full">
<label className="text-sm font-medium mb-2 block text-text-primary">Description</label>
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.descriptionLabel)}</label>
<div className="relative">
<Input
value={description}
onChange={(e) => onChange('description', e.target.value)}
placeholder="Optional description..."
placeholder={intl.formatMessage(i18n.descriptionPlaceholder)}
className={`text-text-primary focus:border-border-primary`}
/>
</div>
@@ -1,8 +1,12 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, type RenderOptions, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ExtensionModal from './ExtensionModal';
import { ExtensionFormData } from '../utils';
import { IntlTestWrapper } from '../../../../i18n/test-utils';
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: IntlTestWrapper, ...options });
describe('ExtensionModal', () => {
it('does not show unsaved changes dialog when closing without modifications', async () => {
@@ -25,7 +29,7 @@ describe('ExtensionModal', () => {
headers: [],
};
render(
renderWithIntl(
<ExtensionModal
title="Edit Extension"
initialData={initialData}
@@ -61,7 +65,7 @@ describe('ExtensionModal', () => {
headers: [],
};
render(
renderWithIntl(
<ExtensionModal
title="Edit Extension"
initialData={initialData}
@@ -100,7 +104,7 @@ describe('ExtensionModal', () => {
headers: [],
};
render(
renderWithIntl(
<ExtensionModal
title="Edit Extension"
initialData={initialData}
@@ -139,7 +143,7 @@ describe('ExtensionModal', () => {
headers: [],
};
render(
renderWithIntl(
<ExtensionModal
title="Edit Extension"
initialData={initialData}
@@ -178,7 +182,7 @@ describe('ExtensionModal', () => {
headers: [],
};
render(
renderWithIntl(
<ExtensionModal
title="Add custom extension"
initialData={initialData}
@@ -17,6 +17,46 @@ import ExtensionInfoFields from './ExtensionInfoFields';
import ExtensionTimeoutField from './ExtensionTimeoutField';
import { upsertConfig } from '../../../../api';
import { ConfirmationModal } from '../../../ui/ConfirmationModal';
import { defineMessages, useIntl } from '../../../../i18n';
const i18n = defineMessages({
deleteExtensionTitle: {
id: 'extensionModal.deleteExtensionTitle',
defaultMessage: 'Delete Extension "{name}"',
},
deleteDescription: {
id: 'extensionModal.deleteDescription',
defaultMessage: 'This will permanently remove this extension and all of its settings.',
},
installationNotes: {
id: 'extensionModal.installationNotes',
defaultMessage: 'Installation Notes',
},
cancel: {
id: 'extensionModal.cancel',
defaultMessage: 'Cancel',
},
confirmRemoval: {
id: 'extensionModal.confirmRemoval',
defaultMessage: 'Confirm removal',
},
removeExtension: {
id: 'extensionModal.removeExtension',
defaultMessage: 'Remove extension',
},
unsavedChangesTitle: {
id: 'extensionModal.unsavedChangesTitle',
defaultMessage: 'Unsaved Changes',
},
unsavedChangesMessage: {
id: 'extensionModal.unsavedChangesMessage',
defaultMessage: 'You have unsaved changes to the extension configuration. Are you sure you want to close without saving?',
},
closeWithoutSaving: {
id: 'extensionModal.closeWithoutSaving',
defaultMessage: 'Close Without Saving',
},
});
interface ExtensionModalProps {
title: string;
@@ -37,6 +77,7 @@ export default function ExtensionModal({
submitLabel,
modalType,
}: ExtensionModalProps) {
const intl = useIntl();
const [formData, setFormData] = useState<ExtensionFormData>(initialData);
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [submitAttempted, setSubmitAttempted] = useState(false);
@@ -315,7 +356,7 @@ export default function ExtensionModal({
};
// Update title based on current state
const modalTitle = showDeleteConfirmation ? `Delete Extension "${formData.name}"` : title;
const modalTitle = showDeleteConfirmation ? intl.formatMessage(i18n.deleteExtensionTitle, { name: formData.name }) : title;
return (
<>
@@ -328,7 +369,7 @@ export default function ExtensionModal({
</DialogTitle>
{showDeleteConfirmation && (
<DialogDescription>
This will permanently remove this extension and all of its settings.
{intl.formatMessage(i18n.deleteDescription)}
</DialogDescription>
)}
</DialogHeader>
@@ -336,7 +377,7 @@ export default function ExtensionModal({
{showDeleteConfirmation ? (
<div className="py-4">
<p className="text-text-primary">
This will permanently remove this extension and all of its settings.
{intl.formatMessage(i18n.deleteDescription)}
</p>
</div>
) : (
@@ -347,7 +388,7 @@ export default function ExtensionModal({
<Info className="h-5 w-5 text-blue-400 shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-text-primary mb-1">
Installation Notes
{intl.formatMessage(i18n.installationNotes)}
</h4>
<p className="text-sm text-text-secondary">{formData.installation_notes}</p>
</div>
@@ -426,7 +467,7 @@ export default function ExtensionModal({
{showDeleteConfirmation ? (
<>
<Button variant="outline" onClick={() => setShowDeleteConfirmation(false)}>
Cancel
{intl.formatMessage(i18n.cancel)}
</Button>
<Button
onClick={() => {
@@ -438,7 +479,7 @@ export default function ExtensionModal({
variant="destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Confirm removal
{intl.formatMessage(i18n.confirmRemoval)}
</Button>
</>
) : (
@@ -450,11 +491,11 @@ export default function ExtensionModal({
className="text-red-500 hover:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove extension
{intl.formatMessage(i18n.removeExtension)}
</Button>
)}
<Button variant="outline" onClick={handleClose}>
Cancel
{intl.formatMessage(i18n.cancel)}
</Button>
<Button
data-testid="extension-submit-btn"
@@ -473,9 +514,9 @@ export default function ExtensionModal({
{showCloseConfirmation && (
<ConfirmationModal
isOpen={showCloseConfirmation}
title="Unsaved Changes"
message="You have unsaved changes to the extension configuration. Are you sure you want to close without saving?"
confirmLabel="Close Without Saving"
title={intl.formatMessage(i18n.unsavedChangesTitle)}
message={intl.formatMessage(i18n.unsavedChangesMessage)}
confirmLabel={intl.formatMessage(i18n.closeWithoutSaving)}
onConfirm={handleConfirmClose}
onCancel={handleCancelClose}
/>
@@ -1,4 +1,12 @@
import { Input } from '../../../ui/input';
import { defineMessages, useIntl } from '../../../../i18n';
const i18n = defineMessages({
timeoutLabel: {
id: 'extensionTimeoutField.timeoutLabel',
defaultMessage: 'Timeout',
},
});
interface ExtensionTimeoutFieldProps {
timeout: number;
@@ -24,12 +32,14 @@ export default function ExtensionTimeoutField({
return !isNaN(timeoutValue) && timeoutValue > 0;
};
const intl = useIntl();
return (
<div className="flex flex-col gap-4 mb-6">
{/* Row with Timeout and timeout input side by side */}
<div className="flex flex-col">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block text-text-primary">Timeout</label>
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.timeoutLabel)}</label>
</div>
<Input
@@ -3,6 +3,42 @@ import { Button } from '../../../ui/button';
import { Plus, X } from 'lucide-react';
import { Input } from '../../../ui/input';
import { cn } from '../../../../utils';
import { defineMessages, useIntl } from '../../../../i18n';
const i18n = defineMessages({
requestHeaders: {
id: 'headersSection.requestHeaders',
defaultMessage: 'Request Headers',
},
headersDescription: {
id: 'headersSection.headersDescription',
defaultMessage: 'Add custom HTTP headers to include in requests to the MCP server. Click the "+" button to add after filling both fields.',
},
headerName: {
id: 'headersSection.headerName',
defaultMessage: 'Header name',
},
value: {
id: 'headersSection.value',
defaultMessage: 'Value',
},
bothRequired: {
id: 'headersSection.bothRequired',
defaultMessage: 'Both header name and value must be entered',
},
noSpaces: {
id: 'headersSection.noSpaces',
defaultMessage: 'Header name cannot contain spaces',
},
duplicateHeader: {
id: 'headersSection.duplicateHeader',
defaultMessage: 'A header with this name already exists',
},
add: {
id: 'headersSection.add',
defaultMessage: 'Add',
},
});
interface HeadersSectionProps {
headers: { key: string; value: string; isEdited?: boolean }[];
@@ -24,6 +60,7 @@ export default function HeadersSection({
submitAttempted,
onPendingInputChange,
}: HeadersSectionProps) {
const intl = useIntl();
const [newKey, setNewKey] = React.useState('');
const [newValue, setNewValue] = React.useState('');
const [validationError, setValidationError] = React.useState<string | null>(null);
@@ -52,7 +89,7 @@ export default function HeadersSection({
key: keyEmpty,
value: valueEmpty,
});
setValidationError('Both header name and value must be entered');
setValidationError(intl.formatMessage(i18n.bothRequired));
return;
}
@@ -61,7 +98,7 @@ export default function HeadersSection({
key: true,
value: false,
});
setValidationError('Header name cannot contain spaces');
setValidationError(intl.formatMessage(i18n.noSpaces));
return;
}
@@ -70,7 +107,7 @@ export default function HeadersSection({
key: true,
value: false,
});
setValidationError('A header with this name already exists');
setValidationError(intl.formatMessage(i18n.duplicateHeader));
return;
}
@@ -95,10 +132,9 @@ export default function HeadersSection({
return (
<div>
<div className="relative mb-2">
<label className="text-sm font-medium text-text-primary mb-2 block">Request Headers</label>
<label className="text-sm font-medium text-text-primary mb-2 block">{intl.formatMessage(i18n.requestHeaders)}</label>
<p className="text-xs text-text-secondary mb-4">
Add custom HTTP headers to include in requests to the MCP server. Click the "+" button to
add after filling both fields.
{intl.formatMessage(i18n.headersDescription)}
</p>
</div>
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
@@ -109,7 +145,7 @@ export default function HeadersSection({
<Input
value={header.key}
onChange={(e) => onChange(index, 'key', e.target.value)}
placeholder="Header name"
placeholder={intl.formatMessage(i18n.headerName)}
className={cn(
'w-full text-text-primary border-border-primary hover:border-border-primary',
isFieldInvalid(index, 'key') && 'border-red-500 focus:border-red-500'
@@ -120,7 +156,7 @@ export default function HeadersSection({
<Input
value={header.value}
onChange={(e) => onChange(index, 'value', e.target.value)}
placeholder="Value"
placeholder={intl.formatMessage(i18n.value)}
className={cn(
'w-full text-text-primary border-border-primary hover:border-border-primary',
isFieldInvalid(index, 'value') && 'border-red-500 focus:border-red-500'
@@ -144,7 +180,7 @@ export default function HeadersSection({
setNewKey(e.target.value);
clearValidation();
}}
placeholder="Header name"
placeholder={intl.formatMessage(i18n.headerName)}
className={cn(
'w-full text-text-primary border-border-primary hover:border-border-primary',
invalidFields.key && 'border-red-500 focus:border-red-500'
@@ -156,7 +192,7 @@ export default function HeadersSection({
setNewValue(e.target.value);
clearValidation();
}}
placeholder="Value"
placeholder={intl.formatMessage(i18n.value)}
className={cn(
'w-full text-text-primary border-border-primary hover:border-border-primary',
invalidFields.value && 'border-red-500 focus:border-red-500'
@@ -167,7 +203,7 @@ export default function HeadersSection({
variant="ghost"
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-text-primary bg-background-primary border border-border-primary hover:border-border-primary transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
>
<Plus /> Add
<Plus /> {intl.formatMessage(i18n.add)}
</Button>
</div>
{validationError && <div className="mt-2 text-red-500 text-sm">{validationError}</div>}
@@ -5,6 +5,18 @@ import { Gear } from '../../../icons';
import { FixedExtensionEntry } from '../../../ConfigContext';
import { getSubtitle, getFriendlyTitle } from './ExtensionList';
import { Card, CardHeader, CardTitle, CardContent, CardAction } from '../../../ui/card';
import { defineMessages, useIntl } from '../../../../i18n';
const i18n = defineMessages({
configureExtension: {
id: 'extensionItem.configureExtension',
defaultMessage: 'Configure {name} Extension',
},
toggleExtension: {
id: 'extensionItem.toggleExtension',
defaultMessage: 'Toggle {name} extension On or Off',
},
});
interface ExtensionItemProps {
extension: FixedExtensionEntry;
@@ -19,6 +31,7 @@ export default function ExtensionItem({
onConfigure,
isStatic,
}: ExtensionItemProps) {
const intl = useIntl();
// Add local state to track the visual toggle state
const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled);
// Track if we're in the process of toggling
@@ -84,7 +97,7 @@ export default function ExtensionItem({
{editable && (
<button
className="text-text-secondary hover:text-text-primary"
aria-label={`Configure ${getFriendlyTitle(extension)} Extension`}
aria-label={intl.formatMessage(i18n.configureExtension, { name: getFriendlyTitle(extension) })}
onClick={() => onConfigure?.(extension)}
>
<Gear className="w-4 h-4" />
@@ -95,7 +108,7 @@ export default function ExtensionItem({
onCheckedChange={() => handleToggle(extension)}
disabled={isToggling}
variant="mono"
aria-label={`Toggle ${getFriendlyTitle(extension)} extension On or Off`}
aria-label={intl.formatMessage(i18n.toggleExtension, { name: getFriendlyTitle(extension) })}
/>
</div>
</CardAction>
@@ -3,6 +3,26 @@ import builtInExtensionsData from '../../../../built-in-extensions.json';
import { ExtensionConfig } from '../../../../api';
import { FixedExtensionEntry } from '../../../ConfigContext';
import { combineCmdAndArgs } from '../utils';
import { defineMessages, useIntl } from '../../../../i18n';
const i18n = defineMessages({
defaultExtensions: {
id: 'extensionList.defaultExtensions',
defaultMessage: 'Default Extensions ({count})',
},
availableExtensions: {
id: 'extensionList.availableExtensions',
defaultMessage: 'Available Extensions ({count})',
},
noExtensions: {
id: 'extensionList.noExtensions',
defaultMessage: 'No extensions available',
},
builtInExtension: {
id: 'extensionList.builtInExtension',
defaultMessage: 'Built-in extension',
},
});
interface ExtensionListProps {
extensions: FixedExtensionEntry[];
@@ -35,6 +55,8 @@ export default function ExtensionList({
);
};
const intl = useIntl();
// Separate enabled and disabled extensions, then filter by search term
const enabledExtensions = extensions.filter((ext) => ext.enabled && matchesSearch(ext));
const disabledExtensions = extensions.filter((ext) => !ext.enabled && matchesSearch(ext));
@@ -53,7 +75,7 @@ export default function ExtensionList({
<div>
<h2 className="text-lg font-medium text-text-primary mb-4 flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Default Extensions ({sortedEnabledExtensions.length})
{intl.formatMessage(i18n.defaultExtensions, { count: sortedEnabledExtensions.length })}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-2">
{sortedEnabledExtensions.map((extension) => (
@@ -73,7 +95,7 @@ export default function ExtensionList({
<div>
<h2 className="text-lg font-medium text-text-secondary mb-4 flex items-center gap-2">
<span className="w-2 h-2 bg-gray-400 rounded-full"></span>
Available Extensions ({sortedDisabledExtensions.length})
{intl.formatMessage(i18n.availableExtensions, { count: sortedDisabledExtensions.length })}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-2">
{sortedDisabledExtensions.map((extension) => (
@@ -90,7 +112,7 @@ export default function ExtensionList({
)}
{extensions.length === 0 && (
<div className="text-center text-text-secondary py-8">No extensions available</div>
<div className="text-center text-text-secondary py-8">{intl.formatMessage(i18n.noExtensions)}</div>
)}
</div>
);