diff --git a/I18N.md b/I18N.md new file mode 100644 index 00000000..274ed4fd --- /dev/null +++ b/I18N.md @@ -0,0 +1,174 @@ +# Internationalization (i18n) — Goose Desktop UI + +This document describes the i18n infrastructure for the Goose Desktop UI (`ui/desktop/`). + +## Overview + +The i18n system is built on [react-intl](https://formatjs.io/docs/react-intl/) (part of the FormatJS suite). It uses the **ICU MessageFormat** standard for translations, which provides full support for pluralization, gender/select, number/date formatting, and nested messages — all governed by CLDR rules. + +**Key design decisions:** + +- English strings live in source code as `defaultMessage` values — no duplication between code and catalog. +- The `@formatjs/cli` tool extracts messages automatically from source into translation catalogs. +- Date, time, and number formatting use the same locale as text translations (single source of truth via `IntlProvider`). +- No build pipeline changes required — react-intl is a pure runtime library. + +## Marking strings for translation + +### In React components + +```tsx +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + greeting: { + id: 'myComponent.greeting', + defaultMessage: 'Hello, {name}!', + }, + itemCount: { + id: 'myComponent.itemCount', + defaultMessage: '{count, plural, one {# item} other {# items}}', + }, +}); + +function MyComponent({ name, count }: { name: string; count: number }) { + const intl = useIntl(); + return ( +
+

{intl.formatMessage(messages.greeting, { name })}

+

{intl.formatMessage(messages.itemCount, { count })}

