Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .roo/rules-translate/001-general-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 react-i18next's `useTranslation` and `Trans` (`import { Trans, useTranslation } from "react-i18next"`):
- Always pass the component's `t` to `Trans` (`<Trans t={t} i18nKey="..." />`); `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` to helpers instead, as `utils/format.ts` and `utils/validate.ts` do
- `useTranslation()` takes fully qualified keys (`t("chat:about")`); `useTranslation("chat")` also takes keys relative to that namespace (`t("about")`); fully qualified keys still work
- All user interface strings should be internationalized
- Always use the Trans component with named components for text with embedded components

Expand All @@ -40,6 +43,7 @@

```
<Trans
t={t}
i18nKey="welcome:telemetry.changeSettings"
components={{
settingsLink: <VSCodeLink href="#" onClick={handleOpenSettings} />
Expand Down
6 changes: 5 additions & 1 deletion .roo/skills/roo-translation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 react-i18next's `useTranslation` and `Trans` (`import { Trans, useTranslation } from "react-i18next"`):
- Always pass the component's `t` to `Trans` (`<Trans t={t} i18nKey="..." />`); `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` to helpers instead, as `utils/format.ts` and `utils/validate.ts` do
- `useTranslation()` takes fully qualified keys (`t("chat:about")`); `useTranslation("chat")` also takes keys relative to that namespace (`t("about")`); fully qualified keys still work
- All user interface strings should be internationalized
- Always use the `Trans` component with named components for text with embedded components

Expand All @@ -86,6 +89,7 @@ React component usage:

```tsx
<Trans
t={t}
i18nKey="welcome:telemetry.changeSettings"
components={{
settingsLink: <VSCodeLink href="#" onClick={handleOpenSettings} />,
Expand Down
34 changes: 34 additions & 0 deletions webview-ui/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,40 @@ export default [
"no-case-declarations": "off",
},
},
{
// Translations must come from the component's `t` (react-i18next's `useTranslation`), which changes
// identity on a language switch; the global `t` gives React (and React Compiler caches) nothing to
// invalidate on, so memoized output would keep the previous language.
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/i18n/**"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "i18next",
importNames: ["t"],
message:
"Use t from react-i18next's useTranslation so memoized output follows language changes.",
},
],
},
],
"no-restricted-syntax": [
"error",
{
selector: "MemberExpression[object.name='i18next'][property.name='t']",
message: "Use t from react-i18next's useTranslation so memoized output follows language changes.",
},
{
selector: "JSXOpeningElement[name.name='Trans']:not(:has(JSXAttribute[name.name='t']))",
message:
"Pass t={t} from useTranslation: Trans does not subscribe to language changes, so a cached <Trans> keeps the previous language.",
},
],
},
},
{
files: ["src/__mocks__/**/*.js"],
rules: {
Expand Down
17 changes: 5 additions & 12 deletions webview-ui/playwright/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import { I18nextProvider } from "react-i18next"
import { providerIdentifiers } from "@roo-code/types"

import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
import TranslationProvider from "@/i18n/TranslationContext"
import i18next, { loadTranslations } from "@/i18n/setup"
import { TooltipProvider } from "@/components/ui/tooltip"
import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext"

loadTranslations()

Expand Down Expand Up @@ -42,16 +40,11 @@ export function AppProviders({ children, initialState }: AppProvidersProps) {
return (
<I18nextProvider i18n={i18next}>
<ExtensionStateContextProvider initialState={{ ...defaultInitialState, ...initialState }}>
<TranslationProvider>
<PlaywrightTranslationContext.Provider
value={{ t: (key, options) => i18next.t(key, options), i18n: i18next }}>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<div data-testid="ct-app-shell">{children}</div>
</TooltipProvider>
</QueryClientProvider>
</PlaywrightTranslationContext.Provider>
</TranslationProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<div data-testid="ct-app-shell">{children}</div>
</TooltipProvider>
</QueryClientProvider>
</ExtensionStateContextProvider>
</I18nextProvider>
)
Expand Down
13 changes: 0 additions & 13 deletions webview-ui/playwright/TranslationContext.ts

This file was deleted.

4 changes: 0 additions & 4 deletions webview-ui/playwright/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") },
Expand Down
14 changes: 6 additions & 8 deletions webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"

import { type ExtensionMessage, TelemetryEventName } from "@roo-code/types"

import TranslationProvider from "./i18n/TranslationContext"
import "./i18n/setup"
import { MarketplaceViewStateManager } from "./components/marketplace/MarketplaceViewStateManager"

import { vscode } from "./utils/vscode"
Expand Down Expand Up @@ -344,13 +344,11 @@ const queryClient = new QueryClient()
const AppWithProviders = () => (
<ErrorBoundary>
<ExtensionStateContextProvider>
<TranslationProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
<App />
</TooltipProvider>
</QueryClientProvider>
</TranslationProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
<App />
</TooltipProvider>
</QueryClientProvider>
</ExtensionStateContextProvider>
</ErrorBoundary>
)
Expand Down
30 changes: 6 additions & 24 deletions webview-ui/src/__tests__/App.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,36 +114,18 @@ vi.mock("react-i18next", () => {
MockedComponent.displayName = `withTranslation(${Component.displayName || Component.name || "Component"})`
return MockedComponent
},
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useTranslation: () => {
return {
t: tFunction,
i18n: {
t: tFunction,
changeLanguage: vi.fn(() => Promise.resolve()),
},
}
},
initReactI18next: {
type: "3rdParty",
init: vi.fn(),
},
}
})

// Mock TranslationProvider to pass through children
vi.mock("@src/i18n/TranslationContext", () => {
const tFunction = (key: string) => key
return {
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useAppTranslation: () => ({
useTranslation: () => ({
t: tFunction,
i18n: {
t: tFunction,
changeLanguage: vi.fn(() => Promise.resolve()),
},
}),
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
initReactI18next: {
type: "3rdParty",
init: vi.fn(),
},
}
})

Expand Down
37 changes: 6 additions & 31 deletions webview-ui/src/__tests__/FileChangesPanel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React from "react"
import { act } from "@testing-library/react"
import { fireEvent, render, screen } from "@/utils/test-utils"
import type { ClineMessage } from "@roo-code/types"
import { TranslationProvider } from "@/i18n/__mocks__/TranslationContext"
import FileChangesPanel from "../components/chat/FileChangesPanel"

const mockPostMessage = vi.fn()
Expand Down Expand Up @@ -69,11 +68,7 @@ function createFileEditMessage(
}

function renderPanel(messages: ClineMessage[] | undefined, taskId?: string) {
return render(
<TranslationProvider>
<FileChangesPanel clineMessages={messages} taskId={taskId} />
</TranslationProvider>,
)
return render(<FileChangesPanel clineMessages={messages} taskId={taskId} />)
}

describe("FileChangesPanel", () => {
Expand Down Expand Up @@ -333,11 +328,7 @@ describe("FileChangesPanel", () => {
expandRow()
expect(requestsOfType("readOriginalContent")).toHaveLength(1)

rerender(
<TranslationProvider>
<FileChangesPanel clineMessages={messages} taskId="task-1" />
</TranslationProvider>,
)
rerender(<FileChangesPanel clineMessages={messages} taskId="task-1" />)
fireEvent.click(screen.getByTestId("accordian-toggle"))

const requests = requestsOfType("readOriginalContent")
Expand All @@ -347,11 +338,7 @@ describe("FileChangesPanel", () => {

it("does not send a duplicate request when switching A -> B -> A before the first response arrives", () => {
const messages = [createEditWithOriginal({ originalContentLength: 5000 })]
const panel = (taskId: string) => (
<TranslationProvider>
<FileChangesPanel clineMessages={messages} taskId={taskId} />
</TranslationProvider>
)
const panel = (taskId: string) => <FileChangesPanel clineMessages={messages} taskId={taskId} />
const { rerender } = renderPanel(messages, "task-A")
expandRow()
expect(requestsOfType("readOriginalContent")).toHaveLength(1)
Expand All @@ -372,11 +359,7 @@ describe("FileChangesPanel", () => {

it("requests again after a response that arrived for a task that is no longer current", () => {
const messages = [createEditWithOriginal({ originalContentLength: 5000 })]
const panel = (taskId: string) => (
<TranslationProvider>
<FileChangesPanel clineMessages={messages} taskId={taskId} />
</TranslationProvider>
)
const panel = (taskId: string) => <FileChangesPanel clineMessages={messages} taskId={taskId} />
const { rerender } = renderPanel(messages, "task-A")
expandRow()

Expand All @@ -397,11 +380,7 @@ describe("FileChangesPanel", () => {
expandRow()
mockPostMessage.mockClear()

rerender(
<TranslationProvider>
<FileChangesPanel clineMessages={messages} taskId="task-B" />
</TranslationProvider>,
)
rerender(<FileChangesPanel clineMessages={messages} taskId="task-B" />)

expect(mockPostMessage).not.toHaveBeenCalled()
})
Expand All @@ -417,11 +396,7 @@ describe("FileChangesPanel", () => {
})
expect(requestsOfType("readOriginalContent")).toHaveLength(1)

rerender(
<TranslationProvider>
<FileChangesPanel clineMessages={[...first]} taskId="task-A" />
</TranslationProvider>,
)
rerender(<FileChangesPanel clineMessages={[...first]} taskId="task-A" />)
fireEvent.click(screen.getByTestId("accordian-toggle"))
respond({ type: "fileContent", fileContent: { path: "src/foo.ts", content: "new line\n" } })

Expand Down
7 changes: 3 additions & 4 deletions webview-ui/src/components/chat/Announcement.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { memo, type MouseEvent, type ReactNode, useState } from "react"
import { Trans } from "react-i18next"
import { SiDiscord, SiReddit, SiX } from "react-icons/si"

import { Package } from "@roo/package"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { Trans, useTranslation } from "react-i18next"
import { vscode } from "@src/utils/vscode"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@src/components/ui"
import { EXTERNAL_LINKS } from "@src/constants/externalLinks"
Expand All @@ -24,7 +23,7 @@ interface AnnouncementProps {
*/

const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {
const { t } = useAppTranslation()
const { t } = useTranslation()
const [open, setOpen] = useState(true)

return (
Expand Down Expand Up @@ -72,7 +71,7 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {
</div>

<div className="mt-3 text-sm text-center text-vscode-descriptionForeground">
<Trans i18nKey="chat:announcement.support" components={{ githubLink: <GitHubLink /> }} />
<Trans t={t} i18nKey="chat:announcement.support" components={{ githubLink: <GitHubLink /> }} />
</div>
</div>
</DialogContent>
Expand Down
4 changes: 2 additions & 2 deletions webview-ui/src/components/chat/ApiConfigSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"
import { enabledSelectorTriggerClassName, selectorTriggerClassName } from "@/components/ui/selectorTriggerStyles"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useTranslation } from "react-i18next"
import { vscode } from "@/utils/vscode"
import { Button } from "@/components/ui"

Expand Down Expand Up @@ -38,7 +38,7 @@ export const ApiConfigSelector = ({
lockApiConfigAcrossModes,
onToggleLockApiConfig,
}: ApiConfigSelectorProps) => {
const { t } = useAppTranslation()
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState("")
const portalContainer = useRooPortal("roo-portal")
Expand Down
4 changes: 2 additions & 2 deletions webview-ui/src/components/chat/AutoApproveDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { enabledSelectorTriggerClassName, selectorTriggerClassName } from "@/com

import { useExtensionState } from "@/context/ExtensionStateContext"

import { useAppTranslation } from "@/i18n/TranslationContext"
import { useTranslation } from "react-i18next"

import { useAutoApprovalToggles } from "@/hooks/useAutoApprovalToggles"
import { useAutoApprovalState } from "@/hooks/useAutoApprovalState"
Expand All @@ -27,7 +27,7 @@ interface AutoApproveDropdownProps {
export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }: AutoApproveDropdownProps) => {
const [open, setOpen] = React.useState(false)
const portalContainer = useRooPortal("roo-portal")
const { t } = useAppTranslation()
const { t } = useTranslation()

const {
autoApprovalEnabled,
Expand Down
Loading
Loading