diff --git a/.roo/rules-translate/001-general-rules.md b/.roo/rules-translate/001-general-rules.md index e27b9793e2..bdf821601b 100644 --- a/.roo/rules-translate/001-general-rules.md +++ b/.roo/rules-translate/001-general-rules.md @@ -30,7 +30,10 @@ # 4. WEBVIEW UI LOCALIZATION (webview-ui/src/) - Located in webview-ui/src/i18n/locales/ -- Uses standard React i18next patterns with the useTranslation hook +- Use `useAppTranslation` from `@src/i18n/TranslationContext`, not react-i18next's `useTranslation` (ESLint enforces this). It returns `{ t, language }`; switching languages belongs to the provider, so the i18next instance is not exposed: + - Always write fully qualified keys (`t("chat:about")`) + - Always pass that `t` to `Trans` (``); `Trans` does not follow language changes on its own (ESLint enforces this) + - Never use the global `t` from `i18next` (`i18next.t`); memoized output would keep showing the previous language after a switch (ESLint enforces this). Pass `t` (and `language` for dates) to helpers instead, as `utils/format.ts` and `utils/validate.ts` do - All user interface strings should be internationalized - Always use the Trans component with named components for text with embedded components @@ -40,6 +43,7 @@ ``` diff --git a/.roo/skills/roo-translation/SKILL.md b/.roo/skills/roo-translation/SKILL.md index 82eab76fbf..79e7ddbade 100644 --- a/.roo/skills/roo-translation/SKILL.md +++ b/.roo/skills/roo-translation/SKILL.md @@ -70,7 +70,10 @@ This guidance file is loaded at runtime and should be consulted for the latest b ## WebView UI Localization (webview-ui/src/) - Located in `webview-ui/src/i18n/locales/` -- Uses standard React i18next patterns with the `useTranslation` hook +- Use `useAppTranslation` from `@src/i18n/TranslationContext`, not react-i18next's `useTranslation` (ESLint enforces this). It returns `{ t, language }`; switching languages belongs to the provider, so the i18next instance is not exposed: + - Always write fully qualified keys (`t("chat:about")`) + - Always pass that `t` to `Trans` (``); `Trans` does not follow language changes on its own (ESLint enforces this) + - Never use the global `t` from `i18next` (`i18next.t`); memoized output would keep showing the previous language after a switch (ESLint enforces this). Pass `t` (and `language` for dates) to helpers instead, as `utils/format.ts` and `utils/validate.ts` do - All user interface strings should be internationalized - Always use the `Trans` component with named components for text with embedded components @@ -86,6 +89,7 @@ React component usage: ```tsx , diff --git a/packages/config-eslint/i18n-language-switch.js b/packages/config-eslint/i18n-language-switch.js new file mode 100644 index 0000000000..8ea1bd69a5 --- /dev/null +++ b/packages/config-eslint/i18n-language-switch.js @@ -0,0 +1,73 @@ +// Guards that keep translated output in step with a language switch. Built-in no-restricted-syntax only. +// Translations must come from the component's `t` (webview-ui's `useAppTranslation`), which changes identity +// on a language switch; the global instance's `t` gives React (and React Compiler caches) nothing to +// invalidate on, so memoized output would keep the previous language. + +// Selectors cannot follow bindings, so the instance is recognised by name: a default import or +// `useTranslation().i18n` held as `i18n`/`i18next`. The aliasing selectors below keep it under those names. +// Passing the instance as an argument and reading `.t` from the parameter is not caught. +const i18nInstanceName = "/^(i18n|i18next)$/" +const i18nInstance = `:matches([name=${i18nInstanceName}], MemberExpression[property.name=${i18nInstanceName}])` + +// i18next itself and modules that re-export its instance as their default (webview-ui's i18n/setup). +const i18nInstanceSource = "/^(i18next|(.*\\/)?i18n\\/setup|\\.\\.?\\/setup)$/" + +const globalTMessage = "Use t from useAppTranslation so memoized output follows language changes." +const aliasMessage = + "Keep the i18next instance named i18n or i18next: the global-t check recognises it by name, so an alias hides i18n.t." + +export const i18nLanguageSwitchSelectors = [ + { + // Global t read off the instance: i18next.t(...), i18n?.t(...), i18next["t"], useTranslation().i18n.t. + selector: `MemberExpression:matches([property.name='t'][computed=false], [property.value='t']):has(> ${i18nInstance}.object)`, + message: globalTMessage, + }, + { + // Global t destructured from the instance: const { t } = i18next, const { t: translate } = i18n. + // Other properties stay allowed: const { language } = i18next. + selector: `VariableDeclarator:has(> ${i18nInstance}.init) > ObjectPattern > Property[key.name='t']`, + message: globalTMessage, + }, + { + // Default import under another name: import lang from "i18next", import { default as lang } from "@/i18n/setup". + // Named imports such as initReactI18next or `type TFunction` are other specifiers and are not matched. + selector: `ImportDeclaration[source.value=${i18nInstanceSource}] > :matches(ImportDefaultSpecifier, ImportSpecifier[imported.name='default']):not([local.name=${i18nInstanceName}])`, + message: aliasMessage, + }, + { + // Namespace import: import * as lang from "i18next" exposes the instance's t as lang.t whatever lang is called. + selector: `ImportDeclaration[source.value=${i18nInstanceSource}] > ImportNamespaceSpecifier`, + message: aliasMessage, + }, + { + // Re-export under another name: export { default as lang } from "i18next" lets importers alias it unseen. + selector: `ExportNamedDeclaration[source.value=${i18nInstanceSource}] > ExportSpecifier[local.name='default']:not([exported.name=${i18nInstanceName}])`, + message: aliasMessage, + }, + { + // Variable alias: const lang = i18next, const lang = useTranslation().i18n. + // Keeping the name is fine (const i18n = useTranslation().i18n); calls such as i18next.createInstance() are not + // the instance itself and are not matched. + selector: `VariableDeclarator[id.type='Identifier']:not([id.name=${i18nInstanceName}]):has(> ${i18nInstance}.init)`, + message: aliasMessage, + }, + { + // Assignment alias to an existing variable: lang = i18n. + selector: `AssignmentExpression:not([left.name=${i18nInstanceName}]):has(> ${i18nInstance}.right)`, + message: aliasMessage, + }, + { + // Destructuring rename, in declarations and parameters: const { i18n: lang } = useTranslation(), + // ({ i18n: lang }) => ..., and a nested pattern such as const { i18n: { t } } = useTranslation(). + // The shorthand const { i18n } = useTranslation() keeps the name and is allowed. + selector: `ObjectPattern > Property[key.name=${i18nInstanceName}]:not([value.name=${i18nInstanceName}])`, + message: aliasMessage, + }, + { + // without its own t={t}: it reads the global instance, so a memoized element keeps the old language. + // Direct child only: }} /> is allowed. + selector: "JSXOpeningElement[name.name='Trans']:not(:has(> JSXAttribute[name.name='t']))", + message: + "Pass t={t} from useAppTranslation: Trans does not subscribe to language changes, so a cached keeps the previous language.", + }, +] diff --git a/packages/config-eslint/i18n-language-switch.test.js b/packages/config-eslint/i18n-language-switch.test.js new file mode 100644 index 0000000000..985830aaba --- /dev/null +++ b/packages/config-eslint/i18n-language-switch.test.js @@ -0,0 +1,111 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { Linter } from "eslint" +import typescriptEslint from "typescript-eslint" + +import { i18nLanguageSwitchSelectors } from "./i18n-language-switch.js" + +const linter = new Linter({ configType: "flat" }) +const config = [ + { + files: ["**/*.tsx"], + languageOptions: { parser: typescriptEslint.parser, parserOptions: { ecmaFeatures: { jsx: true } } }, + rules: { "no-restricted-syntax": ["error", ...i18nLanguageSwitchSelectors] }, + }, +] + +const lint = (code) => linter.verify(code, config, { filename: "example.tsx" }).map((message) => message.message) + +describe("i18n language switch: global t", () => { + for (const code of [ + 'i18next.t("chat:about")', + 'import i18n from "i18next"\ni18n.t("chat:about")', + 'i18n?.t("chat:about")', + 'i18next["t"]("chat:about")', + 'useTranslation().i18n.t("chat:about")', + "const { t } = i18next", + "const { t: translate } = i18n", + "const { t } = useTranslation().i18n", + ]) { + it(`flags: ${code}`, () => { + assert.equal(lint(code).length, 1) + }) + } + + for (const code of [ + 'const { t } = useTranslation()\nt("chat:about")', + 'i18next.changeLanguage("es")', + "i18n.language", + "i18next[t]", + "const { language } = i18n", + 'other.t("chat:about")', + ]) { + it(`allows: ${code}`, () => { + assert.deepEqual(lint(code), []) + }) + } +}) + +describe("i18n language switch: aliasing the instance", () => { + for (const code of [ + 'import lang from "i18next"', + 'import { default as lang } from "i18next"', + 'import * as lang from "i18next"', + 'import lang from "@src/i18n/setup"', + 'import lang from "@/i18n/setup"', + 'import lang from "../setup"', + 'export { default as lang } from "i18next"', + "const lang = i18next", + "const lang = useTranslation().i18n", + "lang = i18n", + "const { i18n: lang } = useTranslation()", + "const { i18n: { t } } = useTranslation()", + "function Foo({ i18n: lang }) {}", + ]) { + it(`flags: ${code}`, () => { + assert.equal(lint(code).length, 1) + }) + } + + for (const code of [ + 'import i18next from "i18next"', + 'import i18n from "i18next"', + 'import i18next, { type TFunction } from "i18next"', + 'import { initReactI18next } from "react-i18next"', + 'import i18next from "@src/i18n/setup"', + 'import { default as i18next } from "i18next"', + 'import config from "./config"', + "const i18n = useTranslation().i18n", + "const { i18n } = useTranslation()", + "const { language } = i18next", + "const instance = i18next.createInstance()", + "changeLanguage(i18next)", + ]) { + it(`allows: ${code}`, () => { + assert.deepEqual(lint(code), []) + }) + } +}) + +describe("i18n language switch: Trans", () => { + for (const code of [ + '', + " }} />", + "", + ]) { + it(`flags a Trans without its own t: ${code}`, () => { + assert.equal(lint(code).length, 1) + }) + } + + for (const code of [ + '', + " }} />", + '', + ]) { + it(`allows: ${code}`, () => { + assert.deepEqual(lint(code), []) + }) + } +}) diff --git a/packages/config-eslint/package.json b/packages/config-eslint/package.json index c2a7537d85..7f165b58b1 100644 --- a/packages/config-eslint/package.json +++ b/packages/config-eslint/package.json @@ -8,11 +8,12 @@ "./next-js": "./next.js", "./provider-identifiers": "./provider-identifiers.js", "./api-provider-hygiene": "./api-provider-hygiene.js", - "./i18n-keys": "./i18n-keys.js" + "./i18n-keys": "./i18n-keys.js", + "./i18n-language-switch": "./i18n-language-switch.js" }, "scripts": { "lint": "eslint . --max-warnings=0", - "test": "node --test provider-identifiers.test.js api-provider-hygiene.test.js i18n-keys.test.js" + "test": "node --test *.test.js" }, "devDependencies": { "@eslint/js": "9.39.5", diff --git a/webview-ui/eslint.config.mjs b/webview-ui/eslint.config.mjs index 051513b526..71d682ef9f 100644 --- a/webview-ui/eslint.config.mjs +++ b/webview-ui/eslint.config.mjs @@ -1,4 +1,5 @@ import { reactConfig } from "@roo-code/config-eslint/react" +import { i18nLanguageSwitchSelectors } from "@roo-code/config-eslint/i18n-language-switch" import { createProviderIdentifierConfig } from "@roo-code/config-eslint/provider-identifiers" import { createI18nDotKeyConfig } from "@roo-code/config-eslint/i18n-keys" import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" @@ -75,6 +76,36 @@ export default [ message: "axios was removed from the webview bundle in favour of global fetch. Use fetch() for network calls. The dependency is no longer declared in webview-ui/package.json, so an import here only resolves through the hoisted store and would fail at build time.", }, + { + name: "i18next", + importNames: ["t"], + message: "Use t from useAppTranslation so memoized output follows language changes.", + }, + { + name: "react-i18next", + importNames: ["useTranslation"], + message: + "Use useAppTranslation from @src/i18n/TranslationContext: it loads the selected language and exposes the active one for date formatting.", + }, + ], + }, + ], + "no-restricted-syntax": ["error", ...i18nLanguageSwitchSelectors], + }, + }, + { + // The provider is the one place that reads react-i18next's useTranslation. + files: ["src/i18n/TranslationContext.tsx"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "i18next", + importNames: ["t"], + message: "Use t from useAppTranslation so memoized output follows language changes.", + }, ], }, ], @@ -108,6 +139,17 @@ export default [ message: "axios was removed from the webview bundle in favour of global fetch. Use fetch() for network calls. The dependency is no longer declared in webview-ui/package.json, so an import here only resolves through the hoisted store and would fail at build time.", }, + { + name: "i18next", + importNames: ["t"], + message: "Use t from useAppTranslation so memoized output follows language changes.", + }, + { + name: "react-i18next", + importNames: ["useTranslation"], + message: + "Use useAppTranslation from @src/i18n/TranslationContext: it loads the selected language and exposes the active one for date formatting.", + }, { name: "@tanstack/react-query", importNames: ["QueryClientProvider"], diff --git a/webview-ui/playwright/AppProviders.tsx b/webview-ui/playwright/AppProviders.tsx index 44ec76b3db..10c6d7861d 100644 --- a/webview-ui/playwright/AppProviders.tsx +++ b/webview-ui/playwright/AppProviders.tsx @@ -7,7 +7,6 @@ import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" import TranslationProvider from "@/i18n/TranslationContext" import i18next from "@/i18n/setup" import { TooltipProvider } from "@/components/ui/tooltip" -import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext" type InitialState = NonNullable["initialState"]> @@ -41,14 +40,11 @@ export function AppProviders({ children, initialState }: AppProvidersProps) { - i18next.t(key, options), i18n: i18next }}> - - -
{children}
-
-
-
+ + +
{children}
+
+
diff --git a/webview-ui/playwright/TranslationContext.ts b/webview-ui/playwright/TranslationContext.ts deleted file mode 100644 index b2024e29d9..0000000000 --- a/webview-ui/playwright/TranslationContext.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, useContext } from "react" - -type TranslationContextValue = { - t: (key: string, options?: Record) => string - i18n: unknown -} - -export const TranslationContext = createContext({ - t: (key: string) => key, - i18n: null, -}) - -export const useAppTranslation = () => useContext(TranslationContext) diff --git a/webview-ui/playwright/vite.config.ts b/webview-ui/playwright/vite.config.ts index f3649be72d..faae6282bc 100644 --- a/webview-ui/playwright/vite.config.ts +++ b/webview-ui/playwright/vite.config.ts @@ -48,10 +48,6 @@ export default defineConfig({ find: "@src/context/ExtensionStateContext", replacement: path.resolve(dirname, "ExtensionStateContext.tsx"), }, - { - find: "@src/i18n/TranslationContext", - replacement: path.resolve(dirname, "TranslationContext.ts"), - }, { find: "@", replacement: path.resolve(webviewRoot, "src") }, { find: "@src", replacement: path.resolve(webviewRoot, "src") }, { find: "@roo", replacement: path.resolve(webviewRoot, "../src/shared") }, diff --git a/webview-ui/src/__tests__/FileChangesPanel.spec.tsx b/webview-ui/src/__tests__/FileChangesPanel.spec.tsx index 7b74a12bc2..2a787d0cd0 100644 --- a/webview-ui/src/__tests__/FileChangesPanel.spec.tsx +++ b/webview-ui/src/__tests__/FileChangesPanel.spec.tsx @@ -14,8 +14,8 @@ vi.mock("@src/utils/vscode", () => ({ })) // Mock i18n to return readable header with count -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string, opts?: { count?: number }) => { if (key === "chat:fileChangesInConversation.header" && opts?.count != null) { return `${opts.count} file(s) changed in this conversation` diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index a1231505a1..2aaf74fb0f 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -72,7 +72,7 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {
- }} /> + }} />
diff --git a/webview-ui/src/components/chat/AutoApprovedRequestLimitWarning.tsx b/webview-ui/src/components/chat/AutoApprovedRequestLimitWarning.tsx index c7744f1bfd..7207efb750 100644 --- a/webview-ui/src/components/chat/AutoApprovedRequestLimitWarning.tsx +++ b/webview-ui/src/components/chat/AutoApprovedRequestLimitWarning.tsx @@ -1,5 +1,6 @@ import React, { memo, useState } from "react" import { Trans } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import type { ClineMessage } from "@roo-code/types" @@ -11,6 +12,7 @@ type AutoApprovedRequestLimitWarningProps = { } export const AutoApprovedRequestLimitWarning = memo(({ message }: AutoApprovedRequestLimitWarningProps) => { + const { t } = useAppTranslation() const [buttonClicked, setButtonClicked] = useState(false) const { count, type = "requests" } = JSON.parse(message.text ?? "{}") @@ -34,7 +36,7 @@ export const AutoApprovedRequestLimitWarning = memo(({ message }: AutoApprovedRe
- +
@@ -48,7 +50,7 @@ export const AutoApprovedRequestLimitWarning = memo(({ message }: AutoApprovedRe justifyContent: "center", }}>
- +
diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 2040501b3f..52647cc9b0 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1,6 +1,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useSize } from "react-use" -import { useTranslation, Trans } from "react-i18next" +import { Trans } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import deepEqual from "fast-deep-equal" import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" @@ -221,7 +222,7 @@ export const ChatRowContent = ({ completionCheckpoint, onJumpToPreviousCheckpoint, }: ChatRowContentProps) => { - const { t, i18n } = useTranslation() + const { t } = useAppTranslation() const { mcpServers, @@ -599,12 +600,14 @@ export const ChatRowContent = ({ {tool.path ? ( }} values={{ query: tool.query, path: tool.path }} /> ) : ( }} values={{ query: tool.query }} @@ -818,6 +821,7 @@ export const ChatRowContent = ({ {message.type === "ask" ? ( ) : ( {tool.reason ? ( {tool.mode} }} values={{ mode: tool.mode, reason: tool.reason }} /> ) : ( {tool.mode} }} values={{ mode: tool.mode }} @@ -876,12 +883,14 @@ export const ChatRowContent = ({ <> {tool.reason ? ( {tool.mode} }} values={{ mode: tool.mode, reason: tool.reason }} /> ) : ( {tool.mode} }} values={{ mode: tool.mode }} @@ -908,6 +917,7 @@ export const ChatRowContent = ({ {tool.mode} }} values={{ mode: tool.mode }} @@ -1157,8 +1167,9 @@ export const ChatRowContent = ({ if (!isNaN(potentialCode) && potentialCode >= 400) { code = potentialCode const stringForError = `chat:apiRequest.errorMessage.${code}` - if (i18n.exists(stringForError)) { - body = t(stringForError) + const errorMessage = t(stringForError, { defaultValue: "" }) + if (errorMessage) { + body = errorMessage // Fill this out in upcoming PRs // Do not remove this // switch(code) { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 820e30849d..cf08a220ed 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -180,8 +180,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction(undefined) const [enableButtons, setEnableButtons] = useState(false) - const [primaryButtonText, setPrimaryButtonText] = useState(undefined) - const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) + // Translation keys rather than labels, so an open prompt follows a UI language change. + const [primaryButtonKey, setPrimaryButtonKey] = useState(undefined) + const [secondaryButtonKey, setSecondaryButtonKey] = useState(undefined) + const primaryButtonText = primaryButtonKey ? t(primaryButtonKey) : undefined + const secondaryButtonText = secondaryButtonKey ? t(secondaryButtonKey) : undefined const [_didClickCancel, setDidClickCancel] = useState(false) const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) @@ -314,16 +317,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction msg.ask === "completion_result" || msg.say === "completion_result", ) if (isCompletedSubtask) { - setPrimaryButtonText(t("chat:startNewTask.title")) - setSecondaryButtonText(undefined) + setPrimaryButtonKey("chat:startNewTask.title") + setSecondaryButtonKey(undefined) } else { - setPrimaryButtonText(t("chat:resumeTask.title")) - setSecondaryButtonText(t("chat:terminate.title")) + setPrimaryButtonKey("chat:resumeTask.title") + setSecondaryButtonKey("chat:terminate.title") } setDidClickCancel(false) // special case where we reset the cancel button state break @@ -446,8 +449,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction msg.ask === "completion_result" || msg.say === "completion_result", ) if (hasCompletionResult) { - setPrimaryButtonText(t("chat:startNewTask.title")) - setSecondaryButtonText(undefined) + setPrimaryButtonKey("chat:startNewTask.title") + setSecondaryButtonKey(undefined) } } - }, [clineAsk, currentTaskItem?.parentTaskId, messages, t]) + }, [clineAsk, currentTaskItem?.parentTaskId, messages]) useEffect(() => { if (messages.length === 0) { setSendingDisabled(false) setClineAsk(undefined) setEnableButtons(false) - setPrimaryButtonText(undefined) - setSecondaryButtonText(undefined) + setPrimaryButtonKey(undefined) + setSecondaryButtonKey(undefined) } }, [messages.length]) @@ -630,8 +633,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction @@ -1817,13 +1820,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction diff --git a/webview-ui/src/components/chat/CheckpointWarning.tsx b/webview-ui/src/components/chat/CheckpointWarning.tsx index 81675f4993..8ca44fc3c7 100644 --- a/webview-ui/src/components/chat/CheckpointWarning.tsx +++ b/webview-ui/src/components/chat/CheckpointWarning.tsx @@ -1,5 +1,6 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { Trans } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" interface CheckpointWarningProps { warning: { @@ -9,6 +10,7 @@ interface CheckpointWarningProps { } export const CheckpointWarning = ({ warning }: CheckpointWarningProps) => { + const { t } = useAppTranslation() const settingsLink = ( { = ({

{t("settings:codeIndex.title")}

- + = ({ filePath, score, startLine, endLine }) => { - const { t } = useTranslation("chat") + const { t } = useAppTranslation() const handleClick = () => { console.log(filePath) @@ -27,7 +27,7 @@ const CodebaseSearchResult: React.FC = ({ filePath, s } return ( - +

diff --git a/webview-ui/src/components/chat/CodebaseSearchResultsDisplay.tsx b/webview-ui/src/components/chat/CodebaseSearchResultsDisplay.tsx index 93d8fa7da8..7b1d68a703 100644 --- a/webview-ui/src/components/chat/CodebaseSearchResultsDisplay.tsx +++ b/webview-ui/src/components/chat/CodebaseSearchResultsDisplay.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react" import CodebaseSearchResult from "./CodebaseSearchResult" import { Trans } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" interface CodebaseSearchResultsDisplayProps { results: Array<{ @@ -13,6 +14,7 @@ interface CodebaseSearchResultsDisplayProps { } const CodebaseSearchResultsDisplay: React.FC = ({ results }) => { + const { t } = useAppTranslation() const [codebaseSearchResultsExpanded, setCodebaseSearchResultsExpanded] = useState(false) return ( @@ -22,6 +24,7 @@ const CodebaseSearchResultsDisplay: React.FC className="cursor-pointer flex items-center justify-between px-2 py-2 border bg-[var(--vscode-editor-background)] border-[var(--vscode-editorGroup-border)]"> { + const { t } = useAppTranslation() const { terminalShellIntegrationDisabled = false, collapseCommandOutputByDefault = DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, diff --git a/webview-ui/src/components/chat/CommandExecutionError.tsx b/webview-ui/src/components/chat/CommandExecutionError.tsx index 71b838d888..7c981bc341 100644 --- a/webview-ui/src/components/chat/CommandExecutionError.tsx +++ b/webview-ui/src/components/chat/CommandExecutionError.tsx @@ -1,10 +1,11 @@ import { useCallback } from "react" -import { useTranslation, Trans } from "react-i18next" +import { Trans } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { buildDocLink } from "../../utils/docLinks" export const CommandExecutionError = () => { - const { t } = useTranslation() + const { t } = useAppTranslation() const onClick = useCallback((e: React.MouseEvent) => { e.preventDefault() @@ -22,6 +23,7 @@ export const CommandExecutionError = () => {
, diff --git a/webview-ui/src/components/chat/CommandPatternSelector.tsx b/webview-ui/src/components/chat/CommandPatternSelector.tsx index ca19c6edd0..9d3d97d6ba 100644 --- a/webview-ui/src/components/chat/CommandPatternSelector.tsx +++ b/webview-ui/src/components/chat/CommandPatternSelector.tsx @@ -1,7 +1,7 @@ import React, { useState, useMemo } from "react" import { Check, CheckCheck, ChevronUp, X } from "lucide-react" import { cn } from "../../lib/utils" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { StandardTooltip } from "../ui/standard-tooltip" interface CommandPattern { @@ -24,7 +24,7 @@ export const CommandPatternSelector: React.FC = ({ onAllowPatternChange, onDenyPatternChange, }) => { - const { t } = useTranslation() + const { t } = useAppTranslation() const [isExpanded, setIsExpanded] = useState(false) const [editingStates, setEditingStates] = useState>({}) diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index 04ceb2eab6..3c15afbfd9 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react" import { getIconForFilePath, getIconUrlByName, getIconForDirectoryPath } from "vscode-material-icons" import { Trans } from "react-i18next" -import { t } from "i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { Settings } from "lucide-react" import type { ModeConfig, Command } from "@roo-code/types" @@ -45,6 +45,7 @@ const ContextMenu: React.FC = ({ commands = [], }) => { const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("") + const { t } = useAppTranslation() const menuRef = useRef(null) const filteredOptions = useMemo(() => { @@ -297,6 +298,7 @@ const ContextMenu: React.FC = ({

Slash Commands

{ - const { t } = useTranslation() + const { t } = useAppTranslation() // Use the shared utility function to calculate all token distribution values const tokenDistribution = useMemo( @@ -32,21 +32,21 @@ export const ContextWindowProgress = ({ contextWindow, contextTokens, maxTokens

{t("chat:tokenProgress.tokensUsed", { - used: formatLargeNumber(safeContextTokens), - total: formatLargeNumber(safeContextWindow), + used: formatLargeNumber(safeContextTokens, t), + total: formatLargeNumber(safeContextWindow, t), })}
{reservedForOutput > 0 && (
{t("chat:tokenProgress.reservedForResponse", { - amount: formatLargeNumber(reservedForOutput), + amount: formatLargeNumber(reservedForOutput, t), })}
)} {availableSize > 0 && (
{t("chat:tokenProgress.availableSpace", { - amount: formatLargeNumber(availableSize), + amount: formatLargeNumber(availableSize, t), })}
)} @@ -56,7 +56,7 @@ export const ContextWindowProgress = ({ contextWindow, contextTokens, maxTokens return ( <>
-
{formatLargeNumber(safeContextTokens)}
+
{formatLargeNumber(safeContextTokens, t)}
{/* Main progress bar container */} @@ -91,7 +91,7 @@ export const ContextWindowProgress = ({ contextWindow, contextTokens, maxTokens
-
{formatLargeNumber(safeContextWindow)}
+
{formatLargeNumber(safeContextWindow, t)}
) diff --git a/webview-ui/src/components/chat/ErrorRow.tsx b/webview-ui/src/components/chat/ErrorRow.tsx index 26dc795bd3..5622d98761 100644 --- a/webview-ui/src/components/chat/ErrorRow.tsx +++ b/webview-ui/src/components/chat/ErrorRow.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, memo, useMemo } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { BookOpenText, MessageCircleWarning, Copy, Check, Microscope, Info } from "lucide-react" @@ -89,7 +89,7 @@ const ErrorRow = memo( code, errorDetails, }: ErrorRowProps) => { - const { t } = useTranslation() + const { t } = useAppTranslation() const [isExpanded, setIsExpanded] = useState(defaultExpanded) const [showCopySuccess, setShowCopySuccess] = useState(false) const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false) diff --git a/webview-ui/src/components/chat/FileChangesPanel.tsx b/webview-ui/src/components/chat/FileChangesPanel.tsx index 527f135463..510d87a461 100644 --- a/webview-ui/src/components/chat/FileChangesPanel.tsx +++ b/webview-ui/src/components/chat/FileChangesPanel.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useMemo, useState, useCallback, useRef } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { ChevronDown, ChevronRight, FileDiff } from "lucide-react" import { createTwoFilesPatch } from "diff" @@ -22,7 +22,7 @@ interface FileChangesPanelProps { const originalKey = (entry: { messageId?: string; ts: number }) => entry.messageId ?? `ts:${entry.ts}` const FileChangesPanel = memo(({ clineMessages, taskId, className }: FileChangesPanelProps) => { - const { t } = useTranslation() + const { t } = useAppTranslation() const [panelExpanded, setPanelExpanded] = useState(false) const [expandedPaths, setExpandedPaths] = useState>(new Set()) const [finalContentByPath, setFinalContentByPath] = useState>({}) diff --git a/webview-ui/src/components/chat/McpExecution.tsx b/webview-ui/src/components/chat/McpExecution.tsx index 9e48552fdc..21e1d973fc 100644 --- a/webview-ui/src/components/chat/McpExecution.tsx +++ b/webview-ui/src/components/chat/McpExecution.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, memo } from "react" import { Server, ChevronDown } from "lucide-react" import { useEvent } from "react-use" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { type ExtensionMessage, @@ -48,7 +48,7 @@ export const McpExecution = ({ useMcpServer, alwaysAllowMcp = false, }: McpExecutionProps) => { - const { t } = useTranslation("mcp") + const { t } = useAppTranslation() // State for tracking MCP response status const [status, setStatus] = useState(null) @@ -210,10 +210,10 @@ export const McpExecution = ({ "text-vscode-errorForeground": status.status === "error", })}> {status.status === "started" - ? t("execution.running") + ? t("mcp:execution.running") : status.status === "completed" - ? t("execution.completed") - : t("execution.error")} + ? t("mcp:execution.completed") + : t("mcp:execution.error")}
{status.status === "error" && "error" in status && status.error && (
({status.error})
diff --git a/webview-ui/src/components/chat/MessageTimestamp.tsx b/webview-ui/src/components/chat/MessageTimestamp.tsx index c76ef2e14b..bad8f9bf19 100644 --- a/webview-ui/src/components/chat/MessageTimestamp.tsx +++ b/webview-ui/src/components/chat/MessageTimestamp.tsx @@ -1,3 +1,4 @@ +import { useAppTranslation } from "@src/i18n/TranslationContext" import { Clock } from "lucide-react" import { cn } from "@src/lib/utils" @@ -31,11 +32,13 @@ export const isShowableTimestamp = (ts: number) => * gives assistive tech and copy/paste the exact value. */ export const MessageTimestamp = ({ ts, timeFormat, dateFormat, className }: MessageTimestampProps) => { + const { language } = useAppTranslation() + if (!isShowableTimestamp(ts)) return null const date = new Date(ts) return ( - + ) diff --git a/webview-ui/src/components/chat/QueuedMessages.tsx b/webview-ui/src/components/chat/QueuedMessages.tsx index 5bfb6fdb48..03b4d082e8 100644 --- a/webview-ui/src/components/chat/QueuedMessages.tsx +++ b/webview-ui/src/components/chat/QueuedMessages.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { QueuedMessage } from "@roo-code/types" @@ -16,7 +16,7 @@ interface QueuedMessagesProps { } export const QueuedMessages = ({ queue, onRemove, onUpdate }: QueuedMessagesProps) => { - const { t } = useTranslation("chat") + const { t } = useAppTranslation() const [editingStates, setEditingStates] = useState>({}) if (queue.length === 0) { @@ -41,7 +41,7 @@ export const QueuedMessages = ({ queue, onRemove, onUpdate }: QueuedMessagesProp return (
-
{t("queuedMessages.title")}
+
{t("chat:queuedMessages.title")}
{queue.map((message, index) => { const editState = getEditState(message.id, message.text) diff --git a/webview-ui/src/components/chat/ReasoningBlock.tsx b/webview-ui/src/components/chat/ReasoningBlock.tsx index 11166f5ae1..c1d2a53510 100644 --- a/webview-ui/src/components/chat/ReasoningBlock.tsx +++ b/webview-ui/src/components/chat/ReasoningBlock.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { useExtensionState } from "@src/context/ExtensionStateContext" import MarkdownBlock from "../common/MarkdownBlock" @@ -15,7 +15,7 @@ interface ReasoningBlockProps { } export const ReasoningBlock = ({ content, isStreaming, isLast }: ReasoningBlockProps) => { - const { t } = useTranslation() + const { t } = useAppTranslation() const { reasoningBlockCollapsed } = useExtensionState() const [isCollapsed, setIsCollapsed] = useState(reasoningBlockCollapsed) diff --git a/webview-ui/src/components/chat/SeeNewChangesButtons.tsx b/webview-ui/src/components/chat/SeeNewChangesButtons.tsx index 09cac1163e..1e2ba165b9 100644 --- a/webview-ui/src/components/chat/SeeNewChangesButtons.tsx +++ b/webview-ui/src/components/chat/SeeNewChangesButtons.tsx @@ -1,11 +1,11 @@ import { useCallback, useState } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import { vscode } from "@src/utils/vscode" export const SeeNewChangesButtons = () => { - const { t } = useTranslation() + const { t } = useAppTranslation() const [restoringChanges, setRestoringChanges] = useState(false) const seeNewChangesCallback = useCallback(() => { diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index f3e4b29cc0..300a36989e 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import type { HistoryItem } from "@roo-code/types" @@ -27,7 +27,7 @@ interface TaskActionsProps { export const TaskActions = ({ item, buttonsDisabled, onRename }: TaskActionsProps) => { const [deleteTaskId, setDeleteTaskId] = useState(null) - const { t } = useTranslation() + const { t } = useAppTranslation() const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() const { debug } = useExtensionState() diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index e203711c25..9f1714c3c7 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,5 +1,5 @@ import { memo, useRef, useState, useMemo } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { ChevronUp, ChevronDown, @@ -64,7 +64,7 @@ const TaskHeader = ({ handleCondenseContext, todos, }: TaskHeaderProps) => { - const { t } = useTranslation() + const { t } = useAppTranslation() const { apiConfiguration, currentTaskItem, timeFormat, dateFormat } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) @@ -235,8 +235,8 @@ const TaskHeader = ({ {t("chat:tokenProgress.tokensUsedLabel")} - {formatLargeNumber(contextTokens || 0)} /{" "} - {formatLargeNumber(contextWindow)} + {formatLargeNumber(contextTokens || 0, t)} /{" "} + {formatLargeNumber(contextWindow, t)} {reservedForOutput > 0 && ( @@ -245,7 +245,7 @@ const TaskHeader = ({ {t("chat:tokenProgress.reservedForResponseLabel")} - {formatLargeNumber(reservedForOutput)} + {formatLargeNumber(reservedForOutput, t)} )} @@ -255,7 +255,7 @@ const TaskHeader = ({ {t("chat:tokenProgress.availableSpaceLabel")} - {formatLargeNumber(availableSpace)} + {formatLargeNumber(availableSpace, t)} )} @@ -397,10 +397,10 @@ const TaskHeader = ({
{typeof tokensIn === "number" && tokensIn > 0 && ( - ↑ {formatLargeNumber(tokensIn)} + ↑ {formatLargeNumber(tokensIn, t)} )} {typeof tokensOut === "number" && tokensOut > 0 && ( - ↓ {formatLargeNumber(tokensOut)} + ↓ {formatLargeNumber(tokensOut, t)} )}
@@ -417,13 +417,13 @@ const TaskHeader = ({ {typeof cacheWrites === "number" && cacheWrites > 0 && ( <> - {formatLargeNumber(cacheWrites)} + {formatLargeNumber(cacheWrites, t)} )} {typeof cacheReads === "number" && cacheReads > 0 && ( <> - {formatLargeNumber(cacheReads)} + {formatLargeNumber(cacheReads, t)} )}
diff --git a/webview-ui/src/components/chat/TodoChangeDisplay.tsx b/webview-ui/src/components/chat/TodoChangeDisplay.tsx index 3904cd9c9f..5157161fa7 100644 --- a/webview-ui/src/components/chat/TodoChangeDisplay.tsx +++ b/webview-ui/src/components/chat/TodoChangeDisplay.tsx @@ -1,4 +1,4 @@ -import { t } from "i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { ArrowRight, Check, ListChecks, SquareDashed } from "lucide-react" type TodoStatus = "completed" | "in_progress" | "pending" @@ -26,6 +26,7 @@ function getTodoIcon(status: TodoStatus | null) { } export function TodoChangeDisplay({ previousTodos, newTodos }: TodoChangeDisplayProps) { + const { t } = useAppTranslation() const isInitialState = previousTodos.length === 0 // Determine which todos to display diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index f2dbbc4d80..510b97074c 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -1,5 +1,5 @@ import { cn } from "@/lib/utils" -import { t } from "i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { ArrowRight, Check, ListChecks, SquareDashed } from "lucide-react" import { useState, useRef, useMemo, useEffect } from "react" @@ -18,6 +18,7 @@ function getTodoIcon(status: TodoStatus | null) { export function TodoListDisplay({ todos }: { todos: any[] }) { const [isCollapsed, setIsCollapsed] = useState(true) + const { t } = useAppTranslation() const ulRef = useRef(null) const itemRefs = useRef<(HTMLLIElement | null)[]>([]) const scrollIndex = useMemo(() => { diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.command-denied.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.command-denied.spec.tsx index 014f3a04c0..93b3b789a1 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.command-denied.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.command-denied.spec.tsx @@ -4,10 +4,12 @@ import { renderWithExtensionState, screen } from "@/utils/test-utils" import { ChatRowContent } from "../ChatRow" +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => <>{children || i18nKey}, - initReactI18next: { type: "3rdParty", init: () => {} }, })) vi.mock("../CommandExecution", () => ({ diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx index cbddb45417..2362c7beea 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx @@ -12,8 +12,8 @@ vi.mock("@src/utils/vscode", () => ({ })) // Mock i18n -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => { const map: Record = { "chat:fileOperations.wantsToEdit": "Roo wants to edit this file", @@ -24,8 +24,10 @@ vi.mock("react-i18next", () => ({ return map[key] || key }, }), +})) + +vi.mock("react-i18next", () => ({ Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, - initReactI18next: { type: "3rdParty", init: () => {} }, })) // Mock CodeBlock (avoid ESM/highlighter costs) diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx index 54193bd223..873d670a26 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx @@ -4,24 +4,24 @@ import { renderWithExtensionState, screen } from "@/utils/test-utils" import { ChatRowContent } from "../ChatRow" // Mock i18n -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => { const map: Record = { "chat:apiRequest.rateLimitWait": "Rate limiting", "chat:apiRequest.failed": "API Request Failed", "chat:apiRequest.errorTitle": "Provider Error", + "chat:apiRequest.errorMessage.401": "Couldn't authenticate with provider.", "chat:apiRequest.errorMessage.unknown": "Unknown API error. Please report this on GitHub.", "chat:apiRequest.errorMessage.docs": "Docs", } - return map[key] ?? key - }, - i18n: { - exists: () => false, + return map[key] ?? options?.defaultValue ?? key }, }), +})) + +vi.mock("react-i18next", () => ({ Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, - initReactI18next: { type: "3rdParty", init: () => {} }, })) function renderChatRow(message: any) { @@ -75,6 +75,20 @@ describe("ChatRow - rate limit wait", () => { expect(container.firstChild).toBeNull() }) + it("shows the message for a known API error code", () => { + const message: any = { + type: "say", + say: "api_req_retry_delayed", + ts: Date.now(), + text: "401 Unauthorized", + } + + renderChatRow(message) + + expect(screen.getByText("Couldn't authenticate with provider.")).toBeInTheDocument() + expect(screen.queryByText("Unknown API error. Please report this on GitHub.")).not.toBeInTheDocument() + }) + it("links unknown API errors to GitHub issues", () => { const message: any = { type: "say", diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx index a346e1b56c..d270e156c1 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx @@ -4,8 +4,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest" import { ChatRowContent } from "../ChatRow" // Mock i18n -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => { const translations: Record = { "chat:slashCommand.wantsToRun": "Roo wants to run slash command:", @@ -14,13 +14,12 @@ vi.mock("react-i18next", () => ({ return translations[key] || key }, }), +})) + +vi.mock("react-i18next", () => ({ Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { return <>{children || i18nKey} }, - initReactI18next: { - type: "3rdParty", - init: () => {}, - }, })) // Mock VSCodeBadge diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.subtask-links.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.subtask-links.spec.tsx index 79561189e1..49a7d7f2bc 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.subtask-links.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.subtask-links.spec.tsx @@ -10,8 +10,8 @@ vi.mock("@src/utils/vscode", () => ({ }, })) -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => { const map: Record = { "chat:subtasks.wantsToCreate": "Roo wants to create a new subtask", @@ -20,10 +20,11 @@ vi.mock("react-i18next", () => ({ } return map[key] ?? key }, - i18n: { exists: () => true }, }), +})) + +vi.mock("react-i18next", () => ({ Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, - initReactI18next: { type: "3rdParty", init: () => {} }, })) let mockCurrentTaskItem: Partial | undefined = undefined diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.timestamp.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.timestamp.spec.tsx index 2143905152..b7f620b171 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.timestamp.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.timestamp.spec.tsx @@ -6,10 +6,18 @@ import { ChatRowContent } from "../ChatRow" vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) +// Mutable so a test can switch the display language the timestamp is formatted in. +const mockTranslation = vi.hoisted(() => ({ language: "en" })) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + language: mockTranslation.language, + }), +})) + vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key, i18n: { exists: () => true } }), Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, - initReactI18next: { type: "3rdParty", init: () => {} }, })) // The edit box is a large component of its own. This stand-in shows the text it was given and offers the two ways @@ -236,10 +244,8 @@ describe("ChatRow – message timestamp", () => { expect(text).toContain("14:41") }) - it("writes the automatic date in the display language, not only in English", async () => { - const i18next = (await import("i18next")).default - const original = i18next.language - i18next.language = "de" + it("writes the automatic date in the display language, not only in English", () => { + mockTranslation.language = "de" try { mockState = { dateFormat: "auto", timeFormat: "24h" } renderRow(message) @@ -249,7 +255,7 @@ describe("ChatRow – message timestamp", () => { expect(text).toContain("14:41") expect(text).not.toContain("Mar") } finally { - i18next.language = original + mockTranslation.language = "en" } }) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.approval-tooltips.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.approval-tooltips.spec.tsx new file mode 100644 index 0000000000..dc0568af3a --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.approval-tooltips.spec.tsx @@ -0,0 +1,225 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.approval-tooltips.spec.tsx + +import React from "react" +import { screen, waitFor, act } from "@/utils/test-utils" +import { renderTranslated } from "@src/i18n/__tests__/renderTranslated" + +import i18next from "@src/i18n/setup" + +import ChatView, { ChatViewProps } from "../ChatView" + +interface ClineMessage { + type: "say" | "ask" + say?: string + ask?: string + ts: number + text?: string + partial?: boolean +} + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +vi.mock("../ChatRow", () => ({ + default: () => null, +})) + +vi.mock("../AutoApproveMenu", () => ({ + default: () => null, +})) + +vi.mock("react-virtuoso", () => ({ + Virtuoso: () => null, +})) + +vi.mock("../../common/VersionIndicator", () => ({ + default: vi.fn(() => null), +})) + +vi.mock("@src/components/welcome/RooTips", () => ({ + default: () => null, +})) + +vi.mock("@src/components/welcome/RooHero", () => ({ + default: () => null, +})) + +vi.mock("../../common/TelemetryBanner", () => ({ + default: () => null, +})) + +// Expose tooltip content on a wrapper so the key-to-tooltip mapping is observable without hover. +vi.mock("@src/components/ui", async (importOriginal) => ({ + ...(await importOriginal()), + StandardTooltip: ({ children, content }: { children: React.ReactNode; content?: React.ReactNode }) => ( + {children} + ), +})) + +const postState = (state: Record) => + act(async () => { + window.postMessage( + { + type: "state", + state: { + version: "1.0.0", + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + ...state, + }, + }, + "*", + ) + }) + +const defaultProps: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +const tooltipOf = (name: string) => + screen.getByRole("button", { name }).closest("[data-tooltip]")?.getAttribute("data-tooltip") + +const askMessages = (ask: string, text?: string): ClineMessage[] => [ + { type: "say", say: "task", ts: 1, text: "Initial task" }, + { type: "ask", ask, ts: 2, text, partial: false }, +] + +describe("ChatView approval button tooltips", () => { + beforeEach(async () => { + await i18next.changeLanguage("en") + }) + + it.each([ + { + ask: "api_req_failed", + primary: ["Retry", "Try the operation again"], + secondary: ["Start New Task", "Begin a new task"], + }, + { + ask: "mistake_limit_reached", + primary: ["Proceed Anyways", "Continue while command executes"], + secondary: ["Start New Task", "Begin a new task"], + }, + { + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", path: "a.ts" }), + primary: ["Save", "Save the message changes"], + secondary: ["Deny", "Prevent this action from occurring"], + }, + { + ask: "tool", + text: JSON.stringify({ tool: "editedExistingFile", batchDiffs: [] }), + primary: ["Save All", "Save All"], + secondary: ["Deny All", "Deny All"], + }, + { + ask: "tool", + text: JSON.stringify({ tool: "readFile", path: "a.ts" }), + primary: ["Approve", "Allow this action to happen"], + secondary: ["Deny", "Prevent this action from occurring"], + }, + { + ask: "tool", + text: JSON.stringify({ tool: "readFile", batchFiles: [] }), + primary: ["Approve All", "Approve All"], + secondary: ["Deny All", "Deny All"], + }, + { + ask: "tool", + text: JSON.stringify({ tool: "listFilesTopLevel", path: "src" }), + primary: ["Approve", "Allow this action to happen"], + secondary: ["Deny", "Prevent this action from occurring"], + }, + { + ask: "tool", + text: JSON.stringify({ tool: "listFilesRecursive", batchDirs: [] }), + primary: ["Approve All", "Approve All"], + secondary: ["Deny All", "Deny All"], + }, + { + ask: "tool", + text: JSON.stringify({ tool: "generateImage", path: "a.png" }), + primary: ["Save", "Save the message changes"], + secondary: ["Deny", "Prevent this action from occurring"], + }, + { + ask: "tool", + text: JSON.stringify({ tool: "finishTask" }), + primary: ["Complete subtask and keep going", "Complete subtask and keep going"], + }, + { + ask: "tool", + text: JSON.stringify({ tool: "searchFiles", path: "src" }), + primary: ["Approve", "Allow this action to happen"], + secondary: ["Deny", "Prevent this action from occurring"], + }, + { + ask: "use_mcp_server", + text: JSON.stringify({ type: "use_mcp_tool", serverName: "s", toolName: "t" }), + primary: ["Approve", "Allow this action to happen"], + secondary: ["Deny", "Prevent this action from occurring"], + }, + { + ask: "command", + text: "echo hi", + primary: ["Run", "Execute this command"], + secondary: ["Deny", "Prevent this action from occurring"], + }, + { + ask: "command_output", + primary: ["Continue While Running", "Keep going despite warnings"], + secondary: ["Kill Command", "Kill the current command"], + }, + { + ask: "resume_task", + primary: ["Continue", "Resume the current task"], + secondary: ["New task", "Start a new task"], + }, + { + ask: "completion_result", + primary: ["Start New Task", "Begin a new task"], + }, + { + ask: "resume_completed_task", + primary: ["Start New Task", "Begin a new task"], + }, + ])("maps the $ask $primary.0 prompt to its tooltips", async ({ ask, text, primary, secondary }) => { + renderTranslated() + + await postState({ clineMessages: askMessages(ask, text) }) + + await waitFor(() => expect(tooltipOf(primary[0])).toBe(primary[1])) + if (secondary) { + expect(tooltipOf(secondary[0])).toBe(secondary[1]) + } + }) + + it("offers a new task instead of resuming a completed subtask", async () => { + renderTranslated() + + await postState({ + currentTaskItem: { id: "child", parentTaskId: "parent", ts: 1, task: "Initial task", number: 1 }, + clineMessages: [ + { type: "say", say: "task", ts: 1, text: "Initial task" }, + { type: "say", say: "completion_result", ts: 2, text: "Done" }, + { type: "ask", ask: "resume_task", ts: 3, partial: false }, + ], + }) + + await waitFor(() => expect(tooltipOf("Start New Task")).toBe("Begin a new task")) + expect(screen.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx index 14ccce9751..03a2da93de 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx @@ -94,14 +94,13 @@ vi.mock("../common/TelemetryBanner", () => ({ })) // The i18n mock returns the key itself, so button labels render as their keys. -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key, }), - initReactI18next: { - type: "3rdParty", - init: () => {}, - }, +})) + +vi.mock("react-i18next", () => ({ Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => <>{children || i18nKey}, })) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx index 8f2a2c8fb2..c376924789 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx @@ -55,14 +55,13 @@ vi.mock("../common/TelemetryBanner", () => ({ })) // Mock i18n -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key, }), - initReactI18next: { - type: "3rdParty", - init: () => {}, - }, +})) + +vi.mock("react-i18next", () => ({ Trans: ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}, })) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.language-switch.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.language-switch.spec.tsx new file mode 100644 index 0000000000..de11e805b0 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.language-switch.spec.tsx @@ -0,0 +1,112 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.language-switch.spec.tsx + +import React from "react" +import { screen, waitFor, act } from "@/utils/test-utils" +import { renderTranslated } from "@src/i18n/__tests__/renderTranslated" + +import i18next from "@src/i18n/setup" + +import ChatView, { ChatViewProps } from "../ChatView" + +interface ClineMessage { + type: "say" | "ask" + say?: string + ask?: string + ts: number + text?: string + partial?: boolean +} + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => [vi.fn()]), +})) + +vi.mock("../ChatRow", () => ({ + default: () => null, +})) + +vi.mock("../AutoApproveMenu", () => ({ + default: () => null, +})) + +vi.mock("react-virtuoso", () => ({ + Virtuoso: () => null, +})) + +vi.mock("../../common/VersionIndicator", () => ({ + default: vi.fn(() => null), +})) + +vi.mock("@src/components/welcome/RooTips", () => ({ + default: () => null, +})) + +vi.mock("@src/components/welcome/RooHero", () => ({ + default: () => null, +})) + +vi.mock("../../common/TelemetryBanner", () => ({ + default: () => null, +})) + +const commandAsk: ClineMessage[] = [ + { type: "say", say: "task", ts: 1, text: "Initial task" }, + { type: "ask", ask: "command", ts: 2, text: "echo hi", partial: false }, +] + +const postState = (state: Record) => + act(async () => { + window.postMessage( + { + type: "state", + state: { + version: "1.0.0", + clineMessages: commandAsk, + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + ...state, + }, + }, + "*", + ) + }) + +const defaultProps: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +describe("ChatView approval buttons on language change", () => { + beforeEach(async () => { + await i18next.changeLanguage("en") + }) + + it("relabels an open prompt's buttons in the new language", async () => { + renderTranslated() + + await postState({ language: "en" }) + await waitFor(() => { + expect(screen.getByRole("button", { name: "Run" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Deny" })).toBeInTheDocument() + }) + + // Same messages, so only the language changes; the approval-label effect does not rerun. + await postState({ language: "es" }) + await waitFor(() => { + expect(screen.getByRole("button", { name: "Comando" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Rechazar" })).toBeInTheDocument() + }) + expect(screen.queryByRole("button", { name: "Run" })).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx index 162fc601d8..3c7a48211d 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx @@ -124,8 +124,8 @@ vi.mock("../common/TelemetryBanner", () => ({ })) // Mock i18n -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string, options?: any) => { if (key === "chat:versionIndicator.ariaLabel" && options?.version) { return `Version ${options.version}` @@ -133,10 +133,9 @@ vi.mock("react-i18next", () => ({ return key }, }), - initReactI18next: { - type: "3rdParty", - init: () => {}, - }, +})) + +vi.mock("react-i18next", () => ({ Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { return <>{children || i18nKey} }, diff --git a/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx index 99eedaf229..8776ebd2f8 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx @@ -119,8 +119,8 @@ vi.mock("../common/TelemetryBanner", () => ({ })) // Mock i18n -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string, options?: any) => { if (key === "chat:versionIndicator.ariaLabel" && options?.version) { return `Version ${options.version}` @@ -128,10 +128,9 @@ vi.mock("react-i18next", () => ({ return key }, }), - initReactI18next: { - type: "3rdParty", - init: () => {}, - }, +})) + +vi.mock("react-i18next", () => ({ Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { return <>{children || i18nKey} }, diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 42edc94b9f..165b33bca4 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -222,8 +222,8 @@ vi.mock("../common/TelemetryBanner", () => ({ })) // Mock i18n -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string, options?: any) => { if (key === "chat:versionIndicator.ariaLabel" && options?.version) { return `Version ${options.version}` @@ -231,10 +231,9 @@ vi.mock("react-i18next", () => ({ return key }, }), - initReactI18next: { - type: "3rdParty", - init: () => {}, - }, +})) + +vi.mock("react-i18next", () => ({ Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { return <>{children || i18nKey} }, diff --git a/webview-ui/src/components/chat/__tests__/CheckpointWarning.spec.tsx b/webview-ui/src/components/chat/__tests__/CheckpointWarning.spec.tsx new file mode 100644 index 0000000000..5be58328eb --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CheckpointWarning.spec.tsx @@ -0,0 +1,45 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/CheckpointWarning.spec.tsx + +import { fireEvent, screen } from "@testing-library/react" + +import i18next from "@src/i18n/setup" +import { renderTranslated } from "@src/i18n/__tests__/renderTranslated" + +import { CheckpointWarning } from "../CheckpointWarning" + +describe("CheckpointWarning", () => { + beforeEach(async () => { + await i18next.changeLanguage("en") + }) + + it.each([ + ["WAIT_TIMEOUT", "Waited 15 seconds for checkpoint initialization."], + ["INIT_TIMEOUT", "Checkpoint initialization has taken more than 15 seconds"], + ] as const)("renders the %s message with the timeout", (type, expected) => { + renderTranslated() + + expect(screen.getByText(expected, { exact: false })).toBeInTheDocument() + }) + + it("opens the checkpoint settings from the inline link", () => { + const postMessage = vi.spyOn(window, "postMessage") + renderTranslated() + + fireEvent.click(screen.getByText("the checkpoint settings")) + + expect(postMessage).toHaveBeenCalledWith( + { type: "action", action: "settingsButtonClicked", values: { section: "checkpoints" } }, + "*", + ) + postMessage.mockRestore() + }) + + it("follows a UI language change without remounting", async () => { + const { setLanguage } = renderTranslated() + + await setLanguage("es") + + expect(screen.getByText("Has esperado 15 segundos", { exact: false })).toBeInTheDocument() + expect(screen.getByText("la configuración del punto de control")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/CodebaseSearchResult.spec.tsx b/webview-ui/src/components/chat/__tests__/CodebaseSearchResult.spec.tsx index 3cebafa598..115d256c54 100644 --- a/webview-ui/src/components/chat/__tests__/CodebaseSearchResult.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CodebaseSearchResult.spec.tsx @@ -6,8 +6,8 @@ import { vscode } from "@/utils/vscode" vi.mock("@/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), })) describe("CodebaseSearchResult", () => { diff --git a/webview-ui/src/components/chat/__tests__/CodebaseSearchResultsDisplay.spec.tsx b/webview-ui/src/components/chat/__tests__/CodebaseSearchResultsDisplay.spec.tsx new file mode 100644 index 0000000000..8230a44e22 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CodebaseSearchResultsDisplay.spec.tsx @@ -0,0 +1,48 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/CodebaseSearchResultsDisplay.spec.tsx + +import { fireEvent, screen } from "@testing-library/react" + +import i18next from "@src/i18n/setup" +import { renderTranslated } from "@src/i18n/__tests__/renderTranslated" + +import CodebaseSearchResultsDisplay from "../CodebaseSearchResultsDisplay" + +vi.mock("../CodebaseSearchResult", () => ({ + default: ({ filePath }: { filePath: string }) =>
{filePath}
, +})) + +const makeResult = (filePath: string) => ({ filePath, score: 0.9, startLine: 1, endLine: 5, codeChunk: "code" }) + +describe("CodebaseSearchResultsDisplay", () => { + beforeEach(async () => { + await i18next.changeLanguage("en") + }) + + it.each([ + [[makeResult("src/a.ts")], "Found 1 result"], + [[makeResult("src/a.ts"), makeResult("src/b.ts")], "Found 2 results"], + ])("renders a pluralized summary for %#", (results, expected) => { + renderTranslated() + + expect(screen.getByText(expected)).toBeInTheDocument() + }) + + it("toggles the result list when the header is clicked", () => { + renderTranslated() + expect(screen.queryByTestId("search-result")).not.toBeInTheDocument() + + fireEvent.click(screen.getByText("Found 2 results")) + expect(screen.getAllByTestId("search-result").map((el) => el.textContent)).toEqual(["src/a.ts", "src/b.ts"]) + + fireEvent.click(screen.getByText("Found 2 results")) + expect(screen.queryByTestId("search-result")).not.toBeInTheDocument() + }) + + it("follows a UI language change without remounting", async () => { + const { setLanguage } = renderTranslated() + + await setLanguage("es") + + expect(screen.getByText("Se encontró 1 resultado")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index 9bd4b89b01..59a394e0ba 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -8,6 +8,7 @@ import i18next from "i18next" import { CommandExecution } from "../CommandExecution" import { ExtensionStateContext } from "../../../context/ExtensionStateContext" import { TooltipProvider } from "@src/components/ui/tooltip" +import { TranslationContext } from "@src/i18n/TranslationContext" import enChat from "../../../i18n/locales/en/chat.json" // Mock dependencies @@ -55,8 +56,11 @@ const mockExtensionState = { setDeniedCommands: vi.fn(), } +// The status dot renders a tooltip once its label resolves, so the wrapper supplies a TooltipProvider. const ExtensionStateWrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + {children} + ) describe("CommandExecution", () => { @@ -863,11 +867,13 @@ Output: const user = userEvent.setup() render( - - - - - , + + + + + + + , ) await act(async () => { diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx index 15bf24414f..431c5737a8 100644 --- a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -5,10 +5,13 @@ import { CommandPatternSelector } from "../CommandPatternSelector" import { TooltipProvider } from "../../../components/ui/tooltip" // Mock react-i18next -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key, }), +})) + +vi.mock("react-i18next", () => ({ Trans: ({ i18nKey, children }: any) => {i18nKey || children}, })) diff --git a/webview-ui/src/components/chat/__tests__/ContextMenu.spec.tsx b/webview-ui/src/components/chat/__tests__/ContextMenu.spec.tsx new file mode 100644 index 0000000000..514622ac0f --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ContextMenu.spec.tsx @@ -0,0 +1,120 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ContextMenu.spec.tsx + +import { fireEvent, screen } from "@testing-library/react" + +import type { Command } from "@roo-code/types" + +import i18next from "@src/i18n/setup" +import { renderTranslated } from "@src/i18n/__tests__/renderTranslated" +import { ContextMenuOptionType } from "@src/utils/context-mentions" +import { vscode } from "@src/utils/vscode" + +import ContextMenu from "../ContextMenu" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { postMessage: vi.fn() }, +})) + +const commands: Command[] = [{ name: "review", source: "project", description: "Review the diff" }] + +const renderMenu = (props: Partial> = {}) => { + const onSelect = vi.fn() + const setSelectedIndex = vi.fn() + const { setLanguage } = renderTranslated( + {}} + selectedIndex={0} + setSelectedIndex={setSelectedIndex} + selectedType={null} + queryItems={[]} + {...props} + />, + ) + return { onSelect, setSelectedIndex, setLanguage } +} + +describe("ContextMenu", () => { + beforeEach(async () => { + vi.mocked(vscode.postMessage).mockClear() + await i18next.changeLanguage("en") + }) + + it("renders the default mention options with translated labels", () => { + renderMenu() + + expect(screen.getByText("Problems")).toBeInTheDocument() + expect(screen.getByText("Terminal")).toBeInTheDocument() + expect(screen.getByText("Paste URL to fetch contents")).toBeInTheDocument() + expect(screen.getByText("Add Folder")).toBeInTheDocument() + expect(screen.getByText("Add File")).toBeInTheDocument() + expect(screen.getByText("Git Commits")).toBeInTheDocument() + }) + + it("selects selectable options and ignores the URL hint", () => { + const { onSelect, setSelectedIndex } = renderMenu() + + fireEvent.mouseEnter(screen.getByText("Terminal")) + fireEvent.click(screen.getByText("Terminal")) + fireEvent.click(screen.getByText("Paste URL to fetch contents")) + + expect(setSelectedIndex).toHaveBeenCalledWith(1) + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(ContextMenuOptionType.Terminal, undefined) + }) + + it("renders file paths split into name and folder", () => { + renderMenu({ + selectedType: ContextMenuOptionType.File, + queryItems: [{ type: ContextMenuOptionType.File, value: "/src/components/App.tsx" }], + }) + + expect(screen.getByText("App.tsx")).toBeInTheDocument() + expect(screen.getByText("src/components")).toBeInTheDocument() + }) + + it("shows the no-results row when nothing matches", () => { + renderMenu({ selectedType: ContextMenuOptionType.Folder }) + + expect(screen.getByText("No results")).toBeInTheDocument() + }) + + it("renders slash commands with the description and a settings shortcut", () => { + const { onSelect } = renderMenu({ searchQuery: "/", commands }) + + expect(screen.getByText("/review")).toBeInTheDocument() + expect(screen.getByText("Review the diff")).toBeInTheDocument() + expect(screen.getByText(/Slash commands are shortcuts/)).toBeInTheDocument() + expect(screen.getByRole("link", { name: "Learn more" })).toBeInTheDocument() + + fireEvent.click(screen.getByText("/review")) + expect(onSelect).toHaveBeenCalledWith(ContextMenuOptionType.Command, "review") + + fireEvent.click(screen.getByTitle("Manage slash commands in settings")) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "switchTab", + tab: "settings", + values: { section: "slashCommands" }, + }) + }) + + it("follows a UI language change without remounting", async () => { + const { setLanguage } = renderMenu({ searchQuery: "/", commands }) + + await setLanguage("es") + + expect(screen.getByText(/Gestiona tus comandos de barra/)).toBeInTheDocument() + expect(screen.getByTitle("Administrar comandos de barra en la configuración")).toBeInTheDocument() + }) + + it("relabels the default options after a language change", async () => { + const { setLanguage } = renderMenu() + + await setLanguage("es") + + expect(screen.getByText("Problemas")).toBeInTheDocument() + expect(screen.getByText("Pega la URL para obtener el contenido")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ErrorRow.spec.tsx b/webview-ui/src/components/chat/__tests__/ErrorRow.spec.tsx index d60cf9c272..22a9edb293 100644 --- a/webview-ui/src/components/chat/__tests__/ErrorRow.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ErrorRow.spec.tsx @@ -29,8 +29,8 @@ vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ })) // Mock i18n -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => { const map: Record = { "chat:error": "Error", @@ -42,10 +42,6 @@ vi.mock("react-i18next", () => ({ return map[key] ?? key }, }), - initReactI18next: { - type: "3rdParty", - init: vi.fn(), - }, })) describe("ErrorRow diagnostics download", () => { diff --git a/webview-ui/src/components/chat/__tests__/IgnoredDisabledToolsNotice.spec.tsx b/webview-ui/src/components/chat/__tests__/IgnoredDisabledToolsNotice.spec.tsx index 4de917c6d6..25d9ba0f92 100644 --- a/webview-ui/src/components/chat/__tests__/IgnoredDisabledToolsNotice.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/IgnoredDisabledToolsNotice.spec.tsx @@ -5,8 +5,8 @@ import type { ClineMessage } from "@roo-code/types" import { ChatRowContent } from "../ChatRow" -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string, options?: Record) => { const map: Record = { "chat:ignoredDisabledTools.title": "Disabled tool ignored", @@ -17,12 +17,11 @@ vi.mock("react-i18next", () => ({ if (!options) return template return template.replace("{{tools}}", String(options.tools)) }, - i18n: { - exists: () => false, - }, }), +})) + +vi.mock("react-i18next", () => ({ Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, - initReactI18next: { type: "3rdParty", init: () => {} }, })) function renderChatRow(message: ClineMessage) { diff --git a/webview-ui/src/components/chat/__tests__/SeeNewChangesButtons.spec.tsx b/webview-ui/src/components/chat/__tests__/SeeNewChangesButtons.spec.tsx index df276d5d8f..016e746530 100644 --- a/webview-ui/src/components/chat/__tests__/SeeNewChangesButtons.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/SeeNewChangesButtons.spec.tsx @@ -11,8 +11,8 @@ vi.mock("@src/utils/vscode", () => ({ }, })) -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key, }), })) diff --git a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx index 2e4be8d561..d4602a82f1 100644 --- a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx @@ -26,8 +26,8 @@ vi.mock("@/utils/clipboard", () => ({ useCopyToClipboard: vi.fn(), })) -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => { const translations: Record = { "chat:task.export": "Export task history", @@ -40,10 +40,6 @@ vi.mock("react-i18next", () => ({ return translations[key] || key }, }), - initReactI18next: { - type: "3rdParty", - init: vi.fn(), - }, })) vi.mock("pretty-bytes", () => ({ diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 4065f4a80c..be780bfe0a 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -9,10 +9,13 @@ import type { ProviderSettings } from "@roo-code/types" import TaskHeader, { TaskHeaderProps } from "../TaskHeader" // Mock i18n -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key, // Simple mock that returns the key }), +})) + +vi.mock("react-i18next", () => ({ // Mock initReactI18next to prevent initialization errors in tests initReactI18next: { type: "3rdParty", diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx index 3c15bc4d87..be07c4ce90 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from "react" import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { Button, Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" import { useRooPortal } from "@/components/ui/hooks" @@ -29,7 +29,7 @@ export const CheckpointMenu = ({ onOpenChange, onJumpToPreviousCheckpoint, }: CheckpointMenuProps) => { - const { t } = useTranslation() + const { t } = useAppTranslation() const [internalRestoreOpen, setInternalRestoreOpen] = useState(false) const [restoreConfirming, setRestoreConfirming] = useState(false) const [internalMoreOpen, setInternalMoreOpen] = useState(false) diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx index 8328b9ce1e..c080709836 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState, useEffect } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { cn } from "@/lib/utils" import { CheckpointMenu } from "./CheckpointMenu" @@ -20,7 +20,7 @@ export const CheckpointSaved = ({ onJumpToPreviousCheckpoint, ...props }: CheckpointSavedProps) => { - const { t } = useTranslation() + const { t } = useAppTranslation() const isCurrent = currentHash === props.commitHash const [isPopoverOpen, setIsPopoverOpen] = useState(false) const [isClosing, setIsClosing] = useState(false) diff --git a/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx b/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx index 62d2d8fd10..47392d8802 100644 --- a/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx +++ b/webview-ui/src/components/chat/context-management/CondensationErrorRow.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" interface CondensationErrorRowProps { errorText?: string @@ -9,7 +9,7 @@ interface CondensationErrorRowProps { * Shows a warning icon with the error header and optional error details. */ export function CondensationErrorRow({ errorText }: CondensationErrorRowProps) { - const { t } = useTranslation() + const { t } = useAppTranslation() return (
diff --git a/webview-ui/src/components/chat/context-management/CondensationResultRow.tsx b/webview-ui/src/components/chat/context-management/CondensationResultRow.tsx index e9a9cfdf09..8879e5bbf3 100644 --- a/webview-ui/src/components/chat/context-management/CondensationResultRow.tsx +++ b/webview-ui/src/components/chat/context-management/CondensationResultRow.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" import { ListChevronsDownUp } from "lucide-react" @@ -16,7 +16,7 @@ interface CondensationResultRowProps { * Shows token reduction, cost, and an expandable summary section. */ export function CondensationResultRow({ data }: CondensationResultRowProps) { - const { t } = useTranslation() + const { t } = useAppTranslation() const [isExpanded, setIsExpanded] = useState(false) const { cost, prevContextTokens, newContextTokens, summary } = data diff --git a/webview-ui/src/components/chat/context-management/InProgressRow.tsx b/webview-ui/src/components/chat/context-management/InProgressRow.tsx index fbe260d9b6..19dfa056e9 100644 --- a/webview-ui/src/components/chat/context-management/InProgressRow.tsx +++ b/webview-ui/src/components/chat/context-management/InProgressRow.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { ProgressIndicator } from "../ProgressIndicator" @@ -11,7 +11,7 @@ interface InProgressRowProps { * Shows a spinner with operation-specific text based on the event type. */ export function InProgressRow({ eventType }: InProgressRowProps) { - const { t } = useTranslation() + const { t } = useAppTranslation() const progressText = eventType === "condense_context" diff --git a/webview-ui/src/components/chat/context-management/TruncationResultRow.tsx b/webview-ui/src/components/chat/context-management/TruncationResultRow.tsx index 625c850161..19de8339d2 100644 --- a/webview-ui/src/components/chat/context-management/TruncationResultRow.tsx +++ b/webview-ui/src/components/chat/context-management/TruncationResultRow.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { ScissorsLineDashed } from "lucide-react" import type { ContextTruncation } from "@roo-code/types" @@ -17,7 +17,7 @@ interface TruncationResultRowProps { * were previously silent, addressing a gap in the context management UI. */ export function TruncationResultRow({ data }: TruncationResultRowProps) { - const { t } = useTranslation() + const { t } = useAppTranslation() const [isExpanded, setIsExpanded] = useState(false) const { messagesRemoved, prevContextTokens, newContextTokens } = data diff --git a/webview-ui/src/components/common/TelemetryBanner.tsx b/webview-ui/src/components/common/TelemetryBanner.tsx index 6ed058d8bf..7d47408eb7 100644 --- a/webview-ui/src/components/common/TelemetryBanner.tsx +++ b/webview-ui/src/components/common/TelemetryBanner.tsx @@ -31,6 +31,7 @@ const TelemetryBanner = () => {
{t("welcome:telemetry.helpImprove")}
, diff --git a/webview-ui/src/components/common/VersionIndicator.tsx b/webview-ui/src/components/common/VersionIndicator.tsx index ac4a1c5146..744f4d27df 100644 --- a/webview-ui/src/components/common/VersionIndicator.tsx +++ b/webview-ui/src/components/common/VersionIndicator.tsx @@ -1,5 +1,5 @@ import React from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { Package } from "@roo/package" interface VersionIndicatorProps { @@ -8,7 +8,7 @@ interface VersionIndicatorProps { } const VersionIndicator: React.FC = ({ onClick, className = "" }) => { - const { t } = useTranslation() + const { t } = useAppTranslation() return (
- + { />
{

{t("prompts:globalCustomInstructions.title")}

- + { />

, @@ -160,6 +161,7 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug, , diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 6e22859618..a3aeb1ddde 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -308,11 +308,12 @@ const ApiOptions = ({ const apiValidationResult = validateApiConfigurationExcludingModelErrors( apiConfiguration, + t, routerModels, organizationAllowList, ) setErrorMessage(apiValidationResult) - }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage, isRetiredSelectedProvider]) + }, [apiConfiguration, t, routerModels, organizationAllowList, setErrorMessage, isRetiredSelectedProvider]) const onProviderChange = useCallback( (value: ProviderName) => { @@ -378,8 +379,8 @@ const ApiOptions = ({ ) const modelValidationError = useMemo(() => { - return getModelValidationError(apiConfiguration, routerModels, organizationAllowList) - }, [apiConfiguration, routerModels, organizationAllowList]) + return getModelValidationError(apiConfiguration, t, routerModels, organizationAllowList) + }, [apiConfiguration, t, routerModels, organizationAllowList]) const docs = useMemo(() => { const provider = PROVIDERS.find(({ value }) => value === selectedProvider) diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 58034960fb..2930d975a4 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -145,6 +145,7 @@ export const AutoApproveSettings = ({

{t("settings:autoApprove.description")}

{t("settings:checkpoints.enable.label")}

- + diff --git a/webview-ui/src/components/settings/LanguageSettings.tsx b/webview-ui/src/components/settings/LanguageSettings.tsx index c5dc5fa918..2e929da55b 100644 --- a/webview-ui/src/components/settings/LanguageSettings.tsx +++ b/webview-ui/src/components/settings/LanguageSettings.tsx @@ -3,7 +3,6 @@ import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import { useAppTranslation } from "@/i18n/TranslationContext" - import type { Language } from "@roo-code/types" import { LANGUAGES } from "@roo/language" @@ -45,9 +44,9 @@ export const LanguageSettings = ({ // Three short Intl calls, computed on every render. They use the language selected above, not the // saved one, so "auto" previews the new language before Save. const preview = { - chatTime: formatTimeOnly(SAMPLE_TS, timeFormat, language), - dateExample: formatDateOnly(SAMPLE_TS, dateFormat, language), - hover: formatDateTime(SAMPLE_TS, timeFormat, dateFormat, language), + chatTime: formatTimeOnly(SAMPLE_TS, language, timeFormat), + dateExample: formatDateOnly(SAMPLE_TS, language, dateFormat), + hover: formatDateTime(SAMPLE_TS, language, timeFormat, dateFormat), } return ( diff --git a/webview-ui/src/components/settings/MaxCostInput.tsx b/webview-ui/src/components/settings/MaxCostInput.tsx index 177ece5a1d..f77ca2773c 100644 --- a/webview-ui/src/components/settings/MaxCostInput.tsx +++ b/webview-ui/src/components/settings/MaxCostInput.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { FormattedTextField, unlimitedDecimalFormatter } from "../common/FormattedTextField" @@ -8,7 +8,7 @@ interface MaxCostInputProps { } export function MaxCostInput({ allowedMaxCost, onValueChange }: MaxCostInputProps) { - const { t } = useTranslation() + const { t } = useAppTranslation() return ( <> diff --git a/webview-ui/src/components/settings/MaxLimitInputs.tsx b/webview-ui/src/components/settings/MaxLimitInputs.tsx index c09d886eb3..4ed9a22519 100644 --- a/webview-ui/src/components/settings/MaxLimitInputs.tsx +++ b/webview-ui/src/components/settings/MaxLimitInputs.tsx @@ -1,5 +1,5 @@ import React from "react" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { MaxRequestsInput } from "./MaxRequestsInput" import { MaxCostInput } from "./MaxCostInput" @@ -16,7 +16,7 @@ export const MaxLimitInputs: React.FC = ({ onMaxRequestsChange, onMaxCostChange, }) => { - const { t } = useTranslation() + const { t } = useAppTranslation() return (
diff --git a/webview-ui/src/components/settings/MaxRequestsInput.tsx b/webview-ui/src/components/settings/MaxRequestsInput.tsx index 72dcad5123..1071dd5bd7 100644 --- a/webview-ui/src/components/settings/MaxRequestsInput.tsx +++ b/webview-ui/src/components/settings/MaxRequestsInput.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { FormattedTextField, unlimitedIntegerFormatter } from "../common/FormattedTextField" @@ -8,7 +8,7 @@ interface MaxRequestsInputProps { } export function MaxRequestsInput({ allowedMaxRequests, onValueChange }: MaxRequestsInputProps) { - const { t } = useTranslation() + const { t } = useAppTranslation() return ( <> diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index ecc3678a97..f8043827c7 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -3,6 +3,7 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { Trans } from "react-i18next" import { ChevronsUpDown, Check, X, Info } from "lucide-react" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { type ProviderSettings, type ModelInfo, @@ -11,7 +12,6 @@ import { providerIdentifiers, } from "@roo-code/types" -import { useAppTranslation } from "@src/i18n/TranslationContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" import { filterModels } from "./utils/organizationFilters" import { cn } from "@src/lib/utils" @@ -316,6 +316,7 @@ export const ModelPicker = ({ {!hidePricing && apiConfiguration.apiProvider !== providerIdentifiers.mimo && (
, diff --git a/webview-ui/src/components/settings/SkillsSettings.tsx b/webview-ui/src/components/settings/SkillsSettings.tsx index bf81d0c6c7..b6de39df5c 100644 --- a/webview-ui/src/components/settings/SkillsSettings.tsx +++ b/webview-ui/src/components/settings/SkillsSettings.tsx @@ -2,11 +2,11 @@ import React, { useState, useEffect, useMemo, useCallback } from "react" import { Plus, Globe, Folder, Edit, Trash2, Settings } from "lucide-react" import { Trans } from "react-i18next" +import { useAppTranslation } from "@/i18n/TranslationContext" import type { SkillMetadata } from "@roo-code/types" import { getAllModes } from "@roo/modes" -import { useAppTranslation } from "@/i18n/TranslationContext" import { useExtensionState } from "@/context/ExtensionStateContext" import { AlertDialog, @@ -218,6 +218,7 @@ export const SkillsSettings: React.FC = () => {

{ {/* Fixed Footer */}

{

- + - + {t("settings:terminal.inheritEnv.label")}
- +
- + {terminalCommandDelay ?? 0}ms
- +
- +
- + {t("settings:terminal.zshOhMy.label")}
- + {t("settings:terminal.zshP10k.label")}
- + {t("settings:terminal.zdotdir.label")}
- + ({ vscode: { postMessage: vi.fn() }, })) -vi.mock("react-i18next", () => ({ - useTranslation: () => { +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => { const translations: Record = { "settings:autoApprove.apiCostLimit.title": "Max API Cost", "settings:autoApprove.apiCostLimit.unlimited": "Unlimited", diff --git a/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx b/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx index 59b495bcd9..ec4540cd02 100644 --- a/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx @@ -6,8 +6,8 @@ vi.mock("@/utils/vscode", () => ({ vscode: { postMessage: vi.fn() }, })) -vi.mock("react-i18next", () => ({ - useTranslation: () => { +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => { const translations: Record = { "settings:autoApprove.apiRequestLimit.title": "Max Count", "settings:autoApprove.apiRequestLimit.unlimited": "Unlimited", diff --git a/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx index b007d4be69..57d9d8fd0e 100644 --- a/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx @@ -4,6 +4,7 @@ import { OpenAiServiceTier, type ModelInfo } from "@roo-code/types/model" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { TranslationContext } from "@src/i18n/TranslationContext" +import { createFixtureTranslation } from "@src/i18n/__tests__/createFixtureI18n.visual.i18n" import { ModelInfoView } from "../ModelInfoView" const modelInfo: ModelInfo = { @@ -49,12 +50,10 @@ const translations: Record = { "settings:serviceTier.priority": "Priority", } +const translation = createFixtureTranslation(translations) + export const ModelInfoViewFixture = () => ( - translations[key] ?? key, - i18n: null as unknown as typeof import("../../../i18n/setup").default, - }}> +
{ const { awsCustomArn, awsRegion } = apiConfiguration - return awsCustomArn ? validateBedrockArn(awsCustomArn, awsRegion) : { isValid: true, errorMessage: undefined } - }, [apiConfiguration]) + return awsCustomArn + ? validateBedrockArn(awsCustomArn, t, awsRegion) + : { isValid: true, errorMessage: undefined } + }, [apiConfiguration, t]) const baseModelId = resolveBedrockCustomArnBaseModelId(apiConfiguration.awsCustomArn, apiConfiguration.awsCustomArnBaseModelId) ?? diff --git a/webview-ui/src/components/settings/providers/LMStudio.tsx b/webview-ui/src/components/settings/providers/LMStudio.tsx index c83aae63ef..25f2594486 100644 --- a/webview-ui/src/components/settings/providers/LMStudio.tsx +++ b/webview-ui/src/components/settings/providers/LMStudio.tsx @@ -4,6 +4,7 @@ import { Trans } from "react-i18next" import { Checkbox } from "vscrui" import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { type ProviderSettings, type ExtensionMessage, @@ -12,7 +13,6 @@ import { providerIdentifiers, } from "@roo-code/types" -import { useAppTranslation } from "@src/i18n/TranslationContext" import { requestLmStudioModels } from "@src/components/ui/hooks/useLmStudioModels" import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" @@ -158,6 +158,7 @@ export const LMStudio = ({ apiConfiguration, setApiConfigurationField }: LMStudi )}
, diff --git a/webview-ui/src/components/settings/providers/Vertex.tsx b/webview-ui/src/components/settings/providers/Vertex.tsx index 3953f2322e..a8c8167367 100644 --- a/webview-ui/src/components/settings/providers/Vertex.tsx +++ b/webview-ui/src/components/settings/providers/Vertex.tsx @@ -3,9 +3,9 @@ import { Trans } from "react-i18next" import { Checkbox } from "vscrui" import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { type ProviderSettings, VERTEX_REGIONS, VERTEX_1M_CONTEXT_MODEL_IDS, looksLikeFilePath } from "@roo-code/types" -import { useAppTranslation } from "@src/i18n/TranslationContext" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" import { inputEventTransform } from "../transforms" @@ -80,6 +80,7 @@ export const Vertex = ({ apiConfiguration, setApiConfigurationField }: VertexPro role="status" className="text-sm text-vscode-errorForeground"> , diff --git a/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx index 49cfdeb783..36e2d90643 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx @@ -1,12 +1,11 @@ /* v8 ignore file -- Playwright component fixture is covered by the visual test. */ import React, { useState } from "react" -import i18next from "i18next" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" -import { TranslationContext as AppTranslationContext } from "@/i18n/TranslationContext" -import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext" +import { TranslationContext } from "@src/i18n/TranslationContext" +import { createFixtureTranslation } from "@src/i18n/__tests__/createFixtureI18n.visual.i18n" import { TooltipProvider } from "@src/components/ui/tooltip" import { Ollama } from "../Ollama" import enSettings from "@/i18n/locales/en/settings.json" @@ -24,10 +23,7 @@ function flattenTranslations(obj: Record, prefix = "settings:") return result } -// An uninitialized instance is a valid `i18n` for the context. The stories read `t`. -const i18n = i18next.createInstance() - -const translations = flattenTranslations(enSettings as Record) +const translation = createFixtureTranslation(flattenTranslations(enSettings as Record)) const apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.ollama, @@ -45,19 +41,9 @@ const Providers = ({ children }: React.PropsWithChildren) => { ) return ( - translations[key] ?? key, - i18n, - }}> - translations[key] ?? key, - i18n, - }}> - {children} - - + + {children} + ) } diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx index 2061f00614..114ce05c54 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx @@ -4,6 +4,7 @@ import React from "react" import { OpenAiCodexServiceTier } from "@roo-code/types/model" import { TranslationContext } from "@src/i18n/TranslationContext" +import { createFixtureTranslation } from "@src/i18n/__tests__/createFixtureI18n.visual.i18n" import { TooltipProvider } from "@src/components/ui/tooltip" import { OpenAICodexSpeedSelector } from "../OpenAICodexSpeedSelector" @@ -16,12 +17,10 @@ const translations: Record = { "settings:openAiCodexSpeed.fast": "Fast (1.5x speed, increased usage)", } +const translation = createFixtureTranslation(translations) + export const OpenAICodexFixture = () => ( - translations[key] ?? key, - i18n: null as unknown as typeof import("../../../../i18n/setup").default, - }}> +
{}} /> diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx index 9e0aeb71bc..0fe06a0364 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx @@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" -import { TranslationContext as AppTranslationContext } from "@/i18n/TranslationContext" -import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext" +import { TranslationContext } from "@src/i18n/TranslationContext" +import { createFixtureTranslation } from "@src/i18n/__tests__/createFixtureI18n.visual.i18n" import { TooltipProvider } from "@src/components/ui/tooltip" import { OpenAICompatible } from "../OpenAICompatible" import enSettings from "@/i18n/locales/en/settings.json" @@ -23,7 +23,7 @@ function flattenTranslations(obj: Record, prefix = "settings:") return result } -const translations = flattenTranslations(enSettings as Record) +const translation = createFixtureTranslation(flattenTranslations(enSettings as Record)) const apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.openai, @@ -48,19 +48,9 @@ const Providers = ({ children }: React.PropsWithChildren) => { ) return ( - translations[key] ?? key, - i18n: null as unknown as typeof import("../../../../i18n/setup").default, - }}> - translations[key] ?? key, - i18n: null as unknown as typeof import("../../../../i18n/setup").default, - }}> - {children} - - + + {children} + ) } diff --git a/webview-ui/src/components/ui/select-dropdown.tsx b/webview-ui/src/components/ui/select-dropdown.tsx index 288d81b16f..836605a03c 100644 --- a/webview-ui/src/components/ui/select-dropdown.tsx +++ b/webview-ui/src/components/ui/select-dropdown.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { CaretUpIcon } from "@radix-ui/react-icons" import { Check, X } from "lucide-react" import { Fzf } from "fzf" -import { useTranslation } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { cn } from "@/lib/utils" import { useRooPortal } from "./hooks/useRooPortal" @@ -62,7 +62,7 @@ export const SelectDropdown = React.memo( }, ref, ) => { - const { t } = useTranslation() + const { t } = useAppTranslation() const [open, setOpen] = React.useState(false) const [searchValue, setSearchValue] = React.useState("") const searchInputRef = React.useRef(null) diff --git a/webview-ui/src/components/welcome/RooTips.tsx b/webview-ui/src/components/welcome/RooTips.tsx index b180a53c26..d726647722 100644 --- a/webview-ui/src/components/welcome/RooTips.tsx +++ b/webview-ui/src/components/welcome/RooTips.tsx @@ -1,6 +1,6 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" -import { useTranslation } from "react-i18next" import { Trans } from "react-i18next" +import { useAppTranslation } from "@src/i18n/TranslationContext" import { buildDocLink } from "@src/utils/docLinks" import { ReplaceAll, Users } from "lucide-react" @@ -9,24 +9,24 @@ const tips = [ { icon: , href: buildDocLink("basic-usage/using-modes", "tips"), - titleKey: "rooTips.customizableModes.title", - descriptionKey: "rooTips.customizableModes.description", + titleKey: "chat:rooTips.customizableModes.title", + descriptionKey: "chat:rooTips.customizableModes.description", }, { icon: , href: buildDocLink("getting-started/connecting-api-provider", "tips"), - titleKey: "rooTips.modelAgnostic.title", - descriptionKey: "rooTips.modelAgnostic.description", + titleKey: "chat:rooTips.modelAgnostic.title", + descriptionKey: "chat:rooTips.modelAgnostic.description", }, ] const RooTips = () => { - const { t } = useTranslation("chat") + const { t } = useAppTranslation() return (

- +

{tips.map((tip) => ( @@ -43,6 +43,7 @@ const RooTips = () => {

- +

diff --git a/webview-ui/src/components/welcome/WelcomeViewProvider.tsx b/webview-ui/src/components/welcome/WelcomeViewProvider.tsx index b038146fd9..cffd4fe91e 100644 --- a/webview-ui/src/components/welcome/WelcomeViewProvider.tsx +++ b/webview-ui/src/components/welcome/WelcomeViewProvider.tsx @@ -8,6 +8,7 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" import { validateApiConfiguration } from "@src/utils/validate" import { vscode } from "@src/utils/vscode" import { useAppTranslation } from "@src/i18n/TranslationContext" +import type { TFunction } from "i18next" import { Button } from "@src/components/ui" import ApiOptions from "../settings/ApiOptions" @@ -21,6 +22,7 @@ const DEFAULT_WELCOME_API_CONFIGURATION: ProviderSettings = { } const getWelcomeApiConfiguration = ( + t: TFunction, apiConfiguration: ProviderSettings, zooCodeIsAuthenticated?: boolean, ): ProviderSettings => { @@ -30,7 +32,7 @@ const getWelcomeApiConfiguration = ( return DEFAULT_WELCOME_API_CONFIGURATION } - const validationError = validateApiConfiguration(apiConfiguration, undefined, undefined, zooCodeIsAuthenticated) + const validationError = validateApiConfiguration(apiConfiguration, t, undefined, undefined, zooCodeIsAuthenticated) if (validationError) { return DEFAULT_WELCOME_API_CONFIGURATION } @@ -46,7 +48,7 @@ const WelcomeViewProvider = () => { const [showProviderSetup, setShowProviderSetup] = useState(false) const [welcomeApiConfiguration, setWelcomeApiConfiguration] = useState() const effectiveApiConfiguration = - welcomeApiConfiguration ?? getWelcomeApiConfiguration(apiConfiguration, zooCodeIsAuthenticated) + welcomeApiConfiguration ?? getWelcomeApiConfiguration(t, apiConfiguration, zooCodeIsAuthenticated) const setApiConfigurationFieldForApiOptions = useCallback( (field: K, value: ProviderSettings[K]) => { @@ -61,7 +63,7 @@ const WelcomeViewProvider = () => { const handleGetStarted = useCallback(() => { if (!showProviderSetup) { - const initialApiConfiguration = getWelcomeApiConfiguration(apiConfiguration, zooCodeIsAuthenticated) + const initialApiConfiguration = getWelcomeApiConfiguration(t, apiConfiguration, zooCodeIsAuthenticated) setWelcomeApiConfiguration(initialApiConfiguration) setApiConfiguration(initialApiConfiguration) @@ -70,7 +72,13 @@ const WelcomeViewProvider = () => { return } - const error = validateApiConfiguration(effectiveApiConfiguration, undefined, undefined, zooCodeIsAuthenticated) + const error = validateApiConfiguration( + effectiveApiConfiguration, + t, + undefined, + undefined, + zooCodeIsAuthenticated, + ) if (error) { setErrorMessage(error) @@ -90,6 +98,7 @@ const WelcomeViewProvider = () => { effectiveApiConfiguration, currentApiConfigName, zooCodeIsAuthenticated, + t, ]) if (!showProviderSetup) { @@ -108,7 +117,7 @@ const WelcomeViewProvider = () => {

{t("welcome:providerSignup.heading")}

- +

diff --git a/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx b/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx index eb121aaadf..a52ac35a41 100644 --- a/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx +++ b/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx @@ -3,10 +3,13 @@ import { render, screen } from "@/utils/test-utils" import RooTips from "../RooTips" -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key, // Simple mock that returns the key }), +})) + +vi.mock("react-i18next", () => ({ Trans: ({ children, components, diff --git a/webview-ui/src/i18n/TranslationContext.tsx b/webview-ui/src/i18n/TranslationContext.tsx index 978c77018a..5ec39a6c4b 100644 --- a/webview-ui/src/i18n/TranslationContext.tsx +++ b/webview-ui/src/i18n/TranslationContext.tsx @@ -1,15 +1,27 @@ -import React, { createContext, useContext, ReactNode, useEffect, useCallback } from "react" +import React, { + createContext, + useContext, + ReactNode, + useEffect, + useCallback, + useMemo, + useSyncExternalStore, +} from "react" import { useTranslation } from "react-i18next" -import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "./setup" +import type { TFunction } from "i18next" +import { FALLBACK_LANGUAGE, loadLanguage } from "./setup" import { useExtensionState } from "@/context/ExtensionStateContext" -// Create context for translations -export const TranslationContext = createContext<{ - t: (key: string, options?: Record) => string - i18n: typeof i18next -}>({ - t: (key: string) => key, - i18n: i18next, +export type TranslationContextValue = { + t: TFunction + /** The language i18next is rendering, which trails the requested one until its bundles load. */ + language: string +} + +// Create context for translations. Without a provider (isolated component tests) keys render as-is. +export const TranslationContext = createContext({ + t: ((key: string) => key) as TFunction, + language: FALLBACK_LANGUAGE, }) // Translation provider component @@ -18,47 +30,45 @@ const TranslationProvider: React.FC<{ children: ReactNode }> = ({ children }) => const { i18n } = useTranslation() // Get the extension state directly - it already contains all state properties const extensionState = useExtensionState() - const language = extensionState.language || FALLBACK_LANGUAGE + const requestedLanguage = extensionState.language || FALLBACK_LANGUAGE // Only the fallback locale ships in the entry chunk, so any other language // has to fetch its bundles before i18next can resolve a key. Switching after - // the fetch is deliberate: `changeLanguage` is what emits `languageChanged`, - // the event `useTranslation` subscribes to for re-renders. Until it fires the - // subtree keeps resolving keys through whichever language is still active: - // `fallbackLng` on a cold start, the previous language on a warm switch - // between two non-English locales. The swap-then-render shape is the same one - // this provider had when every locale was bundled. + // the fetch is deliberate: until `changeLanguage` runs, the subtree keeps + // rendering whichever language is still active (`fallbackLng` on a cold + // start, the previous language on a warm switch) instead of English keys + // under the new language. A newer request cancels a fetch still in flight. useEffect(() => { let cancelled = false - void loadLanguage(language).then(() => { + void loadLanguage(requestedLanguage).then(() => { if (!cancelled) { - void i18n.changeLanguage(language) + void i18n.changeLanguage(requestedLanguage) } }) return () => { cancelled = true } - }, [i18n, language]) + }, [i18n, requestedLanguage]) + + const subscribe = useCallback( + (onLanguageChanged: () => void) => { + i18n.on("languageChanged", onLanguageChanged) - // Memoize the translation function to prevent unnecessary re-renders - const translate = useCallback( - (key: string, options?: Record) => { - return i18n.t(key, options) + return () => i18n.off("languageChanged", onLanguageChanged) }, [i18n], ) - return ( - - {children} - - ) + const language = useSyncExternalStore(subscribe, () => i18n.language) + + // `t` is bound to the active language, so its identity changes exactly when the language does. + // Memoized consumers (and React Compiler caches) depend on `t`, so they re-render on a switch + // instead of serving output translated in the previous language. + const value = useMemo(() => ({ t: i18n.getFixedT(language), language }), [i18n, language]) + + return {children} } // Custom hook for easy translations diff --git a/webview-ui/src/i18n/__mocks__/TranslationContext.tsx b/webview-ui/src/i18n/__mocks__/TranslationContext.tsx index 3fdfcbdc2c..2266bc3a86 100644 --- a/webview-ui/src/i18n/__mocks__/TranslationContext.tsx +++ b/webview-ui/src/i18n/__mocks__/TranslationContext.tsx @@ -33,10 +33,10 @@ const mockTranslate = (key: string, options?: Record): string => { // Create mock context export const TranslationContext = createContext<{ t: (key: string, options?: Record) => string - i18n: any + language: string }>({ t: mockTranslate, - i18n: {}, + language: "en", }) // Mock translation provider component @@ -45,7 +45,7 @@ export const TranslationProvider: React.FC<{ children: ReactNode }> = ({ childre {children} diff --git a/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx b/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx index 0717361f50..819a3ab721 100644 --- a/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx +++ b/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx @@ -13,12 +13,12 @@ vi.mock("@/context/ExtensionStateContext", () => ({ })) const Probe = () => { - const { t, i18n } = useAppTranslation() + const { t, language } = useAppTranslation() return (
{t("common:errorBoundary.title")} - {i18n.language} + {language}
) } @@ -129,6 +129,25 @@ describe("TranslationProvider locale loading", () => { expect(screen.getByTestId("active-language")).toHaveTextContent("fr") }) + it("lets a newer request win over an earlier one that is still loading", async () => { + const { rerender } = renderProbe("pt-BR") + + // Portuguese is still in flight when English is requested; its late resolution must not switch back. + extensionState.language = "en" + await act(async () => { + rerender( + + + , + ) + }) + await loadLanguage("pt-BR") + + expect(i18next.language).toBe("en") + expect(screen.getByTestId("active-language")).toHaveTextContent("en") + expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") + }) + it("skips the language switch when the provider unmounts before the fetch resolves", async () => { const changeLanguage = vi.spyOn(i18next, "changeLanguage") diff --git a/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx b/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx index 953234ff6d..acda04b629 100644 --- a/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx +++ b/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx @@ -1,75 +1,76 @@ -import { render } from "@/utils/test-utils" +import { memo, useMemo } from "react" +import { Trans } from "react-i18next" +import type { TFunction } from "i18next" +import { renderHook, screen } from "@testing-library/react" -import TranslationProvider, { useAppTranslation } from "../TranslationContext" +import { renderTranslated } from "./renderTranslated" -vi.mock("@/context/ExtensionStateContext", () => ({ - useExtensionState: () => ({ - language: "en", - }), -})) +import i18next from "../setup" +import { useAppTranslation } from "../TranslationContext" -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - i18n: { - t: (key: string, options?: Record) => { - // Mock specific translations used in tests - if (key === "settings.autoApprove.title") return "Auto-Approve" - if (key === "notifications.error") { - return options?.message ? `Operation failed: ${options.message}` : "Operation failed" - } - return key - }, - changeLanguage: vi.fn(), - }, - }), -})) +// React Compiler (enabled in vite.config.ts, not in Vitest) caches render output and only recomputes +// it when a dependency changes. These consumers reproduce that caching by hand: a stale value here is +// exactly what the compiled webview showed after a language switch. +const MemoizedConsumer = memo(function MemoizedConsumer() { + const { t, language } = useAppTranslation() + const label = useMemo(() => t("history:recentTasks"), [t]) + const element = useMemo(() => , [t]) -vi.mock("../setup", () => ({ - default: { - t: (key: string, options?: Record) => { - // Mock specific translations used in tests - if (key === "settings.autoApprove.title") return "Auto-Approve" - if (key === "notifications.error") { - return options?.message ? `Operation failed: ${options.message}` : "Operation failed" - } - return key - }, - changeLanguage: vi.fn(), - }, - FALLBACK_LANGUAGE: "en", - loadLanguage: vi.fn(() => Promise.resolve()), -})) - -const TestComponent = () => { - const { t } = useAppTranslation() return (
-

{t("settings.autoApprove.title")}

-

{t("notifications.error", { message: "Test error" })}

+ {label} + {element} + {t("common:time_ago.days_ago", { count: 3 })} + {language}
) -} +}) + +describe("TranslationProvider", () => { + beforeEach(async () => { + await i18next.changeLanguage("en") + }) + + it("provides translations with interpolation and the active language", () => { + renderTranslated() + + expect(screen.getByTestId("label")).toHaveTextContent("Recent Tasks") + expect(screen.getByTestId("trans")).toHaveTextContent("Zoo is a whole AI dev team in your editor") + expect(screen.getByTestId("interpolated")).toHaveTextContent("3 days ago") + expect(screen.getByTestId("language")).toHaveTextContent("en") + }) + + it("updates memoized consumers and Trans on a language switch without remounting", async () => { + const { setLanguage } = renderTranslated() + + await setLanguage("es") + + expect(screen.getByTestId("label")).toHaveTextContent("Tareas") + // eslint-disable-next-line no-restricted-syntax -- expected text computed outside React; nothing is memoized + expect(screen.getByTestId("trans")).toHaveTextContent(i18next.t("chat:about", { lng: "es" })) + expect(screen.getByTestId("language")).toHaveTextContent("es") + }) + + it("keeps t stable while the language is unchanged and replaces it on a switch", async () => { + const seen: TFunction[] = [] + const Probe = () => { + seen.push(useAppTranslation().t) + return null + } + const { rerender, setLanguage } = renderTranslated() -describe("TranslationContext", () => { - it("should provide translations via context", () => { - const { getByTestId } = render( - - - , - ) + rerender() + expect(seen.length).toBeGreaterThan(1) + expect(new Set(seen).size).toBe(1) - // Check if translation is provided correctly - expect(getByTestId("translation-test")).toHaveTextContent("Auto-Approve") + await setLanguage("es") + expect(seen.at(-1)).not.toBe(seen[0]) }) - it("should handle interpolation correctly", () => { - const { getByTestId } = render( - - - , - ) + it("renders keys as-is without a provider", () => { + const { result } = renderHook(() => useAppTranslation()) - // Check if interpolation works - expect(getByTestId("translation-interpolation")).toHaveTextContent("Operation failed: Test error") + expect(result.current.t("history:recentTasks")).toBe("history:recentTasks") + expect(result.current.language).toBe("en") }) }) diff --git a/webview-ui/src/i18n/__tests__/createFixtureI18n.visual.i18n.ts b/webview-ui/src/i18n/__tests__/createFixtureI18n.visual.i18n.ts new file mode 100644 index 0000000000..bcd5d12e7a --- /dev/null +++ b/webview-ui/src/i18n/__tests__/createFixtureI18n.visual.i18n.ts @@ -0,0 +1,44 @@ +import i18next, { type i18n } from "i18next" + +import type { TranslationContextValue } from "../TranslationContext" + +/** + * An i18next instance for a visual fixture that serves exactly `translations`, keyed `"ns:dotted.key"`. + * Missing keys render as the qualified key. + */ +function createFixtureI18n(translations: Record): i18n { + const resources: Record> = {} + + for (const [qualifiedKey, value] of Object.entries(translations)) { + const [ns, key] = qualifiedKey.split(":") + const path = key.split(".") + let node = (resources[ns] ??= {}) + + for (const segment of path.slice(0, -1)) { + node = (node[segment] ??= {}) as Record + } + + node[path[path.length - 1]] = value + } + + const instance = i18next.createInstance() + + // Inline resources and `initAsync: false` make the instance ready before the fixture renders. + void instance.init({ + lng: "en", + resources: { en: resources }, + ns: Object.keys(resources), + initAsync: false, + appendNamespaceToMissingKey: true, + interpolation: { escapeValue: false }, + }) + + return instance +} + +/** A `TranslationContext` value for a visual fixture, translating with `createFixtureI18n(translations)`. */ +export function createFixtureTranslation(translations: Record): TranslationContextValue { + const instance = createFixtureI18n(translations) + + return { t: instance.getFixedT("en"), language: "en" } +} diff --git a/webview-ui/src/i18n/__tests__/renderTranslated.tsx b/webview-ui/src/i18n/__tests__/renderTranslated.tsx new file mode 100644 index 0000000000..eb7f1a49a3 --- /dev/null +++ b/webview-ui/src/i18n/__tests__/renderTranslated.tsx @@ -0,0 +1,44 @@ +import React from "react" +import { act, waitFor } from "@testing-library/react" + +import i18next from "@src/i18n/setup" +import TranslationProvider from "@src/i18n/TranslationContext" + +import { + makeExtensionState, + renderWithExtensionState, + type RenderWithExtensionStateOptions, +} from "@src/utils/test-utils" + +/** + * Post a "state" message the way the extension host does. TranslationProvider picks up the + * language from it, so this drives a UI language change through the real switching path. + */ +export const postExtensionState = (state: Record) => + act(async () => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "state", state } })) + }) + +/** + * Render `ui` under the real TranslationProvider (kept out of test-utils so specs that mock + * react-i18next do not load i18next). `setLanguage` resolves once i18next renders the new language. + */ +export const renderTranslated = ( + ui: React.ReactElement, + { state, ...options }: RenderWithExtensionStateOptions = {}, +) => { + const result = renderWithExtensionState({ui}, { + state: makeExtensionState(state), + ...options, + }) + + const setLanguage = async (language: string) => { + await postExtensionState({ language }) + await waitFor(() => expect(i18next.language).toBe(language)) + } + + // Keep the provider around a rerendered `ui` so the rerender does not remount the subtree. + const rerender = (next: React.ReactElement) => result.rerender({next}) + + return { ...result, rerender, setLanguage } +} diff --git a/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts b/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts index e3fe3e0528..f6622a55f8 100644 --- a/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts +++ b/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts @@ -35,6 +35,7 @@ describe("i18n setup locale load failure", () => { // registered either, so no half-translated locale can be activated. expect(i18next.hasResourceBundle("ja", "common")).toBe(false) expect(i18next.hasResourceBundle("ja", "settings")).toBe(false) + // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("Something went wrong") }) @@ -65,6 +66,7 @@ describe("i18n setup locale load failure", () => { loader.fail = false await loadLanguage("ja") + // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("問題が発生しました") }) }) diff --git a/webview-ui/src/i18n/__tests__/setup.spec.ts b/webview-ui/src/i18n/__tests__/setup.spec.ts index 867fc03f4e..d691945bd9 100644 --- a/webview-ui/src/i18n/__tests__/setup.spec.ts +++ b/webview-ui/src/i18n/__tests__/setup.spec.ts @@ -19,6 +19,7 @@ describe("i18n setup locale loading", () => { expect(i18next.hasResourceBundle("ko", "common")).toBe(true) expect(i18next.hasResourceBundle("ko", "settings")).toBe(true) + // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized expect(i18next.t("common:errorBoundary.title", { lng: "ko" })).toBe("문제가 발생했습니다") }) @@ -36,11 +37,13 @@ describe("i18n setup locale loading", () => { expect(results).toHaveLength(3) // 9 namespaces registered once each, not 27. expect(addResourceBundle).toHaveBeenCalledTimes(9) + // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("問題が発生しました") }) it("resolves without throwing for a locale that has no bundles", async () => { await expect(loadLanguage("xx-unsupported")).resolves.toBeUndefined() + // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized expect(i18next.t("common:errorBoundary.title", { lng: "xx-unsupported" })).toBe("Something went wrong") }) }) diff --git a/webview-ui/src/utils/__tests__/format.spec.ts b/webview-ui/src/utils/__tests__/format.spec.ts index 3ee442bcd4..42acb59cae 100644 --- a/webview-ui/src/utils/__tests__/format.spec.ts +++ b/webview-ui/src/utils/__tests__/format.spec.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next" import { formatLargeNumber, formatDateOnly, @@ -11,58 +12,65 @@ import { // Mock i18next vi.mock("i18next", () => ({ - default: { - t: vi.fn((key: string, options?: any) => { - // Mock translations for testing - const translations: Record = { - "common:number_format.billion_suffix": "b", - "common:number_format.million_suffix": "m", - "common:number_format.thousand_suffix": "k", - "common:time_ago.just_now": "just now", - "common:time_ago.seconds_ago": "{{count}} seconds ago", - "common:time_ago.minute_ago": "a minute ago", - "common:time_ago.minutes_ago": "{{count}} minutes ago", - "common:time_ago.hour_ago": "an hour ago", - "common:time_ago.hours_ago": "{{count}} hours ago", - "common:time_ago.day_ago": "a day ago", - "common:time_ago.days_ago": "{{count}} days ago", - "common:time_ago.week_ago": "a week ago", - "common:time_ago.weeks_ago": "{{count}} weeks ago", - "common:time_ago.month_ago": "a month ago", - "common:time_ago.months_ago": "{{count}} months ago", - "common:time_ago.year_ago": "a year ago", - "common:time_ago.years_ago": "{{count}} years ago", - } - - let result = translations[key] || key - if (options?.count !== undefined) { - result = result.replace("{{count}}", options.count.toString()) - } - return result - }), - language: "en", - }, + default: { language: "en" }, })) +// Returns translation keys with interpolated values appended. +const t = ((key: string, options?: { count?: number }) => { + const translations: Record = { + "common:number_format.billion_suffix": "b", + "common:number_format.million_suffix": "m", + "common:number_format.thousand_suffix": "k", + "common:time_ago.just_now": "just now", + "common:time_ago.seconds_ago": "{{count}} seconds ago", + "common:time_ago.minute_ago": "a minute ago", + "common:time_ago.minutes_ago": "{{count}} minutes ago", + "common:time_ago.hour_ago": "an hour ago", + "common:time_ago.hours_ago": "{{count}} hours ago", + "common:time_ago.day_ago": "a day ago", + "common:time_ago.days_ago": "{{count}} days ago", + "common:time_ago.week_ago": "a week ago", + "common:time_ago.weeks_ago": "{{count}} weeks ago", + "common:time_ago.month_ago": "a month ago", + "common:time_ago.months_ago": "{{count}} months ago", + "common:time_ago.year_ago": "a year ago", + "common:time_ago.years_ago": "{{count}} years ago", + } + + let result = translations[key] || key + if (options?.count !== undefined) { + result = result.replace("{{count}}", options.count.toString()) + } + return result +}) as unknown as TFunction + describe("formatLargeNumber", () => { it("should format billions", () => { - expect(formatLargeNumber(1500000000)).toBe("1.5b") - expect(formatLargeNumber(2000000000)).toBe("2.0b") + expect(formatLargeNumber(1500000000, t)).toBe("1.5b") + expect(formatLargeNumber(2000000000, t)).toBe("2.0b") }) it("should format millions", () => { - expect(formatLargeNumber(1500000)).toBe("1.5m") - expect(formatLargeNumber(2000000)).toBe("2.0m") + expect(formatLargeNumber(1500000, t)).toBe("1.5m") + expect(formatLargeNumber(2000000, t)).toBe("2.0m") }) it("should format thousands", () => { - expect(formatLargeNumber(1500)).toBe("1.5k") - expect(formatLargeNumber(2000)).toBe("2.0k") + expect(formatLargeNumber(1500, t)).toBe("1.5k") + expect(formatLargeNumber(2000, t)).toBe("2.0k") }) it("should return string for small numbers", () => { - expect(formatLargeNumber(999)).toBe("999") - expect(formatLargeNumber(100)).toBe("100") + expect(formatLargeNumber(999, t)).toBe("999") + expect(formatLargeNumber(100, t)).toBe("100") + }) + + it("takes suffixes from the given t", () => { + const givenT = vi.fn((key: string) => (key === "common:number_format.thousand_suffix" ? " mil" : key)) + + // `TFunction` is branded; the mock only needs to answer a key lookup. + expect(formatLargeNumber(1500, givenT as unknown as TFunction)).toBe("1.5 mil") + expect(givenT).toHaveBeenCalledWith("common:number_format.thousand_suffix") }) }) @@ -87,12 +95,13 @@ describe("formatDateOnly", () => { ["dmy", "07/10/2026"], ["ymd", "2026-10-07"], ] as const)("writes %s as %s, zero-padded and locale-independent", (format, expected) => { - expect(formatDateOnly(ts, format)).toBe(expected) + expect(formatDateOnly(ts, "en", format)).toBe(expected) }) - it("follows the display language for auto", () => { - expect(formatDateOnly(ts, "auto")).toMatch(/2026/) - expect(formatDateOnly(ts)).toBe(formatDateOnly(ts, "auto")) + it("follows the given locale for auto", () => { + expect(formatDateOnly(ts, "en", "auto")).toMatch(/2026/) + expect(formatDateOnly(ts, "en")).toBe(formatDateOnly(ts, "en", "auto")) + expect(formatDateOnly(ts, "de", "auto")).toBe("07.10.2026") }) }) @@ -100,23 +109,23 @@ describe("formatTimeOnly", () => { const ts = new Date(2026, 9, 27, 14, 41).getTime() it("honours 24h and 12h", () => { - expect(formatTimeOnly(ts, "24h")).toBe("14:41") - expect(formatTimeOnly(ts, "12h")).toMatch(/^2:41\s?PM$/i) + expect(formatTimeOnly(ts, "en", "24h")).toBe("14:41") + expect(formatTimeOnly(ts, "en", "12h")).toMatch(/^2:41\s?PM$/i) }) }) describe("formatDateTime", () => { it("includes date and time honouring the formats", () => { const timestamp = new Date("2024-01-15T00:05:00").getTime() - const result = formatDateTime(timestamp, "24h") + const result = formatDateTime(timestamp, "en", "24h") expect(result).toMatch(/2024/) expect(result).toMatch(/00:05/) }) it("combines the chosen date order with the chosen clock", () => { const ts = new Date(2026, 9, 27, 14, 41).getTime() - expect(formatDateTime(ts, "24h", "dmy")).toBe("27/10/2026, 14:41") - expect(formatDateTime(ts, "24h", "ymd")).toBe("2026-10-27, 14:41") + expect(formatDateTime(ts, "en", "24h", "dmy")).toBe("27/10/2026, 14:41") + expect(formatDateTime(ts, "en", "24h", "ymd")).toBe("2026-10-27, 14:41") }) }) @@ -136,72 +145,81 @@ describe("formatTimeAgo", () => { it('should return "just now" for very recent times', () => { const timestamp = new Date("2024-01-15T11:59:35").getTime() // 25 seconds ago - expect(formatTimeAgo(timestamp)).toBe("just now") + expect(formatTimeAgo(timestamp, t)).toBe("just now") }) it("should format seconds ago", () => { const timestamp = new Date("2024-01-15T11:59:15").getTime() // 45 seconds ago - expect(formatTimeAgo(timestamp)).toBe("45 seconds ago") + expect(formatTimeAgo(timestamp, t)).toBe("45 seconds ago") }) it("should format a minute ago", () => { const timestamp = new Date("2024-01-15T11:59:00").getTime() // 1 minute ago - expect(formatTimeAgo(timestamp)).toBe("a minute ago") + expect(formatTimeAgo(timestamp, t)).toBe("a minute ago") }) it("should format minutes ago", () => { const timestamp = new Date("2024-01-15T11:45:00").getTime() // 15 minutes ago - expect(formatTimeAgo(timestamp)).toBe("15 minutes ago") + expect(formatTimeAgo(timestamp, t)).toBe("15 minutes ago") }) it("should format an hour ago", () => { const timestamp = new Date("2024-01-15T11:00:00").getTime() // 1 hour ago - expect(formatTimeAgo(timestamp)).toBe("an hour ago") + expect(formatTimeAgo(timestamp, t)).toBe("an hour ago") }) it("should format hours ago", () => { const timestamp = new Date("2024-01-15T09:00:00").getTime() // 3 hours ago - expect(formatTimeAgo(timestamp)).toBe("3 hours ago") + expect(formatTimeAgo(timestamp, t)).toBe("3 hours ago") }) it("should format a day ago", () => { const timestamp = new Date("2024-01-14T12:00:00").getTime() // 1 day ago - expect(formatTimeAgo(timestamp)).toBe("a day ago") + expect(formatTimeAgo(timestamp, t)).toBe("a day ago") }) it("should format days ago", () => { const timestamp = new Date("2024-01-12T12:00:00").getTime() // 3 days ago - expect(formatTimeAgo(timestamp)).toBe("3 days ago") + expect(formatTimeAgo(timestamp, t)).toBe("3 days ago") }) it("should format a week ago", () => { const timestamp = new Date("2024-01-08T12:00:00").getTime() // 7 days ago - expect(formatTimeAgo(timestamp)).toBe("a week ago") + expect(formatTimeAgo(timestamp, t)).toBe("a week ago") }) it("should format weeks ago", () => { const timestamp = new Date("2024-01-01T12:00:00").getTime() // 14 days ago - expect(formatTimeAgo(timestamp)).toBe("2 weeks ago") + expect(formatTimeAgo(timestamp, t)).toBe("2 weeks ago") }) it("should format a month ago", () => { const timestamp = new Date("2023-12-15T12:00:00").getTime() // ~1 month ago - expect(formatTimeAgo(timestamp)).toBe("a month ago") + expect(formatTimeAgo(timestamp, t)).toBe("a month ago") }) it("should format months ago", () => { const timestamp = new Date("2023-10-15T12:00:00").getTime() // ~3 months ago - expect(formatTimeAgo(timestamp)).toBe("3 months ago") + expect(formatTimeAgo(timestamp, t)).toBe("3 months ago") }) it("should format a year ago", () => { const timestamp = new Date("2023-01-15T12:00:00").getTime() // 1 year ago - expect(formatTimeAgo(timestamp)).toBe("a year ago") + expect(formatTimeAgo(timestamp, t)).toBe("a year ago") }) it("should format years ago", () => { const timestamp = new Date("2021-01-15T12:00:00").getTime() // 3 years ago - expect(formatTimeAgo(timestamp)).toBe("3 years ago") + expect(formatTimeAgo(timestamp, t)).toBe("3 years ago") + }) + + it("takes translations from the given t", () => { + const givenT = vi.fn((key: string, options?: Record) => `es:${key}:${options?.count}`) + const timestamp = new Date("2024-01-15T09:00:00").getTime() // 3 hours ago + + // `TFunction` is branded; the mock only needs to answer a key lookup with `count`. + expect(formatTimeAgo(timestamp, givenT as unknown as TFunction)).toBe("es:common:time_ago.hours_ago:3") + expect(givenT).toHaveBeenCalledWith("common:time_ago.hours_ago", { count: 3 }) }) }) @@ -209,8 +227,8 @@ describe("formatMessageTime", () => { // Wed 14 Oct 2026, 15:00 local. const NOW = new Date(2026, 9, 14, 15, 0).getTime() const at = (y: number, m: number, d: number, h: number, min = 0) => new Date(y, m, d, h, min).getTime() - const fmt = (ts: number, opts: Parameters[1] = {}) => - formatMessageTime(ts, { now: NOW, timeFormat: "12h", ...opts }) + const fmt = (ts: number, opts: Parameters[2] = {}, locale = "en") => + formatMessageTime(ts, locale, { now: NOW, timeFormat: "12h", ...opts }) it.each([ ["tomorrow", at(2026, 9, 15, 9, 5), /^Oct 15, 9:05\s?AM$/], @@ -257,7 +275,9 @@ describe("formatMessageTime", () => { it("treats a message from earlier in a different year as older even when under 7 days away", () => { const newYear = new Date(2027, 0, 2, 12, 0).getTime() // 30 Dec 2026 is 3 days before → weekday, no year needed. - expect(formatMessageTime(at(2026, 11, 30, 9, 0), { now: newYear, timeFormat: "24h" })).toMatch(/^Wed 09:00$/) + expect(formatMessageTime(at(2026, 11, 30, 9, 0), "en", { now: newYear, timeFormat: "24h" })).toMatch( + /^Wed 09:00$/, + ) }) it("honours the 24-hour setting", () => { @@ -265,46 +285,27 @@ describe("formatMessageTime", () => { }) describe("with a stored language that is not a usable locale", () => { - let original: string const SENT = new Date(2024, 2, 7, 14, 41).getTime() - beforeEach(async () => { - const i18next = (await import("i18next")).default as unknown as { language: string } - original = i18next.language - }) - - afterEach(async () => { - const i18next = (await import("i18next")).default as unknown as { language: string } - i18next.language = original - }) - it.each(["not a locale!!", "xx-ZZ-garbage", "xx-ZZ", "en_US", "---", "x"])( "formats in English instead of throwing for %j", - async (bad) => { - const i18next = (await import("i18next")).default as unknown as { language: string } - i18next.language = bad - - expect(() => formatTimeOnly(SENT)).not.toThrow() - expect(() => formatDateOnly(SENT)).not.toThrow() - expect(() => formatDateTime(SENT)).not.toThrow() - expect(() => formatMessageTime(SENT, { now: SENT })).not.toThrow() - expect(formatMessageTime(SENT, { now: SENT })).toContain("Today") - expect(formatDateOnly(SENT)).toBe(formatDateOnly(SENT, "auto", "en")) + (bad) => { + expect(() => formatTimeOnly(SENT, bad)).not.toThrow() + expect(() => formatDateOnly(SENT, bad)).not.toThrow() + expect(() => formatDateTime(SENT, bad)).not.toThrow() + expect(() => formatMessageTime(SENT, bad, { now: SENT })).not.toThrow() + expect(formatMessageTime(SENT, bad, { now: SENT })).toContain("Today") + expect(formatDateOnly(SENT, bad)).toBe(formatDateOnly(SENT, "en", "auto")) + expect(formatTimeOnly(SENT, bad)).toBe(formatTimeOnly(SENT, "en", "auto")) + expect(formatDateTime(SENT, bad)).toBe(formatDateTime(SENT, "en", "auto", "auto")) }, ) - it("formats in English when no language is set at all", async () => { - const i18next = (await import("i18next")).default as unknown as { language: string } - i18next.language = "" - - expect(formatDateOnly(SENT)).toBe(formatDateOnly(SENT, "auto", "en")) - expect(formatTimeOnly(SENT)).toBe(formatTimeOnly(SENT, "auto", "en")) - expect(formatDateTime(SENT)).toBe(formatDateTime(SENT, "auto", "auto", "en")) - }) - - it("falls back for a locale passed in directly, too", () => { - expect(formatDateOnly(SENT, "auto", "not a locale!!")).toBe(formatDateOnly(SENT, "auto", "en")) - expect(formatTimeOnly(SENT, "auto", "not a locale!!")).toBe(formatTimeOnly(SENT, "auto", "en")) + it("formats in English when no language is set at all", () => { + expect(formatDateOnly(SENT, "")).toBe(formatDateOnly(SENT, "en", "auto")) + expect(formatTimeOnly(SENT, "")).toBe(formatTimeOnly(SENT, "en", "auto")) + expect(formatDateTime(SENT, "")).toBe(formatDateTime(SENT, "en", "auto", "auto")) + expect(formatMessageTime(SENT, "", { now: SENT })).toContain("Today") }) it("resolves a supported locale to itself, and a missing one to English", () => { @@ -315,36 +316,25 @@ describe("formatMessageTime", () => { }) describe("in another display language (automatic date format)", () => { - let original: string - - beforeEach(async () => { - const i18next = (await import("i18next")).default as unknown as { language: string } - original = i18next.language - i18next.language = "de" - }) - - afterEach(async () => { - const i18next = (await import("i18next")).default as unknown as { language: string } - i18next.language = original - }) - it("names today and yesterday in that language", () => { - expect(fmt(at(2026, 9, 14, 14, 41), { timeFormat: "24h" })).toBe("Heute 14:41") - expect(fmt(at(2026, 9, 13, 11, 43), { timeFormat: "24h" })).toBe("Gestern 11:43") + expect(fmt(at(2026, 9, 14, 14, 41), { timeFormat: "24h" }, "de")).toBe("Heute 14:41") + expect(fmt(at(2026, 9, 13, 11, 43), { timeFormat: "24h" }, "de")).toBe("Gestern 11:43") }) it("names the weekday in that language", () => { - expect(fmt(at(2026, 9, 12, 8, 0), { timeFormat: "24h" })).toMatch(/^Mo\.? 0?8:00$/) // a Monday + expect(fmt(at(2026, 9, 12, 8, 0), { timeFormat: "24h" }, "de")).toMatch(/^Mo\.? 0?8:00$/) // a Monday }) it("writes an older date in that language's own order and month name", () => { - expect(fmt(at(2024, 2, 7, 14, 41), { timeFormat: "24h" })).toBe("7. März 2024, 14:41") + expect(fmt(at(2024, 2, 7, 14, 41), { timeFormat: "24h" }, "de")).toBe("7. März 2024, 14:41") // The same date within the current year leaves the year out. - expect(fmt(at(2026, 2, 7, 14, 41), { timeFormat: "24h" })).toMatch(/^7\. März, 14:41$/) + expect(fmt(at(2026, 2, 7, 14, 41), { timeFormat: "24h" }, "de")).toMatch(/^7\. März, 14:41$/) }) it("still lets an explicit date order win over the language", () => { - expect(fmt(at(2024, 2, 7, 14, 41), { timeFormat: "24h", dateFormat: "mdy" })).toBe("03/07/2024, 14:41") + expect(fmt(at(2024, 2, 7, 14, 41), { timeFormat: "24h", dateFormat: "mdy" }, "de")).toBe( + "03/07/2024, 14:41", + ) }) }) diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index bf25faf7f2..d4b9a4da2a 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -4,22 +4,7 @@ import { type OrganizationAllowList, type RouterModels, } from "@roo-code/types" - -// Mock i18next to return translation keys with interpolated values -vi.mock("i18next", () => ({ - default: { - t: (key: string, options?: Record) => { - if (options) { - let result = key - Object.entries(options).forEach(([k, v]) => { - result += ` ${k}=${v}` - }) - return result - } - return key - }, - }, -})) +import type { TFunction } from "i18next" import { formatOpenAiExtraBodyValidationError, @@ -29,6 +14,18 @@ import { validateBedrockArn, } from "../validate" +// Returns translation keys with interpolated values appended. +const t = ((key: string, options?: Record) => { + if (options) { + let result = key + Object.entries(options).forEach(([k, v]) => { + result += ` ${k}=${v}` + }) + return result + } + return key +}) as unknown as TFunction + describe("Model Validation Functions", () => { const mockRouterModels: RouterModels = { openrouter: { @@ -88,7 +85,7 @@ describe("Model Validation Functions", () => { openRouterModelId: "valid-model", } - const result = getModelValidationError(config, mockRouterModels, allowAllOrganization) + const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization) expect(result).toBeUndefined() }) @@ -98,7 +95,7 @@ describe("Model Validation Functions", () => { openRouterModelId: "invalid-model", } - const result = getModelValidationError(config, mockRouterModels, allowAllOrganization) + const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization) expect(result).toContain("settings:validation.modelAvailability") }) @@ -108,7 +105,7 @@ describe("Model Validation Functions", () => { openRouterModelId: "another-valid-model", } - const result = getModelValidationError(config, mockRouterModels, restrictiveOrganization) + const result = getModelValidationError(config, t, mockRouterModels, restrictiveOrganization) expect(result).toContain("model") }) @@ -118,7 +115,7 @@ describe("Model Validation Functions", () => { openAiModelId: "gpt-4", } - const result = getModelValidationError(config, undefined, allowAllOrganization) + const result = getModelValidationError(config, t, undefined, allowAllOrganization) expect(result).toBeUndefined() }) @@ -152,7 +149,7 @@ describe("Model Validation Functions", () => { }, } - const result = getModelValidationError(config, undefined, organizationAllowList) + const result = getModelValidationError(config, t, undefined, organizationAllowList) expect(result).toContain("settings:validation.modelNotAllowed") expect(result).toContain("model=blocked-model") @@ -164,7 +161,7 @@ describe("Model Validation Functions", () => { openRouterModelId: "", } - const result = getModelValidationError(config, mockRouterModels, allowAllOrganization) + const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization) expect(result).toBe("settings:validation.modelId") }) @@ -174,14 +171,14 @@ describe("Model Validation Functions", () => { // openRouterModelId is undefined } - const result = getModelValidationError(config, mockRouterModels, allowAllOrganization) + const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization) expect(result).toBe("settings:validation.modelId") }) }) describe("validateApiConfiguration for GitHub Copilot", () => { const validate = (vsCodeLmModelSelector: ProviderSettings["vsCodeLmModelSelector"]) => - validateApiConfiguration({ apiProvider: providerIdentifiers.githubCopilot, vsCodeLmModelSelector }) + validateApiConfiguration({ apiProvider: providerIdentifiers.githubCopilot, vsCodeLmModelSelector }, t) it.each([ ["a model id", { vendor: "copilot", id: "gpt-x" }], @@ -207,7 +204,12 @@ describe("Model Validation Functions", () => { openRouterModelId: "valid-model", } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBeUndefined() }) @@ -218,7 +220,12 @@ describe("Model Validation Functions", () => { // Missing openRouterApiKey } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBe("settings:validation.apiKey") }) @@ -229,7 +236,12 @@ describe("Model Validation Functions", () => { openRouterModelId: "invalid-model", // This should be ignored } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBeUndefined() // Should not return model validation error }) @@ -242,6 +254,7 @@ describe("Model Validation Functions", () => { const result = validateApiConfigurationExcludingModelErrors( config, + t, mockRouterModels, restrictiveOrganization, ) @@ -258,7 +271,7 @@ describe("Model Validation Functions", () => { } expect( - validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization), + validateApiConfigurationExcludingModelErrors(config, t, mockRouterModels, allowAllOrganization), ).toBeUndefined() }) @@ -279,9 +292,9 @@ describe("Model Validation Functions", () => { openAiExtraBody, } - expect(validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)).toBe( - expectedError, - ) + expect( + validateApiConfigurationExcludingModelErrors(config, t, mockRouterModels, allowAllOrganization), + ).toBe(expectedError) }) }) @@ -297,9 +310,9 @@ describe("Model Validation Functions", () => { it("accepts a valid Extra Body object through both validation entry points", () => { const config = createConfig(JSON.stringify({ metadata: { completion_window: "balanced" } })) - expect(validateApiConfiguration(config, mockRouterModels, allowAllOrganization)).toBeUndefined() + expect(validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization)).toBeUndefined() expect( - validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization), + validateApiConfigurationExcludingModelErrors(config, t, mockRouterModels, allowAllOrganization), ).toBeUndefined() }) @@ -324,14 +337,14 @@ describe("Model Validation Functions", () => { ])("rejects %s with the localized error", (_name, openAiExtraBody, expectedError) => { const config = createConfig(openAiExtraBody) - expect(validateApiConfiguration(config, mockRouterModels, allowAllOrganization)).toBe(expectedError) + expect(validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization)).toBe(expectedError) }) it("returns required-field errors before Extra Body errors", () => { const config = createConfig("not json") config.openAiApiKey = undefined - expect(validateApiConfiguration(config, mockRouterModels, allowAllOrganization)).toBe( + expect(validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization)).toBe( "settings:validation.openAi", ) }) @@ -364,7 +377,7 @@ describe("Model Validation Functions", () => { openAiExtraBody: "not json", } - expect(validateApiConfiguration(config, mockRouterModels, allowAllOrganization)).toBeUndefined() + expect(validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization)).toBeUndefined() }) }) @@ -376,7 +389,12 @@ describe("Model Validation Functions", () => { // Missing opencodeGoApiKey } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBe("settings:validation.apiKey") }) @@ -387,7 +405,12 @@ describe("Model Validation Functions", () => { opencodeGoModelId: "glm-5.1", } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBeUndefined() }) @@ -398,7 +421,7 @@ describe("Model Validation Functions", () => { // Missing opencodeGoModelId } - const result = getModelValidationError(config, mockRouterModels, allowAllOrganization) + const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization) expect(result).toBe("settings:validation.modelId") }) }) @@ -410,7 +433,12 @@ describe("Model Validation Functions", () => { // Missing kenariApiKey } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBe("settings:validation.apiKey") }) @@ -421,7 +449,12 @@ describe("Model Validation Functions", () => { kenariModelId: "glm-5.1", } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBeUndefined() }) @@ -432,7 +465,7 @@ describe("Model Validation Functions", () => { // Missing kenariModelId } - const result = getModelValidationError(config, mockRouterModels, allowAllOrganization) + const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization) expect(result).toBe("settings:validation.modelId") }) }) @@ -444,7 +477,7 @@ describe("Model Validation Functions", () => { ioIntelligenceModelId: "zai-org/GLM-4.6", } - expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.apiKey") + expect(validateApiConfiguration(config, t, mockRouterModels)).toBe("settings:validation.apiKey") }) it("accepts an IO Intelligence API key and selected model", () => { @@ -454,7 +487,7 @@ describe("Model Validation Functions", () => { ioIntelligenceModelId: "zai-org/GLM-4.6", } - expect(validateApiConfiguration(config, mockRouterModels)).toBeUndefined() + expect(validateApiConfiguration(config, t, mockRouterModels)).toBeUndefined() }) }) @@ -465,7 +498,7 @@ describe("Model Validation Functions", () => { nanoGptModelId: "openai/model", } - expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.apiKey") + expect(validateApiConfiguration(config, t, mockRouterModels)).toBe("settings:validation.apiKey") }) it("returns a modelId error when the NanoGPT model is missing", () => { @@ -474,7 +507,7 @@ describe("Model Validation Functions", () => { nanoGptApiKey: "valid-key", } - expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.modelId") + expect(validateApiConfiguration(config, t, mockRouterModels)).toBe("settings:validation.modelId") }) it("accepts a NanoGPT API key and selected model", () => { @@ -484,7 +517,7 @@ describe("Model Validation Functions", () => { nanoGptModelId: "openai/model", } - expect(validateApiConfiguration(config, mockRouterModels)).toBeUndefined() + expect(validateApiConfiguration(config, t, mockRouterModels)).toBeUndefined() }) }) @@ -496,7 +529,12 @@ describe("Model Validation Functions", () => { // Missing friendliApiKey } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBe("settings:validation.apiKey") }) @@ -507,7 +545,12 @@ describe("Model Validation Functions", () => { apiModelId: "zai-org/GLM-5.2", } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBeUndefined() }) }) @@ -520,7 +563,7 @@ describe("Model Validation Functions", () => { zooGatewayModelId: "anthropic/claude-sonnet-4", } - const result = validateApiConfiguration(config, mockRouterModels, allowAllOrganization, false) + const result = validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization, false) expect(result).toBe("settings:validation.zooGatewaySignIn") }) @@ -530,7 +573,7 @@ describe("Model Validation Functions", () => { zooGatewayModelId: "anthropic/claude-sonnet-4", } - const result = validateApiConfiguration(config, mockRouterModels, allowAllOrganization, true) + const result = validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization, true) expect(result).toBeUndefined() }) @@ -541,7 +584,7 @@ describe("Model Validation Functions", () => { zooSessionToken: "zoo_ext_test_token", } - const result = validateApiConfiguration(config, mockRouterModels, allowAllOrganization, false) + const result = validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization, false) expect(result).toBeUndefined() }) }) @@ -558,6 +601,7 @@ describe("Model Validation Functions", () => { const result = validateApiConfigurationExcludingModelErrors( config, + t, mockRouterModels, allowAllOrganization, ) @@ -573,6 +617,7 @@ describe("Model Validation Functions", () => { const result = validateApiConfigurationExcludingModelErrors( config, + t, mockRouterModels, allowAllOrganization, ) @@ -594,6 +639,7 @@ describe("Model Validation Functions", () => { const result = validateApiConfigurationExcludingModelErrors( config, + t, mockRouterModels, orgWithoutZooGateway, ) @@ -609,7 +655,12 @@ describe("Model Validation Functions", () => { kimiCodeAuthMethod: "oauth", } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBeUndefined() }) @@ -618,7 +669,12 @@ describe("Model Validation Functions", () => { apiProvider: providerIdentifiers.kimiCode, } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBeUndefined() }) @@ -628,7 +684,12 @@ describe("Model Validation Functions", () => { kimiCodeAuthMethod: "api-key", } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBe("settings:validation.apiKey") }) @@ -639,7 +700,12 @@ describe("Model Validation Functions", () => { kimiCodeApiKey: "valid-key", } - const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + const result = validateApiConfigurationExcludingModelErrors( + config, + t, + mockRouterModels, + allowAllOrganization, + ) expect(result).toBeUndefined() }) }) @@ -650,6 +716,7 @@ describe("validateBedrockArn", () => { it("accepts standard AWS Bedrock ARNs", () => { const result = validateBedrockArn( "arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-3-5-sonnet-v2", + t, ) expect(result.isValid).toBe(true) expect(result.arnRegion).toBe("us-west-2") @@ -659,6 +726,7 @@ describe("validateBedrockArn", () => { it("accepts AWS GovCloud ARNs", () => { const result = validateBedrockArn( "arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:inference-profile/model", + t, ) expect(result.isValid).toBe(true) expect(result.arnRegion).toBe("us-gov-west-1") @@ -666,14 +734,14 @@ describe("validateBedrockArn", () => { }) it("accepts AWS China ARNs", () => { - const result = validateBedrockArn("arn:aws-cn:bedrock:cn-north-1:123456789012:inference-profile/model") + const result = validateBedrockArn("arn:aws-cn:bedrock:cn-north-1:123456789012:inference-profile/model", t) expect(result.isValid).toBe(true) expect(result.arnRegion).toBe("cn-north-1") expect(result.errorMessage).toBeUndefined() }) it("accepts SageMaker ARNs", () => { - const result = validateBedrockArn("arn:aws:sagemaker:us-east-1:123456789012:endpoint/my-endpoint") + const result = validateBedrockArn("arn:aws:sagemaker:us-east-1:123456789012:endpoint/my-endpoint", t) expect(result.isValid).toBe(true) expect(result.arnRegion).toBe("us-east-1") expect(result.errorMessage).toBeUndefined() @@ -681,7 +749,7 @@ describe("validateBedrockArn", () => { it("accepts non-standard ARN formats without validation errors", () => { // Users are advanced - trust their input - const result = validateBedrockArn("arn:custom:service:region:account:resource") + const result = validateBedrockArn("arn:custom:service:region:account:resource", t) expect(result.isValid).toBe(true) expect(result.arnRegion).toBe("region") expect(result.errorMessage).toBeUndefined() @@ -689,7 +757,7 @@ describe("validateBedrockArn", () => { it("accepts completely custom ARN strings", () => { // Even unusual formats should be accepted - const result = validateBedrockArn("some-custom-arn-format") + const result = validateBedrockArn("some-custom-arn-format", t) expect(result.isValid).toBe(true) // May not be able to extract region from non-standard format expect(result.errorMessage).toBeUndefined() @@ -700,6 +768,7 @@ describe("validateBedrockArn", () => { it("shows warning when ARN region differs from provided region", () => { const result = validateBedrockArn( "arn:aws:bedrock:us-west-2:123456789012:inference-profile/model", + t, "us-east-1", ) expect(result.isValid).toBe(true) // Still valid, just a warning @@ -711,6 +780,7 @@ describe("validateBedrockArn", () => { it("shows no warning when ARN region matches provided region", () => { const result = validateBedrockArn( "arn:aws:bedrock:us-west-2:123456789012:inference-profile/model", + t, "us-west-2", ) expect(result.isValid).toBe(true) @@ -719,14 +789,14 @@ describe("validateBedrockArn", () => { }) it("shows no warning when no region is provided to check against", () => { - const result = validateBedrockArn("arn:aws:bedrock:us-west-2:123456789012:inference-profile/model") + const result = validateBedrockArn("arn:aws:bedrock:us-west-2:123456789012:inference-profile/model", t) expect(result.isValid).toBe(true) expect(result.arnRegion).toBe("us-west-2") expect(result.errorMessage).toBeUndefined() }) it("shows no warning when region cannot be extracted from ARN", () => { - const result = validateBedrockArn("non-arn-format", "us-east-1") + const result = validateBedrockArn("non-arn-format", t, "us-east-1") expect(result.isValid).toBe(true) expect(result.arnRegion).toBeUndefined() expect(result.errorMessage).toBeUndefined() diff --git a/webview-ui/src/utils/format.ts b/webview-ui/src/utils/format.ts index 8baf5c6f14..3f1cc9bebd 100644 --- a/webview-ui/src/utils/format.ts +++ b/webview-ui/src/utils/format.ts @@ -1,17 +1,16 @@ -import i18next from "i18next" - export type TimeFormat = "auto" | "12h" | "24h" export type DateFormat = "auto" | "mdy" | "dmy" | "ymd" +import { type TFunction } from "i18next" -export function formatLargeNumber(num: number): string { +export function formatLargeNumber(num: number, t: TFunction): string { if (num >= 1e9) { - return (num / 1e9).toFixed(1) + i18next.t("common:number_format.billion_suffix") + return (num / 1e9).toFixed(1) + t("common:number_format.billion_suffix") } if (num >= 1e6) { - return (num / 1e6).toFixed(1) + i18next.t("common:number_format.million_suffix") + return (num / 1e6).toFixed(1) + t("common:number_format.million_suffix") } if (num >= 1e3) { - return (num / 1e3).toFixed(1) + i18next.t("common:number_format.thousand_suffix") + return (num / 1e3).toFixed(1) + t("common:number_format.thousand_suffix") } return num.toString() } @@ -50,11 +49,7 @@ export const resolveLocale = (locale: string | undefined): string => { * explicit orders use a fixed numeric form (e.g. 10/27/2026, 27/10/2026, * 2026-10-27) so the preview and the app agree regardless of locale. */ -export const formatDateOnly = ( - timestamp: number, - dateFormat: DateFormat = "auto", - locale: string = i18next.language || "en", -) => { +export const formatDateOnly = (timestamp: number, locale: string, dateFormat: DateFormat = "auto") => { const date = new Date(timestamp) const formatLocale = resolveLocale(locale) @@ -71,14 +66,9 @@ export const formatDateOnly = ( } /** - * Clock time only, honouring the 12h/24h choice. `locale` defaults to the active display language; - * pass one to preview a language that has not been saved yet. + * Clock time only, honouring the 12h/24h choice and the locale. */ -export const formatTimeOnly = ( - timestamp: number, - timeFormat: TimeFormat = "auto", - locale: string = i18next.language || "en", -) => +export const formatTimeOnly = (timestamp: number, locale: string, timeFormat: TimeFormat = "auto") => new Date(timestamp).toLocaleTimeString(resolveLocale(locale), { hour: "numeric", minute: "2-digit", @@ -88,12 +78,12 @@ export const formatTimeOnly = ( /** Full date + time, used for tooltips on relative timestamps and for absolute task times. */ export const formatDateTime = ( timestamp: number, + locale: string, timeFormat: TimeFormat = "auto", dateFormat: DateFormat = "auto", - locale: string = i18next.language || "en", -) => `${formatDateOnly(timestamp, dateFormat, locale)}, ${formatTimeOnly(timestamp, timeFormat, locale)}` +) => `${formatDateOnly(timestamp, locale, dateFormat)}, ${formatTimeOnly(timestamp, locale, timeFormat)}` -export const formatTimeAgo = (timestamp: number) => { +export const formatTimeAgo = (timestamp: number, t: TFunction) => { const now = Date.now() const diff = now - timestamp const seconds = Math.floor(diff / 1000) @@ -105,40 +95,28 @@ export const formatTimeAgo = (timestamp: number) => { const years = Math.floor(days / 365) if (years > 0) { - return years === 1 - ? i18next.t("common:time_ago.year_ago") - : i18next.t("common:time_ago.years_ago", { count: years }) + return years === 1 ? t("common:time_ago.year_ago") : t("common:time_ago.years_ago", { count: years }) } if (months > 0) { - return months === 1 - ? i18next.t("common:time_ago.month_ago") - : i18next.t("common:time_ago.months_ago", { count: months }) + return months === 1 ? t("common:time_ago.month_ago") : t("common:time_ago.months_ago", { count: months }) } if (weeks > 0) { - return weeks === 1 - ? i18next.t("common:time_ago.week_ago") - : i18next.t("common:time_ago.weeks_ago", { count: weeks }) + return weeks === 1 ? t("common:time_ago.week_ago") : t("common:time_ago.weeks_ago", { count: weeks }) } if (days > 0) { - return days === 1 - ? i18next.t("common:time_ago.day_ago") - : i18next.t("common:time_ago.days_ago", { count: days }) + return days === 1 ? t("common:time_ago.day_ago") : t("common:time_ago.days_ago", { count: days }) } if (hours > 0) { - return hours === 1 - ? i18next.t("common:time_ago.hour_ago") - : i18next.t("common:time_ago.hours_ago", { count: hours }) + return hours === 1 ? t("common:time_ago.hour_ago") : t("common:time_ago.hours_ago", { count: hours }) } if (minutes > 0) { - return minutes === 1 - ? i18next.t("common:time_ago.minute_ago") - : i18next.t("common:time_ago.minutes_ago", { count: minutes }) + return minutes === 1 ? t("common:time_ago.minute_ago") : t("common:time_ago.minutes_ago", { count: minutes }) } if (seconds > 30) { - return i18next.t("common:time_ago.seconds_ago", { count: seconds }) + return t("common:time_ago.seconds_ago", { count: seconds }) } - return i18next.t("common:time_ago.just_now") + return t("common:time_ago.just_now") } const DAY_MS = 24 * 60 * 60 * 1000 @@ -164,35 +142,36 @@ const startOfDay = (ts: number) => { */ export const formatMessageTime = ( timestamp: number, + locale: string, { now = Date.now(), timeFormat = "auto", dateFormat = "auto", }: { now?: number; timeFormat?: TimeFormat; dateFormat?: DateFormat } = {}, ): string => { - const locale = resolveLocale(i18next.language) - const time = formatTimeOnly(timestamp, timeFormat, locale) + const formatLocale = resolveLocale(locale) + const time = formatTimeOnly(timestamp, formatLocale, timeFormat) const daysAgo = Math.round((startOfDay(now) - startOfDay(timestamp)) / DAY_MS) if (daysAgo === 0 || daysAgo === 1) { - const label = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-daysAgo, "day") + const label = new Intl.RelativeTimeFormat(formatLocale, { numeric: "auto" }).format(-daysAgo, "day") // Sentence case: "today" -> "Today" (a no-op for scripts without case). - return `${label.charAt(0).toLocaleUpperCase(locale)}${label.slice(1)} ${time}` + return `${label.charAt(0).toLocaleUpperCase(formatLocale)}${label.slice(1)} ${time}` } if (daysAgo > 1 && daysAgo < 7) { - return `${new Date(timestamp).toLocaleDateString(locale, { weekday: "short" })} ${time}` + return `${new Date(timestamp).toLocaleDateString(formatLocale, { weekday: "short" })} ${time}` } const sameYear = new Date(timestamp).getFullYear() === new Date(now).getFullYear() if (dateFormat !== "auto") { // Explicit numeric orders are always written in full so they stay unambiguous. - return `${formatDateOnly(timestamp, dateFormat, locale)}, ${time}` + return `${formatDateOnly(timestamp, formatLocale, dateFormat)}, ${time}` } const date = new Date(timestamp).toLocaleDateString( - locale, + formatLocale, sameYear ? { month: "short", day: "numeric" } : { month: "short", day: "numeric", year: "numeric" }, ) return `${date}, ${time}` diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 9e4addf7b1..3e84a03c83 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -1,5 +1,4 @@ -import i18next from "i18next" - +import type { TFunction } from "i18next" import { type ProviderSettings, type OrganizationAllowList, @@ -33,27 +32,28 @@ export function formatOpenAiExtraBodyValidationError( return t(`settings:validation.openAiExtraBody.${result.reason}`) } -function validateOpenAiExtraBody(apiConfiguration: ProviderSettings): string | undefined { +function validateOpenAiExtraBody(apiConfiguration: ProviderSettings, t: TFunction): string | undefined { if (apiConfiguration.apiProvider !== providerIdentifiers.openai) { return undefined } - return formatOpenAiExtraBodyValidationError(parseOpenAiExtraBody(apiConfiguration.openAiExtraBody), i18next.t) + return formatOpenAiExtraBodyValidationError(parseOpenAiExtraBody(apiConfiguration.openAiExtraBody), t) } export function validateApiConfiguration( apiConfiguration: ProviderSettings, + t: TFunction, routerModels?: RouterModels, organizationAllowList?: OrganizationAllowList, zooCodeIsAuthenticated?: boolean, ): string | undefined { - const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration, zooCodeIsAuthenticated) + const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration, t, zooCodeIsAuthenticated) if (keysAndIdsPresentErrorMessage) { return keysAndIdsPresentErrorMessage } - const extraBodyError = validateOpenAiExtraBody(apiConfiguration) + const extraBodyError = validateOpenAiExtraBody(apiConfiguration, t) if (extraBodyError) { return extraBodyError @@ -61,6 +61,7 @@ export function validateApiConfiguration( const organizationAllowListError = validateProviderAgainstOrganizationSettings( apiConfiguration, + t, organizationAllowList, ) @@ -68,82 +69,83 @@ export function validateApiConfiguration( return organizationAllowListError.message } - return validateDynamicProviderModelId(apiConfiguration, routerModels) + return validateDynamicProviderModelId(apiConfiguration, t, routerModels) } function validateModelsAndKeysProvided( apiConfiguration: ProviderSettings, + t: TFunction, zooCodeIsAuthenticated?: boolean, ): string | undefined { switch (apiConfiguration.apiProvider) { case providerIdentifiers.openrouter: if (!apiConfiguration.openRouterApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.requesty: if (!apiConfiguration.requestyApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.unbound: if (!apiConfiguration.unboundApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.litellm: if (!apiConfiguration.litellmApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.anthropic: if (!apiConfiguration.apiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.bedrock: if (!apiConfiguration.awsRegion) { - return i18next.t("settings:validation.awsRegion") + return t("settings:validation.awsRegion") } break case providerIdentifiers.vertex: if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) { - return i18next.t("settings:validation.googleCloud") + return t("settings:validation.googleCloud") } break case providerIdentifiers.gemini: if (!apiConfiguration.geminiApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.openaiNative: if (!apiConfiguration.openAiNativeApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.mistral: if (!apiConfiguration.mistralApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.openai: if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) { - return i18next.t("settings:validation.openAi") + return t("settings:validation.openAi") } break case providerIdentifiers.ollama: if (!apiConfiguration.ollamaModelId) { - return i18next.t("settings:validation.modelId") + return t("settings:validation.modelId") } break case providerIdentifiers.lmstudio: if (!apiConfiguration.lmStudioModelId) { - return i18next.t("settings:validation.modelId") + return t("settings:validation.modelId") } break case providerIdentifiers.vscodeLm: if (!apiConfiguration.vsCodeLmModelSelector) { - return i18next.t("settings:validation.modelSelector") + return t("settings:validation.modelSelector") } break case providerIdentifiers.githubCopilot: @@ -151,62 +153,62 @@ function validateModelsAndKeysProvided( apiConfiguration.vsCodeLmModelSelector?.vendor !== githubCopilotLanguageModel.vendor || (!apiConfiguration.vsCodeLmModelSelector.id && !apiConfiguration.vsCodeLmModelSelector.family) ) { - return i18next.t("settings:validation.modelSelector") + return t("settings:validation.modelSelector") } break case providerIdentifiers.fireworks: if (!apiConfiguration.fireworksApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.friendli: if (!apiConfiguration.friendliApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.qwenCode: if (!apiConfiguration.qwenCodeOauthPath) { - return i18next.t("settings:validation.qwenCodeOauthPath") + return t("settings:validation.qwenCodeOauthPath") } break case providerIdentifiers.kimiCode: if ((apiConfiguration.kimiCodeAuthMethod ?? "oauth") === "api-key" && !apiConfiguration.kimiCodeApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.vercelAiGateway: if (!apiConfiguration.vercelAiGatewayApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.opencodeGo: if (!apiConfiguration.opencodeGoApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.kenari: if (!apiConfiguration.kenariApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.ioIntelligence: if (!apiConfiguration.ioIntelligenceApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.nanogpt: if (!apiConfiguration.nanoGptApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break case providerIdentifiers.zooGateway: if (!apiConfiguration.zooSessionToken && !zooCodeIsAuthenticated) { - return i18next.t("settings:validation.zooGatewaySignIn") + return t("settings:validation.zooGatewaySignIn") } break case providerIdentifiers.baseten: if (!apiConfiguration.basetenApiKey) { - return i18next.t("settings:validation.apiKey") + return t("settings:validation.apiKey") } break } @@ -221,6 +223,7 @@ type ValidationError = { function validateProviderAgainstOrganizationSettings( apiConfiguration: ProviderSettings, + t: TFunction, organizationAllowList?: OrganizationAllowList, ): ValidationError | undefined { if (organizationAllowList && !organizationAllowList.allowAll) { @@ -234,7 +237,7 @@ function validateProviderAgainstOrganizationSettings( if (!providerConfig) { return { - message: i18next.t("settings:validation.providerNotAllowed", { provider }), + message: t("settings:validation.providerNotAllowed", { provider }), code: "PROVIDER_NOT_ALLOWED", } } @@ -245,7 +248,7 @@ function validateProviderAgainstOrganizationSettings( if (modelId && !allowedModels.includes(modelId)) { return { - message: i18next.t("settings:validation.modelNotAllowed", { + message: t("settings:validation.modelNotAllowed", { model: modelId, provider, }), @@ -265,10 +268,11 @@ function validateProviderAgainstOrganizationSettings( * provide valid ARNs without restriction. See issue #10108. * * @param arn The ARN string to validate + * @param t Translates the region mismatch message * @param region Optional region to check against the ARN's region * @returns An object with validation results: { isValid, arnRegion, errorMessage } */ -export function validateBedrockArn(arn: string, region?: string) { +export function validateBedrockArn(arn: string, t: TFunction, region?: string) { // Try to extract region from ARN for region mismatch warning. // This is a permissive regex that attempts to find the region component // without enforcing strict ARN format validation. @@ -280,7 +284,7 @@ export function validateBedrockArn(arn: string, region?: string) { return { isValid: true, arnRegion, - errorMessage: i18next.t("settings:validation.arn.regionMismatch", { arnRegion, region }), + errorMessage: t("settings:validation.arn.regionMismatch", { arnRegion, region }), } } @@ -290,6 +294,7 @@ export function validateBedrockArn(arn: string, region?: string) { function validateDynamicProviderModelId( apiConfiguration: ProviderSettings, + t: TFunction, routerModels?: RouterModels, ): string | undefined { const provider = apiConfiguration.apiProvider ?? "" @@ -302,13 +307,13 @@ function validateDynamicProviderModelId( const modelId = getModelId(apiConfiguration) if (!modelId) { - return i18next.t("settings:validation.modelId") + return t("settings:validation.modelId") } const models = routerModels?.[provider] if (models && Object.keys(models).length > 1 && !Object.keys(models).includes(modelId)) { - return i18next.t("settings:validation.modelAvailability", { modelId }) + return t("settings:validation.modelAvailability", { modelId }) } return undefined @@ -320,6 +325,7 @@ function validateDynamicProviderModelId( */ export function getModelValidationError( apiConfiguration: ProviderSettings, + t: TFunction, routerModels?: RouterModels, organizationAllowList?: OrganizationAllowList, ): string | undefined { @@ -330,13 +336,13 @@ export function getModelValidationError( apiModelId: modelId || "", } - const orgError = validateProviderAgainstOrganizationSettings(configWithModelId, organizationAllowList) + const orgError = validateProviderAgainstOrganizationSettings(configWithModelId, t, organizationAllowList) if (orgError && orgError.code === "MODEL_NOT_ALLOWED") { return orgError.message } - return validateDynamicProviderModelId(configWithModelId, routerModels) + return validateDynamicProviderModelId(configWithModelId, t, routerModels) } /** @@ -351,18 +357,19 @@ export function getModelValidationError( */ export function validateApiConfigurationExcludingModelErrors( apiConfiguration: ProviderSettings, + t: TFunction, _routerModels?: RouterModels, // Keeping this for compatibility with the old function. organizationAllowList?: OrganizationAllowList, ): string | undefined { if (apiConfiguration.apiProvider !== providerIdentifiers.zooGateway) { - const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration) + const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration, t) if (keysAndIdsPresentErrorMessage) { return keysAndIdsPresentErrorMessage } } - const extraBodyError = validateOpenAiExtraBody(apiConfiguration) + const extraBodyError = validateOpenAiExtraBody(apiConfiguration, t) if (extraBodyError) { return extraBodyError @@ -370,6 +377,7 @@ export function validateApiConfigurationExcludingModelErrors( const organizationAllowListError = validateProviderAgainstOrganizationSettings( apiConfiguration, + t, organizationAllowList, )