+
+ ); +} +``` + +### Message ID conventions + +Use dot-separated, hierarchical IDs that reflect the component location: + +``` +settings.appearance.title +sessions.delete.confirmMessage +launcher.placeholder +searchBar.caseSensitive +``` + +### ICU MessageFormat syntax + +| Feature | Syntax | Example | +|---|---|---| +| Interpolation | `{variable}` | `Hello, {name}!` | +| Plural | `{var, plural, one {…} other {…}}` | `{count, plural, one {# file} other {# files}}` | +| Select | `{var, select, male {…} female {…} other {…}}` | `{gender, select, male {He} female {She} other {They}}` | +| Number | `{var, number}` | `{price, number, ::currency/USD}` | +| Date | `{var, date, medium}` | `{when, date, long}` | + +The `#` symbol inside plural/selectordinal is replaced with the formatted number. + +For full syntax details, see the [ICU MessageFormat specification](https://unicode-org.github.io/icu/userguide/format_parse/messages/). + +## Extracting messages + +After adding or modifying `defineMessages` calls, regenerate the English catalog: + +```bash +cd ui/desktop +pnpm i18n:extract +``` + +This scans all `src/**/*.{ts,tsx}` files and writes the canonical English catalog to `src/i18n/messages/en.json`. Commit this file — it serves as the reference for translators. + +### Keeping en.json in sync (automated check) + +The `lint:check` script includes `i18n:check`, which re-runs extraction and verifies the output matches what's committed: + +```bash +pnpm i18n:check +``` + +This runs as part of `pnpm lint:check` (and therefore CI). If a developer changes a `defaultMessage` in source but forgets to run `pnpm i18n:extract`, the check fails with a diff showing exactly what's out of date. + +To compile messages into an optimized AST format (optional, for production performance): + +```bash +pnpm i18n:compile +``` + +Compiled files go to `src/i18n/compiled/` (gitignored). + +## Locale detection + +The locale is resolved at startup in the following order: + +1. **`GOOSE_LOCALE`** — explicit override (set on the `window` object or via env) +2. **`navigator.language`** — the browser/OS locale +3. **`"en"`** — fallback default + +The resolved locale is used for both text translations and all Intl formatting (dates, numbers, relative times). + +## Date and number formatting + +### Inside React components + +Use `intl.formatDate()`, `intl.formatNumber()`, `intl.formatRelativeTime()` from the `useIntl()` hook. These automatically use the same locale as text translations: + +```tsx +const intl = useIntl(); +intl.formatDate(new Date(), { month: 'long', day: 'numeric' }); +intl.formatNumber(1234.5, { style: 'currency', currency: 'USD' }); +``` + +### Outside React context + +For utility functions that don't have access to the React tree (e.g., `timeUtils.ts`), import the resolved locale directly: + +```ts +import { currentLocale } from '../i18n'; +new Intl.DateTimeFormat(currentLocale, { ... }).format(date); +``` + +This ensures date/number formatting uses the same locale as the rest of the UI. + +## Adding a new language + +1. Copy `src/i18n/messages/en.json` to a new file, e.g., `src/i18n/messages/ja.json`. +2. Translate the `defaultMessage` values. Keep ICU syntax intact (e.g., `{count, plural, ...}`). +3. Add the locale code to `SUPPORTED_LOCALES` in `src/i18n/index.ts`. +4. Optionally run `pnpm i18n:compile` to pre-compile. + +No other code changes are needed — `loadMessages()` dynamically imports the correct catalog at runtime. + +## Testing + +### Wrapping test renders with IntlProvider + +Any component that uses `useIntl()` must be rendered inside an `IntlProvider`. Use the test helper: + +```tsx +import { IntlTestWrapper } from '../i18n/test-utils'; + +render(, { wrapper: IntlTestWrapper }); +``` + +### i18n-specific tests + +Unit tests for locale detection and message loading live in `src/i18n/i18n.test.ts`. Run them with: + +```bash +cd ui/desktop +pnpm test:run -- src/i18n/i18n.test.ts +``` + +## Architecture summary + +``` +src/i18n/ +├── index.ts # Locale detection, loadMessages(), re-exports +├── messages/ +│ └── en.json # Extracted English catalog (committed) +├── compiled/ # Compiled catalogs (gitignored) +├── test-utils.tsx # IntlTestWrapper for tests +└── i18n.test.ts # Unit tests + +src/renderer.tsx # IntlProvider wraps the entire app tree +``` diff --git a/ui/desktop/.gitignore b/ui/desktop/.gitignore index 2e53ae74..36330319 100644 --- a/ui/desktop/.gitignore +++ b/ui/desktop/.gitignore @@ -11,3 +11,5 @@ src/bin/goose-npm/ src/bin/temporal.db # Signing credentials .env.signing +# Compiled i18n message catalogs (generated by `pnpm i18n:compile`) +src/i18n/compiled/ diff --git a/ui/desktop/package.json b/ui/desktop/package.json index fe625773..6dc809d7 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -11,12 +11,12 @@ "scripts": { "typecheck": "tsc --noEmit", "generate-api": "openapi-ts", - "start-gui": "pnpm run generate-api && electron-forge start", - "start-gui-debug": "pnpm run generate-api && electron-forge start -- --inspect=9229", + "start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start", + "start-gui-debug": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start -- --inspect=9229", "start": "cd ../.. && just run-ui", "start:test-error": "GOOSE_TEST_ERROR=true electron-forge start", - "package": "electron-forge package", - "make": "electron-forge make", + "package": "pnpm run i18n:compile && electron-forge package", + "make": "pnpm run i18n:compile && electron-forge make", "bundle:default": "node scripts/prepare-platform-binaries.js && pnpm run make && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-arm64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}.zip\") || echo \"${APP_BUNDLE} not found; either the binary is not built or you are not on macOS\"", "bundle:alpha": "ALPHA=true node scripts/prepare-platform-binaries.js && ALPHA=true pnpm run make && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-arm64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}_alpha.zip\") || echo \"${APP_BUNDLE} not found; either the binary is not built or you are not on macOS\"", "bundle:intel": "node scripts/prepare-platform-binaries.js && pnpm run make --arch=x64 && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-x64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}_intel_mac.zip\")", @@ -28,7 +28,7 @@ "test-e2e:report": "playwright show-report", "test-e2e:single": "pnpm run generate-api && playwright test -g", "lint": "eslint \"src/**/*.{ts,tsx}\" --fix --no-warn-ignored", - "lint:check": "pnpm run typecheck && eslint \"src/**/*.{ts,tsx}\" --max-warnings 0 --no-warn-ignored", + "lint:check": "pnpm run typecheck && eslint \"src/**/*.{ts,tsx}\" --max-warnings 0 --no-warn-ignored && pnpm run i18n:check", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"", "test": "vitest", @@ -38,7 +38,10 @@ "test:integration": "vitest run --config vitest.integration.config.ts", "test:integration:watch": "vitest --config vitest.integration.config.ts", "test:integration:debug": "DEBUG=1 vitest run --config vitest.integration.config.ts", - "start-alpha-gui": "ALPHA=true pnpm run start-gui" + "start-alpha-gui": "ALPHA=true pnpm run start-gui", + "i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --out-file src/i18n/messages/en.json --flatten && pnpm run i18n:compile", + "i18n:check": "node scripts/i18n-check.js", + "i18n:compile": "node scripts/i18n-compile.js" }, "dependencies": { "@aaif/goose-acp": "workspace:*", @@ -77,6 +80,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.5.0", + "react-intl": "^10.1.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.1", "react-select": "^5.10.2", @@ -108,6 +112,7 @@ "@electron-forge/plugin-vite": "^7.11.1", "@electron/fuses": "^1.8.0", "@eslint/js": "^9.39.2", + "@formatjs/cli": "^6.14.0", "@hey-api/openapi-ts": "^0.93.0", "@modelcontextprotocol/sdk": "^1.27.0", "@playwright/test": "^1.58.2", diff --git a/ui/desktop/scripts/i18n-check.js b/ui/desktop/scripts/i18n-check.js new file mode 100644 index 00000000..fc3418a0 --- /dev/null +++ b/ui/desktop/scripts/i18n-check.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Cross-platform i18n check script. + * Extracts messages to a temp file and compares against the committed file + * to ensure src/i18n/messages/en.json is up to date. + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const projectDir = path.join(__dirname, '..'); +const formatjs = require.resolve('@formatjs/cli/bin/formatjs'); +const enFile = path.join(projectDir, 'src', 'i18n', 'messages', 'en.json'); +const tmpFile = path.join(os.tmpdir(), 'en.i18n-check.json'); + +execFileSync( + process.execPath, + [formatjs, 'extract', 'src/**/*.{ts,tsx}', '--out-file', tmpFile, '--flatten'], + { stdio: 'inherit', cwd: projectDir } +); + +const committed = fs.readFileSync(enFile, 'utf8'); +const extracted = fs.readFileSync(tmpFile, 'utf8'); + +try { + fs.unlinkSync(tmpFile); +} catch (_) { + // ignore cleanup errors +} + +if (JSON.stringify(JSON.parse(committed)) !== JSON.stringify(JSON.parse(extracted))) { + console.error( + 'Error: src/i18n/messages/en.json is out of date. Run pnpm i18n:extract to update it.' + ); + process.exit(1); +} diff --git a/ui/desktop/scripts/i18n-compile.js b/ui/desktop/scripts/i18n-compile.js new file mode 100644 index 00000000..f817c059 --- /dev/null +++ b/ui/desktop/scripts/i18n-compile.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * Cross-platform i18n compile script. + * Compiles all JSON message files in src/i18n/messages/ using formatjs. + */ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const projectDir = path.join(__dirname, '..'); +const formatjs = require.resolve('@formatjs/cli/bin/formatjs'); +const messagesDir = path.join(projectDir, 'src', 'i18n', 'messages'); +const compiledDir = path.join(projectDir, 'src', 'i18n', 'compiled'); + +fs.mkdirSync(compiledDir, { recursive: true }); + +const files = fs.readdirSync(messagesDir).filter((f) => f.endsWith('.json')); + +for (const file of files) { + const locale = path.basename(file, '.json'); + const inFile = path.join(messagesDir, file); + const outFile = path.join(compiledDir, `${locale}.json`); + execFileSync(process.execPath, [formatjs, 'compile', inFile, '--out-file', outFile], { + stdio: 'inherit', + cwd: projectDir, + }); +} diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index 073dd549..769b9a9a 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { screen, render, waitFor } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { AppInner } from './App'; +import { IntlTestWrapper } from './i18n/test-utils'; // Set up globals for jsdom Object.defineProperty(window, 'location', { @@ -215,7 +216,7 @@ describe('App Component - Brand New State', () => { GOOSE_ALLOWLIST_WARNING: false, }); - render(); + render(, { wrapper: IntlTestWrapper }); // Wait for initialization await waitFor(() => { @@ -238,7 +239,7 @@ describe('App Component - Brand New State', () => { // Set up search params to simulate view=settings deep link mockSearchParams.set('view', 'settings'); - render(); + render(, { wrapper: IntlTestWrapper }); // Wait for initialization await waitFor(() => { @@ -256,7 +257,7 @@ describe('App Component - Brand New State', () => { GOOSE_ALLOWLIST_WARNING: false, }); - render(); + render(, { wrapper: IntlTestWrapper }); // Wait for initialization await waitFor(() => { @@ -279,7 +280,7 @@ describe('App Component - Brand New State', () => { GOOSE_ALLOWLIST_WARNING: false, }); - render(); + render(, { wrapper: IntlTestWrapper }); // Wait for initialization and recovery await waitFor(() => { diff --git a/ui/desktop/src/components/AnnouncementModal.tsx b/ui/desktop/src/components/AnnouncementModal.tsx index b8a986aa..6c8c6249 100644 --- a/ui/desktop/src/components/AnnouncementModal.tsx +++ b/ui/desktop/src/components/AnnouncementModal.tsx @@ -5,6 +5,14 @@ import { ANNOUNCEMENTS_ENABLED } from '../updates'; import packageJson from '../../package.json'; import { getAnnouncementContent } from '../../announcements/content'; import { Button } from './ui/button'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + gotIt: { + id: 'announcementModal.gotIt', + defaultMessage: 'Got it!', + }, +}); interface AnnouncementMeta { id: string; @@ -33,6 +41,7 @@ function compareVersions(a: string, b: string): number { } export default function AnnouncementModal() { + const intl = useIntl(); const [showAnnouncementModal, setShowAnnouncementModal] = useState(false); const [combinedAnnouncementContent, setCombinedAnnouncementContent] = useState( null @@ -137,7 +146,7 @@ export default function AnnouncementModal() { onClick={handleCloseAnnouncement} className="w-full h-[60px] rounded-none border-b border-border-primary bg-transparent hover:bg-background-secondary text-text-primary font-medium text-md" > - Got it! + {intl.formatMessage(i18n.gotIt)} } diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 6ab1a9c4..0a0788cb 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -6,6 +6,7 @@ import React, { useRef, useState, } from 'react'; +import { defineMessages, useIntl } from '../i18n'; import { useLocation, useNavigate } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import LoadingGoose from './LoadingGoose'; @@ -40,7 +41,28 @@ import { useAutoSubmit } from '../hooks/useAutoSubmit'; import { Goose } from './icons'; import EnvironmentBadge from './GooseSidebar/EnvironmentBadge'; - +const i18n = defineMessages({ + failedToLoadSession: { + id: 'baseChat.failedToLoadSession', + defaultMessage: 'Failed to Load Session', + }, + goHome: { + id: 'baseChat.goHome', + defaultMessage: 'Go home', + }, + noSession: { + id: 'baseChat.noSession', + defaultMessage: 'No Session', + }, + recipeCreatedTitle: { + id: 'baseChat.recipeCreatedTitle', + defaultMessage: 'Recipe created successfully!', + }, + recipeCreatedMessage: { + id: 'baseChat.recipeCreatedMessage', + defaultMessage: '"{title}" has been saved and is ready to use.', + }, +}); interface BaseChatProps { setChat: (chat: ChatType) => void; @@ -66,6 +88,7 @@ export default function BaseChat({ initialMessage, isActiveSession, }: BaseChatProps) { + const intl = useIntl(); const location = useLocation(); const navigate = useNavigate(); const scrollRef = useRef(null); @@ -298,8 +321,8 @@ export default function BaseChat({ const handleRecipeCreated = (recipe: Recipe) => { toastSuccess({ - title: 'Recipe created successfully!', - msg: `"${recipe.title}" has been saved and is ready to use.`, + title: intl.formatMessage(i18n.recipeCreatedTitle), + msg: intl.formatMessage(i18n.recipeCreatedMessage, { title: recipe.title }), }); }; @@ -310,7 +333,7 @@ export default function BaseChat({ messages, recipe, sessionId, - name: session?.name || 'No Session', + name: session?.name || intl.formatMessage(i18n.noSession), }; const lastSetNameRef = useRef(''); @@ -352,7 +375,7 @@ export default function BaseChat({
-

Failed to Load Session

+

{intl.formatMessage(i18n.failedToLoadSession)}

{sessionLoadError}

diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 49bc25e4..1d6c6125 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -42,6 +42,7 @@ import { getNavigationShortcutText } from '../utils/keyboardShortcuts'; import { UserInput, ImageData } from '../types/message'; import { compressImageDataUrl } from '../utils/conversionUtils'; import { fetchCanonicalModelInfo } from '../utils/canonical'; +import { defineMessages, useIntl } from '../i18n'; interface PastedImage { id: string; @@ -59,6 +60,77 @@ const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning // Manual compact trigger message - must match backend constant const MANUAL_COMPACT_TRIGGER = '/compact'; +const i18n = defineMessages({ + dictationError: { + id: 'chatInput.dictationError', + defaultMessage: 'Dictation Error', + }, + removeImage: { + id: 'chatInput.removeImage', + defaultMessage: 'Remove image', + }, + removeFile: { + id: 'chatInput.removeFile', + defaultMessage: 'Remove file', + }, + unknownType: { + id: 'chatInput.unknownType', + defaultMessage: 'Unknown type', + }, + contextWindow: { + id: 'chatInput.contextWindow', + defaultMessage: 'Context window', + }, + tooManyTools: { + id: 'chatInput.tooManyTools', + defaultMessage: 'Too many tools can degrade performance.\nTool count: {toolCount} (recommend: {recommended})', + }, + viewExtensions: { + id: 'chatInput.viewExtensions', + defaultMessage: 'View extensions', + }, + waitingForImages: { + id: 'chatInput.waitingForImages', + defaultMessage: 'Waiting for images to save...', + }, + processingDroppedFiles: { + id: 'chatInput.processingDroppedFiles', + defaultMessage: 'Processing dropped files...', + }, + recording: { + id: 'chatInput.recording', + defaultMessage: 'Recording...', + }, + transcribing: { + id: 'chatInput.transcribing', + defaultMessage: 'Transcribing...', + }, + restartingSession: { + id: 'chatInput.restartingSession', + defaultMessage: 'Restarting session...', + }, + typeMessage: { + id: 'chatInput.typeMessage', + defaultMessage: 'Type a message to send', + }, + send: { + id: 'chatInput.send', + defaultMessage: 'Send', + }, + failedToReadImage: { + id: 'chatInput.failedToReadImage', + defaultMessage: 'Failed to read image file', + }, + viewEditRecipe: { + id: 'chatInput.viewEditRecipe', + defaultMessage: 'View/Edit Recipe', + }, + createRecipeFromSession: { + id: 'chatInput.createRecipeFromSession', + defaultMessage: 'Create Recipe from Session', + }, +}); + interface ChatInputProps { sessionId: string | null; handleSubmit: (input: UserInput) => void; @@ -144,6 +216,7 @@ export default function ChatInput({ const dropdownRef: React.RefObject = useRef( null ) as React.RefObject; + const intl = useIntl(); const { getProviders } = useConfig(); const { getCurrentModelAndProvider, @@ -326,7 +399,7 @@ export default function ChatInput({ const errorType = 'DictationError'; trackVoiceDictation('error', undefined, errorType); toastError({ - title: 'Dictation Error', + title: intl.formatMessage(i18n.dictationError), msg: message, }); }, @@ -471,7 +544,7 @@ export default function ChatInput({ if ((totalTokens && totalTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { addAlert({ type: AlertType.Info, - message: 'Context window', + message: intl.formatMessage(i18n.contextWindow), progress: { current: totalTokens || 0, total: tokenLimit, @@ -490,9 +563,9 @@ export default function ChatInput({ if (toolCount !== null && toolCount > TOOLS_MAX_SUGGESTED) { addAlert({ type: AlertType.Warning, - message: `Too many tools can degrade performance.\nTool count: ${toolCount} (recommend: ${TOOLS_MAX_SUGGESTED})`, + message: intl.formatMessage(i18n.tooManyTools, { toolCount, recommended: TOOLS_MAX_SUGGESTED }), action: { - text: 'View extensions', + text: intl.formatMessage(i18n.viewExtensions), onClick: () => setView('extensions'), }, autoShow: false, // Don't auto-show tool count warnings @@ -729,7 +802,7 @@ export default function ChatInput({ setPastedImages((prev) => prev.map((img) => img.id === imageId - ? { ...img, error: 'Failed to read image file.', isLoading: false } + ? { ...img, error: intl.formatMessage(i18n.failedToReadImage), isLoading: false } : img ) ); @@ -1071,7 +1144,7 @@ export default function ChatInput({ setPastedImages((prev) => prev.map((img) => img.id === uniqueId - ? { ...img, isLoading: false, error: 'Failed to read image file' } + ? { ...img, isLoading: false, error: intl.formatMessage(i18n.failedToReadImage) } : img ) ); @@ -1130,13 +1203,13 @@ export default function ChatInput({ chatState === ChatState.RestartingAgent; const getSubmitButtonTooltip = (): string => { - if (isAnyImageLoading) return 'Waiting for images to save...'; - if (isAnyDroppedFileLoading) return 'Processing dropped files...'; - if (isRecording) return 'Recording...'; - if (isTranscribing) return 'Transcribing...'; - if (chatState === ChatState.RestartingAgent) return 'Restarting session...'; - if (!hasSubmittableContent) return 'Type a message to send'; - return 'Send'; + if (isAnyImageLoading) return intl.formatMessage(i18n.waitingForImages); + if (isAnyDroppedFileLoading) return intl.formatMessage(i18n.processingDroppedFiles); + if (isRecording) return intl.formatMessage(i18n.recording); + if (isTranscribing) return intl.formatMessage(i18n.transcribing); + if (chatState === ChatState.RestartingAgent) return intl.formatMessage(i18n.restartingSession); + if (!hasSubmittableContent) return intl.formatMessage(i18n.typeMessage); + return intl.formatMessage(i18n.send); }; // Queue management functions - no storage persistence, only in-memory @@ -1439,7 +1512,7 @@ export default function ChatInput({ shape="round" onClick={() => handleRemovePastedImage(img.id)} className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10" - aria-label="Remove image" + aria-label={intl.formatMessage(i18n.removeImage)} variant="outline" size="xs" > @@ -1485,7 +1558,7 @@ export default function ChatInput({

{file.name}

-

{file.type || 'Unknown type'}

+

{file.type || intl.formatMessage(i18n.unknownType)}

)} @@ -1495,7 +1568,7 @@ export default function ChatInput({ shape="round" onClick={() => handleRemoveDroppedFile(file.id)} className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10" - aria-label="Remove file" + aria-label={intl.formatMessage(i18n.removeFile)} variant="outline" size="xs" > @@ -1597,7 +1670,7 @@ export default function ChatInput({ - {recipe ? 'View/Edit Recipe' : 'Create Recipe from Session'} + {recipe ? intl.formatMessage(i18n.viewEditRecipe) : intl.formatMessage(i18n.createRecipeFromSession)} diff --git a/ui/desktop/src/components/ElicitationRequest.tsx b/ui/desktop/src/components/ElicitationRequest.tsx index b8b6c3cf..195e6679 100644 --- a/ui/desktop/src/components/ElicitationRequest.tsx +++ b/ui/desktop/src/components/ElicitationRequest.tsx @@ -1,8 +1,36 @@ import { useState, useEffect, useRef } from 'react'; import { ActionRequired } from '../api'; +import { defineMessages, useIntl } from '../i18n'; import JsonSchemaForm from './ui/JsonSchemaForm'; import type { JsonSchema } from './ui/JsonSchemaForm'; +const i18n = defineMessages({ + cancelled: { + id: 'elicitationRequest.cancelled', + defaultMessage: 'Information request was cancelled.', + }, + submitted: { + id: 'elicitationRequest.submitted', + defaultMessage: 'Information submitted', + }, + expired: { + id: 'elicitationRequest.expired', + defaultMessage: 'This request has expired. The extension will need to ask again.', + }, + defaultMessage: { + id: 'elicitationRequest.defaultMessage', + defaultMessage: 'Goose needs some information from you.', + }, + submit: { + id: 'elicitationRequest.submit', + defaultMessage: 'Submit', + }, + waitingForResponse: { + id: 'elicitationRequest.waitingForResponse', + defaultMessage: 'Waiting for your response ({timeRemaining} remaining)', + }, +}); + const ELICITATION_TIMEOUT_SECONDS = 300; interface ElicitationRequestProps { @@ -24,6 +52,7 @@ export default function ElicitationRequest({ actionRequiredContent, onSubmit, }: ElicitationRequestProps) { + const intl = useIntl(); const [submitted, setSubmitted] = useState(isClicked); const [timeRemaining, setTimeRemaining] = useState(ELICITATION_TIMEOUT_SECONDS); const startTimeRef = useRef(Date.now()); @@ -58,7 +87,7 @@ export default function ElicitationRequest({ if (isCancelledMessage) { return (
- Information request was cancelled. + {intl.formatMessage(i18n.cancelled)}
); } @@ -77,7 +106,7 @@ export default function ElicitationRequest({ > - Information submitted + {intl.formatMessage(i18n.submitted)} ); @@ -104,7 +133,7 @@ export default function ElicitationRequest({ d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> - This request has expired. The extension will need to ask again. + {intl.formatMessage(i18n.expired)} ); @@ -114,14 +143,14 @@ export default function ElicitationRequest({
- {message || 'Goose needs some information from you.'} + {message || intl.formatMessage(i18n.defaultMessage)}
- Waiting for your response ({formatTime(timeRemaining)} remaining) + {intl.formatMessage(i18n.waitingForResponse, { timeRemaining: formatTime(timeRemaining) })}
diff --git a/ui/desktop/src/components/ErrorBoundary.tsx b/ui/desktop/src/components/ErrorBoundary.tsx index 3360c72a..8a9dd001 100644 --- a/ui/desktop/src/components/ErrorBoundary.tsx +++ b/ui/desktop/src/components/ErrorBoundary.tsx @@ -3,6 +3,26 @@ import { Button } from './ui/button'; import { AlertTriangle } from 'lucide-react'; import { errorMessage, formatErrorForLogging } from '../utils/conversionUtils'; import { trackErrorWithContext, trackEvent, getErrorType } from '../utils/analytics'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + heading: { + id: 'errorBoundary.heading', + defaultMessage: 'Honk!', + }, + errorWithVersion: { + id: 'errorBoundary.errorWithVersion', + defaultMessage: 'An error occurred in Goose v{version}.', + }, + errorGeneric: { + id: 'errorBoundary.errorGeneric', + defaultMessage: 'An error occurred.', + }, + reload: { + id: 'errorBoundary.reload', + defaultMessage: 'Reload', + }, +}); function getCurrentPage(): string { return window.location.hash.replace('#', '') || '/'; @@ -34,6 +54,7 @@ window.addEventListener('error', (event) => { }); export function ErrorUI({ error }: { error: string }) { + const intl = useIntl(); const handleReload = () => { trackEvent({ name: 'app_reloaded', @@ -42,6 +63,8 @@ export function ErrorUI({ error }: { error: string }) { window.electron.reloadApp(); }; + const version = window?.appConfig?.get('GOOSE_VERSION') as string | undefined; + return (
@@ -49,23 +72,19 @@ export function ErrorUI({ error }: { error: string }) {
-

Honk!

+

{intl.formatMessage(i18n.heading)}

- {window?.appConfig?.get('GOOSE_VERSION') !== undefined ? ( -

- An error occurred in Goose v{window?.appConfig?.get('GOOSE_VERSION') as string}. -

- ) : ( -

- An error occurred. -

- )} +

+ {version !== undefined + ? intl.formatMessage(i18n.errorWithVersion, { version }) + : intl.formatMessage(i18n.errorGeneric)} +

           {error}
         
- +
); diff --git a/ui/desktop/src/components/ExtensionInstallModal.test.tsx b/ui/desktop/src/components/ExtensionInstallModal.test.tsx index f32e6123..594f3d47 100644 --- a/ui/desktop/src/components/ExtensionInstallModal.test.tsx +++ b/ui/desktop/src/components/ExtensionInstallModal.test.tsx @@ -1,9 +1,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; +import { render, type RenderOptions, screen, act } from '@testing-library/react'; import { ExtensionInstallModal } from './ExtensionInstallModal'; import { addExtensionFromDeepLink } from './settings/extensions/deeplink'; +import { IntlTestWrapper } from '../i18n/test-utils'; + +const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) => + render(ui, { wrapper: IntlTestWrapper, ...options }); vi.mock('./settings/extensions/deeplink', () => ({ addExtensionFromDeepLink: vi.fn(), @@ -51,7 +55,7 @@ describe('ExtensionInstallModal', () => { it('should handle trusted extension (default behaviour, no allowlist)', async () => { mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + renderWithIntl(); const eventHandler = getAddExtensionEventHandler(); @@ -68,7 +72,7 @@ describe('ExtensionInstallModal', () => { it('should handle trusted extension (from allowlist)', async () => { mockElectron.getAllowedExtensions.mockResolvedValue(['npx test-extension']); - render(); + renderWithIntl(); const eventHandler = getAddExtensionEventHandler(); @@ -86,7 +90,7 @@ describe('ExtensionInstallModal', () => { }); mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']); - render(); + renderWithIntl(); const eventHandler = getAddExtensionEventHandler(); @@ -105,7 +109,7 @@ describe('ExtensionInstallModal', () => { it('should handle i-ching-mcp-server as allowed command', async () => { mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + renderWithIntl(); const eventHandler = getAddExtensionEventHandler(); @@ -124,7 +128,7 @@ describe('ExtensionInstallModal', () => { it('should handle blocked extension', async () => { mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']); - render(); + renderWithIntl(); const eventHandler = getAddExtensionEventHandler(); @@ -143,7 +147,7 @@ describe('ExtensionInstallModal', () => { it('should dismiss modal correctly', async () => { mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + renderWithIntl(); const eventHandler = getAddExtensionEventHandler(); @@ -164,7 +168,7 @@ describe('ExtensionInstallModal', () => { vi.mocked(addExtensionFromDeepLink).mockResolvedValue(undefined); mockElectron.getAllowedExtensions.mockResolvedValue([]); - render(); + renderWithIntl(); const eventHandler = getAddExtensionEventHandler(); diff --git a/ui/desktop/src/components/ExtensionInstallModal.tsx b/ui/desktop/src/components/ExtensionInstallModal.tsx index adf29cea..2db0299a 100644 --- a/ui/desktop/src/components/ExtensionInstallModal.tsx +++ b/ui/desktop/src/components/ExtensionInstallModal.tsx @@ -16,6 +16,78 @@ import { View, ViewOptions } from '../utils/navigationUtils'; import { useConfig } from './ConfigContext'; import { toastService } from '../toasts'; import { errorMessage } from '../utils/conversionUtils'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + unknownCommand: { + id: 'extensionInstallModal.unknownCommand', + defaultMessage: 'Unknown Command', + }, + blockedTitle: { + id: 'extensionInstallModal.blockedTitle', + defaultMessage: 'Extension Installation Blocked', + }, + blockedMessage: { + id: 'extensionInstallModal.blockedMessage', + defaultMessage: 'This extension command is not in the allowed list and its installation is blocked.\n\nExtension: {name}\nCommand: {command}\n\nContact your administrator to request approval for this extension.', + }, + ok: { + id: 'extensionInstallModal.ok', + defaultMessage: 'OK', + }, + untrustedTitle: { + id: 'extensionInstallModal.untrustedTitle', + defaultMessage: 'Install Untrusted Extension?', + }, + untrustedSecurityMessage: { + id: 'extensionInstallModal.untrustedSecurityMessage', + defaultMessage: 'This extension command is not in the allowed list and will be able to access your conversations and provide additional functionality.\n\nInstalling extensions from untrusted sources may pose security risks.', + }, + untrustedMessageWithUrl: { + id: 'extensionInstallModal.untrustedMessageWithUrl', + defaultMessage: '{securityMessage}\n\nExtension: {name}\nURL: {url}\n\nContact your administrator if you are unsure about this.', + }, + untrustedMessageWithCommand: { + id: 'extensionInstallModal.untrustedMessageWithCommand', + defaultMessage: '{securityMessage}\n\nExtension: {name}\nCommand: {command}\n\nContact your administrator if you are unsure about this.', + }, + installAnyway: { + id: 'extensionInstallModal.installAnyway', + defaultMessage: 'Install Anyway', + }, + cancel: { + id: 'extensionInstallModal.cancel', + defaultMessage: 'Cancel', + }, + trustedTitle: { + id: 'extensionInstallModal.trustedTitle', + defaultMessage: 'Confirm Extension Installation', + }, + trustedMessage: { + id: 'extensionInstallModal.trustedMessage', + defaultMessage: 'Are you sure you want to install the {name} extension?\n\nCommand: {command}', + }, + yes: { + id: 'extensionInstallModal.yes', + defaultMessage: 'Yes', + }, + no: { + id: 'extensionInstallModal.no', + defaultMessage: 'No', + }, + alreadyInstalledTitle: { + id: 'extensionInstallModal.alreadyInstalledTitle', + defaultMessage: "Extension ''{name}'' Already Installed", + }, + alreadyInstalledMessage: { + id: 'extensionInstallModal.alreadyInstalledMessage', + defaultMessage: "''{name}'' extension has already been installed successfully. Start a new chat session to use it.", + }, + installing: { + id: 'extensionInstallModal.installing', + defaultMessage: 'Installing...', + }, +}); type ModalType = 'blocked' | 'untrusted' | 'trusted'; @@ -58,7 +130,7 @@ function extractCommand(link: string): string { } // For stdio extensions, return the command - const cmd = url.searchParams.get('cmd') || 'Unknown Command'; + const cmd = url.searchParams.get('cmd') || ''; const args = url.searchParams.getAll('arg').map(decodeURIComponent); return `${cmd} ${args.join(' ')}`.trim(); } @@ -69,6 +141,7 @@ function extractRemoteUrl(link: string): string | null { } export function ExtensionInstallModal({ addExtension, setView }: ExtensionInstallModalProps) { + const intl = useIntl(); const { getExtensions } = useConfig(); const getExtensionsRef = useRef(getExtensions); const processingLinkRef = useRef(null); @@ -121,26 +194,30 @@ export function ExtensionInstallModal({ addExtension, setView }: ExtensionInstal extensionInfo: ExtensionInfo ): ExtensionModalConfig => { const { name, command, remoteUrl } = extensionInfo; + const displayCommand = command || remoteUrl || intl.formatMessage(i18n.unknownCommand); switch (modalType) { case 'blocked': return { - title: 'Extension Installation Blocked', - message: `\n\nThis extension command is not in the allowed list and its installation is blocked.\n\nExtension: ${name}\nCommand: ${command || remoteUrl}\n\nContact your administrator to request approval for this extension.`, - confirmLabel: 'OK', + title: intl.formatMessage(i18n.blockedTitle), + message: '\n\n' + intl.formatMessage(i18n.blockedMessage, { name, command: displayCommand }), + confirmLabel: intl.formatMessage(i18n.ok), cancelLabel: '', showSingleButton: true, isBlocked: true, }; case 'untrusted': { - const securityMessage = `\n\nThis extension command is not in the allowed list and will be able to access your conversations and provide additional functionality.\n\nInstalling extensions from untrusted sources may pose security risks.`; + const securityMessage = '\n\n' + intl.formatMessage(i18n.untrustedSecurityMessage); + const message = remoteUrl + ? intl.formatMessage(i18n.untrustedMessageWithUrl, { securityMessage, name, url: remoteUrl }) + : intl.formatMessage(i18n.untrustedMessageWithCommand, { securityMessage, name, command: displayCommand }); return { - title: 'Install Untrusted Extension?', - message: `${securityMessage}\n\nExtension: ${name}\n${remoteUrl ? `URL: ${remoteUrl}` : `Command: ${command}`}\n\nContact your administrator if you are unsure about this.`, - confirmLabel: 'Install Anyway', - cancelLabel: 'Cancel', + title: intl.formatMessage(i18n.untrustedTitle), + message, + confirmLabel: intl.formatMessage(i18n.installAnyway), + cancelLabel: intl.formatMessage(i18n.cancel), showSingleButton: false, isBlocked: false, }; @@ -149,10 +226,10 @@ export function ExtensionInstallModal({ addExtension, setView }: ExtensionInstal case 'trusted': default: return { - title: 'Confirm Extension Installation', - message: `Are you sure you want to install the ${name} extension?\n\nCommand: ${command || remoteUrl}`, - confirmLabel: 'Yes', - cancelLabel: 'No', + title: intl.formatMessage(i18n.trustedTitle), + message: intl.formatMessage(i18n.trustedMessage, { name, command: displayCommand }), + confirmLabel: intl.formatMessage(i18n.yes), + cancelLabel: intl.formatMessage(i18n.no), showSingleButton: false, isBlocked: false, }; @@ -175,8 +252,8 @@ export function ExtensionInstallModal({ addExtension, setView }: ExtensionInstal if (extensionsList?.find((ext) => ext.name === extName)) { toastService.success({ - title: `Extension '${extName}' Already Installed`, - msg: `'${extName}' extension has already been installed successfully. Start a new chat session to use it.`, + title: intl.formatMessage(i18n.alreadyInstalledTitle, { name: extName }), + msg: intl.formatMessage(i18n.alreadyInstalledMessage, { name: extName }), }); return; } @@ -210,7 +287,7 @@ export function ExtensionInstallModal({ addExtension, setView }: ExtensionInstal } finally { processingLinkRef.current = null; } - }, []); + }, [intl]); const dismissModal = useCallback(() => { setModalState({ @@ -328,7 +405,7 @@ export function ExtensionInstallModal({ addExtension, setView }: ExtensionInstal disabled={modalState.isPending} variant={getConfirmButtonVariant()} > - {modalState.isPending ? 'Installing...' : config.confirmLabel} + {modalState.isPending ? intl.formatMessage(i18n.installing) : config.confirmLabel} )} diff --git a/ui/desktop/src/components/GooseSidebar/EnvironmentBadge.tsx b/ui/desktop/src/components/GooseSidebar/EnvironmentBadge.tsx index 5f64c997..135fcd9c 100644 --- a/ui/desktop/src/components/GooseSidebar/EnvironmentBadge.tsx +++ b/ui/desktop/src/components/GooseSidebar/EnvironmentBadge.tsx @@ -1,11 +1,24 @@ import React from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + alpha: { + id: 'environmentBadge.alpha', + defaultMessage: 'Alpha', + }, + dev: { + id: 'environmentBadge.dev', + defaultMessage: 'Dev', + }, +}); interface EnvironmentBadgeProps { className?: string; } const EnvironmentBadge: React.FC = ({ className = '' }) => { + const intl = useIntl(); const isAlpha = process.env.ALPHA; const isDevelopment = import.meta.env.DEV; @@ -14,7 +27,9 @@ const EnvironmentBadge: React.FC = ({ className = '' }) = return null; } - const tooltipText = isAlpha ? 'Alpha' : 'Dev'; + const tooltipText = isAlpha + ? intl.formatMessage(i18n.alpha) + : intl.formatMessage(i18n.dev); const bgColor = isAlpha ? 'bg-purple-600' : 'bg-orange-400'; return ( diff --git a/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx b/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx index f3783bd7..ae6f5e34 100644 --- a/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx +++ b/ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx @@ -2,6 +2,26 @@ import React from 'react'; import { Moon, Sliders, Sun } from 'lucide-react'; import { Button } from '../ui/button'; import { useTheme } from '../../contexts/ThemeContext'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + theme: { + id: 'themeSelector.theme', + defaultMessage: 'Theme', + }, + light: { + id: 'themeSelector.light', + defaultMessage: 'Light', + }, + dark: { + id: 'themeSelector.dark', + defaultMessage: 'Dark', + }, + system: { + id: 'themeSelector.system', + defaultMessage: 'System', + }, +}); interface ThemeSelectorProps { className?: string; @@ -14,11 +34,12 @@ const ThemeSelector: React.FC = ({ hideTitle = false, horizontal = false, }) => { + const intl = useIntl(); const { userThemePreference, setUserThemePreference } = useTheme(); return (
- {!hideTitle &&
Theme
} + {!hideTitle &&
{intl.formatMessage(i18n.theme)}
}
@@ -34,7 +55,7 @@ const ThemeSelector: React.FC = ({ size="sm" > - Light + {intl.formatMessage(i18n.light)}
diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx index c8623df2..87e96dde 100644 --- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx +++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx @@ -7,6 +7,58 @@ import { useNavigation } from '../hooks/useNavigation'; import { formatExtensionErrorMessage } from '../utils/extensionErrorUtils'; import { getInitialWorkingDir } from '../utils/workingDir'; import { formatExtensionName } from './settings/extensions/subcomponents/ExtensionList'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + loadingExtensions: { + id: 'groupedExtensionLoadingToast.loadingExtensions', + defaultMessage: 'Loading {count, plural, one {# extension} other {# extensions}}...', + }, + successfullyLoaded: { + id: 'groupedExtensionLoadingToast.successfullyLoaded', + defaultMessage: 'Successfully loaded {count, plural, one {# extension} other {# extensions}}', + }, + partiallyLoaded: { + id: 'groupedExtensionLoadingToast.partiallyLoaded', + defaultMessage: 'Loaded {successCount}/{totalCount, plural, one {# extension} other {# extensions}}', + }, + failedToLoad: { + id: 'groupedExtensionLoadingToast.failedToLoad', + defaultMessage: '{count, plural, one {# extension} other {# extensions}} failed to load', + }, + failedToAddExtension: { + id: 'groupedExtensionLoadingToast.failedToAddExtension', + defaultMessage: 'Failed to add extension', + }, + askGoose: { + id: 'groupedExtensionLoadingToast.askGoose', + defaultMessage: 'Ask goose', + }, + copied: { + id: 'groupedExtensionLoadingToast.copied', + defaultMessage: 'Copied!', + }, + copyError: { + id: 'groupedExtensionLoadingToast.copyError', + defaultMessage: 'Copy error', + }, + showLess: { + id: 'groupedExtensionLoadingToast.showLess', + defaultMessage: 'Show less', + }, + showDetails: { + id: 'groupedExtensionLoadingToast.showDetails', + defaultMessage: 'Show details', + }, + collapseDetails: { + id: 'groupedExtensionLoadingToast.collapseDetails', + defaultMessage: 'Collapse details', + }, + expandDetails: { + id: 'groupedExtensionLoadingToast.expandDetails', + defaultMessage: 'Expand details', + }, +}); export interface ExtensionLoadingStatus { name: string; @@ -29,6 +81,7 @@ export function GroupedExtensionLoadingToast({ const [isOpen, setIsOpen] = useState(false); const [copiedExtension, setCopiedExtension] = useState(null); const setView = useNavigation(); + const intl = useIntl(); const successCount = extensions.filter((ext) => ext.status === 'success').length; const errorCount = extensions.filter((ext) => ext.status === 'error').length; @@ -46,14 +99,14 @@ export function GroupedExtensionLoadingToast({ const getSummaryText = () => { if (!isComplete) { - return `Loading ${totalCount} extension${totalCount !== 1 ? 's' : ''}...`; + return intl.formatMessage(i18n.loadingExtensions, { count: totalCount }); } if (errorCount === 0) { - return `Successfully loaded ${successCount} extension${successCount !== 1 ? 's' : ''}`; + return intl.formatMessage(i18n.successfullyLoaded, { count: successCount }); } - return `Loaded ${successCount}/${totalCount} extension${totalCount !== 1 ? 's' : ''}`; + return intl.formatMessage(i18n.partiallyLoaded, { successCount, totalCount }); }; const getSummaryIcon = () => { @@ -81,7 +134,7 @@ export function GroupedExtensionLoadingToast({
{getSummaryText()}
{errorCount > 0 && (
- {errorCount} extension{errorCount !== 1 ? 's' : ''} failed to load + {intl.formatMessage(i18n.failedToLoad, { count: errorCount })}
)} @@ -105,7 +158,7 @@ export function GroupedExtensionLoadingToast({ {ext.status === 'error' && ext.error && (
- {formatExtensionErrorMessage(ext.error, 'Failed to add extension')} + {formatExtensionErrorMessage(ext.error, intl.formatMessage(i18n.failedToAddExtension))}
{ext.recoverHints && setView && ( @@ -120,7 +173,7 @@ export function GroupedExtensionLoadingToast({ ); }} > - Ask goose + {intl.formatMessage(i18n.askGoose)} )}
@@ -149,16 +202,16 @@ export function GroupedExtensionLoadingToast({ diff --git a/ui/desktop/src/components/Layout/CondensedRenderer.tsx b/ui/desktop/src/components/Layout/CondensedRenderer.tsx index 9f1713ac..20d0f451 100644 --- a/ui/desktop/src/components/Layout/CondensedRenderer.tsx +++ b/ui/desktop/src/components/Layout/CondensedRenderer.tsx @@ -1,11 +1,19 @@ import React, { useState } from 'react'; import { GripVertical, ChevronDown, ChevronRight, Plus } from 'lucide-react'; import { motion } from 'framer-motion'; +import { defineMessages, useIntl } from '../../i18n'; import { cn } from '../../utils'; import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu'; import { ChatSessionsDropdown, SessionsList } from './navigation'; import type { NavigationRendererProps } from './navigation/types'; +const i18n = defineMessages({ + newChat: { + id: 'condensedRenderer.newChat', + defaultMessage: 'New Chat', + }, +}); + export const CondensedRenderer: React.FC = ({ isOverlayMode, navigationPosition, @@ -26,6 +34,7 @@ export const CondensedRenderer: React.FC = ({ drag, navFocusRef, }) => { + const intl = useIntl(); const [chatPopoverOpen, setChatPopoverOpen] = useState(false); const isVertical = navigationPosition === 'left' || navigationPosition === 'right'; @@ -176,7 +185,7 @@ export const CondensedRenderer: React.FC = ({ 'bg-background-tertiary hover:bg-background-inverse hover:text-text-inverse', 'flex items-center justify-center' )} - title="New Chat" + title={intl.formatMessage(i18n.newChat)} > diff --git a/ui/desktop/src/components/Layout/navigation/ChatSessionsDropdown.tsx b/ui/desktop/src/components/Layout/navigation/ChatSessionsDropdown.tsx index fa52a653..2f2006a7 100644 --- a/ui/desktop/src/components/Layout/navigation/ChatSessionsDropdown.tsx +++ b/ui/desktop/src/components/Layout/navigation/ChatSessionsDropdown.tsx @@ -8,9 +8,21 @@ import { import { SessionIndicators } from '../../SessionIndicators'; import { cn } from '../../../utils'; import { getSessionDisplayName, truncateMessage } from '../../../hooks/useNavigationSessions'; +import { defineMessages, useIntl } from '../../../i18n'; import type { Session } from '../../../api'; import type { SessionStatus } from './types'; +const i18n = defineMessages({ + newChat: { + id: 'chatSessionsDropdown.newChat', + defaultMessage: 'New Chat', + }, + showAll: { + id: 'chatSessionsDropdown.showAll', + defaultMessage: 'Show All', + }, +}); + interface ChatSessionsDropdownProps { sessions: Session[]; activeSessionId?: string; @@ -34,6 +46,7 @@ export const ChatSessionsDropdown: React.FC = ({ onSessionClick, onShowAll, }) => { + const intl = useIntl(); return ( = ({ className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg cursor-pointer" > - New Chat + {intl.formatMessage(i18n.newChat)} {sessions.length > 0 && } @@ -96,7 +109,7 @@ export const ChatSessionsDropdown: React.FC = ({ className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg cursor-pointer text-text-secondary" > - Show All + {intl.formatMessage(i18n.showAll)} )} diff --git a/ui/desktop/src/components/Layout/navigation/SessionsList.tsx b/ui/desktop/src/components/Layout/navigation/SessionsList.tsx index 94247367..42705d24 100644 --- a/ui/desktop/src/components/Layout/navigation/SessionsList.tsx +++ b/ui/desktop/src/components/Layout/navigation/SessionsList.tsx @@ -8,6 +8,22 @@ import { getSessionDisplayName } from '../../../hooks/useNavigationSessions'; import { updateSessionName } from '../../../api'; import type { Session } from '../../../api'; import type { SessionStatus } from './types'; +import { defineMessages, useIntl } from '../../../i18n'; + +const i18n = defineMessages({ + startNewChat: { + id: 'sessionsList.startNewChat', + defaultMessage: 'Start New Chat', + }, + untitledSession: { + id: 'sessionsList.untitledSession', + defaultMessage: 'Untitled session', + }, + showAll: { + id: 'sessionsList.showAll', + defaultMessage: 'Show All', + }, +}); interface SessionsListProps { sessions: Session[]; @@ -32,6 +48,7 @@ export const SessionsList: React.FC = ({ onNewChat, onShowAll, }) => { + const intl = useIntl(); const [editingSessionId, setEditingSessionId] = useState(null); const handleSaveSessionName = useCallback( @@ -68,7 +85,7 @@ export const SessionsList: React.FC = ({ >
- Start New Chat + {intl.formatMessage(i18n.startNewChat)}
)} @@ -105,7 +122,7 @@ export const SessionsList: React.FC = ({ handleSaveSessionName(session.id, newName)} - placeholder="Untitled session" + placeholder={intl.formatMessage(i18n.untitledSession)} disabled={isStreaming} singleClickEdit={false} className="truncate text-text-primary flex-1 !px-0 !py-0 hover:bg-transparent" @@ -134,7 +151,7 @@ export const SessionsList: React.FC = ({ >
- Show All + {intl.formatMessage(i18n.showAll)}
)} diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index 45703075..caff7cce 100644 --- a/ui/desktop/src/components/LoadingGoose.tsx +++ b/ui/desktop/src/components/LoadingGoose.tsx @@ -2,21 +2,43 @@ import GooseLogo from './GooseLogo'; import AnimatedIcons from './AnimatedIcons'; import FlyingBird from './FlyingBird'; import { ChatState } from '../types/chatState'; +import { defineMessages, useIntl } from '../i18n'; interface LoadingGooseProps { message?: string; chatState?: ChatState; } -const STATE_MESSAGES: Record = { - [ChatState.LoadingConversation]: 'loading conversation...', - [ChatState.Thinking]: 'goose is thinking…', - [ChatState.Streaming]: 'goose is working on it…', - [ChatState.WaitingForUserInput]: 'goose is waiting…', - [ChatState.Compacting]: 'goose is compacting the conversation...', - [ChatState.Idle]: 'goose is working on it…', - [ChatState.RestartingAgent]: 'restarting session...', -}; +const i18n = defineMessages({ + loadingConversation: { + id: 'loadingGoose.loadingConversation', + defaultMessage: 'loading conversation...', + }, + thinking: { + id: 'loadingGoose.thinking', + defaultMessage: 'goose is thinking…', + }, + streaming: { + id: 'loadingGoose.streaming', + defaultMessage: 'goose is working on it…', + }, + waiting: { + id: 'loadingGoose.waiting', + defaultMessage: 'goose is waiting…', + }, + compacting: { + id: 'loadingGoose.compacting', + defaultMessage: 'goose is compacting the conversation...', + }, + idle: { + id: 'loadingGoose.idle', + defaultMessage: 'goose is working on it…', + }, + restartingAgent: { + id: 'loadingGoose.restartingAgent', + defaultMessage: 'restarting session...', + }, +}); const STATE_ICONS: Record = { [ChatState.LoadingConversation]: , @@ -30,8 +52,19 @@ const STATE_ICONS: Record = { [ChatState.RestartingAgent]: , }; +const STATE_MESSAGE_KEYS: Record = { + [ChatState.LoadingConversation]: 'loadingConversation', + [ChatState.Thinking]: 'thinking', + [ChatState.Streaming]: 'streaming', + [ChatState.WaitingForUserInput]: 'waiting', + [ChatState.Compacting]: 'compacting', + [ChatState.Idle]: 'idle', + [ChatState.RestartingAgent]: 'restartingAgent', +}; + const LoadingGoose = ({ message, chatState = ChatState.Idle }: LoadingGooseProps) => { - const displayMessage = message || STATE_MESSAGES[chatState]; + const intl = useIntl(); + const displayMessage = message || intl.formatMessage(i18n[STATE_MESSAGE_KEYS[chatState]]); const icon = STATE_ICONS[chatState]; return ( diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 8edb939a..861b03a2 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -14,6 +14,43 @@ import { EmbeddedResource } from '../api'; import { useTheme } from '../contexts/ThemeContext'; import { errorMessage } from '../utils/conversionUtils'; import { isProtocolSafe, getProtocol } from '../utils/urlSecurity'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + toastTitle: { + id: 'mcpUIResourceRenderer.toastTitle', + defaultMessage: 'MCP-UI {messageType} message', + }, + toastMessageReceived: { + id: 'mcpUIResourceRenderer.toastMessageReceived', + defaultMessage: 'Message received for {message}.', + }, + toastUnsupported: { + id: 'mcpUIResourceRenderer.toastUnsupported', + defaultMessage: + "Message received for {message}. {messageType} messages aren't supported yet, refer to console for more details.", + }, + openExternalLinkTitle: { + id: 'mcpUIResourceRenderer.openExternalLinkTitle', + defaultMessage: 'Open External Link', + }, + openProtocolLink: { + id: 'mcpUIResourceRenderer.openProtocolLink', + defaultMessage: 'Open {protocol} link?', + }, + openLinkDetail: { + id: 'mcpUIResourceRenderer.openLinkDetail', + defaultMessage: 'This will open: {url}', + }, + cancelButton: { + id: 'mcpUIResourceRenderer.cancelButton', + defaultMessage: 'Cancel', + }, + openButton: { + id: 'mcpUIResourceRenderer.openButton', + defaultMessage: 'Open', + }, +}); interface MCPUIResourceRendererProps { content: EmbeddedResource & { type: 'resource' }; @@ -70,21 +107,24 @@ const ToastComponent = ({ message?: string; isImplemented?: boolean; }) => { - const title = `MCP-UI ${messageType} message`; + const intl = useIntl(); + const title = intl.formatMessage(i18n.toastTitle, { messageType }); return (

{title}

{isImplemented ? (

- Message received for {message}. + {intl.formatMessage(i18n.toastMessageReceived, { + message: {message}, + })}

) : (

- Message received for {message}. -
- {messageType.charAt(0).toUpperCase() + messageType.slice(1)} messages aren't supported - yet, refer to console for more details. + {intl.formatMessage(i18n.toastUnsupported, { + message: {message}, + messageType: messageType.charAt(0).toUpperCase() + messageType.slice(1), + })}

)}
@@ -95,6 +135,7 @@ export default function MCPUIResourceRenderer({ content, appendPromptToChat, }: MCPUIResourceRendererProps) { + const intl = useIntl(); const { resolvedTheme } = useTheme(); const [proxyUrl, setProxyUrl] = useState(undefined); @@ -203,11 +244,14 @@ export default function MCPUIResourceRenderer({ const result = await window.electron.showMessageBox({ type: 'question', - buttons: ['Cancel', 'Open'], + buttons: [ + intl.formatMessage(i18n.cancelButton), + intl.formatMessage(i18n.openButton), + ], defaultId: 0, - title: 'Open External Link', - message: `Open ${protocol} link?`, - detail: `This will open: ${url}`, + title: intl.formatMessage(i18n.openExternalLinkTitle), + message: intl.formatMessage(i18n.openProtocolLink, { protocol }), + detail: intl.formatMessage(i18n.openLinkDetail, { url }), }); if (result.response !== 1) { diff --git a/ui/desktop/src/components/MarkdownContent.test.tsx b/ui/desktop/src/components/MarkdownContent.test.tsx index 403c97f2..1798a067 100644 --- a/ui/desktop/src/components/MarkdownContent.test.tsx +++ b/ui/desktop/src/components/MarkdownContent.test.tsx @@ -1,7 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; -import { render } from '@testing-library/react'; +import { render, type RenderOptions } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/dom'; import MarkdownContent from './MarkdownContent'; +import { IntlTestWrapper } from '../i18n/test-utils'; + +const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) => + render(ui, { wrapper: IntlTestWrapper, ...options }); // Mock the icons to avoid import issues vi.mock('./icons', () => ({ @@ -20,7 +24,7 @@ Contact for support. Use \`Array\` for generics.`; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText('Test Title')).toBeInTheDocument(); @@ -44,7 +48,7 @@ This is safe text. More safe text.`; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText('Security Test')).toBeInTheDocument(); @@ -70,7 +74,7 @@ More safe text.`; Normal text continues.`; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText('Comment Test')).toBeInTheDocument(); @@ -93,7 +97,7 @@ console.log(html);
This should be wrapped
`; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText('Code Block Test')).toBeInTheDocument(); @@ -119,7 +123,7 @@ console.log(html); 3. Real markup: 4. Placeholder path: /src`; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText('Mixed Content Test')).toBeInTheDocument(); @@ -148,7 +152,7 @@ console.log(html); console.log('Hello, World!'); \`\`\``; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText(/console/)).toBeInTheDocument(); @@ -160,7 +164,7 @@ console.log('Hello, World!'); it('renders inline code', async () => { const content = 'Use `console.log()` to debug.'; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText(/Use/)).toBeInTheDocument(); @@ -176,7 +180,7 @@ console.log('Hello, World!'); ## H2 Header ### H3 Header`; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByRole('heading', { level: 1, name: 'H1 Header' })).toBeInTheDocument(); @@ -193,7 +197,7 @@ console.log('Hello, World!'); 1. Numbered 1 2. Numbered 2`; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText('Item 1')).toBeInTheDocument(); @@ -207,7 +211,7 @@ console.log('Hello, World!'); it('renders links with correct attributes', async () => { const content = '[Visit Block](https://block.dev)'; - render(); + renderWithIntl(); await waitFor(() => { const link = screen.getByRole('link', { name: 'Visit Block' }); @@ -224,7 +228,7 @@ console.log('Hello, World!'); | Test | 123 | | Demo | 456 |`; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText('Name')).toBeInTheDocument(); @@ -239,7 +243,7 @@ console.log('Hello, World!'); describe('Error Handling', () => { it('handles empty content gracefully', async () => { - render(); + renderWithIntl(); // Should not throw and should render the component const container = document.querySelector('.w-full.overflow-x-hidden'); @@ -252,7 +256,7 @@ console.log('Hello, World!'); \`\`\` Unclosed code block`; - render(); + renderWithIntl(); await waitFor(() => { // Should still render what it can @@ -267,7 +271,7 @@ Unclosed code block`; Second line Third line`; - const { container } = render(); + const { container } = renderWithIntl(); await waitFor(() => { // Check that all text content is present (text may be split by
tags) @@ -289,7 +293,7 @@ line breaks. - List item 1 - List item 2`; - const { container } = render(); + const { container } = renderWithIntl(); await waitFor(() => { expect(screen.getByRole('heading', { level: 1, name: 'Header' })).toBeInTheDocument(); @@ -309,7 +313,7 @@ with line break \`code\` and more text`; - const { container } = render(); + const { container } = renderWithIntl(); await waitFor(() => { // Bold text should still work @@ -331,7 +335,7 @@ more text`; Another very long URL: https://www.example.com/very/long/path/with/many/segments/and/parameters?param1=value1¶m2=value2¶m3=value3¶m4=value4¶m5=value5`; - const { container } = render(); + const { container } = renderWithIntl(); await waitFor(() => { expect(screen.getByText(/Check out this document/)).toBeInTheDocument(); @@ -354,7 +358,7 @@ Another very long URL: https://www.example.com/very/long/path/with/many/segments 'https://example-docs.com/document/d/1oruk3lcrnhoOXMFzBJB8X6qQ5AtQTmj4XXxXk3xK-3g/edit?usp=sharing&mode=edit&version=1'; const content = `[Click here for the document](${longUrl})`; - render(); + renderWithIntl(); await waitFor(() => { const link = screen.getByRole('link', { name: 'Click here for the document' }); @@ -370,7 +374,7 @@ Another very long URL: https://www.example.com/very/long/path/with/many/segments 2. Another long URL: https://www.example.com/very/long/path/with/many/segments/and/parameters?param1=value1¶m2=value2¶m3=value3 3. Third URL: https://api.example.com/v1/users/12345/documents/67890/attachments/abcdef123456789?format=json&include=metadata&sort=created_at`; - render(); + renderWithIntl(); await waitFor(() => { expect(screen.getByText(/Here are some long URLs/)).toBeInTheDocument(); @@ -382,7 +386,7 @@ Another very long URL: https://www.example.com/very/long/path/with/many/segments it('applies word-break CSS classes to the container', () => { const content = 'Test content'; - render(); + renderWithIntl(); const markdownContainer = document.querySelector('.prose'); expect(markdownContainer).toBeInTheDocument(); @@ -395,7 +399,7 @@ Another very long URL: https://www.example.com/very/long/path/with/many/segments it('treats single dollar signs as plain text', async () => { const content = 'The formula $x_i$ represents the i-th element.'; - const { container } = render(); + const { container } = renderWithIntl(); await waitFor(() => { const katexElements = container.querySelectorAll('.katex'); @@ -413,7 +417,7 @@ $$ for the result.`; - const { container } = render(); + const { container } = renderWithIntl(); await waitFor(() => { const katexDisplay = container.querySelector('.katex-display'); @@ -424,7 +428,7 @@ for the result.`; it('handles shell commands without triggering math mode', async () => { const content = 'Run echo "$FOO_BAR" to see the value.'; - const { container } = render(); + const { container } = renderWithIntl(); await waitFor(() => { const katexElements = container.querySelectorAll('.katex'); @@ -436,7 +440,7 @@ for the result.`; it('preserves math in code blocks', async () => { const content = 'The formula `math\nx^2\n` uses inline code.'; - const { container } = render(); + const { container } = renderWithIntl(); await waitFor(() => { expect(container).toHaveTextContent('x^2'); diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index e223c049..76a031a4 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -30,6 +30,42 @@ import { Check, Copy } from './icons'; import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity'; import { isProtocolSafe, getProtocol, BLOCKED_PROTOCOLS } from '../utils/urlSecurity'; import { ConfirmationModal } from './ui/ConfirmationModal'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + copyCode: { + id: 'markdownContent.copyCode', + defaultMessage: 'Copy code', + }, + openExternalLink: { + id: 'markdownContent.openExternalLink', + defaultMessage: 'Open External Link', + }, + openProtocolLink: { + id: 'markdownContent.openProtocolLink', + defaultMessage: 'Open {protocol} link?', + }, + thisWillOpen: { + id: 'markdownContent.thisWillOpen', + defaultMessage: 'This will open: {href}', + }, + open: { + id: 'markdownContent.open', + defaultMessage: 'Open', + }, + cancel: { + id: 'markdownContent.cancel', + defaultMessage: 'Cancel', + }, + failedToOpenLink: { + id: 'markdownContent.failedToOpenLink', + defaultMessage: 'Failed to Open Link', + }, + noApplicationFound: { + id: 'markdownContent.noApplicationFound', + defaultMessage: 'No application found to open this link.', + }, +}); interface CodeProps extends React.ClassAttributes, React.HTMLAttributes { inline?: boolean; @@ -48,6 +84,7 @@ const CodeBlock = memo(function CodeBlock({ language: string; children: string; }) { + const intl = useIntl(); const [copied, setCopied] = useState(false); const timeoutRef = useRef(null); @@ -120,7 +157,7 @@ const CodeBlock = memo(function CodeBlock({ className="absolute right-2 bottom-2 p-1.5 rounded-lg bg-gray-700/50 text-gray-300 font-sans text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200 hover:bg-gray-600/50 hover:text-gray-100 z-10" - title="Copy code" + title={intl.formatMessage(i18n.copyCode)} > {copied ? : } @@ -164,6 +201,7 @@ const MarkdownContent = memo(function MarkdownContent({ content, className = '', }: MarkdownContentProps) { + const intl = useIntl(); const [processedContent, setProcessedContent] = useState(content); const [pendingLink, setPendingLink] = useState<{ protocol: string; href: string } | null>(null); @@ -185,14 +223,14 @@ const MarkdownContent = memo(function MarkdownContent({ await window.electron.showMessageBox({ type: 'error', buttons: ['OK'], - title: 'Failed to Open Link', - message: `No application found to open this link.`, + title: intl.formatMessage(i18n.failedToOpenLink), + message: intl.formatMessage(i18n.noApplicationFound), detail: pendingLink.href, }); } } setPendingLink(null); - }, [pendingLink]); + }, [pendingLink, intl]); const handleCancelOpen = useCallback(() => { setPendingLink(null); @@ -262,13 +300,13 @@ const MarkdownContent = memo(function MarkdownContent({ ); diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index 0efc8b8d..d5da5ec0 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -33,6 +33,7 @@ import { useTheme } from '../../contexts/ThemeContext'; import { cn } from '../../utils'; import { errorMessage } from '../../utils/conversionUtils'; import { getProtocol, isProtocolSafe } from '../../utils/urlSecurity'; +import { defineMessages, useIntl } from '../../i18n'; import FlyingBird from '../FlyingBird'; import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList'; import { @@ -55,6 +56,73 @@ import { PIP_MARGIN_BOTTOM, } from './useDisplayMode'; +const i18n = defineMessages({ + appFallbackTitle: { + id: 'mcpAppRenderer.appFallbackTitle', + defaultMessage: 'App', + }, + pictureInPicture: { + id: 'mcpAppRenderer.pictureInPicture', + defaultMessage: 'Picture-in-Picture', + }, + exitFullscreenTitle: { + id: 'mcpAppRenderer.exitFullscreenTitle', + defaultMessage: 'Exit fullscreen (Esc)', + }, + exitFullscreen: { + id: 'mcpAppRenderer.exitFullscreen', + defaultMessage: 'Exit fullscreen', + }, + fullscreen: { + id: 'mcpAppRenderer.fullscreen', + defaultMessage: 'Fullscreen', + }, + close: { + id: 'mcpAppRenderer.close', + defaultMessage: 'Close', + }, + movePipWindow: { + id: 'mcpAppRenderer.movePipWindow', + defaultMessage: 'Move Picture-in-Picture window (use arrow keys)', + }, + playingInPip: { + id: 'mcpAppRenderer.playingInPip', + defaultMessage: 'Playing in Picture-in-Picture', + }, + invalidUrl: { + id: 'mcpAppRenderer.invalidUrl', + defaultMessage: 'Invalid URL', + }, + openExternalLinkTitle: { + id: 'mcpAppRenderer.openExternalLinkTitle', + defaultMessage: 'Open External Link', + }, + openProtocolLink: { + id: 'mcpAppRenderer.openProtocolLink', + defaultMessage: 'Open {protocol} link?', + }, + openLinkDetail: { + id: 'mcpAppRenderer.openLinkDetail', + defaultMessage: 'This will open: {url}', + }, + cancelButton: { + id: 'mcpAppRenderer.cancelButton', + defaultMessage: 'Cancel', + }, + openButton: { + id: 'mcpAppRenderer.openButton', + defaultMessage: 'Open', + }, + failedToLoadResource: { + id: 'mcpAppRenderer.failedToLoadResource', + defaultMessage: 'Failed to load resource', + }, + failedToInitSandbox: { + id: 'mcpAppRenderer.failedToInitSandbox', + defaultMessage: 'Failed to initialize sandbox proxy', + }, +}); + const DEFAULT_IFRAME_HEIGHT = 200; const FULLSCREEN_HEADER_HEIGHT = 48; @@ -250,6 +318,7 @@ export default function McpAppRenderer({ cachedHtml, onDisplayModeChange, }: McpAppRendererProps) { + const intl = useIntl(); const containerRef = useRef(null); const contentRef = useRef(null); @@ -442,7 +511,7 @@ export default function McpAppRenderer({ } dispatch({ type: 'RESOURCE_FAILED', - message: errorMessage(err, 'Failed to load resource'), + message: errorMessage(err, intl.formatMessage(i18n.failedToLoadResource)), }); return; } @@ -454,7 +523,7 @@ export default function McpAppRenderer({ return () => { cancelled = true; }; - }, [resourceUri, extensionName, sessionId, cachedHtml]); + }, [resourceUri, extensionName, sessionId, cachedHtml, intl]); // Create the sandbox proxy URL once we have HTML and metadata. // On StrictMode remount, reuse the cached URL to avoid recreating the proxy @@ -474,10 +543,10 @@ export default function McpAppRenderer({ sandboxUrlRef.current = { url, csp: pendingCsp }; dispatch({ type: 'SANDBOX_READY', sandboxUrl: url, sandboxCsp: pendingCsp }); } else { - dispatch({ type: 'SANDBOX_FAILED', message: 'Failed to initialize sandbox proxy' }); + dispatch({ type: 'SANDBOX_FAILED', message: intl.formatMessage(i18n.failedToInitSandbox) }); } }); - }, [state.status, pendingCsp]); + }, [state.status, pendingCsp, intl]); const handleOpenLink = useCallback(async ({ url }: { url: string }) => { if (isProtocolSafe(url)) { @@ -487,16 +556,16 @@ export default function McpAppRenderer({ const protocol = getProtocol(url); if (!protocol) { - return { status: 'error' as const, message: 'Invalid URL' }; + return { status: 'error' as const, message: intl.formatMessage(i18n.invalidUrl) }; } const result = await window.electron.showMessageBox({ type: 'question', - buttons: ['Cancel', 'Open'], + buttons: [intl.formatMessage(i18n.cancelButton), intl.formatMessage(i18n.openButton)], defaultId: 0, - title: 'Open External Link', - message: `Open ${protocol} link?`, - detail: `This will open: ${url}`, + title: intl.formatMessage(i18n.openExternalLinkTitle), + message: intl.formatMessage(i18n.openProtocolLink, { protocol }), + detail: intl.formatMessage(i18n.openLinkDetail, { url }), }); if (result.response !== 1) { @@ -505,7 +574,7 @@ export default function McpAppRenderer({ await window.electron.openExternal(url); return { status: 'success' as const }; - }, []); + }, [intl]); const handleMessage = useCallback( async ({ content }: { content: Array<{ type: string; text?: string }> }) => { @@ -800,8 +869,8 @@ export default function McpAppRenderer({ const fullscreenTitle = useMemo(() => { if (appTitle) return appTitle; if (extensionName) return formatExtensionName(extensionName); - return 'App'; - }, [appTitle, extensionName]); + return intl.formatMessage(i18n.appFallbackTitle); + }, [appTitle, extensionName, intl]); const renderFullscreenHeader = () => (
changeDisplayMode('pip')} className="no-drag cursor-pointer rounded-md p-1.5 text-text-secondary transition-colors hover:bg-black/10 hover:text-text-primary dark:hover:bg-white/10" - title="Picture-in-Picture" - aria-label="Picture-in-Picture" + title={intl.formatMessage(i18n.pictureInPicture)} + aria-label={intl.formatMessage(i18n.pictureInPicture)} > @@ -827,8 +896,8 @@ export default function McpAppRenderer({ ref={fullscreenCloseRef} onClick={() => changeDisplayMode('inline')} className="no-drag cursor-pointer rounded-md p-1.5 text-text-secondary transition-colors hover:bg-black/10 hover:text-text-primary dark:hover:bg-white/10" - title="Exit fullscreen (Esc)" - aria-label="Exit fullscreen" + title={intl.formatMessage(i18n.exitFullscreenTitle)} + aria-label={intl.formatMessage(i18n.exitFullscreen)} > @@ -849,8 +918,8 @@ export default function McpAppRenderer({ @@ -858,8 +927,8 @@ export default function McpAppRenderer({ @@ -874,8 +943,8 @@ export default function McpAppRenderer({ @@ -884,8 +953,8 @@ export default function McpAppRenderer({ @@ -942,7 +1011,7 @@ export default function McpAppRenderer({ className="cursor-pointer flex items-center gap-2 rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5" > - Playing in Picture-in-Picture + {intl.formatMessage(i18n.playingInPip)}
)} @@ -959,7 +1028,7 @@ export default function McpAppRenderer({
{ + const intl = useIntl(); const [items, setItems] = useState([]); const [isLoading, setIsLoading] = useState(false); const popoverRef = useRef(null); @@ -544,13 +561,13 @@ const MentionPopover = forwardRef< {isLoading ? (
- Scanning files... + {intl.formatMessage(i18n.scanningFiles)}
) : ( <> {displayItems.length > 0 && (
- {displayItems.length} item{displayItems.length !== 1 ? 's' : ''} found + {intl.formatMessage(i18n.itemsFound, { count: displayItems.length })}
)}
- No items found matching "{query}" + {intl.formatMessage(i18n.noItemsFound, { query })}
)}
diff --git a/ui/desktop/src/components/MessageCopyLink.tsx b/ui/desktop/src/components/MessageCopyLink.tsx index fb61a87a..b3dfaadd 100644 --- a/ui/desktop/src/components/MessageCopyLink.tsx +++ b/ui/desktop/src/components/MessageCopyLink.tsx @@ -2,6 +2,18 @@ import React, { useState } from 'react'; import { Copy } from './icons'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + copied: { + id: 'messageCopyLink.copied', + defaultMessage: 'Copied!', + }, + copy: { + id: 'messageCopyLink.copy', + defaultMessage: 'Copy', + }, +}); interface MessageCopyLinkProps { text: string; @@ -9,6 +21,7 @@ interface MessageCopyLinkProps { } export default function MessageCopyLink({ text, contentRef }: MessageCopyLinkProps) { + const intl = useIntl(); const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -54,7 +67,7 @@ export default function MessageCopyLink({ text, contentRef }: MessageCopyLinkPro className="flex font-mono items-center gap-1 text-xs text-text-secondary hover:cursor-pointer hover:text-text-primary transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0" > - {copied ? 'Copied!' : 'Copy'} + {copied ? intl.formatMessage(i18n.copied) : intl.formatMessage(i18n.copy)} ); } diff --git a/ui/desktop/src/components/MessageQueue.tsx b/ui/desktop/src/components/MessageQueue.tsx index dd3472b8..ce856ae7 100644 --- a/ui/desktop/src/components/MessageQueue.tsx +++ b/ui/desktop/src/components/MessageQueue.tsx @@ -2,6 +2,90 @@ import React, { useState } from 'react'; import { X, Clock, Send, GripVertical, Zap, Sparkles, ChevronDown, ChevronUp } from 'lucide-react'; import { Button } from './ui/button'; import { ImageData } from '../types/message'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + paused: { + id: 'messageQueue.paused', + defaultMessage: 'Paused', + }, + next: { + id: 'messageQueue.next', + defaultMessage: 'Next', + }, + sendNow: { + id: 'messageQueue.sendNow', + defaultMessage: 'Send this message now', + }, + expandQueue: { + id: 'messageQueue.expandQueue', + defaultMessage: 'Expand queue', + }, + queuePausedCompact: { + id: 'messageQueue.queuePausedCompact', + defaultMessage: 'Queue paused - click "Send" or add new message to resume', + }, + queuePaused: { + id: 'messageQueue.queuePaused', + defaultMessage: 'Queue Paused', + }, + messageQueue: { + id: 'messageQueue.messageQueue', + defaultMessage: 'Message Queue', + }, + messageCount: { + id: 'messageQueue.messageCount', + defaultMessage: '{count, plural, one {# message} other {# messages}} {status}', + }, + waiting: { + id: 'messageQueue.waiting', + defaultMessage: 'waiting', + }, + queued: { + id: 'messageQueue.queued', + defaultMessage: 'queued', + }, + clearAll: { + id: 'messageQueue.clearAll', + defaultMessage: 'Clear All', + }, + collapseQueue: { + id: 'messageQueue.collapseQueue', + defaultMessage: 'Collapse queue', + }, + queuePausedExpanded: { + id: 'messageQueue.queuePausedExpanded', + defaultMessage: 'Queue paused by interruption. Use "Send Now" or add a new message to resume.', + }, + save: { + id: 'messageQueue.save', + defaultMessage: 'Save', + }, + cancel: { + id: 'messageQueue.cancel', + defaultMessage: 'Cancel', + }, + clickToEdit: { + id: 'messageQueue.clickToEdit', + defaultMessage: '{content} (Click to edit)', + }, + cannotSendWhileEditing: { + id: 'messageQueue.cannotSendWhileEditing', + defaultMessage: 'Cannot send while editing', + }, + stopAndSend: { + id: 'messageQueue.stopAndSend', + defaultMessage: 'Stop current processing and send this message now', + }, + removeFromQueue: { + id: 'messageQueue.removeFromQueue', + defaultMessage: 'Remove this message from queue', + }, + dragToReorder: { + id: 'messageQueue.dragToReorder', + defaultMessage: 'Drag messages to reorder priority', + }, +}); export interface QueuedMessage { id: string; @@ -35,6 +119,7 @@ export const MessageQueue: React.FC = ({ className = '', isPaused = false, }) => { + const intl = useIntl(); const [isExpanded, setIsExpanded] = useState(true); const [draggedItem, setDraggedItem] = useState(null); const [dragOverItem, setDragOverItem] = useState(null); @@ -118,7 +203,7 @@ export const MessageQueue: React.FC = ({
)} - {isPaused ? 'Paused' : 'Next'} + {isPaused ? intl.formatMessage(i18n.paused) : intl.formatMessage(i18n.next)}
@@ -150,7 +235,7 @@ export const MessageQueue: React.FC = ({ onStopAndSend(nextMessage.id); }} className="h-7 px-2 text-xs text-info hover:text-info/80 hover:bg-info/10" - title="Send this message now" + title={intl.formatMessage(i18n.sendNow)} > @@ -161,7 +246,7 @@ export const MessageQueue: React.FC = ({ variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground" - title="Expand queue" + title={intl.formatMessage(i18n.expandQueue)} > @@ -173,7 +258,7 @@ export const MessageQueue: React.FC = ({
- Queue paused - click "Send" or add new message to resume + {intl.formatMessage(i18n.queuePausedCompact)}
)} @@ -202,11 +287,13 @@ export const MessageQueue: React.FC = ({
- {isPaused ? 'Queue Paused' : 'Message Queue'} + {isPaused ? intl.formatMessage(i18n.queuePaused) : intl.formatMessage(i18n.messageQueue)} - {queuedMessages.length} message{queuedMessages.length !== 1 ? 's' : ''} - {isPaused ? ' waiting' : ' queued'} + {intl.formatMessage(i18n.messageCount, { + count: queuedMessages.length, + status: isPaused ? intl.formatMessage(i18n.waiting) : intl.formatMessage(i18n.queued), + })}
@@ -219,7 +306,7 @@ export const MessageQueue: React.FC = ({ onClick={onClearQueue} className="text-xs h-7 px-3 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" > - Clear All + {intl.formatMessage(i18n.clearAll)} )} @@ -229,7 +316,7 @@ export const MessageQueue: React.FC = ({ size="sm" onClick={() => setIsExpanded(false)} className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground" - title="Collapse queue" + title={intl.formatMessage(i18n.collapseQueue)} > @@ -242,7 +329,7 @@ export const MessageQueue: React.FC = ({
- Queue paused by interruption. Use "Send Now" or add a new message to resume. + {intl.formatMessage(i18n.queuePausedExpanded)}
@@ -328,7 +415,7 @@ export const MessageQueue: React.FC = ({ }} className="h-6 px-2 text-xs" > - Save + {intl.formatMessage(i18n.save)} ) : (

{ setEditingMessage(message.id); if (editingMessageIdRef) editingMessageIdRef.current = message.id; @@ -385,8 +472,8 @@ export const MessageQueue: React.FC = ({ }`} title={ editingMessage === message.id - ? 'Cannot send while editing' - : 'Stop current processing and send this message now' + ? intl.formatMessage(i18n.cannotSendWhileEditing) + : intl.formatMessage(i18n.stopAndSend) } > @@ -399,7 +486,7 @@ export const MessageQueue: React.FC = ({ size="sm" onClick={() => onRemoveMessage(message.id)} className="opacity-60 hover:opacity-100 transition-opacity h-6 w-6 p-0 hover:bg-destructive/20 hover:text-destructive rounded-full" - title="Remove this message from queue" + title={intl.formatMessage(i18n.removeFromQueue)} > @@ -414,7 +501,7 @@ export const MessageQueue: React.FC = ({ {/* Next up indicator */} {index === 0 && !isPaused && (

- Next + {intl.formatMessage(i18n.next)}
)} @@ -425,7 +512,7 @@ export const MessageQueue: React.FC = ({ {onReorderMessages && queuedMessages.length > 1 && (
- Drag messages to reorder priority + {intl.formatMessage(i18n.dragToReorder)}
)} diff --git a/ui/desktop/src/components/ModelAndProviderContext.tsx b/ui/desktop/src/components/ModelAndProviderContext.tsx index a006e1c3..42df0578 100644 --- a/ui/desktop/src/components/ModelAndProviderContext.tsx +++ b/ui/desktop/src/components/ModelAndProviderContext.tsx @@ -8,13 +8,34 @@ import { getModelDisplayName, getProviderDisplayName, } from './settings/models/predefinedModelsUtils'; +import { defineMessages, useIntl } from '../i18n'; -export const UNKNOWN_PROVIDER_TITLE = 'Provider name lookup'; -export const UNKNOWN_PROVIDER_MSG = 'Unknown provider in config -- please inspect your config.yaml'; - -// success -const CHANGE_MODEL_TOAST_TITLE = 'Model changed'; -const SWITCH_MODEL_SUCCESS_MSG = 'Successfully switched models'; +const i18n = defineMessages({ + unknownProviderTitle: { + id: 'modelAndProviderContext.unknownProviderTitle', + defaultMessage: 'Provider name lookup', + }, + unknownProviderMsg: { + id: 'modelAndProviderContext.unknownProviderMsg', + defaultMessage: 'Unknown provider in config -- please inspect your config.yaml', + }, + modelChangedTitle: { + id: 'modelAndProviderContext.modelChangedTitle', + defaultMessage: 'Model changed', + }, + switchModelSuccess: { + id: 'modelAndProviderContext.switchModelSuccess', + defaultMessage: 'Successfully switched models -- using {model} from {provider}', + }, + modelChangeFailed: { + id: 'modelAndProviderContext.modelChangeFailed', + defaultMessage: '{provider}/{model} failed', + }, + selectModel: { + id: 'modelAndProviderContext.selectModel', + defaultMessage: 'Select Model', + }, +}); interface ModelAndProviderContextType { currentModel: string | null; @@ -34,10 +55,13 @@ interface ModelAndProviderProviderProps { const ModelAndProviderContext = createContext(undefined); +export { i18n as modelAndProviderMessages }; + export const ModelAndProviderProvider: React.FC = ({ children }) => { const [currentModel, setCurrentModel] = useState(null); const [currentProvider, setCurrentProvider] = useState(null); const { read, getProviders } = useConfig(); + const intl = useIntl(); const changeModel = useCallback(async (sessionId: string | null, model: Model) => { const modelName = model.name; @@ -79,20 +103,23 @@ export const ModelAndProviderProvider: React.FC = } toastSuccess({ - title: CHANGE_MODEL_TOAST_TITLE, - msg: `${SWITCH_MODEL_SUCCESS_MSG} -- using ${model.alias ?? modelName} from ${model.subtext ?? providerName}`, + title: intl.formatMessage(i18n.modelChangedTitle), + msg: intl.formatMessage(i18n.switchModelSuccess, { + model: model.alias ?? modelName, + provider: model.subtext ?? providerName, + }), }); return true; } catch (error) { console.error(`Failed to change model at ${phase} step -- ${modelName} ${providerName}`); toastError({ - title: `${providerName}/${modelName} failed`, + title: intl.formatMessage(i18n.modelChangeFailed, { provider: providerName, model: modelName }), msg: `${error}`, traceback: errorMessage(error), }); return false; } - }, []); + }, [intl]); const getFallbackModelAndProvider = useCallback(async () => { const provider = window.appConfig.get('GOOSE_DEFAULT_PROVIDER') as string; @@ -154,9 +181,9 @@ export const ModelAndProviderProvider: React.FC = const currentModelName = (await read('GOOSE_MODEL', false)) as string; return getModelDisplayName(currentModelName); } catch { - return 'Select Model'; + return intl.formatMessage(i18n.selectModel); } - }, [read]); + }, [read, intl]); const getCurrentProviderDisplayName = useCallback(async () => { try { diff --git a/ui/desktop/src/components/ParameterInputModal.tsx b/ui/desktop/src/components/ParameterInputModal.tsx index 17a0d170..2888efaf 100644 --- a/ui/desktop/src/components/ParameterInputModal.tsx +++ b/ui/desktop/src/components/ParameterInputModal.tsx @@ -2,6 +2,58 @@ import React, { useState, useEffect } from 'react'; import { Parameter } from '../recipe'; import { Button } from './ui/button'; import { getInitialWorkingDir } from '../utils/workingDir'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + cancelRecipeSetup: { + id: 'parameterInputModal.cancelRecipeSetup', + defaultMessage: 'Cancel Recipe Setup', + }, + whatToDo: { + id: 'parameterInputModal.whatToDo', + defaultMessage: 'What would you like to do?', + }, + backToForm: { + id: 'parameterInputModal.backToForm', + defaultMessage: 'Back to Parameter Form', + }, + startNewChat: { + id: 'parameterInputModal.startNewChat', + defaultMessage: 'Start New Chat (No Recipe)', + }, + recipeParameters: { + id: 'parameterInputModal.recipeParameters', + defaultMessage: 'Recipe Parameters', + }, + selectOption: { + id: 'parameterInputModal.selectOption', + defaultMessage: 'Select an option...', + }, + select: { + id: 'parameterInputModal.select', + defaultMessage: 'Select...', + }, + true: { + id: 'parameterInputModal.true', + defaultMessage: 'True', + }, + false: { + id: 'parameterInputModal.false', + defaultMessage: 'False', + }, + enterValue: { + id: 'parameterInputModal.enterValue', + defaultMessage: 'Enter value for {key}...', + }, + cancel: { + id: 'parameterInputModal.cancel', + defaultMessage: 'Cancel', + }, + startRecipe: { + id: 'parameterInputModal.startRecipe', + defaultMessage: 'Start Recipe', + }, +}); interface ParameterInputModalProps { parameters: Parameter[]; @@ -16,6 +68,7 @@ const ParameterInputModal: React.FC = ({ onClose, initialValues, }) => { + const intl = useIntl(); const [inputValues, setInputValues] = useState>({}); const [validationErrors, setValidationErrors] = useState>({}); const [showCancelOptions, setShowCancelOptions] = useState(false); @@ -91,8 +144,10 @@ const ParameterInputModal: React.FC = ({ {showCancelOptions ? ( // Cancel options modal
-

Cancel Recipe Setup

-

What would you like to do?

+

+ {intl.formatMessage(i18n.cancelRecipeSetup)} +

+

{intl.formatMessage(i18n.whatToDo)}

@@ -116,7 +171,9 @@ const ParameterInputModal: React.FC = ({ // Main parameter form
-

Recipe Parameters

+

+ {intl.formatMessage(i18n.recipeParameters)} +

@@ -140,7 +197,7 @@ const ParameterInputModal: React.FC = ({ : 'border-border-primary focus:ring-border-secondary' }`} > - + {param.options.map((option) => (
diff --git a/ui/desktop/src/components/PopularChatTopics.tsx b/ui/desktop/src/components/PopularChatTopics.tsx index 3073a065..a2651561 100644 --- a/ui/desktop/src/components/PopularChatTopics.tsx +++ b/ui/desktop/src/components/PopularChatTopics.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { FolderTree, MessageSquare, Code } from 'lucide-react'; +import { defineMessages, useIntl } from '../i18n'; interface PopularChatTopicsProps { append: (text: string) => void; @@ -12,38 +13,65 @@ interface ChatTopic { prompt: string; } -const POPULAR_TOPICS: ChatTopic[] = [ - { - id: 'organize-photos', - icon: , - description: 'Organize the photos on my desktop into neat little folders by subject matter', - prompt: 'Organize the photos on my desktop into neat little folders by subject matter', +const i18n = defineMessages({ + heading: { + id: 'popularChatTopics.heading', + defaultMessage: 'Popular chat topics', }, - { - id: 'government-forms', - icon: , - description: - 'Describe in detail how various forms of government works and rank each by units of geese', - prompt: + start: { + id: 'popularChatTopics.start', + defaultMessage: 'Start', + }, + organizePhotos: { + id: 'popularChatTopics.organizePhotos', + defaultMessage: + 'Organize the photos on my desktop into neat little folders by subject matter', + }, + governmentForms: { + id: 'popularChatTopics.governmentForms', + defaultMessage: 'Describe in detail how various forms of government works and rank each by units of geese', }, - { - id: 'tamagotchi-game', - icon: , - description: + tamagotchiGame: { + id: 'popularChatTopics.tamagotchiGame', + defaultMessage: 'Develop a tamagotchi game that lives on my computer and follows a pixelated styling', - prompt: 'Develop a tamagotchi game that lives on my computer and follows a pixelated styling', }, -]; +}); export default function PopularChatTopics({ append }: PopularChatTopicsProps) { + const intl = useIntl(); + + const POPULAR_TOPICS: ChatTopic[] = [ + { + id: 'organize-photos', + icon: , + description: intl.formatMessage(i18n.organizePhotos), + prompt: intl.formatMessage(i18n.organizePhotos), + }, + { + id: 'government-forms', + icon: , + description: intl.formatMessage(i18n.governmentForms), + prompt: intl.formatMessage(i18n.governmentForms), + }, + { + id: 'tamagotchi-game', + icon: , + description: intl.formatMessage(i18n.tamagotchiGame), + prompt: intl.formatMessage(i18n.tamagotchiGame), + }, + ]; + const handleTopicClick = (prompt: string) => { append(prompt); }; return (
-

Popular chat topics

+

+ {intl.formatMessage(i18n.heading)} +

{POPULAR_TOPICS.map((topic) => (
- Start + {intl.formatMessage(i18n.start)}
diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 3264dcab..ef904b6c 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -15,6 +15,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { defineMessages, useIntl } from '../i18n'; import { Message, SystemNotificationContent } from '../api'; import GooseMessage from './GooseMessage'; import UserMessage from './UserMessage'; @@ -31,6 +32,17 @@ import LoadingGoose from './LoadingGoose'; import { ChatType } from '../types/chat'; import { identifyConsecutiveToolCalls, isInChain } from '../utils/toolCallChaining'; +const i18n = defineMessages({ + loadingMessages: { + id: 'progressiveMessageList.loadingMessages', + defaultMessage: 'Loading messages... ({renderedCount}/{totalCount})', + }, + searchHint: { + id: 'progressiveMessageList.searchHint', + defaultMessage: 'Press Cmd/Ctrl+F to load all messages immediately for search', + }, +}); + interface ProgressiveMessageListProps { messages: Message[]; chat: Pick; @@ -66,6 +78,7 @@ export default function ProgressiveMessageList({ onRenderingComplete, submitElicitationResponse, }: ProgressiveMessageListProps) { + const intl = useIntl(); const [renderedCount, setRenderedCount] = useState(() => { // Initialize with either all messages (if small) or first batch (if large) return messages.length <= showLoadingThreshold @@ -270,9 +283,9 @@ export default function ProgressiveMessageList({ {/* Loading indicator when progressively rendering */} {isLoading && (
- +
- Press Cmd/Ctrl+F to load all messages immediately for search + {intl.formatMessage(i18n.searchHint)}
)} diff --git a/ui/desktop/src/components/RecipeHeader.tsx b/ui/desktop/src/components/RecipeHeader.tsx index 9696e8d8..92ff0edb 100644 --- a/ui/desktop/src/components/RecipeHeader.tsx +++ b/ui/desktop/src/components/RecipeHeader.tsx @@ -1,14 +1,24 @@ +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + recipeLabel: { + id: 'recipeHeader.recipeLabel', + defaultMessage: 'Recipe', + }, +}); + interface RecipeHeaderProps { title: string; } export function RecipeHeader({ title }: RecipeHeaderProps) { + const intl = useIntl(); return (
- Recipe{' '} + {intl.formatMessage(i18n.recipeLabel)}{' '} {title}
diff --git a/ui/desktop/src/components/SessionIndicators.tsx b/ui/desktop/src/components/SessionIndicators.tsx index 85a61014..49842ba1 100644 --- a/ui/desktop/src/components/SessionIndicators.tsx +++ b/ui/desktop/src/components/SessionIndicators.tsx @@ -1,5 +1,21 @@ import { AlertCircle, Loader2 } from 'lucide-react'; import React from 'react'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + error: { + id: 'sessionIndicators.error', + defaultMessage: 'Session encountered an error', + }, + streaming: { + id: 'sessionIndicators.streaming', + defaultMessage: 'Streaming', + }, + newActivity: { + id: 'sessionIndicators.newActivity', + defaultMessage: 'Has new activity', + }, +}); interface SessionIndicatorsProps { isStreaming: boolean; @@ -12,12 +28,14 @@ interface SessionIndicatorsProps { */ export const SessionIndicators = React.memo( ({ isStreaming, hasUnread, hasError }) => { + const intl = useIntl(); + if (hasError) { return (
); @@ -26,7 +44,7 @@ export const SessionIndicators = React.memo( if (isStreaming) { return (
- +
); } @@ -34,7 +52,7 @@ export const SessionIndicators = React.memo( if (hasUnread) { return (
-
+
); } diff --git a/ui/desktop/src/components/TelemetryOptOutModal.tsx b/ui/desktop/src/components/TelemetryOptOutModal.tsx index 45756f52..5e2adcd6 100644 --- a/ui/desktop/src/components/TelemetryOptOutModal.tsx +++ b/ui/desktop/src/components/TelemetryOptOutModal.tsx @@ -6,6 +6,68 @@ import { TELEMETRY_UI_ENABLED } from '../updates'; import { toastService } from '../toasts'; import { useConfig } from './ConfigContext'; import { trackTelemetryPreference } from '../utils/analytics'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + configError: { + id: 'telemetryOptOutModal.configError', + defaultMessage: 'Configuration Error', + }, + configErrorMessage: { + id: 'telemetryOptOutModal.configErrorMessage', + defaultMessage: 'Failed to check telemetry configuration.', + }, + optIn: { + id: 'telemetryOptOutModal.optIn', + defaultMessage: 'Yes, share anonymous usage data', + }, + optOut: { + id: 'telemetryOptOutModal.optOut', + defaultMessage: 'No thanks', + }, + heading: { + id: 'telemetryOptOutModal.heading', + defaultMessage: 'Help improve goose', + }, + description: { + id: 'telemetryOptOutModal.description', + defaultMessage: + 'Would you like to help improve goose by sharing anonymous usage data? This helps us understand how goose is used and identify areas for improvement.', + }, + whatWeCollect: { + id: 'telemetryOptOutModal.whatWeCollect', + defaultMessage: 'What we collect:', + }, + collectOs: { + id: 'telemetryOptOutModal.collectOs', + defaultMessage: 'Operating system, version, and architecture', + }, + collectVersion: { + id: 'telemetryOptOutModal.collectVersion', + defaultMessage: 'goose version and install method', + }, + collectProvider: { + id: 'telemetryOptOutModal.collectProvider', + defaultMessage: 'Provider and model used', + }, + collectExtensions: { + id: 'telemetryOptOutModal.collectExtensions', + defaultMessage: 'Extensions and tool usage counts (names only)', + }, + collectSession: { + id: 'telemetryOptOutModal.collectSession', + defaultMessage: 'Session metrics (duration, interaction count, token usage)', + }, + collectErrors: { + id: 'telemetryOptOutModal.collectErrors', + defaultMessage: 'Error types (e.g., "rate_limit", "auth" - no details)', + }, + privacyNote: { + id: 'telemetryOptOutModal.privacyNote', + defaultMessage: + 'We never collect your conversations, code, tool arguments, error messages, or any personal data. You can change this setting anytime in Settings → App.', + }, +}); const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; @@ -14,6 +76,7 @@ type TelemetryOptOutModalProps = | { controlled: true; isOpen: boolean; onClose: () => void }; export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) { + const intl = useIntl(); const { read, upsert } = useConfig(); const isControlled = props.controlled; const controlledIsOpen = isControlled ? props.isOpen : undefined; @@ -41,15 +104,15 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) { } catch (error) { console.error('Failed to check telemetry config:', error); toastService.error({ - title: 'Configuration Error', - msg: 'Failed to check telemetry configuration.', + title: intl.formatMessage(i18n.configError), + msg: intl.formatMessage(i18n.configErrorMessage), traceback: error instanceof Error ? error.stack || '' : '', }); } }; checkTelemetryChoice(); - }, [isControlled, read]); + }, [isControlled, read, intl]); const handleChoice = async (enabled: boolean) => { setIsLoading(true); @@ -88,7 +151,7 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) { disabled={isLoading} className="w-full h-[44px] rounded-lg" > - Yes, share anonymous usage data + {intl.formatMessage(i18n.optIn)}
} @@ -106,25 +169,23 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) {

- Help improve goose + {intl.formatMessage(i18n.heading)}

- Would you like to help improve goose by sharing anonymous usage data? This helps us - understand how goose is used and identify areas for improvement. + {intl.formatMessage(i18n.description)}

-

What we collect:

+

{intl.formatMessage(i18n.whatWeCollect)}

    -
  • Operating system, version, and architecture
  • -
  • goose version and install method
  • -
  • Provider and model used
  • -
  • Extensions and tool usage counts (names only)
  • -
  • Session metrics (duration, interaction count, token usage)
  • -
  • Error types (e.g., "rate_limit", "auth" - no details)
  • +
  • {intl.formatMessage(i18n.collectOs)}
  • +
  • {intl.formatMessage(i18n.collectVersion)}
  • +
  • {intl.formatMessage(i18n.collectProvider)}
  • +
  • {intl.formatMessage(i18n.collectExtensions)}
  • +
  • {intl.formatMessage(i18n.collectSession)}
  • +
  • {intl.formatMessage(i18n.collectErrors)}

- We never collect your conversations, code, tool arguments, error messages, or any - personal data. You can change this setting anytime in Settings → App. + {intl.formatMessage(i18n.privacyNote)}

diff --git a/ui/desktop/src/components/ToolApprovalButtons.tsx b/ui/desktop/src/components/ToolApprovalButtons.tsx index 1b93f7f2..b83e1860 100644 --- a/ui/desktop/src/components/ToolApprovalButtons.tsx +++ b/ui/desktop/src/components/ToolApprovalButtons.tsx @@ -1,6 +1,42 @@ import { useState, useEffect } from 'react'; import { Button } from './ui/button'; import { confirmToolAction, Permission } from '../api'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + allowOnce: { + id: 'toolApprovalButtons.allowOnce', + defaultMessage: 'Allow Once', + }, + alwaysAllow: { + id: 'toolApprovalButtons.alwaysAllow', + defaultMessage: 'Always Allow', + }, + deny: { + id: 'toolApprovalButtons.deny', + defaultMessage: 'Deny', + }, + allowedOnce: { + id: 'toolApprovalButtons.allowedOnce', + defaultMessage: 'Allowed once', + }, + alwaysAllowed: { + id: 'toolApprovalButtons.alwaysAllowed', + defaultMessage: 'Always allowed', + }, + denied: { + id: 'toolApprovalButtons.denied', + defaultMessage: 'Denied', + }, + deniedOnce: { + id: 'toolApprovalButtons.deniedOnce', + defaultMessage: 'Denied once', + }, + cancelled: { + id: 'toolApprovalButtons.cancelled', + defaultMessage: 'Cancelled', + }, +}); const globalApprovalState = new Map< string, @@ -19,6 +55,7 @@ export interface ToolApprovalData { } export default function ToolApprovalButtons({ data }: { data: ToolApprovalData }) { + const intl = useIntl(); const { id, toolName, prompt, sessionId, isClicked: initialIsClicked } = data; const storedState = globalApprovalState.get(id); @@ -60,11 +97,11 @@ export default function ToolApprovalButtons({ data }: { data: ToolApprovalData } if (isClicked && decision) { const statusMessages: Record = { - allow_once: 'Allowed once', - always_allow: 'Always allowed', - always_deny: 'Denied', - deny_once: 'Denied once', - cancel: 'Cancelled', + allow_once: intl.formatMessage(i18n.allowedOnce), + always_allow: intl.formatMessage(i18n.alwaysAllowed), + always_deny: intl.formatMessage(i18n.denied), + deny_once: intl.formatMessage(i18n.deniedOnce), + cancel: intl.formatMessage(i18n.cancelled), }; return (

@@ -80,7 +117,7 @@ export default function ToolApprovalButtons({ data }: { data: ToolApprovalData } variant="secondary" onClick={() => handleAction('allow_once')} > - Allow Once + {intl.formatMessage(i18n.allowOnce)} {!prompt && ( )}

); diff --git a/ui/desktop/src/components/ToolCallConfirmation.tsx b/ui/desktop/src/components/ToolCallConfirmation.tsx index 5ce96bd5..487902cc 100644 --- a/ui/desktop/src/components/ToolCallConfirmation.tsx +++ b/ui/desktop/src/components/ToolCallConfirmation.tsx @@ -1,6 +1,18 @@ import { ActionRequired } from '../api'; +import { defineMessages, useIntl } from '../i18n'; import ToolApprovalButtons from './ToolApprovalButtons'; +const i18n = defineMessages({ + allowToolCall: { + id: 'toolConfirmation.allowToolCall', + defaultMessage: 'Do you allow this tool call?', + }, + gooseWouldLikeToCall: { + id: 'toolConfirmation.gooseWouldLikeToCall', + defaultMessage: 'Goose would like to call the above tool. Allow?', + }, +}); + type ToolConfirmationData = Extract; interface ToolConfirmationProps { @@ -14,6 +26,7 @@ export default function ToolConfirmation({ isClicked, actionRequiredContent, }: ToolConfirmationProps) { + const intl = useIntl(); const data = actionRequiredContent.data as ToolConfirmationData; const { id, toolName, prompt } = data; @@ -21,8 +34,8 @@ export default function ToolConfirmation({
{prompt - ? 'Do you allow this tool call?' - : 'Goose would like to call the above tool. Allow?'} + ? intl.formatMessage(i18n.allowToolCall) + : intl.formatMessage(i18n.gooseWouldLikeToCall)}
= ( status, className, }) => { + const intl = useIntl(); const getStatusStyles = () => { switch (status) { case 'success': @@ -33,7 +42,7 @@ export const ToolCallStatusIndicator: React.FC = ( getStatusStyles(), className )} - aria-label={`Tool status: ${status}`} + aria-label={intl.formatMessage(i18n.toolStatus, { status })} /> ); }; diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 529c10be..55849980 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -22,6 +22,46 @@ import { CallToolResponse, ContentBlock, EmbeddedResource } from '../api'; import McpAppRenderer from './McpApps/McpAppRenderer'; import ToolApprovalButtons from './ToolApprovalButtons'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + mcpUiExperimental: { + id: 'toolCallWithResponse.mcpUiExperimental', + defaultMessage: 'MCP UI is experimental and may change at any time.', + }, + viewSubagentSession: { + id: 'toolCallWithResponse.viewSubagentSession', + defaultMessage: 'View subagent session', + }, + toolDetails: { + id: 'toolCallWithResponse.toolDetails', + defaultMessage: 'Tool Details', + }, + code: { + id: 'toolCallWithResponse.code', + defaultMessage: 'Code', + }, + output: { + id: 'toolCallWithResponse.output', + defaultMessage: 'Output', + }, + toolResultAlt: { + id: 'toolCallWithResponse.toolResultAlt', + defaultMessage: 'Tool result', + }, + activityCount: { + id: 'toolCallWithResponse.activityCount', + defaultMessage: 'Activity ({count})', + }, + logs: { + id: 'toolCallWithResponse.logs', + defaultMessage: 'Logs', + }, + loadingSpinner: { + id: 'toolCallWithResponse.loadingSpinner', + defaultMessage: 'Loading spinner', + }, +}); interface ToolGraphNode { tool: string; @@ -189,6 +229,7 @@ export default function ToolCallWithResponse({ confirmationContent, isApprovalClicked, }: ToolCallWithResponseProps) { + const intl = useIntl(); // Handle both the wrapped ToolResult format and the unwrapped format // The server serializes ToolResult as { status: "success", value: T } or { status: "error", error: string } const toolCallData = toolRequest.toolCall as Record; @@ -263,7 +304,7 @@ export default function ToolCallWithResponse({
- MCP UI is experimental and may change at any time. + {intl.formatMessage(i18n.mcpUiExperimental)}
@@ -464,6 +505,7 @@ function ToolCallView({ notifications, isStreamingMessage = false, }: ToolCallViewProps) { + const intl = useIntl(); const [responseStyle, setResponseStyle] = useState('concise'); useEffect(() => { @@ -854,7 +896,7 @@ function ToolCallView({ className="w-full flex items-center gap-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors cursor-pointer" > - View subagent session + {intl.formatMessage(i18n.viewSubagentSession)} ); @@ -872,9 +914,10 @@ interface ToolDetailsViewProps { } function ToolDetailsView({ toolCall, isStartExpanded }: ToolDetailsViewProps) { + const intl = useIntl(); return ( Tool Details} + label={{intl.formatMessage(i18n.toolDetails)}} isStartExpanded={isStartExpanded} >
@@ -892,6 +935,7 @@ interface CodeModeViewProps { } function CodeModeView({ toolGraph, code }: CodeModeViewProps) { + const intl = useIntl(); const renderGraph = () => { const graph = toolGraph ?? []; if (graph.length === 0) return null; @@ -915,7 +959,7 @@ function CodeModeView({ toolGraph, code }: CodeModeViewProps) { {code && (
Code} + label={{intl.formatMessage(i18n.code)}} isStartExpanded={false} > 'text' in c && typeof (c as Record).text === 'string'; @@ -953,7 +998,7 @@ function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { return ( Output} + label={{intl.formatMessage(i18n.output)}} isStartExpanded={isStartExpanded} >
@@ -965,7 +1010,7 @@ function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { {hasImage(result) && ( Tool result { console.error('Failed to load image'); @@ -1018,6 +1063,7 @@ function ToolLogsView({ working: boolean; isStartExpanded?: boolean; }) { + const intl = useIntl(); const boxRef = useRef(null); // Whenever logs update, jump to the newest entry @@ -1034,7 +1080,9 @@ function ToolLogsView({ // down on the possibility of unwanted runs const subagentLogCount = logs.filter((l) => l.startsWith('[subagent:')).length; - const labelText = subagentLogCount > 0 ? `Activity (${subagentLogCount})` : 'Logs'; + const labelText = subagentLogCount > 0 + ? intl.formatMessage(i18n.activityCount, { count: subagentLogCount }) + : intl.formatMessage(i18n.logs); return (
)} diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 112a78e7..77a0f446 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -7,6 +7,70 @@ import MessageCopyLink from './MessageCopyLink'; import { formatMessageTimestamp } from '../utils/timeUtils'; import Edit from './icons/Edit'; import { Button } from './ui/button'; +import { defineMessages, useIntl } from '../i18n'; + +const i18n = defineMessages({ + editPlaceholder: { + id: 'userMessage.editPlaceholder', + defaultMessage: 'Edit your message...', + }, + editAriaLabel: { + id: 'userMessage.editAriaLabel', + defaultMessage: 'Edit message content', + }, + emptyError: { + id: 'userMessage.emptyError', + defaultMessage: 'Message cannot be empty', + }, + editInPlaceDescription: { + id: 'userMessage.editInPlaceDescription', + defaultMessage: 'Edit in Place updates this session • Fork Session creates a new session', + }, + cancel: { + id: 'userMessage.cancel', + defaultMessage: 'Cancel', + }, + cancelAriaLabel: { + id: 'userMessage.cancelAriaLabel', + defaultMessage: 'Cancel editing', + }, + editInPlace: { + id: 'userMessage.editInPlace', + defaultMessage: 'Edit in Place', + }, + editInPlaceAriaLabel: { + id: 'userMessage.editInPlaceAriaLabel', + defaultMessage: 'Edit message in place', + }, + editInPlaceTitle: { + id: 'userMessage.editInPlaceTitle', + defaultMessage: 'Update the message in this session', + }, + forkSession: { + id: 'userMessage.forkSession', + defaultMessage: 'Fork Session', + }, + forkSessionAriaLabel: { + id: 'userMessage.forkSessionAriaLabel', + defaultMessage: 'Fork session with edited message', + }, + forkSessionTitle: { + id: 'userMessage.forkSessionTitle', + defaultMessage: 'Create a new session with the edited message', + }, + editButton: { + id: 'userMessage.editButton', + defaultMessage: 'Edit', + }, + editMessageAriaLabel: { + id: 'userMessage.editMessageAriaLabel', + defaultMessage: 'Edit message: {preview}', + }, + editMessageTitle: { + id: 'userMessage.editMessageTitle', + defaultMessage: 'Edit message', + }, +}); interface UserMessageProps { message: Message; @@ -14,6 +78,7 @@ interface UserMessageProps { } export default function UserMessage({ message, onMessageUpdate }: UserMessageProps) { + const intl = useIntl(); const contentRef = useRef(null); const textareaRef = useRef(null); const [isEditing, setIsEditing] = useState(false); @@ -74,7 +139,7 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro const handleSave = useCallback( (editType: 'fork' | 'edit' = 'fork') => { if (editContent.trim().length === 0) { - setError('Message cannot be empty'); + setError(intl.formatMessage(i18n.emptyError)); return; } @@ -88,7 +153,7 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro onMessageUpdate(message.id, editContent, editType); } }, - [editContent, textContent, onMessageUpdate, message.id] + [editContent, textContent, onMessageUpdate, message.id, intl] ); // Handle cancel action @@ -147,8 +212,8 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro wordBreak: 'break-word', overflowWrap: 'break-word', }} - placeholder="Edit your message..." - aria-label="Edit message content" + placeholder={intl.formatMessage(i18n.editPlaceholder)} + aria-label={intl.formatMessage(i18n.editAriaLabel)} aria-describedby={error ? `error-${message.id}` : undefined} /> {/* Error message */} @@ -164,27 +229,28 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro )}
- Edit in Place updates this session •{' '} - Fork Session creates a new session + {intl.formatMessage(i18n.editInPlaceDescription, { + b: (chunks: React.ReactNode) => {chunks}, + })}
-
@@ -227,12 +293,12 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro } }} className="flex items-center gap-1 text-xs text-text-secondary hover:cursor-pointer hover:text-text-primary transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-50 rounded" - aria-label={`Edit message: ${textContent.substring(0, 50)}${textContent.length > 50 ? '...' : ''}`} + aria-label={intl.formatMessage(i18n.editMessageAriaLabel, { preview: `${textContent.substring(0, 50)}${textContent.length > 50 ? '...' : ''}` })} aria-expanded={isEditing} - title="Edit message" + title={intl.formatMessage(i18n.editMessageTitle)} > - Edit + {intl.formatMessage(i18n.editButton)}
diff --git a/ui/desktop/src/components/__tests__/GroupedExtensionLoadingToast.test.tsx b/ui/desktop/src/components/__tests__/GroupedExtensionLoadingToast.test.tsx index 701e9e04..b28a8806 100644 --- a/ui/desktop/src/components/__tests__/GroupedExtensionLoadingToast.test.tsx +++ b/ui/desktop/src/components/__tests__/GroupedExtensionLoadingToast.test.tsx @@ -2,9 +2,14 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { GroupedExtensionLoadingToast } from '../GroupedExtensionLoadingToast'; +import { IntlTestWrapper } from '../../i18n/test-utils'; const renderWithRouter = (component: React.ReactElement) => { - return render({component}); + return render( + + {component} + + ); }; describe('GroupedExtensionLoadingToast', () => { diff --git a/ui/desktop/src/components/alerts/AlertBox.tsx b/ui/desktop/src/components/alerts/AlertBox.tsx index f5723d0d..746088a4 100644 --- a/ui/desktop/src/components/alerts/AlertBox.tsx +++ b/ui/desktop/src/components/alerts/AlertBox.tsx @@ -6,6 +6,7 @@ import { errorMessage } from '../../utils/conversionUtils'; import { Alert, AlertType } from './types'; import { upsertConfig } from '../../api'; import { useConfig } from '../ConfigContext'; +import { defineMessages, useIntl } from '../../i18n'; const alertIcons: Record = { [AlertType.Error]: , @@ -19,6 +20,21 @@ interface AlertBoxProps { compactButtonEnabled?: boolean; } +const i18n = defineMessages({ + autoCompactAt: { + id: 'alertBox.autoCompactAt', + defaultMessage: 'Auto compact at', + }, + compactNow: { + id: 'alertBox.compactNow', + defaultMessage: 'Compact now', + }, + failedToSaveThreshold: { + id: 'alertBox.failedToSaveThreshold', + defaultMessage: 'Failed to save threshold: {error}', + }, +}); + const alertStyles: Record = { [AlertType.Error]: 'bg-[#d7040e] text-white', [AlertType.Warning]: 'bg-[#cc4b03] text-white', @@ -37,6 +53,7 @@ const formatTokenCount = (count: number): string => { }; export const AlertBox = ({ alert, className }: AlertBoxProps) => { + const intl = useIntl(); const { read } = useConfig(); const [isEditingThreshold, setIsEditingThreshold] = useState(false); const [loadedThreshold, setLoadedThreshold] = useState(0.8); @@ -90,7 +107,7 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => { } } catch (error) { console.error('Error saving threshold:', error); - window.alert(`Failed to save threshold: ${errorMessage(error, 'Unknown error')}`); + window.alert(intl.formatMessage(i18n.failedToSaveThreshold, { error: errorMessage(error, 'Unknown error') })); } finally { setIsSaving(false); } @@ -114,7 +131,7 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
{isEditingThreshold ? ( <> - Auto compact at + {intl.formatMessage(i18n.autoCompactAt)} { ) : ( <> - Auto compact at {Math.round(currentThreshold * 100)}% + {intl.formatMessage(i18n.autoCompactAt)} {Math.round(currentThreshold * 100)}% )}
diff --git a/ui/desktop/src/components/alerts/__tests__/AlertBox.test.tsx b/ui/desktop/src/components/alerts/__tests__/AlertBox.test.tsx index f6cbaa29..43776e80 100644 --- a/ui/desktop/src/components/alerts/__tests__/AlertBox.test.tsx +++ b/ui/desktop/src/components/alerts/__tests__/AlertBox.test.tsx @@ -1,8 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, type RenderOptions, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { AlertBox } from '../AlertBox'; import { Alert, AlertType } from '../types'; +import { IntlTestWrapper } from '../../../i18n/test-utils'; + +const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) => + render(ui, { wrapper: IntlTestWrapper, ...options }); // Mock the ConfigContext vi.mock('../../ConfigContext', () => ({ @@ -25,7 +29,7 @@ describe('AlertBox', () => { message: 'Test info message', }; - render(); + renderWithIntl(); expect(screen.getByText('Test info message')).toBeInTheDocument(); }); @@ -36,7 +40,7 @@ describe('AlertBox', () => { message: 'Test warning message', }; - const { container } = render(); + const { container } = renderWithIntl(); const alertElement = container.querySelector('.bg-\\[\\#cc4b03\\]'); expect(alertElement).toBeInTheDocument(); @@ -49,7 +53,7 @@ describe('AlertBox', () => { message: 'Test error message', }; - const { container } = render(); + const { container } = renderWithIntl(); const alertElement = container.querySelector('.bg-\\[\\#d7040e\\]'); expect(alertElement).toBeInTheDocument(); @@ -62,7 +66,7 @@ describe('AlertBox', () => { message: 'Test message', }; - const { container } = render(); + const { container } = renderWithIntl(); const alertElement = container.firstChild as HTMLElement; expect(alertElement).toHaveClass('custom-class'); @@ -80,7 +84,7 @@ describe('AlertBox', () => { }, }; - render(); + renderWithIntl(); expect(screen.getByText('50')).toBeInTheDocument(); expect(screen.getByText('50%')).toBeInTheDocument(); @@ -103,7 +107,7 @@ describe('AlertBox', () => { }, }; - render(); + renderWithIntl(); expect(screen.getByText('0')).toBeInTheDocument(); expect(screen.getByText('0%')).toBeInTheDocument(); @@ -120,7 +124,7 @@ describe('AlertBox', () => { }, }; - render(); + renderWithIntl(); // Use getAllByText since there are multiple "100" elements (current and total) const hundredElements = screen.getAllByText('100'); @@ -138,7 +142,7 @@ describe('AlertBox', () => { }, }; - render(); + renderWithIntl(); expect(screen.getByText('1.5k')).toBeInTheDocument(); expect(screen.getByText('15%')).toBeInTheDocument(); @@ -155,7 +159,7 @@ describe('AlertBox', () => { }, }; - render(); + renderWithIntl(); expect(screen.getByText('150')).toBeInTheDocument(); expect(screen.getByText('150%')).toBeInTheDocument(); @@ -173,7 +177,7 @@ describe('AlertBox', () => { onCompact: mockOnCompact, }; - render(); + renderWithIntl(); expect(screen.getByText('Compact now')).toBeInTheDocument(); }); @@ -190,7 +194,7 @@ describe('AlertBox', () => { compactIcon: , }; - render(); + renderWithIntl(); expect(screen.getByTestId('compact-icon')).toBeInTheDocument(); expect(screen.getByText('Compact now')).toBeInTheDocument(); @@ -207,7 +211,7 @@ describe('AlertBox', () => { onCompact: mockOnCompact, }; - render(); + renderWithIntl(); const compactButton = screen.getByText('Compact now'); await user.click(compactButton); @@ -226,7 +230,7 @@ describe('AlertBox', () => { onCompact: mockOnCompact, }; - render( + renderWithIntl(
@@ -248,7 +252,7 @@ describe('AlertBox', () => { onCompact: mockOnCompact, }; - render(); + renderWithIntl(); expect(screen.queryByText('Compact now')).not.toBeInTheDocument(); }); @@ -261,7 +265,7 @@ describe('AlertBox', () => { showCompactButton: true, }; - render(); + renderWithIntl(); expect(screen.queryByText('Compact now')).not.toBeInTheDocument(); }); @@ -280,7 +284,7 @@ describe('AlertBox', () => { onCompact: mockOnCompact, }; - render(); + renderWithIntl(); expect(screen.getByText('75')).toBeInTheDocument(); expect(screen.getByText('75%')).toBeInTheDocument(); @@ -294,7 +298,7 @@ describe('AlertBox', () => { message: 'Line 1\nLine 2\nLine 3', }; - render(); + renderWithIntl(); // Use a function matcher to handle the whitespace-pre-line rendering expect( @@ -313,7 +317,7 @@ describe('AlertBox', () => { message: '', }; - const { container } = render(); + const { container } = renderWithIntl(); // Should still render the alert container const alertElement = container.querySelector('.flex.flex-col.gap-2'); @@ -330,7 +334,7 @@ describe('AlertBox', () => { }, }; - render(); + renderWithIntl(); expect(screen.getByText('10')).toBeInTheDocument(); expect(screen.getByText('0')).toBeInTheDocument(); diff --git a/ui/desktop/src/components/apps/AppsView.tsx b/ui/desktop/src/components/apps/AppsView.tsx index d51b775d..526d6ddb 100644 --- a/ui/desktop/src/components/apps/AppsView.tsx +++ b/ui/desktop/src/components/apps/AppsView.tsx @@ -6,6 +6,52 @@ import { exportApp, GooseApp, importApp, listApps } from '../../api'; import { useChatContext } from '../../contexts/ChatContext'; import { formatAppName } from '../../utils/conversionUtils'; import { errorMessage } from '../../utils/conversionUtils'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + errorLoading: { + id: 'appsView.errorLoading', + defaultMessage: 'Error loading apps: {error}', + }, + retry: { + id: 'appsView.retry', + defaultMessage: 'Retry', + }, + title: { + id: 'appsView.title', + defaultMessage: 'Apps', + }, + importApp: { + id: 'appsView.importApp', + defaultMessage: 'Import App', + }, + description: { + id: 'appsView.description', + defaultMessage: + 'Applications from your MCP servers and Apps build by goose itself. You can ask it to create new apps through the chat interface and they will appear here.', + }, + loading: { + id: 'appsView.loading', + defaultMessage: 'Loading apps...', + }, + noAppsTitle: { + id: 'appsView.noAppsTitle', + defaultMessage: 'No apps available', + }, + noAppsDescription: { + id: 'appsView.noAppsDescription', + defaultMessage: + 'Open a chat and ask goose for the app you want to have. It can build one for you and that will appear here. Or if somebody shared an app, you can import it using the button above.', + }, + customApp: { + id: 'appsView.customApp', + defaultMessage: 'Custom app', + }, + launch: { + id: 'appsView.launch', + defaultMessage: 'Launch', + }, +}); const GridLayout = ({ children }: { children: React.ReactNode }) => { return ( @@ -22,6 +68,7 @@ const GridLayout = ({ children }: { children: React.ReactNode }) => { }; export default function AppsView() { + const intl = useIntl(); const [apps, setApps] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -195,8 +242,8 @@ export default function AppsView() { return (
-

Error loading apps: {error}

- +

{intl.formatMessage(i18n.errorLoading, { error })}

+
); @@ -215,7 +262,7 @@ export default function AppsView() {
-

Apps

+

{intl.formatMessage(i18n.title)}

- Applications from your MCP servers and Apps build by goose itself. You can ask it to - create new apps through the chat interface and they will appear here. + {intl.formatMessage(i18n.description)}

@@ -238,16 +284,14 @@ export default function AppsView() {
{loading ? (
-

Loading apps...

+

{intl.formatMessage(i18n.loading)}

) : apps.length === 0 ? (
-

No apps available

+

{intl.formatMessage(i18n.noAppsTitle)}

- Open a chat and ask goose for the app you want to have. It can build one for you - and that will appear here. Or if somebody shared an app, you can import it using - the button above. + {intl.formatMessage(i18n.noAppsDescription)}

@@ -269,7 +313,7 @@ export default function AppsView() { )} {app.mcpServers && app.mcpServers.length > 0 && ( - {isCustomApp ? 'Custom app' : app.mcpServers.join(', ')} + {isCustomApp ? intl.formatMessage(i18n.customApp) : app.mcpServers.join(', ')} )}
@@ -281,7 +325,7 @@ export default function AppsView() { className="flex items-center gap-2 flex-1" > - Launch + {intl.formatMessage(i18n.launch)} {isCustomApp && (
); @@ -139,7 +156,7 @@ export default function StandaloneAppView() { justifyContent: 'center', }} > -

Initializing app...

+

{intl.formatMessage(i18n.initializing)}

); } diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx index 390db66d..e7b2758c 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.tsx @@ -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([]); @@ -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 )} diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index f4088455..35c2ad7e 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -1,9 +1,33 @@ import React, { useEffect, useState, useRef, KeyboardEvent } from 'react'; +import { defineMessages, useIntl } from '../../i18n'; import { Search as SearchIcon } from 'lucide-react'; import { ArrowDown, ArrowUp, Close } from '../icons'; import debounce from 'lodash/debounce'; import { Button } from '../ui/button'; +const i18nMessages = defineMessages({ + defaultPlaceholder: { + id: 'searchBar.placeholder', + defaultMessage: 'Search conversation...', + }, + caseSensitive: { + id: 'searchBar.caseSensitive', + defaultMessage: 'Case Sensitive', + }, + previous: { + id: 'searchBar.previous', + defaultMessage: 'Previous ({shortcut})', + }, + next: { + id: 'searchBar.next', + defaultMessage: 'Next ({shortcut})', + }, + close: { + id: 'searchBar.close', + defaultMessage: 'Close ({shortcut})', + }, +}); + /** * Props for the SearchBar component */ @@ -37,8 +61,10 @@ export const SearchBar: React.FC = ({ searchResults, inputRef: externalInputRef, initialSearchTerm = '', - placeholder = 'Search conversation...', + placeholder, }: SearchBarProps) => { + const intl = useIntl(); + const resolvedPlaceholder = placeholder ?? intl.formatMessage(i18nMessages.defaultPlaceholder); const [searchTerm, setSearchTerm] = useState(initialSearchTerm); const [caseSensitive, setCaseSensitive] = useState(false); const [isExiting, setIsExiting] = useState(false); @@ -161,7 +187,7 @@ export const SearchBar: React.FC = ({ value={searchTerm} onChange={handleSearch} onKeyDown={handleKeyDown} - placeholder={placeholder} + placeholder={resolvedPlaceholder} className="no-drag w-full text-sm pl-9 pr-24 py-3 bg-background-inverse text-text-inverse placeholder:text-text-inverse/50 focus:outline-none active:border-border-secondary" @@ -190,7 +216,7 @@ export const SearchBar: React.FC = ({ ? 'bg-white/20 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)] text-text-inverse hover:bg-white/25' : 'text-text-inverse/70 hover:text-text-inverse hover:bg-white/10' }`} - title="Case Sensitive" + title={intl.formatMessage(i18nMessages.caseSensitive)} > Aa @@ -200,7 +226,7 @@ export const SearchBar: React.FC = ({ onClick={(e) => handleNavigate('prev', e)} variant="ghost" className="no-drag flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10" - title="Previous (↑)" + title={intl.formatMessage(i18nMessages.previous, { shortcut: '↑' })} > = ({ onClick={(e) => handleNavigate('next', e)} variant="ghost" className="no-drag flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10" - title="Next (↓ or Enter)" + title={intl.formatMessage(i18nMessages.next, { shortcut: '↓ or Enter' })} > = ({ onClick={handleClose} variant="ghost" className="no-drag flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10" - title="Close (Esc)" + title={intl.formatMessage(i18nMessages.close, { shortcut: 'Esc' })} > diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index d7ccddba..b0dcaf15 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -17,6 +17,40 @@ import { activateExtensionDefault } from '../settings/extensions'; import { useConfig } from '../ConfigContext'; import { SearchView } from '../conversation/SearchView'; import { getSearchShortcutText } from '../../utils/keyboardShortcuts'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + heading: { + id: 'extensionsView.heading', + defaultMessage: 'Extensions', + }, + description: { + id: 'extensionsView.description', + defaultMessage: + 'These extensions use the Model Context Protocol (MCP). They can expand Goose\'s capabilities using three main components: Prompts, Resources, and Tools. {searchShortcut} to search.', + }, + defaultNote: { + id: 'extensionsView.defaultNote', + defaultMessage: + 'Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat.', + }, + addCustomExtension: { + id: 'extensionsView.addCustomExtension', + defaultMessage: 'Add custom extension', + }, + browseExtensions: { + id: 'extensionsView.browseExtensions', + defaultMessage: 'Browse extensions', + }, + searchPlaceholder: { + id: 'extensionsView.searchPlaceholder', + defaultMessage: 'Search extensions...', + }, + addExtension: { + id: 'extensionsView.addExtension', + defaultMessage: 'Add Extension', + }, +}); export type ExtensionsViewOptions = { deepLinkConfig?: ExtensionConfig; @@ -30,6 +64,7 @@ export default function ExtensionsView({ setView: (view: View, viewOptions?: ViewOptions) => void; viewOptions: ExtensionsViewOptions; }) { + const intl = useIntl(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const [searchTerm, setSearchTerm] = useState(''); @@ -98,16 +133,13 @@ export default function ExtensionsView({
-

Extensions

+

{intl.formatMessage(i18n.heading)}

- These extensions use the Model Context Protocol (MCP). They can expand Goose's - capabilities using three main components: Prompts, Resources, and Tools.{' '} - {getSearchShortcutText()} to search. + {intl.formatMessage(i18n.description, { searchShortcut: getSearchShortcutText() })}

- Extensions enabled here are used as the default for new chats. You can also toggle - active extensions during chat. + {intl.formatMessage(i18n.defaultNote)}

{/* Action Buttons */} @@ -118,7 +150,7 @@ export default function ExtensionsView({ onClick={() => setIsAddModalOpen(true)} > - Add custom extension + {intl.formatMessage(i18n.addCustomExtension)}
- setSearchTerm(term)} placeholder="Search extensions..."> + setSearchTerm(term)} placeholder={intl.formatMessage(i18n.searchPlaceholder)}> )} diff --git a/ui/desktop/src/components/onboarding/FreeOptionCards.tsx b/ui/desktop/src/components/onboarding/FreeOptionCards.tsx index 2851a8b6..4a1f338d 100644 --- a/ui/desktop/src/components/onboarding/FreeOptionCards.tsx +++ b/ui/desktop/src/components/onboarding/FreeOptionCards.tsx @@ -5,6 +5,50 @@ import { Tetrate } from '../icons'; import LocalModelPicker from './LocalModelPicker'; import { HardDrive } from 'lucide-react'; import { useFeatures } from '../../contexts/FeaturesContext'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + chooseOption: { + id: 'freeOptionCards.chooseOption', + defaultMessage: 'Choose an option to get started.', + }, + tetrateTitle: { + id: 'freeOptionCards.tetrateTitle', + defaultMessage: 'Agent Router by Tetrate', + }, + tetrateDescription: { + id: 'freeOptionCards.tetrateDescription', + defaultMessage: 'Access multiple AI models with automatic setup. Sign up to receive $10 credit.', + }, + nanogptTitle: { + id: 'freeOptionCards.nanogptTitle', + defaultMessage: 'NanoGPT', + }, + nanogptDescription: { + id: 'freeOptionCards.nanogptDescription', + defaultMessage: 'Sign up to receive 60M free tokens for 7 days.', + }, + localModelTitle: { + id: 'freeOptionCards.localModelTitle', + defaultMessage: 'Use a Local Model', + }, + freeAndPrivate: { + id: 'freeOptionCards.freeAndPrivate', + defaultMessage: 'Free & Private', + }, + localModelDescription: { + id: 'freeOptionCards.localModelDescription', + defaultMessage: 'Download a model and run entirely on your machine. No API keys, no accounts.', + }, + unexpectedError: { + id: 'freeOptionCards.unexpectedError', + defaultMessage: 'An unexpected error occurred during setup.', + }, + retry: { + id: 'freeOptionCards.retry', + defaultMessage: 'Retry', + }, +}); const TETRATE = 'tetrate' as const; const NANOGPT = 'nano-gpt' as const; @@ -27,6 +71,7 @@ const cardClass = (isSelected: boolean) => }`; export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps) { + const intl = useIntl(); const { localInference } = useFeatures(); const [error, setError] = useState<{ message: string; @@ -45,7 +90,7 @@ export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps) setError({ message: result.message, type }); } } catch { - setError({ message: 'An unexpected error occurred during setup.', type }); + setError({ message: intl.formatMessage(i18n.unexpectedError), type }); } }; @@ -74,7 +119,7 @@ export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps) return (
-

Choose an option to get started.

+

{intl.formatMessage(i18n.chooseOption)}

@@ -82,7 +127,7 @@ export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps)
- Agent Router by Tetrate + {intl.formatMessage(i18n.tetrateTitle)}
@@ -90,7 +135,7 @@ export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps)

- Access multiple AI models with automatic setup. Sign up to receive $10 credit. + {intl.formatMessage(i18n.tetrateDescription)}

@@ -100,14 +145,14 @@ export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps) N - NanoGPT + {intl.formatMessage(i18n.nanogptTitle)}

- Sign up to receive 60M free tokens for 7 days. + {intl.formatMessage(i18n.nanogptDescription)}

@@ -116,9 +161,9 @@ export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps)
- Use a Local Model + {intl.formatMessage(i18n.localModelTitle)} - Free & Private + {intl.formatMessage(i18n.freeAndPrivate)}
@@ -126,7 +171,7 @@ export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps)

- Download a model and run entirely on your machine. No API keys, no accounts. + {intl.formatMessage(i18n.localModelDescription)}

)} @@ -139,7 +184,7 @@ export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps) onClick={handleRetry} className="px-3 py-1 text-sm font-medium text-red-700 dark:text-red-400 bg-white dark:bg-gray-800 border border-red-300 dark:border-red-700 rounded-md hover:bg-red-50 dark:hover:bg-red-900/30 shrink-0" > - Retry + {intl.formatMessage(i18n.retry)} )} diff --git a/ui/desktop/src/components/onboarding/LocalModelPicker.tsx b/ui/desktop/src/components/onboarding/LocalModelPicker.tsx index b3492132..f16467aa 100644 --- a/ui/desktop/src/components/onboarding/LocalModelPicker.tsx +++ b/ui/desktop/src/components/onboarding/LocalModelPicker.tsx @@ -8,6 +8,82 @@ import { type LocalModelResponse, } from '../../api'; import { trackOnboardingSetupFailed } from '../../utils/analytics'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + checkingModels: { + id: 'localModelPicker.checkingModels', + defaultMessage: 'Checking available models...', + }, + tryAgain: { + id: 'localModelPicker.tryAgain', + defaultMessage: 'Try Again', + }, + bestForMachine: { + id: 'localModelPicker.bestForMachine', + defaultMessage: 'Best for your machine', + }, + ready: { + id: 'localModelPicker.ready', + defaultMessage: 'Ready', + }, + showOtherSizes: { + id: 'localModelPicker.showOtherSizes', + defaultMessage: 'Show {count} other sizes', + }, + hideOtherSizes: { + id: 'localModelPicker.hideOtherSizes', + defaultMessage: 'Hide other sizes', + }, + selectModel: { + id: 'localModelPicker.selectModel', + defaultMessage: 'Select a model', + }, + useModel: { + id: 'localModelPicker.useModel', + defaultMessage: 'Use {modelId}', + }, + downloadModel: { + id: 'localModelPicker.downloadModel', + defaultMessage: 'Download {modelId} ({size})', + }, + back: { + id: 'localModelPicker.back', + defaultMessage: 'Back', + }, + downloading: { + id: 'localModelPicker.downloading', + defaultMessage: 'Downloading {modelId}', + }, + startingDownload: { + id: 'localModelPicker.startingDownload', + defaultMessage: 'Starting download...', + }, + cancelDownload: { + id: 'localModelPicker.cancelDownload', + defaultMessage: 'Cancel Download', + }, + localModelsNote: { + id: 'localModelPicker.localModelsNote', + defaultMessage: 'Local models keep everything on your machine for full privacy. Performance and context window size may vary compared to cloud providers depending on your hardware and model size.', + }, + failedToLoad: { + id: 'localModelPicker.failedToLoad', + defaultMessage: 'Failed to load available models. Please try again.', + }, + modelNotFound: { + id: 'localModelPicker.modelNotFound', + defaultMessage: 'Model not found', + }, + failedToStartDownload: { + id: 'localModelPicker.failedToStartDownload', + defaultMessage: 'Failed to start download. Please try again.', + }, + lostConnection: { + id: 'localModelPicker.lostConnection', + defaultMessage: 'Lost connection to download. Please try again.', + }, +}); interface LocalModelPickerProps { onConfigured: (providerName: string, modelId: string) => void; @@ -31,6 +107,7 @@ const LOCAL_PROVIDER = 'local'; type Phase = 'loading' | 'select' | 'downloading' | 'error'; export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPickerProps) { + const intl = useIntl(); const [phase, setPhase] = useState('loading'); const [models, setModels] = useState([]); const [selectedModelId, setSelectedModelId] = useState(null); @@ -65,14 +142,14 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic } } catch (error) { console.error('Failed to load local models:', error); - setErrorMessage('Failed to load available models. Please try again.'); + setErrorMessage(intl.formatMessage(i18n.failedToLoad)); setPhase('error'); return; } setPhase('select'); }; load(); - }, []); + }, [intl]); const finishSetup = (modelId: string) => { onConfigured(LOCAL_PROVIDER, modelId); @@ -85,7 +162,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic const model = models.find((m) => m.id === modelId); if (!model) { - setErrorMessage('Model not found'); + setErrorMessage(intl.formatMessage(i18n.modelNotFound)); setPhase('error'); return; } @@ -94,7 +171,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic await downloadHfModel({ body: { spec: model.id }, throwOnError: true }); } catch (error) { console.error('Failed to start download:', error); - setErrorMessage('Failed to start download. Please try again.'); + setErrorMessage(intl.formatMessage(i18n.failedToStartDownload)); trackOnboardingSetupFailed(LOCAL_PROVIDER, 'download_start_failed'); setPhase('error'); return; @@ -123,7 +200,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic } } catch { cleanup(); - setErrorMessage('Lost connection to download. Please try again.'); + setErrorMessage(intl.formatMessage(i18n.lostConnection)); trackOnboardingSetupFailed(LOCAL_PROVIDER, 'progress_poll_failed'); setPhase('error'); } @@ -162,7 +239,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic return (
-

Checking available models...

+

{intl.formatMessage(i18n.checkingModels)}

); } @@ -182,7 +259,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic }} className="w-full px-4 py-2 bg-transparent border rounded-lg text-text-default text-sm font-medium hover:bg-background-muted/80 transition-colors" > - Try Again + {intl.formatMessage(i18n.tryAgain)} )} @@ -200,7 +277,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic >
- Best for your machine + {intl.formatMessage(i18n.bestForMachine)}
@@ -217,7 +294,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic {recommended.status.state === 'Downloaded' && ( - Ready + {intl.formatMessage(i18n.ready)} )}
@@ -235,7 +312,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic onClick={() => setShowAllModels(!showAllModels)} className="text-sm text-blue-500 hover:text-blue-400 transition-colors flex items-center gap-1" > - {showAllModels ? 'Hide other sizes' : `Show ${otherModels.length} other sizes`} + {showAllModels ? intl.formatMessage(i18n.hideOtherSizes) : intl.formatMessage(i18n.showOtherSizes, { count: otherModels.length })} {selectedModel?.status.state === 'Downloaded' - ? `Use ${selectedModel.id}` + ? intl.formatMessage(i18n.useModel, { modelId: selectedModel.id }) : selectedModel - ? `Download ${selectedModel.id} (${formatSize(selectedModel.size_bytes)})` - : 'Select a model'} + ? intl.formatMessage(i18n.downloadModel, { modelId: selectedModel.id, size: formatSize(selectedModel.size_bytes) }) + : intl.formatMessage(i18n.selectModel)} {onBack && ( @@ -310,7 +387,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic onClick={onBack} className="w-full px-4 py-2.5 text-blue-600 dark:text-blue-400 text-sm font-medium border border-blue-300 dark:border-blue-700 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors cursor-pointer" > - Back + {intl.formatMessage(i18n.back)} )} @@ -320,7 +397,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic

- Downloading {selectedModel.id} + {intl.formatMessage(i18n.downloading, { modelId: selectedModel.id })}

{downloadProgress ? ( @@ -360,7 +437,7 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic ) : (
- Starting download... + {intl.formatMessage(i18n.startingDownload)}
)}
@@ -369,16 +446,14 @@ export default function LocalModelPicker({ onConfigured, onBack }: LocalModelPic onClick={handleCancelDownload} className="w-full px-4 py-2.5 bg-transparent text-text-muted border rounded-lg text-sm hover:bg-background-default/80 transition-colors" > - Cancel Download + {intl.formatMessage(i18n.cancelDownload)}
)}

- Local models keep everything on your machine for full privacy. Performance and context - window size may vary compared to cloud providers depending on your hardware and model - size. + {intl.formatMessage(i18n.localModelsNote)}

diff --git a/ui/desktop/src/components/onboarding/OnboardingGuard.tsx b/ui/desktop/src/components/onboarding/OnboardingGuard.tsx index 89a44b85..8c1c8dd2 100644 --- a/ui/desktop/src/components/onboarding/OnboardingGuard.tsx +++ b/ui/desktop/src/components/onboarding/OnboardingGuard.tsx @@ -12,6 +12,18 @@ import { trackTelemetryPreference, setTelemetryEnabled as setAnalyticsTelemetryEnabled, } from '../../utils/analytics'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + welcomeTitle: { + id: 'onboardingGuard.welcomeTitle', + defaultMessage: 'Welcome to goose', + }, + welcomeDescription: { + id: 'onboardingGuard.welcomeDescription', + defaultMessage: 'Your local AI agent. Connect an AI model provider to get started.', + }, +}); const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; @@ -20,6 +32,7 @@ interface OnboardingGuardProps { } export default function OnboardingGuard({ children }: OnboardingGuardProps) { + const intl = useIntl(); const navigate = useNavigate(); const { read, upsert, getProviders } = useConfig(); const { refreshCurrentModelAndProvider } = useModelAndProvider(); @@ -117,9 +130,9 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) {
-

Welcome to goose

+

{intl.formatMessage(i18n.welcomeTitle)}

- Your local AI agent. Connect an AI model provider to get started. + {intl.formatMessage(i18n.welcomeDescription)}

diff --git a/ui/desktop/src/components/onboarding/OnboardingSuccess.tsx b/ui/desktop/src/components/onboarding/OnboardingSuccess.tsx index 6ba09891..5f0b121c 100644 --- a/ui/desktop/src/components/onboarding/OnboardingSuccess.tsx +++ b/ui/desktop/src/components/onboarding/OnboardingSuccess.tsx @@ -1,15 +1,52 @@ import { useState } from 'react'; import { Button } from '../ui/button'; import PrivacyInfoModal from './PrivacyInfoModal'; +import { defineMessages, useIntl } from '../../i18n'; const LOCAL_PROVIDER = 'local'; +const i18n = defineMessages({ + localModelReady: { + id: 'onboardingSuccess.localModelReady', + defaultMessage: 'Local model ready', + }, + connectedTo: { + id: 'onboardingSuccess.connectedTo', + defaultMessage: 'Connected to {providerName}', + }, + allSet: { + id: 'onboardingSuccess.allSet', + defaultMessage: "You're all set to start using goose.", + }, + privacyTitle: { + id: 'onboardingSuccess.privacyTitle', + defaultMessage: 'Privacy', + }, + privacyDescription: { + id: 'onboardingSuccess.privacyDescription', + defaultMessage: 'Anonymous usage data helps improve goose. We never collect your conversations, code, or personal data.', + }, + learnMore: { + id: 'onboardingSuccess.learnMore', + defaultMessage: 'Learn more', + }, + shareUsageData: { + id: 'onboardingSuccess.shareUsageData', + defaultMessage: 'Share anonymous usage data', + }, + getStarted: { + id: 'onboardingSuccess.getStarted', + defaultMessage: 'Get Started', + }, +}); + interface OnboardingSuccessProps { providerName: string; onFinish: (telemetryEnabled: boolean) => void; } export default function OnboardingSuccess({ providerName, onFinish }: OnboardingSuccessProps) { + const intl = useIntl(); const [showPrivacyInfo, setShowPrivacyInfo] = useState(false); const [telemetryOptIn, setTelemetryOptIn] = useState(true); @@ -36,22 +73,21 @@ export default function OnboardingSuccess({ providerName, onFinish }: Onboarding

{providerName === LOCAL_PROVIDER - ? 'Local model ready' - : `Connected to ${providerName}`} + ? intl.formatMessage(i18n.localModelReady) + : intl.formatMessage(i18n.connectedTo, { providerName })}

-

You're all set to start using goose.

+

{intl.formatMessage(i18n.allSet)}

-

Privacy

+

{intl.formatMessage(i18n.privacyTitle)}

- Anonymous usage data helps improve goose. We never collect your conversations, code, - or personal data.{' '} + {intl.formatMessage(i18n.privacyDescription)}{' '}

diff --git a/ui/desktop/src/components/onboarding/PrivacyInfoModal.tsx b/ui/desktop/src/components/onboarding/PrivacyInfoModal.tsx index 5dd3ee20..739fd69e 100644 --- a/ui/desktop/src/components/onboarding/PrivacyInfoModal.tsx +++ b/ui/desktop/src/components/onboarding/PrivacyInfoModal.tsx @@ -1,4 +1,48 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + title: { + id: 'privacyInfoModal.title', + defaultMessage: 'Privacy details', + }, + description: { + id: 'privacyInfoModal.description', + defaultMessage: 'Anonymous usage data helps us understand how goose is used and identify areas for improvement.', + }, + whatWeCollect: { + id: 'privacyInfoModal.whatWeCollect', + defaultMessage: 'What we collect:', + }, + collectOs: { + id: 'privacyInfoModal.collectOs', + defaultMessage: 'Operating system, version, and architecture', + }, + collectVersion: { + id: 'privacyInfoModal.collectVersion', + defaultMessage: 'goose version and install method', + }, + collectProvider: { + id: 'privacyInfoModal.collectProvider', + defaultMessage: 'Provider and model used', + }, + collectExtensions: { + id: 'privacyInfoModal.collectExtensions', + defaultMessage: 'Extensions and tool usage counts (names only)', + }, + collectSession: { + id: 'privacyInfoModal.collectSession', + defaultMessage: 'Session metrics (duration, interaction count, token usage)', + }, + collectErrors: { + id: 'privacyInfoModal.collectErrors', + defaultMessage: 'Error types (e.g., "rate_limit", "auth" - no details)', + }, + neverCollect: { + id: 'privacyInfoModal.neverCollect', + defaultMessage: 'We never collect your conversations, code, tool arguments, error messages, or any personal data. You can change this setting anytime in Settings.', + }, +}); interface PrivacyInfoModalProps { isOpen: boolean; @@ -6,30 +50,30 @@ interface PrivacyInfoModalProps { } export default function PrivacyInfoModal({ isOpen, onClose }: PrivacyInfoModalProps) { + const intl = useIntl(); + return ( !open && onClose()}> - Privacy details + {intl.formatMessage(i18n.title)}

- Anonymous usage data helps us understand how goose is used and identify areas for - improvement. + {intl.formatMessage(i18n.description)}

-

What we collect:

+

{intl.formatMessage(i18n.whatWeCollect)}

    -
  • Operating system, version, and architecture
  • -
  • goose version and install method
  • -
  • Provider and model used
  • -
  • Extensions and tool usage counts (names only)
  • -
  • Session metrics (duration, interaction count, token usage)
  • -
  • Error types (e.g., "rate_limit", "auth" - no details)
  • +
  • {intl.formatMessage(i18n.collectOs)}
  • +
  • {intl.formatMessage(i18n.collectVersion)}
  • +
  • {intl.formatMessage(i18n.collectProvider)}
  • +
  • {intl.formatMessage(i18n.collectExtensions)}
  • +
  • {intl.formatMessage(i18n.collectSession)}
  • +
  • {intl.formatMessage(i18n.collectErrors)}

- We never collect your conversations, code, tool arguments, error messages, or any - personal data. You can change this setting anytime in Settings. + {intl.formatMessage(i18n.neverCollect)}

diff --git a/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx b/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx index 16c90183..ebf1acd6 100644 --- a/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx +++ b/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx @@ -9,6 +9,39 @@ import ProviderLogo from '../settings/providers/modal/subcomponents/ProviderLogo import { SecureStorageNotice } from '../settings/providers/modal/subcomponents/SecureStorageNotice'; import { Button } from '../ui/button'; import { LogIn, ChevronRight } from 'lucide-react'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + browserWindowOpen: { + id: 'providerConfigForm.browserWindowOpen', + defaultMessage: 'A browser window will open for you to complete the login.', + }, + deviceCodeFlowHint: { + id: 'providerConfigForm.deviceCodeFlowHint', + defaultMessage: + 'A browser window will open and the verification code will be copied to your clipboard. Paste it in the browser to complete sign-in.', + }, + signingIn: { + id: 'providerConfigForm.signingIn', + defaultMessage: 'Signing in...', + }, + signInWith: { + id: 'providerConfigForm.signInWith', + defaultMessage: 'Sign in with {providerName}', + }, + noApiKey: { + id: 'providerConfigForm.noApiKey', + defaultMessage: "Don't have an API key?", + }, + configuring: { + id: 'providerConfigForm.configuring', + defaultMessage: 'Configuring...', + }, + continue: { + id: 'providerConfigForm.continue', + defaultMessage: 'Continue', + }, +}); function parseLinks(text: string) { return text.split(/(https?:\/\/[^\s]+)/g).map((part, i) => @@ -39,6 +72,7 @@ function OAuthForm({ onConfigured: (name: string) => void; onError: (msg: string) => void; }) { + const intl = useIntl(); const [isLoading, setIsLoading] = useState(false); const handleLogin = async () => { @@ -67,12 +101,12 @@ function OAuthForm({ size="lg" > - {isLoading ? 'Signing in...' : `Sign in with ${provider.metadata.display_name}`} + {isLoading ? intl.formatMessage(i18n.signingIn) : intl.formatMessage(i18n.signInWith, { providerName: provider.metadata.display_name })}

{isDeviceCodeFlow - ? 'A browser window will open and the verification code will be copied to your clipboard. Paste it in the browser to complete sign-in.' - : 'A browser window will open for you to complete the login.'} + ? intl.formatMessage(i18n.deviceCodeFlowHint) + : intl.formatMessage(i18n.browserWindowOpen)}

); @@ -87,6 +121,7 @@ function ApiKeyForm({ onConfigured: (name: string) => void; onError: (msg: string) => void; }) { + const intl = useIntl(); const { upsert } = useConfig(); const [configValues, setConfigValues] = useState>({}); const [validationErrors, setValidationErrors] = useState>({}); @@ -159,7 +194,7 @@ function ApiKeyForm({ size={14} className={`transition-transform duration-200 ${showSetupHelp ? 'rotate-90' : ''}`} /> - Don't have an API key? + {intl.formatMessage(i18n.noApiKey)} {showSetupHelp && (
    @@ -172,7 +207,7 @@ function ApiKeyForm({ )}
    diff --git a/ui/desktop/src/components/onboarding/ProviderSelector.tsx b/ui/desktop/src/components/onboarding/ProviderSelector.tsx index 06376d58..ceb58ffc 100644 --- a/ui/desktop/src/components/onboarding/ProviderSelector.tsx +++ b/ui/desktop/src/components/onboarding/ProviderSelector.tsx @@ -11,6 +11,38 @@ import FreeOptionCards from './FreeOptionCards'; import CustomProviderForm from '../settings/providers/modal/subcomponents/forms/CustomProviderForm'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; import { Gift, Key, Plus } from 'lucide-react'; +import { defineMessages, useIntl } from '../../i18n'; + +const i18n = defineMessages({ + useFreeLocal: { + id: 'providerSelector.useFreeLocal', + defaultMessage: 'Use Free/Local Providers', + }, + freeLocalDescription: { + id: 'providerSelector.freeLocalDescription', + defaultMessage: 'Use a local model or a provider with free credits', + }, + connectProvider: { + id: 'providerSelector.connectProvider', + defaultMessage: 'Connect to a Provider', + }, + connectProviderDescription: { + id: 'providerSelector.connectProviderDescription', + defaultMessage: 'Connect OpenAI, Anthropic, Google, etc', + }, + selectProvider: { + id: 'providerSelector.selectProvider', + defaultMessage: 'Select a provider', + }, + addCustomProvider: { + id: 'providerSelector.addCustomProvider', + defaultMessage: 'Add a custom provider', + }, + addCustomProviderTitle: { + id: 'providerSelector.addCustomProviderTitle', + defaultMessage: 'Add Custom Provider', + }, +}); const FREE_OPTIONS = 'free-options' as const; const OWN_PROVIDER = 'own-provider' as const; @@ -32,6 +64,7 @@ export default function ProviderSelector({ onConfigured, onFirstSelection, }: ProviderSelectorProps) { + const intl = useIntl(); const [providerList, setProviderList] = useState([]); const [selectedOption, setSelectedOption] = useState(null); const [selectedPath, setSelectedPath] = useState(null); @@ -116,10 +149,10 @@ export default function ProviderSelector({ > - Use Free/Local Providers + {intl.formatMessage(i18n.useFreeLocal)}

    - Use a local model or a provider with free credits + {intl.formatMessage(i18n.freeLocalDescription)}

    @@ -133,9 +166,9 @@ export default function ProviderSelector({ > - Connect to a Provider + {intl.formatMessage(i18n.connectProvider)} -

    Connect OpenAI, Anthropic, Google, etc

    +

    {intl.formatMessage(i18n.connectProviderDescription)}

    @@ -152,7 +185,7 @@ export default function ProviderSelector({ options={options} value={selectedOption} onChange={(option) => handleProviderSelect(option as ProviderOption | null)} - placeholder="Select a provider" + placeholder={intl.formatMessage(i18n.selectProvider)} isClearable isSearchable autoFocus @@ -165,7 +198,7 @@ export default function ProviderSelector({ className="flex items-center gap-1 text-sm text-text-muted hover:text-text-default transition-colors mb-6" > - Add a custom provider + {intl.formatMessage(i18n.addCustomProvider)} {selectedProvider && ( @@ -181,7 +214,7 @@ export default function ProviderSelector({ - Add Custom Provider + {intl.formatMessage(i18n.addCustomProviderTitle)} = ({ isExpanded = true, onToggleExpanded, }) => { + const intl = useIntl(); const { key, description, requirement } = parameter; const defaultValue = parameter.default || ''; @@ -61,10 +143,10 @@ const ParameterInput: React.FC = ({ {isUnused && (
    - Unused + {intl.formatMessage(i18n.unused)}
    )} @@ -78,7 +160,7 @@ const ParameterInput: React.FC = ({ onDelete(key); }} className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded transition-colors" - title={`Delete parameter: ${key}`} + title={intl.formatMessage(i18n.deleteParameter, { key })} > @@ -91,17 +173,17 @@ const ParameterInput: React.FC = ({
    onChange(key, { description: e.target.value })} className="w-full p-3 border rounded-lg bg-background-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-border-secondary" - placeholder={`E.g., "Enter the name for the new component"`} + placeholder={intl.formatMessage(i18n.descriptionPlaceholder)} />

    - This is the message the end-user will see. + {intl.formatMessage(i18n.descriptionHelp)}

    @@ -109,7 +191,7 @@ const ParameterInput: React.FC = ({
    @@ -145,14 +227,14 @@ const ParameterInput: React.FC = ({ {requirement === 'optional' && (
    onChange(key, { default: e.target.value })} className="w-full p-3 border rounded-lg bg-background-primary text-text-primary" - placeholder="Enter default value" + placeholder={intl.formatMessage(i18n.defaultValuePlaceholder)} />
    )} @@ -162,7 +244,7 @@ const ParameterInput: React.FC = ({ {parameter.input_type === 'select' && (