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..c3107ad8f0 100644 --- a/webview-ui/eslint.config.mjs +++ b/webview-ui/eslint.config.mjs @@ -1,14 +1,17 @@ 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 { createI18nDotKeyConfig, getI18nDotKeySelectors } from "@roo-code/config-eslint/i18n-keys" import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" +const i18nDotKeyConfig = createI18nDotKeyConfig(`${import.meta.dirname}/src/i18n/locales/en`) + /** @type {import("eslint").Linter.Config} */ export default [ { ignores: ["playwright/.cache/**"] }, ...reactConfig, createProviderIdentifierConfig({ providerIdentifiers, retiredProviderIdentifiers }), - createI18nDotKeyConfig(`${import.meta.dirname}/src/i18n/locales/en`), + i18nDotKeyConfig, { rules: { "@typescript-eslint/no-unused-vars": [ @@ -75,6 +78,49 @@ 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 block above replaces no-restricted-syntax for the same files, so repeat the i18n dot-key + // selectors. Keep the dot-key config's ignores so test files stay out of that check. + ...i18nDotKeyConfig, + files: ["src/**/*.{ts,tsx}"], + rules: { + "no-restricted-syntax": [ + "error", + ...i18nLanguageSwitchSelectors, + ...getI18nDotKeySelectors(i18nDotKeyConfig), + ], + }, + }, + { + // 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 +154,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 (