feat(desktop): add i18n infrastructure with react-intl (#8105)
This commit is contained in:
@@ -14,12 +14,61 @@ import {
|
||||
getExtensionOverride,
|
||||
getExtensionOverrides,
|
||||
} from '../../store/extensionOverrides';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
manageExtensions: {
|
||||
id: 'bottomMenuExtensionSelection.manageExtensions',
|
||||
defaultMessage: 'manage extensions',
|
||||
},
|
||||
searchExtensions: {
|
||||
id: 'bottomMenuExtensionSelection.searchExtensions',
|
||||
defaultMessage: 'search extensions...',
|
||||
},
|
||||
extensionsForNewChats: {
|
||||
id: 'bottomMenuExtensionSelection.extensionsForNewChats',
|
||||
defaultMessage: 'Extensions for new chats',
|
||||
},
|
||||
extensionsForThisSession: {
|
||||
id: 'bottomMenuExtensionSelection.extensionsForThisSession',
|
||||
defaultMessage: 'Extensions for this chat session',
|
||||
},
|
||||
noExtensionsFound: {
|
||||
id: 'bottomMenuExtensionSelection.noExtensionsFound',
|
||||
defaultMessage: 'no extensions found',
|
||||
},
|
||||
noExtensionsAvailable: {
|
||||
id: 'bottomMenuExtensionSelection.noExtensionsAvailable',
|
||||
defaultMessage: 'no extensions available',
|
||||
},
|
||||
extensionUpdated: {
|
||||
id: 'bottomMenuExtensionSelection.extensionUpdated',
|
||||
defaultMessage: 'Extension Updated',
|
||||
},
|
||||
extensionWillBeEnabled: {
|
||||
id: 'bottomMenuExtensionSelection.extensionWillBeEnabled',
|
||||
defaultMessage: '{name} will be enabled in new chats',
|
||||
},
|
||||
extensionWillBeDisabled: {
|
||||
id: 'bottomMenuExtensionSelection.extensionWillBeDisabled',
|
||||
defaultMessage: '{name} will be disabled in new chats',
|
||||
},
|
||||
extensionToggleError: {
|
||||
id: 'bottomMenuExtensionSelection.extensionToggleError',
|
||||
defaultMessage: 'Extension Toggle Error',
|
||||
},
|
||||
noActiveSession: {
|
||||
id: 'bottomMenuExtensionSelection.noActiveSession',
|
||||
defaultMessage: 'No active session found. Please start a chat session first.',
|
||||
},
|
||||
});
|
||||
|
||||
interface BottomMenuExtensionSelectionProps {
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionSelectionProps) => {
|
||||
const intl = useIntl();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [sessionExtensions, setSessionExtensions] = useState<ExtensionConfig[]>([]);
|
||||
@@ -113,8 +162,11 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
}, 800);
|
||||
|
||||
toastService.success({
|
||||
title: 'Extension Updated',
|
||||
msg: `${formatExtensionName(extensionConfig.name)} will be ${!currentState ? 'enabled' : 'disabled'} in new chats`,
|
||||
title: intl.formatMessage(i18n.extensionUpdated),
|
||||
msg: intl.formatMessage(
|
||||
!currentState ? i18n.extensionWillBeEnabled : i18n.extensionWillBeDisabled,
|
||||
{ name: formatExtensionName(extensionConfig.name) }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -123,8 +175,8 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
setIsTransitioning(false);
|
||||
setTogglingExtension(null);
|
||||
toastService.error({
|
||||
title: 'Extension Toggle Error',
|
||||
msg: 'No active session found. Please start a chat session first.',
|
||||
title: intl.formatMessage(i18n.extensionToggleError),
|
||||
msg: intl.formatMessage(i18n.noActiveSession),
|
||||
traceback: 'No session ID available',
|
||||
});
|
||||
return;
|
||||
@@ -161,7 +213,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
setTogglingExtension(null);
|
||||
}
|
||||
},
|
||||
[sessionId, isHubView, togglingExtension]
|
||||
[sessionId, isHubView, togglingExtension, intl]
|
||||
);
|
||||
|
||||
// Merge all available extensions with session-specific or hub override state
|
||||
@@ -233,7 +285,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`flex items-center [&_svg]:size-4 text-text-primary/70 hover:text-text-primary hover:scale-100 hover:bg-transparent text-xs cursor-pointer ${allExtensions.length === 0 || (!isHubView && !isSessionExtensionsLoaded) ? 'invisible' : ''}`}
|
||||
title="manage extensions"
|
||||
title={intl.formatMessage(i18n.manageExtensions)}
|
||||
>
|
||||
<Puzzle className="mr-1 h-4 w-4" />
|
||||
<span>{activeCount}</span>
|
||||
@@ -250,14 +302,14 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="search extensions..."
|
||||
placeholder={intl.formatMessage(i18n.searchExtensions)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-text-primary/60 mt-1.5">
|
||||
{isHubView ? 'Extensions for new chats' : 'Extensions for this chat session'}
|
||||
{intl.formatMessage(isHubView ? i18n.extensionsForNewChats : i18n.extensionsForThisSession)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -267,7 +319,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
>
|
||||
{sortedExtensions.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-text-primary/70">
|
||||
{searchQuery ? 'no extensions found' : 'no extensions available'}
|
||||
{intl.formatMessage(searchQuery ? i18n.noExtensionsFound : i18n.noExtensionsAvailable)}
|
||||
</div>
|
||||
) : (
|
||||
sortedExtensions.map((ext) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { render, type RenderOptions, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { BottomMenuModeSelection } from './BottomMenuModeSelection';
|
||||
import { IntlTestWrapper } from '../../i18n/test-utils';
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
|
||||
render(ui, { wrapper: IntlTestWrapper, ...options });
|
||||
|
||||
let mockConfig: Record<string, unknown> = {};
|
||||
const mockUpdateSession = vi.fn().mockResolvedValue({});
|
||||
@@ -37,7 +41,7 @@ describe('BottomMenuModeSelection', () => {
|
||||
|
||||
it('displays mode from config when no session', async () => {
|
||||
mockConfig.GOOSE_MODE = 'approve';
|
||||
render(<BottomMenuModeSelection sessionId={null} />);
|
||||
renderWithIntl(<BottomMenuModeSelection sessionId={null} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('manual')).toBeInTheDocument();
|
||||
});
|
||||
@@ -45,7 +49,7 @@ describe('BottomMenuModeSelection', () => {
|
||||
|
||||
it('defaults to auto when config has no mode', async () => {
|
||||
mockConfig.GOOSE_MODE = undefined;
|
||||
render(<BottomMenuModeSelection sessionId={null} />);
|
||||
renderWithIntl(<BottomMenuModeSelection sessionId={null} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('autonomous')).toBeInTheDocument();
|
||||
});
|
||||
@@ -54,7 +58,7 @@ describe('BottomMenuModeSelection', () => {
|
||||
it('fetches mode from session when sessionId is present', async () => {
|
||||
mockConfig.GOOSE_MODE = 'auto';
|
||||
mockGetSession.mockResolvedValue({ data: { goose_mode: 'approve' } });
|
||||
render(<BottomMenuModeSelection sessionId="test-session-123" />);
|
||||
renderWithIntl(<BottomMenuModeSelection sessionId="test-session-123" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('manual')).toBeInTheDocument();
|
||||
});
|
||||
@@ -65,7 +69,7 @@ describe('BottomMenuModeSelection', () => {
|
||||
|
||||
it('calls updateSession and does not write global config', async () => {
|
||||
mockConfig.GOOSE_MODE = 'auto';
|
||||
render(<BottomMenuModeSelection sessionId="test-session-123" />);
|
||||
renderWithIntl(<BottomMenuModeSelection sessionId="test-session-123" />);
|
||||
|
||||
fireEvent.click(screen.getByText('Manual'));
|
||||
|
||||
@@ -78,7 +82,7 @@ describe('BottomMenuModeSelection', () => {
|
||||
|
||||
it('does not call updateSession when sessionId is null', async () => {
|
||||
mockConfig.GOOSE_MODE = 'auto';
|
||||
render(<BottomMenuModeSelection sessionId={null} />);
|
||||
renderWithIntl(<BottomMenuModeSelection sessionId={null} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Manual'));
|
||||
|
||||
@@ -98,7 +102,7 @@ describe('BottomMenuModeSelection', () => {
|
||||
.mockImplementationOnce(() => promiseA)
|
||||
.mockResolvedValueOnce({ data: { goose_mode: 'auto' } });
|
||||
|
||||
const { rerender } = render(<BottomMenuModeSelection sessionId="session-A" />);
|
||||
const { rerender } = renderWithIntl(<BottomMenuModeSelection sessionId="session-A" />);
|
||||
rerender(<BottomMenuModeSelection sessionId="session-B" />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -10,8 +10,25 @@ import {
|
||||
} from '../ui/dropdown-menu';
|
||||
import { trackModeChanged } from '../../utils/analytics';
|
||||
import { getSession, updateSession } from '../../api';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
autoFallback: {
|
||||
id: 'bottomMenuModeSelection.autoFallback',
|
||||
defaultMessage: 'auto',
|
||||
},
|
||||
automaticModeDescription: {
|
||||
id: 'bottomMenuModeSelection.automaticModeDescription',
|
||||
defaultMessage: 'Automatic mode selection',
|
||||
},
|
||||
currentModeTitle: {
|
||||
id: 'bottomMenuModeSelection.currentModeTitle',
|
||||
defaultMessage: 'Current mode: {label} - {description}',
|
||||
},
|
||||
});
|
||||
|
||||
export const BottomMenuModeSelection = ({ sessionId }: { sessionId: string | null }) => {
|
||||
const intl = useIntl();
|
||||
const [gooseMode, setGooseMode] = useState('auto');
|
||||
const { config } = useConfig();
|
||||
|
||||
@@ -51,18 +68,20 @@ export const BottomMenuModeSelection = ({ sessionId }: { sessionId: string | nul
|
||||
}
|
||||
};
|
||||
|
||||
function getValueByKey(key: string) {
|
||||
function getValueByKey(key: string): string {
|
||||
const mode = all_goose_modes.find((mode) => mode.key === key);
|
||||
return mode ? mode.label : 'auto';
|
||||
if (!mode) return intl.formatMessage(i18n.autoFallback);
|
||||
return intl.formatMessage(mode.labelDescriptor);
|
||||
}
|
||||
|
||||
function getModeDescription(key: string) {
|
||||
function getModeDescription(key: string): string {
|
||||
const mode = all_goose_modes.find((mode) => mode.key === key);
|
||||
return mode ? mode.description : 'Automatic mode selection';
|
||||
if (!mode) return intl.formatMessage(i18n.automaticModeDescription);
|
||||
return intl.formatMessage(mode.descriptionDescriptor);
|
||||
}
|
||||
|
||||
return (
|
||||
<div title={`Current mode: ${getValueByKey(gooseMode)} - ${getModeDescription(gooseMode)}`}>
|
||||
<div title={intl.formatMessage(i18n.currentModeTitle, { label: getValueByKey(gooseMode), description: getModeDescription(gooseMode) })}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span className="flex items-center cursor-pointer [&_svg]:size-4 text-text-primary/70 hover:text-text-primary hover:scale-100 hover:bg-transparent text-xs">
|
||||
|
||||
@@ -3,6 +3,30 @@ import { CoinIcon } from '../icons';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||
import { fetchCanonicalModelInfo } from '../../utils/canonical';
|
||||
import type { ModelInfoData } from '../../api';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
pricingUnavailable: {
|
||||
id: 'costTracker.pricingUnavailable',
|
||||
defaultMessage: 'Pricing data unavailable for {model}',
|
||||
},
|
||||
costUnavailable: {
|
||||
id: 'costTracker.costUnavailable',
|
||||
defaultMessage: 'Cost data not available for {model} ({inputTokens} input, {outputTokens} output tokens)',
|
||||
},
|
||||
sessionCostBreakdown: {
|
||||
id: 'costTracker.sessionCostBreakdown',
|
||||
defaultMessage: 'Session cost breakdown:',
|
||||
},
|
||||
totalSessionCost: {
|
||||
id: 'costTracker.totalSessionCost',
|
||||
defaultMessage: 'Total session cost: {cost}',
|
||||
},
|
||||
inputOutputTooltip: {
|
||||
id: 'costTracker.inputOutputTooltip',
|
||||
defaultMessage: 'Input: {inputTokens} tokens ({inputCost}) | Output: {outputTokens} tokens ({outputCost})',
|
||||
},
|
||||
});
|
||||
|
||||
interface CostTrackerProps {
|
||||
inputTokens?: number;
|
||||
@@ -25,6 +49,7 @@ export function CostTracker({
|
||||
model: currentModel,
|
||||
provider: currentProvider,
|
||||
}: CostTrackerProps) {
|
||||
const intl = useIntl();
|
||||
const [costInfo, setCostInfo] = useState<ModelInfoData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showPricing, setShowPricing] = useState(true);
|
||||
@@ -162,9 +187,13 @@ export function CostTracker({
|
||||
// Otherwise show as unavailable
|
||||
const getUnavailableTooltip = () => {
|
||||
if (pricingFailed) {
|
||||
return `Pricing data unavailable for ${currentModel}`;
|
||||
return intl.formatMessage(i18n.pricingUnavailable, { model: currentModel });
|
||||
}
|
||||
return `Cost data not available for ${currentModel} (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`;
|
||||
return intl.formatMessage(i18n.costUnavailable, {
|
||||
model: currentModel,
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
outputTokens: outputTokens.toLocaleString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -189,13 +218,13 @@ export function CostTracker({
|
||||
const getTooltipContent = (): string => {
|
||||
// Handle error states first
|
||||
if (pricingFailed) {
|
||||
return `Pricing data unavailable for ${currentProvider}/${currentModel}`;
|
||||
return intl.formatMessage(i18n.pricingUnavailable, { model: `${currentProvider}/${currentModel}` });
|
||||
}
|
||||
|
||||
// Handle session costs
|
||||
if (sessionCosts && Object.keys(sessionCosts).length > 0) {
|
||||
// Show session breakdown
|
||||
let tooltip = 'Session cost breakdown:\n';
|
||||
let tooltip = intl.formatMessage(i18n.sessionCostBreakdown) + '\n';
|
||||
|
||||
Object.entries(sessionCosts).forEach(([modelKey, cost]) => {
|
||||
const costStr = `${costInfo?.currency || '$'}${cost.totalCost.toFixed(6)}`;
|
||||
@@ -213,12 +242,20 @@ export function CostTracker({
|
||||
}
|
||||
}
|
||||
|
||||
tooltip += `\nTotal session cost: ${costInfo?.currency || '$'}${totalCost.toFixed(6)}`;
|
||||
tooltip += '\n' + intl.formatMessage(i18n.totalSessionCost, { cost: `${costInfo?.currency || '$'}${totalCost.toFixed(6)}` });
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
// Default tooltip for single model
|
||||
return `Input: ${inputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}) | Output: ${outputTokens.toLocaleString()} tokens (${costInfo?.currency || '$'}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)})`;
|
||||
const currency = costInfo?.currency || '$';
|
||||
const inputCostStr = `${currency}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}`;
|
||||
const outputCostStr = `${currency}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)}`;
|
||||
return intl.formatMessage(i18n.inputOutputTooltip, {
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
inputCost: inputCostStr,
|
||||
outputTokens: outputTokens.toLocaleString(),
|
||||
outputCost: outputCostStr,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,14 @@ import { FolderDot } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
|
||||
import { updateWorkingDir } from '../../api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
failedToUpdateWorkingDir: {
|
||||
id: 'dirSwitcher.failedToUpdateWorkingDir',
|
||||
defaultMessage: 'Failed to update working directory',
|
||||
},
|
||||
});
|
||||
|
||||
interface DirSwitcherProps {
|
||||
className: string;
|
||||
@@ -21,6 +29,7 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
|
||||
onRestartStart,
|
||||
onRestartEnd,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false);
|
||||
|
||||
@@ -53,7 +62,7 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[DirSwitcher] Failed to update working directory:', error);
|
||||
toast.error('Failed to update working directory');
|
||||
toast.error(intl.formatMessage(i18n.failedToUpdateWorkingDir));
|
||||
} finally {
|
||||
onRestartEnd?.();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user