= ({
{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 (
-
+
- {formatMessageTime(ts, { timeFormat, dateFormat })}
+ {formatMessageTime(ts, language, { timeFormat, dateFormat })}
)
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 (
(
- visualTestTranslations[key] ?? key,
- i18n: null as unknown as typeof import("../../../i18n/setup").default,
- }}>
+
diff --git a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts
index 95e7a8d1b6..f9386d0a3f 100644
--- a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts
+++ b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts
@@ -9,10 +9,10 @@ export const visualTestTranslations: Record = {
"welcome:telemetry.decline": "Decline",
}
-// Trans reads from its own react-i18next instance rather than the useAppTranslation
-// context, so it needs a real (if minimal) i18next init to resolve helpImproveMessage
-// and the settingsLink interpolation instead of rendering nothing. init() returns a
-// promise even for inline resources, so callers must await it before mounting.
+// TelemetryBanner translates, Trans included, with the useAppTranslation context's t, so the
+// fixture serves this instance through that context. It needs a real (if minimal) i18next init to
+// resolve helpImproveMessage and the settingsLink interpolation instead of rendering nothing.
+// init() returns a promise even for inline resources, so callers must await it before mounting.
export const visualTestI18n = i18next.createInstance()
export const visualTestI18nReady = visualTestI18n.use(initReactI18next).init({
diff --git a/webview-ui/src/components/history/TaskItemFooter.tsx b/webview-ui/src/components/history/TaskItemFooter.tsx
index 2a0702f2a9..ef7c438c28 100644
--- a/webview-ui/src/components/history/TaskItemFooter.tsx
+++ b/webview-ui/src/components/history/TaskItemFooter.tsx
@@ -28,7 +28,7 @@ const TaskItemFooter: React.FC = ({
onDelete,
onRename,
}) => {
- const { t } = useAppTranslation()
+ const { t, language } = useAppTranslation()
// Optional read: this leaf renders standalone (tests, previews) without a provider.
const ext = useContext(ExtensionStateContext)
const timeFormat = ext?.timeFormat
@@ -56,11 +56,13 @@ const TaskItemFooter: React.FC = ({
{/* Datetime. A relative time carries the full date and time in a tooltip; the absolute time already
shows it, so it gets no tooltip that would only repeat it. */}
{showRelativeTimes ? (
-
- {formatTimeAgo(item.ts)}
+
+ {formatTimeAgo(item.ts, t)}
) : (
- {formatDateTime(item.ts, timeFormat, dateFormat)}
+
+ {formatDateTime(item.ts, language, timeFormat, dateFormat)}
+
)}
{/* Cost */}
diff --git a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx
index c266558e8a..984a52dee8 100644
--- a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx
+++ b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx
@@ -4,9 +4,13 @@ import { formatDateTime } from "@/utils/format"
import TaskItemFooter from "../TaskItemFooter"
+// The language the hook reports; a test can switch it to check the footer formats with it.
+const translation = vi.hoisted(() => ({ language: "en" }))
+
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
+ language: translation.language,
}),
}))
@@ -151,6 +155,10 @@ describe("TaskItemFooter", () => {
})
describe("time display settings", () => {
+ afterEach(() => {
+ translation.language = "en"
+ })
+
const renderWith = (state: Record) =>
render(
@@ -204,8 +212,25 @@ describe("TaskItemFooter", () => {
it("passes the chosen time and date formats to the formatter", () => {
renderWith({ showRelativeTimes: false, timeFormat: "24h", dateFormat: "dmy" })
- expect(formatDateTime).toHaveBeenCalledWith(mockItem.ts, "24h", "dmy")
+ expect(formatDateTime).toHaveBeenCalledWith(mockItem.ts, "en", "24h", "dmy")
expect(screen.getByText("15/01/2024, 14:30")).toBeInTheDocument()
})
+
+ // With automatic formats the locale decides; English would give "Jan 15, 2024, 2:30 PM".
+ describe("in a non-English language with automatic formats", () => {
+ beforeEach(() => {
+ translation.language = "de"
+ })
+
+ it("formats the absolute time in that language", () => {
+ renderWith({ showRelativeTimes: false, timeFormat: "auto", dateFormat: "auto" })
+ expect(screen.getByText("15.01.2024, 14:30")).toBeInTheDocument()
+ })
+
+ it("formats the relative time's tooltip in that language", () => {
+ renderWith({ showRelativeTimes: true, timeFormat: "auto", dateFormat: "auto" })
+ expect(tooltipAround("2 hours ago")).toHaveAttribute("data-content", "15.01.2024, 14:30")
+ })
+ })
})
})
diff --git a/webview-ui/src/components/marketplace/IssueFooter.tsx b/webview-ui/src/components/marketplace/IssueFooter.tsx
index a40cefe5ad..27dc63a9e8 100644
--- a/webview-ui/src/components/marketplace/IssueFooter.tsx
+++ b/webview-ui/src/components/marketplace/IssueFooter.tsx
@@ -1,13 +1,15 @@
import React from "react"
import { Trans } from "react-i18next"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { EXTERNAL_LINKS } from "@/constants/externalLinks"
export const IssueFooter: React.FC = () => {
+ const { t } = useAppTranslation()
return (
-
+
Open a GitHub issue
diff --git a/webview-ui/src/components/marketplace/__tests__/IssueFooter.spec.tsx b/webview-ui/src/components/marketplace/__tests__/IssueFooter.spec.tsx
index ab1ed39860..be27bfc737 100644
--- a/webview-ui/src/components/marketplace/__tests__/IssueFooter.spec.tsx
+++ b/webview-ui/src/components/marketplace/__tests__/IssueFooter.spec.tsx
@@ -1,6 +1,8 @@
import React from "react"
-import { render, screen } from "@/utils/test-utils"
+import { screen } from "@/utils/test-utils"
+import i18next from "@src/i18n/setup"
+import { renderTranslated } from "@src/i18n/__tests__/renderTranslated"
import { EXTERNAL_LINKS } from "@/constants/externalLinks"
import { IssueFooter } from "../IssueFooter"
@@ -13,17 +15,35 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({
),
}))
-vi.mock("react-i18next", () => ({
- Trans: ({ children }: { children: React.ReactNode }) => <>{children}>,
-}))
-
describe("IssueFooter", () => {
+ beforeEach(async () => {
+ await i18next.changeLanguage("en")
+ })
+
it("links marketplace issue reporting to the Zoo Code repository", () => {
- render( )
+ renderTranslated( )
expect(screen.getByRole("link", { name: "Open a GitHub issue" })).toHaveAttribute(
"href",
EXTERNAL_LINKS.MARKETPLACE_ISSUE,
)
})
+
+ it("resolves the footer text and follows a UI language change", async () => {
+ const { container, setLanguage } = renderTranslated( )
+
+ expect(container.textContent).toBe(
+ "Found a problem with a marketplace item or have suggestions for new ones? Open a GitHub issue to let us know!",
+ )
+
+ await setLanguage("es")
+
+ expect(container.textContent).toBe(
+ "¿Encontraste un problema con un elemento del marketplace o tienes sugerencias para nuevos? ¡Abre un issue en GitHub para hacérnoslo saber!",
+ )
+ expect(screen.getByRole("link", { name: "Abre un issue en GitHub" })).toHaveAttribute(
+ "href",
+ EXTERNAL_LINKS.MARKETPLACE_ISSUE,
+ )
+ })
})
diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx
index 3b8df52176..f6cf3d4e69 100644
--- a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx
+++ b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx
@@ -172,7 +172,7 @@ export const MarketplaceItemCard: React.FC = ({ item,
key={tag}
content={
filters.tags.includes(tag)
- ? t("marketplace:filters.tags.clear", { count: tag })
+ ? t("marketplace:filters.tags.clear")
: t("marketplace:filters.tags.clickToFilter")
}>
-
+
diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx
index d5512009a5..529c90cbc6 100644
--- a/webview-ui/src/components/modes/ModesView.tsx
+++ b/webview-ui/src/components/modes/ModesView.tsx
@@ -710,7 +710,7 @@ const ModesView = () => {
-
+
{
/>
{
{t("prompts:globalCustomInstructions.title")}
-
+
{
/>
,
@@ -160,6 +161,7 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
,
diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx
index 6e22859618..a3aeb1ddde 100644
--- a/webview-ui/src/components/settings/ApiOptions.tsx
+++ b/webview-ui/src/components/settings/ApiOptions.tsx
@@ -308,11 +308,12 @@ const ApiOptions = ({
const apiValidationResult = validateApiConfigurationExcludingModelErrors(
apiConfiguration,
+ t,
routerModels,
organizationAllowList,
)
setErrorMessage(apiValidationResult)
- }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage, isRetiredSelectedProvider])
+ }, [apiConfiguration, t, routerModels, organizationAllowList, setErrorMessage, isRetiredSelectedProvider])
const onProviderChange = useCallback(
(value: ProviderName) => {
@@ -378,8 +379,8 @@ const ApiOptions = ({
)
const modelValidationError = useMemo(() => {
- return getModelValidationError(apiConfiguration, routerModels, organizationAllowList)
- }, [apiConfiguration, routerModels, organizationAllowList])
+ return getModelValidationError(apiConfiguration, t, routerModels, organizationAllowList)
+ }, [apiConfiguration, t, routerModels, organizationAllowList])
const docs = useMemo(() => {
const provider = PROVIDERS.find(({ value }) => value === selectedProvider)
diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx
index 58034960fb..2930d975a4 100644
--- a/webview-ui/src/components/settings/AutoApproveSettings.tsx
+++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx
@@ -145,6 +145,7 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.description")}
{t("settings:checkpoints.enable.label")}
-
+
diff --git a/webview-ui/src/components/settings/LanguageSettings.tsx b/webview-ui/src/components/settings/LanguageSettings.tsx
index c5dc5fa918..2e929da55b 100644
--- a/webview-ui/src/components/settings/LanguageSettings.tsx
+++ b/webview-ui/src/components/settings/LanguageSettings.tsx
@@ -3,7 +3,6 @@ import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { useAppTranslation } from "@/i18n/TranslationContext"
-
import type { Language } from "@roo-code/types"
import { LANGUAGES } from "@roo/language"
@@ -45,9 +44,9 @@ export const LanguageSettings = ({
// Three short Intl calls, computed on every render. They use the language selected above, not the
// saved one, so "auto" previews the new language before Save.
const preview = {
- chatTime: formatTimeOnly(SAMPLE_TS, timeFormat, language),
- dateExample: formatDateOnly(SAMPLE_TS, dateFormat, language),
- hover: formatDateTime(SAMPLE_TS, timeFormat, dateFormat, language),
+ chatTime: formatTimeOnly(SAMPLE_TS, language, timeFormat),
+ dateExample: formatDateOnly(SAMPLE_TS, language, dateFormat),
+ hover: formatDateTime(SAMPLE_TS, language, timeFormat, dateFormat),
}
return (
diff --git a/webview-ui/src/components/settings/MaxCostInput.tsx b/webview-ui/src/components/settings/MaxCostInput.tsx
index 177ece5a1d..f77ca2773c 100644
--- a/webview-ui/src/components/settings/MaxCostInput.tsx
+++ b/webview-ui/src/components/settings/MaxCostInput.tsx
@@ -1,4 +1,4 @@
-import { useTranslation } from "react-i18next"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
import { FormattedTextField, unlimitedDecimalFormatter } from "../common/FormattedTextField"
@@ -8,7 +8,7 @@ interface MaxCostInputProps {
}
export function MaxCostInput({ allowedMaxCost, onValueChange }: MaxCostInputProps) {
- const { t } = useTranslation()
+ const { t } = useAppTranslation()
return (
<>
diff --git a/webview-ui/src/components/settings/MaxLimitInputs.tsx b/webview-ui/src/components/settings/MaxLimitInputs.tsx
index c09d886eb3..4ed9a22519 100644
--- a/webview-ui/src/components/settings/MaxLimitInputs.tsx
+++ b/webview-ui/src/components/settings/MaxLimitInputs.tsx
@@ -1,5 +1,5 @@
import React from "react"
-import { useTranslation } from "react-i18next"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
import { MaxRequestsInput } from "./MaxRequestsInput"
import { MaxCostInput } from "./MaxCostInput"
@@ -16,7 +16,7 @@ export const MaxLimitInputs: React.FC = ({
onMaxRequestsChange,
onMaxCostChange,
}) => {
- const { t } = useTranslation()
+ const { t } = useAppTranslation()
return (
diff --git a/webview-ui/src/components/settings/MaxRequestsInput.tsx b/webview-ui/src/components/settings/MaxRequestsInput.tsx
index 72dcad5123..1071dd5bd7 100644
--- a/webview-ui/src/components/settings/MaxRequestsInput.tsx
+++ b/webview-ui/src/components/settings/MaxRequestsInput.tsx
@@ -1,4 +1,4 @@
-import { useTranslation } from "react-i18next"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
import { FormattedTextField, unlimitedIntegerFormatter } from "../common/FormattedTextField"
@@ -8,7 +8,7 @@ interface MaxRequestsInputProps {
}
export function MaxRequestsInput({ allowedMaxRequests, onValueChange }: MaxRequestsInputProps) {
- const { t } = useTranslation()
+ const { t } = useAppTranslation()
return (
<>
diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx
index ecc3678a97..f8043827c7 100644
--- a/webview-ui/src/components/settings/ModelPicker.tsx
+++ b/webview-ui/src/components/settings/ModelPicker.tsx
@@ -3,6 +3,7 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { Trans } from "react-i18next"
import { ChevronsUpDown, Check, X, Info } from "lucide-react"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
import {
type ProviderSettings,
type ModelInfo,
@@ -11,7 +12,6 @@ import {
providerIdentifiers,
} from "@roo-code/types"
-import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import { filterModels } from "./utils/organizationFilters"
import { cn } from "@src/lib/utils"
@@ -316,6 +316,7 @@ export const ModelPicker = ({
{!hidePricing && apiConfiguration.apiProvider !== providerIdentifiers.mimo && (
,
diff --git a/webview-ui/src/components/settings/SkillsSettings.tsx b/webview-ui/src/components/settings/SkillsSettings.tsx
index bf81d0c6c7..b6de39df5c 100644
--- a/webview-ui/src/components/settings/SkillsSettings.tsx
+++ b/webview-ui/src/components/settings/SkillsSettings.tsx
@@ -2,11 +2,11 @@ import React, { useState, useEffect, useMemo, useCallback } from "react"
import { Plus, Globe, Folder, Edit, Trash2, Settings } from "lucide-react"
import { Trans } from "react-i18next"
+import { useAppTranslation } from "@/i18n/TranslationContext"
import type { SkillMetadata } from "@roo-code/types"
import { getAllModes } from "@roo/modes"
-import { useAppTranslation } from "@/i18n/TranslationContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import {
AlertDialog,
@@ -218,6 +218,7 @@ export const SkillsSettings: React.FC = () => {
{
{/* Fixed Footer */}
{
-
+
-
+
{t("settings:terminal.inheritEnv.label")}
-
+
-
+
{terminalCommandDelay ?? 0}ms
-
+
-
+
-
+
{t("settings:terminal.zshOhMy.label")}
-
+
{t("settings:terminal.zshP10k.label")}
-
+
{t("settings:terminal.zdotdir.label")}
-
+
({
vscode: { postMessage: vi.fn() },
}))
-vi.mock("react-i18next", () => ({
- useTranslation: () => {
+vi.mock("@src/i18n/TranslationContext", () => ({
+ useAppTranslation: () => {
const translations: Record = {
"settings:autoApprove.apiCostLimit.title": "Max API Cost",
"settings:autoApprove.apiCostLimit.unlimited": "Unlimited",
diff --git a/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx b/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx
index 59b495bcd9..ec4540cd02 100644
--- a/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx
+++ b/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx
@@ -6,8 +6,8 @@ vi.mock("@/utils/vscode", () => ({
vscode: { postMessage: vi.fn() },
}))
-vi.mock("react-i18next", () => ({
- useTranslation: () => {
+vi.mock("@src/i18n/TranslationContext", () => ({
+ useAppTranslation: () => {
const translations: Record = {
"settings:autoApprove.apiRequestLimit.title": "Max Count",
"settings:autoApprove.apiRequestLimit.unlimited": "Unlimited",
diff --git a/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx
index b007d4be69..57d9d8fd0e 100644
--- a/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx
+++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.visual.fixture.tsx
@@ -4,6 +4,7 @@ import { OpenAiServiceTier, type ModelInfo } from "@roo-code/types/model"
import { providerIdentifiers } from "@roo-code/types/provider-identifiers"
import { TranslationContext } from "@src/i18n/TranslationContext"
+import { createFixtureTranslation } from "@src/i18n/__tests__/createFixtureI18n.visual.i18n"
import { ModelInfoView } from "../ModelInfoView"
const modelInfo: ModelInfo = {
@@ -49,12 +50,10 @@ const translations: Record = {
"settings:serviceTier.priority": "Priority",
}
+const translation = createFixtureTranslation(translations)
+
export const ModelInfoViewFixture = () => (
- translations[key] ?? key,
- i18n: null as unknown as typeof import("../../../i18n/setup").default,
- }}>
+
{
const { awsCustomArn, awsRegion } = apiConfiguration
- return awsCustomArn ? validateBedrockArn(awsCustomArn, awsRegion) : { isValid: true, errorMessage: undefined }
- }, [apiConfiguration])
+ return awsCustomArn
+ ? validateBedrockArn(awsCustomArn, t, awsRegion)
+ : { isValid: true, errorMessage: undefined }
+ }, [apiConfiguration, t])
const baseModelId =
resolveBedrockCustomArnBaseModelId(apiConfiguration.awsCustomArn, apiConfiguration.awsCustomArnBaseModelId) ??
diff --git a/webview-ui/src/components/settings/providers/LMStudio.tsx b/webview-ui/src/components/settings/providers/LMStudio.tsx
index c83aae63ef..25f2594486 100644
--- a/webview-ui/src/components/settings/providers/LMStudio.tsx
+++ b/webview-ui/src/components/settings/providers/LMStudio.tsx
@@ -4,6 +4,7 @@ import { Trans } from "react-i18next"
import { Checkbox } from "vscrui"
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
import {
type ProviderSettings,
type ExtensionMessage,
@@ -12,7 +13,6 @@ import {
providerIdentifiers,
} from "@roo-code/types"
-import { useAppTranslation } from "@src/i18n/TranslationContext"
import { requestLmStudioModels } from "@src/components/ui/hooks/useLmStudioModels"
import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
@@ -158,6 +158,7 @@ export const LMStudio = ({ apiConfiguration, setApiConfigurationField }: LMStudi
)}
,
diff --git a/webview-ui/src/components/settings/providers/Vertex.tsx b/webview-ui/src/components/settings/providers/Vertex.tsx
index 3953f2322e..a8c8167367 100644
--- a/webview-ui/src/components/settings/providers/Vertex.tsx
+++ b/webview-ui/src/components/settings/providers/Vertex.tsx
@@ -3,9 +3,9 @@ import { Trans } from "react-i18next"
import { Checkbox } from "vscrui"
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
import { type ProviderSettings, VERTEX_REGIONS, VERTEX_1M_CONTEXT_MODEL_IDS, looksLikeFilePath } from "@roo-code/types"
-import { useAppTranslation } from "@src/i18n/TranslationContext"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui"
import { inputEventTransform } from "../transforms"
@@ -80,6 +80,7 @@ export const Vertex = ({ apiConfiguration, setApiConfigurationField }: VertexPro
role="status"
className="text-sm text-vscode-errorForeground">
,
diff --git a/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx
index 49cfdeb783..36e2d90643 100644
--- a/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx
+++ b/webview-ui/src/components/settings/providers/__tests__/Ollama.visual.fixture.tsx
@@ -1,12 +1,11 @@
/* v8 ignore file -- Playwright component fixture is covered by the visual test. */
import React, { useState } from "react"
-import i18next from "i18next"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { providerIdentifiers, type ProviderSettings } from "@roo-code/types"
-import { TranslationContext as AppTranslationContext } from "@/i18n/TranslationContext"
-import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext"
+import { TranslationContext } from "@src/i18n/TranslationContext"
+import { createFixtureTranslation } from "@src/i18n/__tests__/createFixtureI18n.visual.i18n"
import { TooltipProvider } from "@src/components/ui/tooltip"
import { Ollama } from "../Ollama"
import enSettings from "@/i18n/locales/en/settings.json"
@@ -24,10 +23,7 @@ function flattenTranslations(obj: Record
, prefix = "settings:")
return result
}
-// An uninitialized instance is a valid `i18n` for the context. The stories read `t`.
-const i18n = i18next.createInstance()
-
-const translations = flattenTranslations(enSettings as Record)
+const translation = createFixtureTranslation(flattenTranslations(enSettings as Record))
const apiConfiguration: ProviderSettings = {
apiProvider: providerIdentifiers.ollama,
@@ -45,19 +41,9 @@ const Providers = ({ children }: React.PropsWithChildren) => {
)
return (
- translations[key] ?? key,
- i18n,
- }}>
- translations[key] ?? key,
- i18n,
- }}>
- {children}
-
-
+
+ {children}
+
)
}
diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx
index 2061f00614..114ce05c54 100644
--- a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx
+++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx
@@ -4,6 +4,7 @@ import React from "react"
import { OpenAiCodexServiceTier } from "@roo-code/types/model"
import { TranslationContext } from "@src/i18n/TranslationContext"
+import { createFixtureTranslation } from "@src/i18n/__tests__/createFixtureI18n.visual.i18n"
import { TooltipProvider } from "@src/components/ui/tooltip"
import { OpenAICodexSpeedSelector } from "../OpenAICodexSpeedSelector"
@@ -16,12 +17,10 @@ const translations: Record = {
"settings:openAiCodexSpeed.fast": "Fast (1.5x speed, increased usage)",
}
+const translation = createFixtureTranslation(translations)
+
export const OpenAICodexFixture = () => (
- translations[key] ?? key,
- i18n: null as unknown as typeof import("../../../../i18n/setup").default,
- }}>
+
{}} />
diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx
index 9e0aeb71bc..0fe06a0364 100644
--- a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx
+++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx
@@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { providerIdentifiers, type ProviderSettings } from "@roo-code/types"
-import { TranslationContext as AppTranslationContext } from "@/i18n/TranslationContext"
-import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext"
+import { TranslationContext } from "@src/i18n/TranslationContext"
+import { createFixtureTranslation } from "@src/i18n/__tests__/createFixtureI18n.visual.i18n"
import { TooltipProvider } from "@src/components/ui/tooltip"
import { OpenAICompatible } from "../OpenAICompatible"
import enSettings from "@/i18n/locales/en/settings.json"
@@ -23,7 +23,7 @@ function flattenTranslations(obj: Record, prefix = "settings:")
return result
}
-const translations = flattenTranslations(enSettings as Record)
+const translation = createFixtureTranslation(flattenTranslations(enSettings as Record))
const apiConfiguration: ProviderSettings = {
apiProvider: providerIdentifiers.openai,
@@ -48,19 +48,9 @@ const Providers = ({ children }: React.PropsWithChildren) => {
)
return (
- translations[key] ?? key,
- i18n: null as unknown as typeof import("../../../../i18n/setup").default,
- }}>
- translations[key] ?? key,
- i18n: null as unknown as typeof import("../../../../i18n/setup").default,
- }}>
- {children}
-
-
+
+ {children}
+
)
}
diff --git a/webview-ui/src/components/ui/select-dropdown.tsx b/webview-ui/src/components/ui/select-dropdown.tsx
index 288d81b16f..836605a03c 100644
--- a/webview-ui/src/components/ui/select-dropdown.tsx
+++ b/webview-ui/src/components/ui/select-dropdown.tsx
@@ -2,7 +2,7 @@ import * as React from "react"
import { CaretUpIcon } from "@radix-ui/react-icons"
import { Check, X } from "lucide-react"
import { Fzf } from "fzf"
-import { useTranslation } from "react-i18next"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
import { cn } from "@/lib/utils"
import { useRooPortal } from "./hooks/useRooPortal"
@@ -62,7 +62,7 @@ export const SelectDropdown = React.memo(
},
ref,
) => {
- const { t } = useTranslation()
+ const { t } = useAppTranslation()
const [open, setOpen] = React.useState(false)
const [searchValue, setSearchValue] = React.useState("")
const searchInputRef = React.useRef(null)
diff --git a/webview-ui/src/components/welcome/RooTips.tsx b/webview-ui/src/components/welcome/RooTips.tsx
index b180a53c26..d726647722 100644
--- a/webview-ui/src/components/welcome/RooTips.tsx
+++ b/webview-ui/src/components/welcome/RooTips.tsx
@@ -1,6 +1,6 @@
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
-import { useTranslation } from "react-i18next"
import { Trans } from "react-i18next"
+import { useAppTranslation } from "@src/i18n/TranslationContext"
import { buildDocLink } from "@src/utils/docLinks"
import { ReplaceAll, Users } from "lucide-react"
@@ -9,24 +9,24 @@ const tips = [
{
icon: ,
href: buildDocLink("basic-usage/using-modes", "tips"),
- titleKey: "rooTips.customizableModes.title",
- descriptionKey: "rooTips.customizableModes.description",
+ titleKey: "chat:rooTips.customizableModes.title",
+ descriptionKey: "chat:rooTips.customizableModes.description",
},
{
icon: ,
href: buildDocLink("getting-started/connecting-api-provider", "tips"),
- titleKey: "rooTips.modelAgnostic.title",
- descriptionKey: "rooTips.modelAgnostic.description",
+ titleKey: "chat:rooTips.modelAgnostic.title",
+ descriptionKey: "chat:rooTips.modelAgnostic.description",
},
]
const RooTips = () => {
- const { t } = useTranslation("chat")
+ const { t } = useAppTranslation()
return (
-
+
{tips.map((tip) => (
@@ -43,6 +43,7 @@ const RooTips = () => {
-
+
diff --git a/webview-ui/src/components/welcome/WelcomeViewProvider.tsx b/webview-ui/src/components/welcome/WelcomeViewProvider.tsx
index b038146fd9..cffd4fe91e 100644
--- a/webview-ui/src/components/welcome/WelcomeViewProvider.tsx
+++ b/webview-ui/src/components/welcome/WelcomeViewProvider.tsx
@@ -8,6 +8,7 @@ import { useExtensionState } from "@src/context/ExtensionStateContext"
import { validateApiConfiguration } from "@src/utils/validate"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@src/i18n/TranslationContext"
+import type { TFunction } from "i18next"
import { Button } from "@src/components/ui"
import ApiOptions from "../settings/ApiOptions"
@@ -21,6 +22,7 @@ const DEFAULT_WELCOME_API_CONFIGURATION: ProviderSettings = {
}
const getWelcomeApiConfiguration = (
+ t: TFunction,
apiConfiguration: ProviderSettings,
zooCodeIsAuthenticated?: boolean,
): ProviderSettings => {
@@ -30,7 +32,7 @@ const getWelcomeApiConfiguration = (
return DEFAULT_WELCOME_API_CONFIGURATION
}
- const validationError = validateApiConfiguration(apiConfiguration, undefined, undefined, zooCodeIsAuthenticated)
+ const validationError = validateApiConfiguration(apiConfiguration, t, undefined, undefined, zooCodeIsAuthenticated)
if (validationError) {
return DEFAULT_WELCOME_API_CONFIGURATION
}
@@ -46,7 +48,7 @@ const WelcomeViewProvider = () => {
const [showProviderSetup, setShowProviderSetup] = useState(false)
const [welcomeApiConfiguration, setWelcomeApiConfiguration] = useState()
const effectiveApiConfiguration =
- welcomeApiConfiguration ?? getWelcomeApiConfiguration(apiConfiguration, zooCodeIsAuthenticated)
+ welcomeApiConfiguration ?? getWelcomeApiConfiguration(t, apiConfiguration, zooCodeIsAuthenticated)
const setApiConfigurationFieldForApiOptions = useCallback(
(field: K, value: ProviderSettings[K]) => {
@@ -61,7 +63,7 @@ const WelcomeViewProvider = () => {
const handleGetStarted = useCallback(() => {
if (!showProviderSetup) {
- const initialApiConfiguration = getWelcomeApiConfiguration(apiConfiguration, zooCodeIsAuthenticated)
+ const initialApiConfiguration = getWelcomeApiConfiguration(t, apiConfiguration, zooCodeIsAuthenticated)
setWelcomeApiConfiguration(initialApiConfiguration)
setApiConfiguration(initialApiConfiguration)
@@ -70,7 +72,13 @@ const WelcomeViewProvider = () => {
return
}
- const error = validateApiConfiguration(effectiveApiConfiguration, undefined, undefined, zooCodeIsAuthenticated)
+ const error = validateApiConfiguration(
+ effectiveApiConfiguration,
+ t,
+ undefined,
+ undefined,
+ zooCodeIsAuthenticated,
+ )
if (error) {
setErrorMessage(error)
@@ -90,6 +98,7 @@ const WelcomeViewProvider = () => {
effectiveApiConfiguration,
currentApiConfigName,
zooCodeIsAuthenticated,
+ t,
])
if (!showProviderSetup) {
@@ -108,7 +117,7 @@ const WelcomeViewProvider = () => {
{t("welcome:providerSignup.heading")}
-
+
diff --git a/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx b/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx
index eb121aaadf..a52ac35a41 100644
--- a/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx
+++ b/webview-ui/src/components/welcome/__tests__/RooTips.spec.tsx
@@ -3,10 +3,13 @@ import { render, screen } from "@/utils/test-utils"
import RooTips from "../RooTips"
-vi.mock("react-i18next", () => ({
- useTranslation: () => ({
+vi.mock("@src/i18n/TranslationContext", () => ({
+ useAppTranslation: () => ({
t: (key: string) => key, // Simple mock that returns the key
}),
+}))
+
+vi.mock("react-i18next", () => ({
Trans: ({
children,
components,
diff --git a/webview-ui/src/i18n/TranslationContext.tsx b/webview-ui/src/i18n/TranslationContext.tsx
index 978c77018a..5ec39a6c4b 100644
--- a/webview-ui/src/i18n/TranslationContext.tsx
+++ b/webview-ui/src/i18n/TranslationContext.tsx
@@ -1,15 +1,27 @@
-import React, { createContext, useContext, ReactNode, useEffect, useCallback } from "react"
+import React, {
+ createContext,
+ useContext,
+ ReactNode,
+ useEffect,
+ useCallback,
+ useMemo,
+ useSyncExternalStore,
+} from "react"
import { useTranslation } from "react-i18next"
-import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "./setup"
+import type { TFunction } from "i18next"
+import { FALLBACK_LANGUAGE, loadLanguage } from "./setup"
import { useExtensionState } from "@/context/ExtensionStateContext"
-// Create context for translations
-export const TranslationContext = createContext<{
- t: (key: string, options?: Record
) => string
- i18n: typeof i18next
-}>({
- t: (key: string) => key,
- i18n: i18next,
+export type TranslationContextValue = {
+ t: TFunction
+ /** The language i18next is rendering, which trails the requested one until its bundles load. */
+ language: string
+}
+
+// Create context for translations. Without a provider (isolated component tests) keys render as-is.
+export const TranslationContext = createContext({
+ t: ((key: string) => key) as TFunction,
+ language: FALLBACK_LANGUAGE,
})
// Translation provider component
@@ -18,47 +30,45 @@ const TranslationProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const { i18n } = useTranslation()
// Get the extension state directly - it already contains all state properties
const extensionState = useExtensionState()
- const language = extensionState.language || FALLBACK_LANGUAGE
+ const requestedLanguage = extensionState.language || FALLBACK_LANGUAGE
// Only the fallback locale ships in the entry chunk, so any other language
// has to fetch its bundles before i18next can resolve a key. Switching after
- // the fetch is deliberate: `changeLanguage` is what emits `languageChanged`,
- // the event `useTranslation` subscribes to for re-renders. Until it fires the
- // subtree keeps resolving keys through whichever language is still active:
- // `fallbackLng` on a cold start, the previous language on a warm switch
- // between two non-English locales. The swap-then-render shape is the same one
- // this provider had when every locale was bundled.
+ // the fetch is deliberate: until `changeLanguage` runs, the subtree keeps
+ // rendering whichever language is still active (`fallbackLng` on a cold
+ // start, the previous language on a warm switch) instead of English keys
+ // under the new language. A newer request cancels a fetch still in flight.
useEffect(() => {
let cancelled = false
- void loadLanguage(language).then(() => {
+ void loadLanguage(requestedLanguage).then(() => {
if (!cancelled) {
- void i18n.changeLanguage(language)
+ void i18n.changeLanguage(requestedLanguage)
}
})
return () => {
cancelled = true
}
- }, [i18n, language])
+ }, [i18n, requestedLanguage])
+
+ const subscribe = useCallback(
+ (onLanguageChanged: () => void) => {
+ i18n.on("languageChanged", onLanguageChanged)
- // Memoize the translation function to prevent unnecessary re-renders
- const translate = useCallback(
- (key: string, options?: Record) => {
- return i18n.t(key, options)
+ return () => i18n.off("languageChanged", onLanguageChanged)
},
[i18n],
)
- return (
-
- {children}
-
- )
+ const language = useSyncExternalStore(subscribe, () => i18n.language)
+
+ // `t` is bound to the active language, so its identity changes exactly when the language does.
+ // Memoized consumers (and React Compiler caches) depend on `t`, so they re-render on a switch
+ // instead of serving output translated in the previous language.
+ const value = useMemo(() => ({ t: i18n.getFixedT(language), language }), [i18n, language])
+
+ return {children}
}
// Custom hook for easy translations
diff --git a/webview-ui/src/i18n/__mocks__/TranslationContext.tsx b/webview-ui/src/i18n/__mocks__/TranslationContext.tsx
index 3fdfcbdc2c..2266bc3a86 100644
--- a/webview-ui/src/i18n/__mocks__/TranslationContext.tsx
+++ b/webview-ui/src/i18n/__mocks__/TranslationContext.tsx
@@ -33,10 +33,10 @@ const mockTranslate = (key: string, options?: Record): string => {
// Create mock context
export const TranslationContext = createContext<{
t: (key: string, options?: Record) => string
- i18n: any
+ language: string
}>({
t: mockTranslate,
- i18n: {},
+ language: "en",
})
// Mock translation provider component
@@ -45,7 +45,7 @@ export const TranslationProvider: React.FC<{ children: ReactNode }> = ({ childre
{children}
diff --git a/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx b/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx
index 0717361f50..819a3ab721 100644
--- a/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx
+++ b/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx
@@ -13,12 +13,12 @@ vi.mock("@/context/ExtensionStateContext", () => ({
}))
const Probe = () => {
- const { t, i18n } = useAppTranslation()
+ const { t, language } = useAppTranslation()
return (
{t("common:errorBoundary.title")}
- {i18n.language}
+ {language}
)
}
@@ -129,6 +129,25 @@ describe("TranslationProvider locale loading", () => {
expect(screen.getByTestId("active-language")).toHaveTextContent("fr")
})
+ it("lets a newer request win over an earlier one that is still loading", async () => {
+ const { rerender } = renderProbe("pt-BR")
+
+ // Portuguese is still in flight when English is requested; its late resolution must not switch back.
+ extensionState.language = "en"
+ await act(async () => {
+ rerender(
+
+
+ ,
+ )
+ })
+ await loadLanguage("pt-BR")
+
+ expect(i18next.language).toBe("en")
+ expect(screen.getByTestId("active-language")).toHaveTextContent("en")
+ expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong")
+ })
+
it("skips the language switch when the provider unmounts before the fetch resolves", async () => {
const changeLanguage = vi.spyOn(i18next, "changeLanguage")
diff --git a/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx b/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx
index 953234ff6d..acda04b629 100644
--- a/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx
+++ b/webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx
@@ -1,75 +1,76 @@
-import { render } from "@/utils/test-utils"
+import { memo, useMemo } from "react"
+import { Trans } from "react-i18next"
+import type { TFunction } from "i18next"
+import { renderHook, screen } from "@testing-library/react"
-import TranslationProvider, { useAppTranslation } from "../TranslationContext"
+import { renderTranslated } from "./renderTranslated"
-vi.mock("@/context/ExtensionStateContext", () => ({
- useExtensionState: () => ({
- language: "en",
- }),
-}))
+import i18next from "../setup"
+import { useAppTranslation } from "../TranslationContext"
-vi.mock("react-i18next", () => ({
- useTranslation: () => ({
- i18n: {
- t: (key: string, options?: Record) => {
- // Mock specific translations used in tests
- if (key === "settings.autoApprove.title") return "Auto-Approve"
- if (key === "notifications.error") {
- return options?.message ? `Operation failed: ${options.message}` : "Operation failed"
- }
- return key
- },
- changeLanguage: vi.fn(),
- },
- }),
-}))
+// React Compiler (enabled in vite.config.ts, not in Vitest) caches render output and only recomputes
+// it when a dependency changes. These consumers reproduce that caching by hand: a stale value here is
+// exactly what the compiled webview showed after a language switch.
+const MemoizedConsumer = memo(function MemoizedConsumer() {
+ const { t, language } = useAppTranslation()
+ const label = useMemo(() => t("history:recentTasks"), [t])
+ const element = useMemo(() => , [t])
-vi.mock("../setup", () => ({
- default: {
- t: (key: string, options?: Record) => {
- // Mock specific translations used in tests
- if (key === "settings.autoApprove.title") return "Auto-Approve"
- if (key === "notifications.error") {
- return options?.message ? `Operation failed: ${options.message}` : "Operation failed"
- }
- return key
- },
- changeLanguage: vi.fn(),
- },
- FALLBACK_LANGUAGE: "en",
- loadLanguage: vi.fn(() => Promise.resolve()),
-}))
-
-const TestComponent = () => {
- const { t } = useAppTranslation()
return (
-
{t("settings.autoApprove.title")}
-
{t("notifications.error", { message: "Test error" })}
+
{label}
+
{element}
+
{t("common:time_ago.days_ago", { count: 3 })}
+
{language}
)
-}
+})
+
+describe("TranslationProvider", () => {
+ beforeEach(async () => {
+ await i18next.changeLanguage("en")
+ })
+
+ it("provides translations with interpolation and the active language", () => {
+ renderTranslated( )
+
+ expect(screen.getByTestId("label")).toHaveTextContent("Recent Tasks")
+ expect(screen.getByTestId("trans")).toHaveTextContent("Zoo is a whole AI dev team in your editor")
+ expect(screen.getByTestId("interpolated")).toHaveTextContent("3 days ago")
+ expect(screen.getByTestId("language")).toHaveTextContent("en")
+ })
+
+ it("updates memoized consumers and Trans on a language switch without remounting", async () => {
+ const { setLanguage } = renderTranslated( )
+
+ await setLanguage("es")
+
+ expect(screen.getByTestId("label")).toHaveTextContent("Tareas")
+ // eslint-disable-next-line no-restricted-syntax -- expected text computed outside React; nothing is memoized
+ expect(screen.getByTestId("trans")).toHaveTextContent(i18next.t("chat:about", { lng: "es" }))
+ expect(screen.getByTestId("language")).toHaveTextContent("es")
+ })
+
+ it("keeps t stable while the language is unchanged and replaces it on a switch", async () => {
+ const seen: TFunction[] = []
+ const Probe = () => {
+ seen.push(useAppTranslation().t)
+ return null
+ }
+ const { rerender, setLanguage } = renderTranslated( )
-describe("TranslationContext", () => {
- it("should provide translations via context", () => {
- const { getByTestId } = render(
-
-
- ,
- )
+ rerender( )
+ expect(seen.length).toBeGreaterThan(1)
+ expect(new Set(seen).size).toBe(1)
- // Check if translation is provided correctly
- expect(getByTestId("translation-test")).toHaveTextContent("Auto-Approve")
+ await setLanguage("es")
+ expect(seen.at(-1)).not.toBe(seen[0])
})
- it("should handle interpolation correctly", () => {
- const { getByTestId } = render(
-
-
- ,
- )
+ it("renders keys as-is without a provider", () => {
+ const { result } = renderHook(() => useAppTranslation())
- // Check if interpolation works
- expect(getByTestId("translation-interpolation")).toHaveTextContent("Operation failed: Test error")
+ expect(result.current.t("history:recentTasks")).toBe("history:recentTasks")
+ expect(result.current.language).toBe("en")
})
})
diff --git a/webview-ui/src/i18n/__tests__/createFixtureI18n.visual.i18n.ts b/webview-ui/src/i18n/__tests__/createFixtureI18n.visual.i18n.ts
new file mode 100644
index 0000000000..bcd5d12e7a
--- /dev/null
+++ b/webview-ui/src/i18n/__tests__/createFixtureI18n.visual.i18n.ts
@@ -0,0 +1,44 @@
+import i18next, { type i18n } from "i18next"
+
+import type { TranslationContextValue } from "../TranslationContext"
+
+/**
+ * An i18next instance for a visual fixture that serves exactly `translations`, keyed `"ns:dotted.key"`.
+ * Missing keys render as the qualified key.
+ */
+function createFixtureI18n(translations: Record): i18n {
+ const resources: Record> = {}
+
+ for (const [qualifiedKey, value] of Object.entries(translations)) {
+ const [ns, key] = qualifiedKey.split(":")
+ const path = key.split(".")
+ let node = (resources[ns] ??= {})
+
+ for (const segment of path.slice(0, -1)) {
+ node = (node[segment] ??= {}) as Record
+ }
+
+ node[path[path.length - 1]] = value
+ }
+
+ const instance = i18next.createInstance()
+
+ // Inline resources and `initAsync: false` make the instance ready before the fixture renders.
+ void instance.init({
+ lng: "en",
+ resources: { en: resources },
+ ns: Object.keys(resources),
+ initAsync: false,
+ appendNamespaceToMissingKey: true,
+ interpolation: { escapeValue: false },
+ })
+
+ return instance
+}
+
+/** A `TranslationContext` value for a visual fixture, translating with `createFixtureI18n(translations)`. */
+export function createFixtureTranslation(translations: Record): TranslationContextValue {
+ const instance = createFixtureI18n(translations)
+
+ return { t: instance.getFixedT("en"), language: "en" }
+}
diff --git a/webview-ui/src/i18n/__tests__/renderTranslated.tsx b/webview-ui/src/i18n/__tests__/renderTranslated.tsx
new file mode 100644
index 0000000000..eb7f1a49a3
--- /dev/null
+++ b/webview-ui/src/i18n/__tests__/renderTranslated.tsx
@@ -0,0 +1,44 @@
+import React from "react"
+import { act, waitFor } from "@testing-library/react"
+
+import i18next from "@src/i18n/setup"
+import TranslationProvider from "@src/i18n/TranslationContext"
+
+import {
+ makeExtensionState,
+ renderWithExtensionState,
+ type RenderWithExtensionStateOptions,
+} from "@src/utils/test-utils"
+
+/**
+ * Post a "state" message the way the extension host does. TranslationProvider picks up the
+ * language from it, so this drives a UI language change through the real switching path.
+ */
+export const postExtensionState = (state: Record) =>
+ act(async () => {
+ window.dispatchEvent(new MessageEvent("message", { data: { type: "state", state } }))
+ })
+
+/**
+ * Render `ui` under the real TranslationProvider (kept out of test-utils so specs that mock
+ * react-i18next do not load i18next). `setLanguage` resolves once i18next renders the new language.
+ */
+export const renderTranslated = (
+ ui: React.ReactElement,
+ { state, ...options }: RenderWithExtensionStateOptions = {},
+) => {
+ const result = renderWithExtensionState({ui} , {
+ state: makeExtensionState(state),
+ ...options,
+ })
+
+ const setLanguage = async (language: string) => {
+ await postExtensionState({ language })
+ await waitFor(() => expect(i18next.language).toBe(language))
+ }
+
+ // Keep the provider around a rerendered `ui` so the rerender does not remount the subtree.
+ const rerender = (next: React.ReactElement) => result.rerender({next} )
+
+ return { ...result, rerender, setLanguage }
+}
diff --git a/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts b/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts
index e3fe3e0528..f6622a55f8 100644
--- a/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts
+++ b/webview-ui/src/i18n/__tests__/setup.rejection.spec.ts
@@ -35,6 +35,7 @@ describe("i18n setup locale load failure", () => {
// registered either, so no half-translated locale can be activated.
expect(i18next.hasResourceBundle("ja", "common")).toBe(false)
expect(i18next.hasResourceBundle("ja", "settings")).toBe(false)
+ // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized
expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("Something went wrong")
})
@@ -65,6 +66,7 @@ describe("i18n setup locale load failure", () => {
loader.fail = false
await loadLanguage("ja")
+ // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized
expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("問題が発生しました")
})
})
diff --git a/webview-ui/src/i18n/__tests__/setup.spec.ts b/webview-ui/src/i18n/__tests__/setup.spec.ts
index 867fc03f4e..d691945bd9 100644
--- a/webview-ui/src/i18n/__tests__/setup.spec.ts
+++ b/webview-ui/src/i18n/__tests__/setup.spec.ts
@@ -19,6 +19,7 @@ describe("i18n setup locale loading", () => {
expect(i18next.hasResourceBundle("ko", "common")).toBe(true)
expect(i18next.hasResourceBundle("ko", "settings")).toBe(true)
+ // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized
expect(i18next.t("common:errorBoundary.title", { lng: "ko" })).toBe("문제가 발생했습니다")
})
@@ -36,11 +37,13 @@ describe("i18n setup locale loading", () => {
expect(results).toHaveLength(3)
// 9 namespaces registered once each, not 27.
expect(addResourceBundle).toHaveBeenCalledTimes(9)
+ // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized
expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("問題が発生しました")
})
it("resolves without throwing for a locale that has no bundles", async () => {
await expect(loadLanguage("xx-unsupported")).resolves.toBeUndefined()
+ // eslint-disable-next-line no-restricted-syntax -- checks loaded resources outside React; nothing is memoized
expect(i18next.t("common:errorBoundary.title", { lng: "xx-unsupported" })).toBe("Something went wrong")
})
})
diff --git a/webview-ui/src/utils/__tests__/format.spec.ts b/webview-ui/src/utils/__tests__/format.spec.ts
index 3ee442bcd4..42acb59cae 100644
--- a/webview-ui/src/utils/__tests__/format.spec.ts
+++ b/webview-ui/src/utils/__tests__/format.spec.ts
@@ -1,3 +1,4 @@
+import type { TFunction } from "i18next"
import {
formatLargeNumber,
formatDateOnly,
@@ -11,58 +12,65 @@ import {
// Mock i18next
vi.mock("i18next", () => ({
- default: {
- t: vi.fn((key: string, options?: any) => {
- // Mock translations for testing
- const translations: Record = {
- "common:number_format.billion_suffix": "b",
- "common:number_format.million_suffix": "m",
- "common:number_format.thousand_suffix": "k",
- "common:time_ago.just_now": "just now",
- "common:time_ago.seconds_ago": "{{count}} seconds ago",
- "common:time_ago.minute_ago": "a minute ago",
- "common:time_ago.minutes_ago": "{{count}} minutes ago",
- "common:time_ago.hour_ago": "an hour ago",
- "common:time_ago.hours_ago": "{{count}} hours ago",
- "common:time_ago.day_ago": "a day ago",
- "common:time_ago.days_ago": "{{count}} days ago",
- "common:time_ago.week_ago": "a week ago",
- "common:time_ago.weeks_ago": "{{count}} weeks ago",
- "common:time_ago.month_ago": "a month ago",
- "common:time_ago.months_ago": "{{count}} months ago",
- "common:time_ago.year_ago": "a year ago",
- "common:time_ago.years_ago": "{{count}} years ago",
- }
-
- let result = translations[key] || key
- if (options?.count !== undefined) {
- result = result.replace("{{count}}", options.count.toString())
- }
- return result
- }),
- language: "en",
- },
+ default: { language: "en" },
}))
+// Returns translation keys with interpolated values appended.
+const t = ((key: string, options?: { count?: number }) => {
+ const translations: Record = {
+ "common:number_format.billion_suffix": "b",
+ "common:number_format.million_suffix": "m",
+ "common:number_format.thousand_suffix": "k",
+ "common:time_ago.just_now": "just now",
+ "common:time_ago.seconds_ago": "{{count}} seconds ago",
+ "common:time_ago.minute_ago": "a minute ago",
+ "common:time_ago.minutes_ago": "{{count}} minutes ago",
+ "common:time_ago.hour_ago": "an hour ago",
+ "common:time_ago.hours_ago": "{{count}} hours ago",
+ "common:time_ago.day_ago": "a day ago",
+ "common:time_ago.days_ago": "{{count}} days ago",
+ "common:time_ago.week_ago": "a week ago",
+ "common:time_ago.weeks_ago": "{{count}} weeks ago",
+ "common:time_ago.month_ago": "a month ago",
+ "common:time_ago.months_ago": "{{count}} months ago",
+ "common:time_ago.year_ago": "a year ago",
+ "common:time_ago.years_ago": "{{count}} years ago",
+ }
+
+ let result = translations[key] || key
+ if (options?.count !== undefined) {
+ result = result.replace("{{count}}", options.count.toString())
+ }
+ return result
+}) as unknown as TFunction
+
describe("formatLargeNumber", () => {
it("should format billions", () => {
- expect(formatLargeNumber(1500000000)).toBe("1.5b")
- expect(formatLargeNumber(2000000000)).toBe("2.0b")
+ expect(formatLargeNumber(1500000000, t)).toBe("1.5b")
+ expect(formatLargeNumber(2000000000, t)).toBe("2.0b")
})
it("should format millions", () => {
- expect(formatLargeNumber(1500000)).toBe("1.5m")
- expect(formatLargeNumber(2000000)).toBe("2.0m")
+ expect(formatLargeNumber(1500000, t)).toBe("1.5m")
+ expect(formatLargeNumber(2000000, t)).toBe("2.0m")
})
it("should format thousands", () => {
- expect(formatLargeNumber(1500)).toBe("1.5k")
- expect(formatLargeNumber(2000)).toBe("2.0k")
+ expect(formatLargeNumber(1500, t)).toBe("1.5k")
+ expect(formatLargeNumber(2000, t)).toBe("2.0k")
})
it("should return string for small numbers", () => {
- expect(formatLargeNumber(999)).toBe("999")
- expect(formatLargeNumber(100)).toBe("100")
+ expect(formatLargeNumber(999, t)).toBe("999")
+ expect(formatLargeNumber(100, t)).toBe("100")
+ })
+
+ it("takes suffixes from the given t", () => {
+ const givenT = vi.fn((key: string) => (key === "common:number_format.thousand_suffix" ? " mil" : key))
+
+ // `TFunction` is branded; the mock only needs to answer a key lookup.
+ expect(formatLargeNumber(1500, givenT as unknown as TFunction)).toBe("1.5 mil")
+ expect(givenT).toHaveBeenCalledWith("common:number_format.thousand_suffix")
})
})
@@ -87,12 +95,13 @@ describe("formatDateOnly", () => {
["dmy", "07/10/2026"],
["ymd", "2026-10-07"],
] as const)("writes %s as %s, zero-padded and locale-independent", (format, expected) => {
- expect(formatDateOnly(ts, format)).toBe(expected)
+ expect(formatDateOnly(ts, "en", format)).toBe(expected)
})
- it("follows the display language for auto", () => {
- expect(formatDateOnly(ts, "auto")).toMatch(/2026/)
- expect(formatDateOnly(ts)).toBe(formatDateOnly(ts, "auto"))
+ it("follows the given locale for auto", () => {
+ expect(formatDateOnly(ts, "en", "auto")).toMatch(/2026/)
+ expect(formatDateOnly(ts, "en")).toBe(formatDateOnly(ts, "en", "auto"))
+ expect(formatDateOnly(ts, "de", "auto")).toBe("07.10.2026")
})
})
@@ -100,23 +109,23 @@ describe("formatTimeOnly", () => {
const ts = new Date(2026, 9, 27, 14, 41).getTime()
it("honours 24h and 12h", () => {
- expect(formatTimeOnly(ts, "24h")).toBe("14:41")
- expect(formatTimeOnly(ts, "12h")).toMatch(/^2:41\s?PM$/i)
+ expect(formatTimeOnly(ts, "en", "24h")).toBe("14:41")
+ expect(formatTimeOnly(ts, "en", "12h")).toMatch(/^2:41\s?PM$/i)
})
})
describe("formatDateTime", () => {
it("includes date and time honouring the formats", () => {
const timestamp = new Date("2024-01-15T00:05:00").getTime()
- const result = formatDateTime(timestamp, "24h")
+ const result = formatDateTime(timestamp, "en", "24h")
expect(result).toMatch(/2024/)
expect(result).toMatch(/00:05/)
})
it("combines the chosen date order with the chosen clock", () => {
const ts = new Date(2026, 9, 27, 14, 41).getTime()
- expect(formatDateTime(ts, "24h", "dmy")).toBe("27/10/2026, 14:41")
- expect(formatDateTime(ts, "24h", "ymd")).toBe("2026-10-27, 14:41")
+ expect(formatDateTime(ts, "en", "24h", "dmy")).toBe("27/10/2026, 14:41")
+ expect(formatDateTime(ts, "en", "24h", "ymd")).toBe("2026-10-27, 14:41")
})
})
@@ -136,72 +145,81 @@ describe("formatTimeAgo", () => {
it('should return "just now" for very recent times', () => {
const timestamp = new Date("2024-01-15T11:59:35").getTime() // 25 seconds ago
- expect(formatTimeAgo(timestamp)).toBe("just now")
+ expect(formatTimeAgo(timestamp, t)).toBe("just now")
})
it("should format seconds ago", () => {
const timestamp = new Date("2024-01-15T11:59:15").getTime() // 45 seconds ago
- expect(formatTimeAgo(timestamp)).toBe("45 seconds ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("45 seconds ago")
})
it("should format a minute ago", () => {
const timestamp = new Date("2024-01-15T11:59:00").getTime() // 1 minute ago
- expect(formatTimeAgo(timestamp)).toBe("a minute ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("a minute ago")
})
it("should format minutes ago", () => {
const timestamp = new Date("2024-01-15T11:45:00").getTime() // 15 minutes ago
- expect(formatTimeAgo(timestamp)).toBe("15 minutes ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("15 minutes ago")
})
it("should format an hour ago", () => {
const timestamp = new Date("2024-01-15T11:00:00").getTime() // 1 hour ago
- expect(formatTimeAgo(timestamp)).toBe("an hour ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("an hour ago")
})
it("should format hours ago", () => {
const timestamp = new Date("2024-01-15T09:00:00").getTime() // 3 hours ago
- expect(formatTimeAgo(timestamp)).toBe("3 hours ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("3 hours ago")
})
it("should format a day ago", () => {
const timestamp = new Date("2024-01-14T12:00:00").getTime() // 1 day ago
- expect(formatTimeAgo(timestamp)).toBe("a day ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("a day ago")
})
it("should format days ago", () => {
const timestamp = new Date("2024-01-12T12:00:00").getTime() // 3 days ago
- expect(formatTimeAgo(timestamp)).toBe("3 days ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("3 days ago")
})
it("should format a week ago", () => {
const timestamp = new Date("2024-01-08T12:00:00").getTime() // 7 days ago
- expect(formatTimeAgo(timestamp)).toBe("a week ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("a week ago")
})
it("should format weeks ago", () => {
const timestamp = new Date("2024-01-01T12:00:00").getTime() // 14 days ago
- expect(formatTimeAgo(timestamp)).toBe("2 weeks ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("2 weeks ago")
})
it("should format a month ago", () => {
const timestamp = new Date("2023-12-15T12:00:00").getTime() // ~1 month ago
- expect(formatTimeAgo(timestamp)).toBe("a month ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("a month ago")
})
it("should format months ago", () => {
const timestamp = new Date("2023-10-15T12:00:00").getTime() // ~3 months ago
- expect(formatTimeAgo(timestamp)).toBe("3 months ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("3 months ago")
})
it("should format a year ago", () => {
const timestamp = new Date("2023-01-15T12:00:00").getTime() // 1 year ago
- expect(formatTimeAgo(timestamp)).toBe("a year ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("a year ago")
})
it("should format years ago", () => {
const timestamp = new Date("2021-01-15T12:00:00").getTime() // 3 years ago
- expect(formatTimeAgo(timestamp)).toBe("3 years ago")
+ expect(formatTimeAgo(timestamp, t)).toBe("3 years ago")
+ })
+
+ it("takes translations from the given t", () => {
+ const givenT = vi.fn((key: string, options?: Record) => `es:${key}:${options?.count}`)
+ const timestamp = new Date("2024-01-15T09:00:00").getTime() // 3 hours ago
+
+ // `TFunction` is branded; the mock only needs to answer a key lookup with `count`.
+ expect(formatTimeAgo(timestamp, givenT as unknown as TFunction)).toBe("es:common:time_ago.hours_ago:3")
+ expect(givenT).toHaveBeenCalledWith("common:time_ago.hours_ago", { count: 3 })
})
})
@@ -209,8 +227,8 @@ describe("formatMessageTime", () => {
// Wed 14 Oct 2026, 15:00 local.
const NOW = new Date(2026, 9, 14, 15, 0).getTime()
const at = (y: number, m: number, d: number, h: number, min = 0) => new Date(y, m, d, h, min).getTime()
- const fmt = (ts: number, opts: Parameters[1] = {}) =>
- formatMessageTime(ts, { now: NOW, timeFormat: "12h", ...opts })
+ const fmt = (ts: number, opts: Parameters[2] = {}, locale = "en") =>
+ formatMessageTime(ts, locale, { now: NOW, timeFormat: "12h", ...opts })
it.each([
["tomorrow", at(2026, 9, 15, 9, 5), /^Oct 15, 9:05\s?AM$/],
@@ -257,7 +275,9 @@ describe("formatMessageTime", () => {
it("treats a message from earlier in a different year as older even when under 7 days away", () => {
const newYear = new Date(2027, 0, 2, 12, 0).getTime()
// 30 Dec 2026 is 3 days before → weekday, no year needed.
- expect(formatMessageTime(at(2026, 11, 30, 9, 0), { now: newYear, timeFormat: "24h" })).toMatch(/^Wed 09:00$/)
+ expect(formatMessageTime(at(2026, 11, 30, 9, 0), "en", { now: newYear, timeFormat: "24h" })).toMatch(
+ /^Wed 09:00$/,
+ )
})
it("honours the 24-hour setting", () => {
@@ -265,46 +285,27 @@ describe("formatMessageTime", () => {
})
describe("with a stored language that is not a usable locale", () => {
- let original: string
const SENT = new Date(2024, 2, 7, 14, 41).getTime()
- beforeEach(async () => {
- const i18next = (await import("i18next")).default as unknown as { language: string }
- original = i18next.language
- })
-
- afterEach(async () => {
- const i18next = (await import("i18next")).default as unknown as { language: string }
- i18next.language = original
- })
-
it.each(["not a locale!!", "xx-ZZ-garbage", "xx-ZZ", "en_US", "---", "x"])(
"formats in English instead of throwing for %j",
- async (bad) => {
- const i18next = (await import("i18next")).default as unknown as { language: string }
- i18next.language = bad
-
- expect(() => formatTimeOnly(SENT)).not.toThrow()
- expect(() => formatDateOnly(SENT)).not.toThrow()
- expect(() => formatDateTime(SENT)).not.toThrow()
- expect(() => formatMessageTime(SENT, { now: SENT })).not.toThrow()
- expect(formatMessageTime(SENT, { now: SENT })).toContain("Today")
- expect(formatDateOnly(SENT)).toBe(formatDateOnly(SENT, "auto", "en"))
+ (bad) => {
+ expect(() => formatTimeOnly(SENT, bad)).not.toThrow()
+ expect(() => formatDateOnly(SENT, bad)).not.toThrow()
+ expect(() => formatDateTime(SENT, bad)).not.toThrow()
+ expect(() => formatMessageTime(SENT, bad, { now: SENT })).not.toThrow()
+ expect(formatMessageTime(SENT, bad, { now: SENT })).toContain("Today")
+ expect(formatDateOnly(SENT, bad)).toBe(formatDateOnly(SENT, "en", "auto"))
+ expect(formatTimeOnly(SENT, bad)).toBe(formatTimeOnly(SENT, "en", "auto"))
+ expect(formatDateTime(SENT, bad)).toBe(formatDateTime(SENT, "en", "auto", "auto"))
},
)
- it("formats in English when no language is set at all", async () => {
- const i18next = (await import("i18next")).default as unknown as { language: string }
- i18next.language = ""
-
- expect(formatDateOnly(SENT)).toBe(formatDateOnly(SENT, "auto", "en"))
- expect(formatTimeOnly(SENT)).toBe(formatTimeOnly(SENT, "auto", "en"))
- expect(formatDateTime(SENT)).toBe(formatDateTime(SENT, "auto", "auto", "en"))
- })
-
- it("falls back for a locale passed in directly, too", () => {
- expect(formatDateOnly(SENT, "auto", "not a locale!!")).toBe(formatDateOnly(SENT, "auto", "en"))
- expect(formatTimeOnly(SENT, "auto", "not a locale!!")).toBe(formatTimeOnly(SENT, "auto", "en"))
+ it("formats in English when no language is set at all", () => {
+ expect(formatDateOnly(SENT, "")).toBe(formatDateOnly(SENT, "en", "auto"))
+ expect(formatTimeOnly(SENT, "")).toBe(formatTimeOnly(SENT, "en", "auto"))
+ expect(formatDateTime(SENT, "")).toBe(formatDateTime(SENT, "en", "auto", "auto"))
+ expect(formatMessageTime(SENT, "", { now: SENT })).toContain("Today")
})
it("resolves a supported locale to itself, and a missing one to English", () => {
@@ -315,36 +316,25 @@ describe("formatMessageTime", () => {
})
describe("in another display language (automatic date format)", () => {
- let original: string
-
- beforeEach(async () => {
- const i18next = (await import("i18next")).default as unknown as { language: string }
- original = i18next.language
- i18next.language = "de"
- })
-
- afterEach(async () => {
- const i18next = (await import("i18next")).default as unknown as { language: string }
- i18next.language = original
- })
-
it("names today and yesterday in that language", () => {
- expect(fmt(at(2026, 9, 14, 14, 41), { timeFormat: "24h" })).toBe("Heute 14:41")
- expect(fmt(at(2026, 9, 13, 11, 43), { timeFormat: "24h" })).toBe("Gestern 11:43")
+ expect(fmt(at(2026, 9, 14, 14, 41), { timeFormat: "24h" }, "de")).toBe("Heute 14:41")
+ expect(fmt(at(2026, 9, 13, 11, 43), { timeFormat: "24h" }, "de")).toBe("Gestern 11:43")
})
it("names the weekday in that language", () => {
- expect(fmt(at(2026, 9, 12, 8, 0), { timeFormat: "24h" })).toMatch(/^Mo\.? 0?8:00$/) // a Monday
+ expect(fmt(at(2026, 9, 12, 8, 0), { timeFormat: "24h" }, "de")).toMatch(/^Mo\.? 0?8:00$/) // a Monday
})
it("writes an older date in that language's own order and month name", () => {
- expect(fmt(at(2024, 2, 7, 14, 41), { timeFormat: "24h" })).toBe("7. März 2024, 14:41")
+ expect(fmt(at(2024, 2, 7, 14, 41), { timeFormat: "24h" }, "de")).toBe("7. März 2024, 14:41")
// The same date within the current year leaves the year out.
- expect(fmt(at(2026, 2, 7, 14, 41), { timeFormat: "24h" })).toMatch(/^7\. März, 14:41$/)
+ expect(fmt(at(2026, 2, 7, 14, 41), { timeFormat: "24h" }, "de")).toMatch(/^7\. März, 14:41$/)
})
it("still lets an explicit date order win over the language", () => {
- expect(fmt(at(2024, 2, 7, 14, 41), { timeFormat: "24h", dateFormat: "mdy" })).toBe("03/07/2024, 14:41")
+ expect(fmt(at(2024, 2, 7, 14, 41), { timeFormat: "24h", dateFormat: "mdy" }, "de")).toBe(
+ "03/07/2024, 14:41",
+ )
})
})
diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts
index bf25faf7f2..d4b9a4da2a 100644
--- a/webview-ui/src/utils/__tests__/validate.spec.ts
+++ b/webview-ui/src/utils/__tests__/validate.spec.ts
@@ -4,22 +4,7 @@ import {
type OrganizationAllowList,
type RouterModels,
} from "@roo-code/types"
-
-// Mock i18next to return translation keys with interpolated values
-vi.mock("i18next", () => ({
- default: {
- t: (key: string, options?: Record) => {
- if (options) {
- let result = key
- Object.entries(options).forEach(([k, v]) => {
- result += ` ${k}=${v}`
- })
- return result
- }
- return key
- },
- },
-}))
+import type { TFunction } from "i18next"
import {
formatOpenAiExtraBodyValidationError,
@@ -29,6 +14,18 @@ import {
validateBedrockArn,
} from "../validate"
+// Returns translation keys with interpolated values appended.
+const t = ((key: string, options?: Record) => {
+ if (options) {
+ let result = key
+ Object.entries(options).forEach(([k, v]) => {
+ result += ` ${k}=${v}`
+ })
+ return result
+ }
+ return key
+}) as unknown as TFunction
+
describe("Model Validation Functions", () => {
const mockRouterModels: RouterModels = {
openrouter: {
@@ -88,7 +85,7 @@ describe("Model Validation Functions", () => {
openRouterModelId: "valid-model",
}
- const result = getModelValidationError(config, mockRouterModels, allowAllOrganization)
+ const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization)
expect(result).toBeUndefined()
})
@@ -98,7 +95,7 @@ describe("Model Validation Functions", () => {
openRouterModelId: "invalid-model",
}
- const result = getModelValidationError(config, mockRouterModels, allowAllOrganization)
+ const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization)
expect(result).toContain("settings:validation.modelAvailability")
})
@@ -108,7 +105,7 @@ describe("Model Validation Functions", () => {
openRouterModelId: "another-valid-model",
}
- const result = getModelValidationError(config, mockRouterModels, restrictiveOrganization)
+ const result = getModelValidationError(config, t, mockRouterModels, restrictiveOrganization)
expect(result).toContain("model")
})
@@ -118,7 +115,7 @@ describe("Model Validation Functions", () => {
openAiModelId: "gpt-4",
}
- const result = getModelValidationError(config, undefined, allowAllOrganization)
+ const result = getModelValidationError(config, t, undefined, allowAllOrganization)
expect(result).toBeUndefined()
})
@@ -152,7 +149,7 @@ describe("Model Validation Functions", () => {
},
}
- const result = getModelValidationError(config, undefined, organizationAllowList)
+ const result = getModelValidationError(config, t, undefined, organizationAllowList)
expect(result).toContain("settings:validation.modelNotAllowed")
expect(result).toContain("model=blocked-model")
@@ -164,7 +161,7 @@ describe("Model Validation Functions", () => {
openRouterModelId: "",
}
- const result = getModelValidationError(config, mockRouterModels, allowAllOrganization)
+ const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization)
expect(result).toBe("settings:validation.modelId")
})
@@ -174,14 +171,14 @@ describe("Model Validation Functions", () => {
// openRouterModelId is undefined
}
- const result = getModelValidationError(config, mockRouterModels, allowAllOrganization)
+ const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization)
expect(result).toBe("settings:validation.modelId")
})
})
describe("validateApiConfiguration for GitHub Copilot", () => {
const validate = (vsCodeLmModelSelector: ProviderSettings["vsCodeLmModelSelector"]) =>
- validateApiConfiguration({ apiProvider: providerIdentifiers.githubCopilot, vsCodeLmModelSelector })
+ validateApiConfiguration({ apiProvider: providerIdentifiers.githubCopilot, vsCodeLmModelSelector }, t)
it.each([
["a model id", { vendor: "copilot", id: "gpt-x" }],
@@ -207,7 +204,12 @@ describe("Model Validation Functions", () => {
openRouterModelId: "valid-model",
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBeUndefined()
})
@@ -218,7 +220,12 @@ describe("Model Validation Functions", () => {
// Missing openRouterApiKey
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBe("settings:validation.apiKey")
})
@@ -229,7 +236,12 @@ describe("Model Validation Functions", () => {
openRouterModelId: "invalid-model", // This should be ignored
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBeUndefined() // Should not return model validation error
})
@@ -242,6 +254,7 @@ describe("Model Validation Functions", () => {
const result = validateApiConfigurationExcludingModelErrors(
config,
+ t,
mockRouterModels,
restrictiveOrganization,
)
@@ -258,7 +271,7 @@ describe("Model Validation Functions", () => {
}
expect(
- validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization),
+ validateApiConfigurationExcludingModelErrors(config, t, mockRouterModels, allowAllOrganization),
).toBeUndefined()
})
@@ -279,9 +292,9 @@ describe("Model Validation Functions", () => {
openAiExtraBody,
}
- expect(validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)).toBe(
- expectedError,
- )
+ expect(
+ validateApiConfigurationExcludingModelErrors(config, t, mockRouterModels, allowAllOrganization),
+ ).toBe(expectedError)
})
})
@@ -297,9 +310,9 @@ describe("Model Validation Functions", () => {
it("accepts a valid Extra Body object through both validation entry points", () => {
const config = createConfig(JSON.stringify({ metadata: { completion_window: "balanced" } }))
- expect(validateApiConfiguration(config, mockRouterModels, allowAllOrganization)).toBeUndefined()
+ expect(validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization)).toBeUndefined()
expect(
- validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization),
+ validateApiConfigurationExcludingModelErrors(config, t, mockRouterModels, allowAllOrganization),
).toBeUndefined()
})
@@ -324,14 +337,14 @@ describe("Model Validation Functions", () => {
])("rejects %s with the localized error", (_name, openAiExtraBody, expectedError) => {
const config = createConfig(openAiExtraBody)
- expect(validateApiConfiguration(config, mockRouterModels, allowAllOrganization)).toBe(expectedError)
+ expect(validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization)).toBe(expectedError)
})
it("returns required-field errors before Extra Body errors", () => {
const config = createConfig("not json")
config.openAiApiKey = undefined
- expect(validateApiConfiguration(config, mockRouterModels, allowAllOrganization)).toBe(
+ expect(validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization)).toBe(
"settings:validation.openAi",
)
})
@@ -364,7 +377,7 @@ describe("Model Validation Functions", () => {
openAiExtraBody: "not json",
}
- expect(validateApiConfiguration(config, mockRouterModels, allowAllOrganization)).toBeUndefined()
+ expect(validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization)).toBeUndefined()
})
})
@@ -376,7 +389,12 @@ describe("Model Validation Functions", () => {
// Missing opencodeGoApiKey
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBe("settings:validation.apiKey")
})
@@ -387,7 +405,12 @@ describe("Model Validation Functions", () => {
opencodeGoModelId: "glm-5.1",
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBeUndefined()
})
@@ -398,7 +421,7 @@ describe("Model Validation Functions", () => {
// Missing opencodeGoModelId
}
- const result = getModelValidationError(config, mockRouterModels, allowAllOrganization)
+ const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization)
expect(result).toBe("settings:validation.modelId")
})
})
@@ -410,7 +433,12 @@ describe("Model Validation Functions", () => {
// Missing kenariApiKey
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBe("settings:validation.apiKey")
})
@@ -421,7 +449,12 @@ describe("Model Validation Functions", () => {
kenariModelId: "glm-5.1",
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBeUndefined()
})
@@ -432,7 +465,7 @@ describe("Model Validation Functions", () => {
// Missing kenariModelId
}
- const result = getModelValidationError(config, mockRouterModels, allowAllOrganization)
+ const result = getModelValidationError(config, t, mockRouterModels, allowAllOrganization)
expect(result).toBe("settings:validation.modelId")
})
})
@@ -444,7 +477,7 @@ describe("Model Validation Functions", () => {
ioIntelligenceModelId: "zai-org/GLM-4.6",
}
- expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.apiKey")
+ expect(validateApiConfiguration(config, t, mockRouterModels)).toBe("settings:validation.apiKey")
})
it("accepts an IO Intelligence API key and selected model", () => {
@@ -454,7 +487,7 @@ describe("Model Validation Functions", () => {
ioIntelligenceModelId: "zai-org/GLM-4.6",
}
- expect(validateApiConfiguration(config, mockRouterModels)).toBeUndefined()
+ expect(validateApiConfiguration(config, t, mockRouterModels)).toBeUndefined()
})
})
@@ -465,7 +498,7 @@ describe("Model Validation Functions", () => {
nanoGptModelId: "openai/model",
}
- expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.apiKey")
+ expect(validateApiConfiguration(config, t, mockRouterModels)).toBe("settings:validation.apiKey")
})
it("returns a modelId error when the NanoGPT model is missing", () => {
@@ -474,7 +507,7 @@ describe("Model Validation Functions", () => {
nanoGptApiKey: "valid-key",
}
- expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.modelId")
+ expect(validateApiConfiguration(config, t, mockRouterModels)).toBe("settings:validation.modelId")
})
it("accepts a NanoGPT API key and selected model", () => {
@@ -484,7 +517,7 @@ describe("Model Validation Functions", () => {
nanoGptModelId: "openai/model",
}
- expect(validateApiConfiguration(config, mockRouterModels)).toBeUndefined()
+ expect(validateApiConfiguration(config, t, mockRouterModels)).toBeUndefined()
})
})
@@ -496,7 +529,12 @@ describe("Model Validation Functions", () => {
// Missing friendliApiKey
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBe("settings:validation.apiKey")
})
@@ -507,7 +545,12 @@ describe("Model Validation Functions", () => {
apiModelId: "zai-org/GLM-5.2",
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBeUndefined()
})
})
@@ -520,7 +563,7 @@ describe("Model Validation Functions", () => {
zooGatewayModelId: "anthropic/claude-sonnet-4",
}
- const result = validateApiConfiguration(config, mockRouterModels, allowAllOrganization, false)
+ const result = validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization, false)
expect(result).toBe("settings:validation.zooGatewaySignIn")
})
@@ -530,7 +573,7 @@ describe("Model Validation Functions", () => {
zooGatewayModelId: "anthropic/claude-sonnet-4",
}
- const result = validateApiConfiguration(config, mockRouterModels, allowAllOrganization, true)
+ const result = validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization, true)
expect(result).toBeUndefined()
})
@@ -541,7 +584,7 @@ describe("Model Validation Functions", () => {
zooSessionToken: "zoo_ext_test_token",
}
- const result = validateApiConfiguration(config, mockRouterModels, allowAllOrganization, false)
+ const result = validateApiConfiguration(config, t, mockRouterModels, allowAllOrganization, false)
expect(result).toBeUndefined()
})
})
@@ -558,6 +601,7 @@ describe("Model Validation Functions", () => {
const result = validateApiConfigurationExcludingModelErrors(
config,
+ t,
mockRouterModels,
allowAllOrganization,
)
@@ -573,6 +617,7 @@ describe("Model Validation Functions", () => {
const result = validateApiConfigurationExcludingModelErrors(
config,
+ t,
mockRouterModels,
allowAllOrganization,
)
@@ -594,6 +639,7 @@ describe("Model Validation Functions", () => {
const result = validateApiConfigurationExcludingModelErrors(
config,
+ t,
mockRouterModels,
orgWithoutZooGateway,
)
@@ -609,7 +655,12 @@ describe("Model Validation Functions", () => {
kimiCodeAuthMethod: "oauth",
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBeUndefined()
})
@@ -618,7 +669,12 @@ describe("Model Validation Functions", () => {
apiProvider: providerIdentifiers.kimiCode,
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBeUndefined()
})
@@ -628,7 +684,12 @@ describe("Model Validation Functions", () => {
kimiCodeAuthMethod: "api-key",
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBe("settings:validation.apiKey")
})
@@ -639,7 +700,12 @@ describe("Model Validation Functions", () => {
kimiCodeApiKey: "valid-key",
}
- const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization)
+ const result = validateApiConfigurationExcludingModelErrors(
+ config,
+ t,
+ mockRouterModels,
+ allowAllOrganization,
+ )
expect(result).toBeUndefined()
})
})
@@ -650,6 +716,7 @@ describe("validateBedrockArn", () => {
it("accepts standard AWS Bedrock ARNs", () => {
const result = validateBedrockArn(
"arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-3-5-sonnet-v2",
+ t,
)
expect(result.isValid).toBe(true)
expect(result.arnRegion).toBe("us-west-2")
@@ -659,6 +726,7 @@ describe("validateBedrockArn", () => {
it("accepts AWS GovCloud ARNs", () => {
const result = validateBedrockArn(
"arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:inference-profile/model",
+ t,
)
expect(result.isValid).toBe(true)
expect(result.arnRegion).toBe("us-gov-west-1")
@@ -666,14 +734,14 @@ describe("validateBedrockArn", () => {
})
it("accepts AWS China ARNs", () => {
- const result = validateBedrockArn("arn:aws-cn:bedrock:cn-north-1:123456789012:inference-profile/model")
+ const result = validateBedrockArn("arn:aws-cn:bedrock:cn-north-1:123456789012:inference-profile/model", t)
expect(result.isValid).toBe(true)
expect(result.arnRegion).toBe("cn-north-1")
expect(result.errorMessage).toBeUndefined()
})
it("accepts SageMaker ARNs", () => {
- const result = validateBedrockArn("arn:aws:sagemaker:us-east-1:123456789012:endpoint/my-endpoint")
+ const result = validateBedrockArn("arn:aws:sagemaker:us-east-1:123456789012:endpoint/my-endpoint", t)
expect(result.isValid).toBe(true)
expect(result.arnRegion).toBe("us-east-1")
expect(result.errorMessage).toBeUndefined()
@@ -681,7 +749,7 @@ describe("validateBedrockArn", () => {
it("accepts non-standard ARN formats without validation errors", () => {
// Users are advanced - trust their input
- const result = validateBedrockArn("arn:custom:service:region:account:resource")
+ const result = validateBedrockArn("arn:custom:service:region:account:resource", t)
expect(result.isValid).toBe(true)
expect(result.arnRegion).toBe("region")
expect(result.errorMessage).toBeUndefined()
@@ -689,7 +757,7 @@ describe("validateBedrockArn", () => {
it("accepts completely custom ARN strings", () => {
// Even unusual formats should be accepted
- const result = validateBedrockArn("some-custom-arn-format")
+ const result = validateBedrockArn("some-custom-arn-format", t)
expect(result.isValid).toBe(true)
// May not be able to extract region from non-standard format
expect(result.errorMessage).toBeUndefined()
@@ -700,6 +768,7 @@ describe("validateBedrockArn", () => {
it("shows warning when ARN region differs from provided region", () => {
const result = validateBedrockArn(
"arn:aws:bedrock:us-west-2:123456789012:inference-profile/model",
+ t,
"us-east-1",
)
expect(result.isValid).toBe(true) // Still valid, just a warning
@@ -711,6 +780,7 @@ describe("validateBedrockArn", () => {
it("shows no warning when ARN region matches provided region", () => {
const result = validateBedrockArn(
"arn:aws:bedrock:us-west-2:123456789012:inference-profile/model",
+ t,
"us-west-2",
)
expect(result.isValid).toBe(true)
@@ -719,14 +789,14 @@ describe("validateBedrockArn", () => {
})
it("shows no warning when no region is provided to check against", () => {
- const result = validateBedrockArn("arn:aws:bedrock:us-west-2:123456789012:inference-profile/model")
+ const result = validateBedrockArn("arn:aws:bedrock:us-west-2:123456789012:inference-profile/model", t)
expect(result.isValid).toBe(true)
expect(result.arnRegion).toBe("us-west-2")
expect(result.errorMessage).toBeUndefined()
})
it("shows no warning when region cannot be extracted from ARN", () => {
- const result = validateBedrockArn("non-arn-format", "us-east-1")
+ const result = validateBedrockArn("non-arn-format", t, "us-east-1")
expect(result.isValid).toBe(true)
expect(result.arnRegion).toBeUndefined()
expect(result.errorMessage).toBeUndefined()
diff --git a/webview-ui/src/utils/format.ts b/webview-ui/src/utils/format.ts
index 8baf5c6f14..3f1cc9bebd 100644
--- a/webview-ui/src/utils/format.ts
+++ b/webview-ui/src/utils/format.ts
@@ -1,17 +1,16 @@
-import i18next from "i18next"
-
export type TimeFormat = "auto" | "12h" | "24h"
export type DateFormat = "auto" | "mdy" | "dmy" | "ymd"
+import { type TFunction } from "i18next"
-export function formatLargeNumber(num: number): string {
+export function formatLargeNumber(num: number, t: TFunction): string {
if (num >= 1e9) {
- return (num / 1e9).toFixed(1) + i18next.t("common:number_format.billion_suffix")
+ return (num / 1e9).toFixed(1) + t("common:number_format.billion_suffix")
}
if (num >= 1e6) {
- return (num / 1e6).toFixed(1) + i18next.t("common:number_format.million_suffix")
+ return (num / 1e6).toFixed(1) + t("common:number_format.million_suffix")
}
if (num >= 1e3) {
- return (num / 1e3).toFixed(1) + i18next.t("common:number_format.thousand_suffix")
+ return (num / 1e3).toFixed(1) + t("common:number_format.thousand_suffix")
}
return num.toString()
}
@@ -50,11 +49,7 @@ export const resolveLocale = (locale: string | undefined): string => {
* explicit orders use a fixed numeric form (e.g. 10/27/2026, 27/10/2026,
* 2026-10-27) so the preview and the app agree regardless of locale.
*/
-export const formatDateOnly = (
- timestamp: number,
- dateFormat: DateFormat = "auto",
- locale: string = i18next.language || "en",
-) => {
+export const formatDateOnly = (timestamp: number, locale: string, dateFormat: DateFormat = "auto") => {
const date = new Date(timestamp)
const formatLocale = resolveLocale(locale)
@@ -71,14 +66,9 @@ export const formatDateOnly = (
}
/**
- * Clock time only, honouring the 12h/24h choice. `locale` defaults to the active display language;
- * pass one to preview a language that has not been saved yet.
+ * Clock time only, honouring the 12h/24h choice and the locale.
*/
-export const formatTimeOnly = (
- timestamp: number,
- timeFormat: TimeFormat = "auto",
- locale: string = i18next.language || "en",
-) =>
+export const formatTimeOnly = (timestamp: number, locale: string, timeFormat: TimeFormat = "auto") =>
new Date(timestamp).toLocaleTimeString(resolveLocale(locale), {
hour: "numeric",
minute: "2-digit",
@@ -88,12 +78,12 @@ export const formatTimeOnly = (
/** Full date + time, used for tooltips on relative timestamps and for absolute task times. */
export const formatDateTime = (
timestamp: number,
+ locale: string,
timeFormat: TimeFormat = "auto",
dateFormat: DateFormat = "auto",
- locale: string = i18next.language || "en",
-) => `${formatDateOnly(timestamp, dateFormat, locale)}, ${formatTimeOnly(timestamp, timeFormat, locale)}`
+) => `${formatDateOnly(timestamp, locale, dateFormat)}, ${formatTimeOnly(timestamp, locale, timeFormat)}`
-export const formatTimeAgo = (timestamp: number) => {
+export const formatTimeAgo = (timestamp: number, t: TFunction) => {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
@@ -105,40 +95,28 @@ export const formatTimeAgo = (timestamp: number) => {
const years = Math.floor(days / 365)
if (years > 0) {
- return years === 1
- ? i18next.t("common:time_ago.year_ago")
- : i18next.t("common:time_ago.years_ago", { count: years })
+ return years === 1 ? t("common:time_ago.year_ago") : t("common:time_ago.years_ago", { count: years })
}
if (months > 0) {
- return months === 1
- ? i18next.t("common:time_ago.month_ago")
- : i18next.t("common:time_ago.months_ago", { count: months })
+ return months === 1 ? t("common:time_ago.month_ago") : t("common:time_ago.months_ago", { count: months })
}
if (weeks > 0) {
- return weeks === 1
- ? i18next.t("common:time_ago.week_ago")
- : i18next.t("common:time_ago.weeks_ago", { count: weeks })
+ return weeks === 1 ? t("common:time_ago.week_ago") : t("common:time_ago.weeks_ago", { count: weeks })
}
if (days > 0) {
- return days === 1
- ? i18next.t("common:time_ago.day_ago")
- : i18next.t("common:time_ago.days_ago", { count: days })
+ return days === 1 ? t("common:time_ago.day_ago") : t("common:time_ago.days_ago", { count: days })
}
if (hours > 0) {
- return hours === 1
- ? i18next.t("common:time_ago.hour_ago")
- : i18next.t("common:time_ago.hours_ago", { count: hours })
+ return hours === 1 ? t("common:time_ago.hour_ago") : t("common:time_ago.hours_ago", { count: hours })
}
if (minutes > 0) {
- return minutes === 1
- ? i18next.t("common:time_ago.minute_ago")
- : i18next.t("common:time_ago.minutes_ago", { count: minutes })
+ return minutes === 1 ? t("common:time_ago.minute_ago") : t("common:time_ago.minutes_ago", { count: minutes })
}
if (seconds > 30) {
- return i18next.t("common:time_ago.seconds_ago", { count: seconds })
+ return t("common:time_ago.seconds_ago", { count: seconds })
}
- return i18next.t("common:time_ago.just_now")
+ return t("common:time_ago.just_now")
}
const DAY_MS = 24 * 60 * 60 * 1000
@@ -164,35 +142,36 @@ const startOfDay = (ts: number) => {
*/
export const formatMessageTime = (
timestamp: number,
+ locale: string,
{
now = Date.now(),
timeFormat = "auto",
dateFormat = "auto",
}: { now?: number; timeFormat?: TimeFormat; dateFormat?: DateFormat } = {},
): string => {
- const locale = resolveLocale(i18next.language)
- const time = formatTimeOnly(timestamp, timeFormat, locale)
+ const formatLocale = resolveLocale(locale)
+ const time = formatTimeOnly(timestamp, formatLocale, timeFormat)
const daysAgo = Math.round((startOfDay(now) - startOfDay(timestamp)) / DAY_MS)
if (daysAgo === 0 || daysAgo === 1) {
- const label = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-daysAgo, "day")
+ const label = new Intl.RelativeTimeFormat(formatLocale, { numeric: "auto" }).format(-daysAgo, "day")
// Sentence case: "today" -> "Today" (a no-op for scripts without case).
- return `${label.charAt(0).toLocaleUpperCase(locale)}${label.slice(1)} ${time}`
+ return `${label.charAt(0).toLocaleUpperCase(formatLocale)}${label.slice(1)} ${time}`
}
if (daysAgo > 1 && daysAgo < 7) {
- return `${new Date(timestamp).toLocaleDateString(locale, { weekday: "short" })} ${time}`
+ return `${new Date(timestamp).toLocaleDateString(formatLocale, { weekday: "short" })} ${time}`
}
const sameYear = new Date(timestamp).getFullYear() === new Date(now).getFullYear()
if (dateFormat !== "auto") {
// Explicit numeric orders are always written in full so they stay unambiguous.
- return `${formatDateOnly(timestamp, dateFormat, locale)}, ${time}`
+ return `${formatDateOnly(timestamp, formatLocale, dateFormat)}, ${time}`
}
const date = new Date(timestamp).toLocaleDateString(
- locale,
+ formatLocale,
sameYear ? { month: "short", day: "numeric" } : { month: "short", day: "numeric", year: "numeric" },
)
return `${date}, ${time}`
diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts
index 9e4addf7b1..3e84a03c83 100644
--- a/webview-ui/src/utils/validate.ts
+++ b/webview-ui/src/utils/validate.ts
@@ -1,5 +1,4 @@
-import i18next from "i18next"
-
+import type { TFunction } from "i18next"
import {
type ProviderSettings,
type OrganizationAllowList,
@@ -33,27 +32,28 @@ export function formatOpenAiExtraBodyValidationError(
return t(`settings:validation.openAiExtraBody.${result.reason}`)
}
-function validateOpenAiExtraBody(apiConfiguration: ProviderSettings): string | undefined {
+function validateOpenAiExtraBody(apiConfiguration: ProviderSettings, t: TFunction): string | undefined {
if (apiConfiguration.apiProvider !== providerIdentifiers.openai) {
return undefined
}
- return formatOpenAiExtraBodyValidationError(parseOpenAiExtraBody(apiConfiguration.openAiExtraBody), i18next.t)
+ return formatOpenAiExtraBodyValidationError(parseOpenAiExtraBody(apiConfiguration.openAiExtraBody), t)
}
export function validateApiConfiguration(
apiConfiguration: ProviderSettings,
+ t: TFunction,
routerModels?: RouterModels,
organizationAllowList?: OrganizationAllowList,
zooCodeIsAuthenticated?: boolean,
): string | undefined {
- const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration, zooCodeIsAuthenticated)
+ const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration, t, zooCodeIsAuthenticated)
if (keysAndIdsPresentErrorMessage) {
return keysAndIdsPresentErrorMessage
}
- const extraBodyError = validateOpenAiExtraBody(apiConfiguration)
+ const extraBodyError = validateOpenAiExtraBody(apiConfiguration, t)
if (extraBodyError) {
return extraBodyError
@@ -61,6 +61,7 @@ export function validateApiConfiguration(
const organizationAllowListError = validateProviderAgainstOrganizationSettings(
apiConfiguration,
+ t,
organizationAllowList,
)
@@ -68,82 +69,83 @@ export function validateApiConfiguration(
return organizationAllowListError.message
}
- return validateDynamicProviderModelId(apiConfiguration, routerModels)
+ return validateDynamicProviderModelId(apiConfiguration, t, routerModels)
}
function validateModelsAndKeysProvided(
apiConfiguration: ProviderSettings,
+ t: TFunction,
zooCodeIsAuthenticated?: boolean,
): string | undefined {
switch (apiConfiguration.apiProvider) {
case providerIdentifiers.openrouter:
if (!apiConfiguration.openRouterApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.requesty:
if (!apiConfiguration.requestyApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.unbound:
if (!apiConfiguration.unboundApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.litellm:
if (!apiConfiguration.litellmApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.anthropic:
if (!apiConfiguration.apiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.bedrock:
if (!apiConfiguration.awsRegion) {
- return i18next.t("settings:validation.awsRegion")
+ return t("settings:validation.awsRegion")
}
break
case providerIdentifiers.vertex:
if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) {
- return i18next.t("settings:validation.googleCloud")
+ return t("settings:validation.googleCloud")
}
break
case providerIdentifiers.gemini:
if (!apiConfiguration.geminiApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.openaiNative:
if (!apiConfiguration.openAiNativeApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.mistral:
if (!apiConfiguration.mistralApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.openai:
if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) {
- return i18next.t("settings:validation.openAi")
+ return t("settings:validation.openAi")
}
break
case providerIdentifiers.ollama:
if (!apiConfiguration.ollamaModelId) {
- return i18next.t("settings:validation.modelId")
+ return t("settings:validation.modelId")
}
break
case providerIdentifiers.lmstudio:
if (!apiConfiguration.lmStudioModelId) {
- return i18next.t("settings:validation.modelId")
+ return t("settings:validation.modelId")
}
break
case providerIdentifiers.vscodeLm:
if (!apiConfiguration.vsCodeLmModelSelector) {
- return i18next.t("settings:validation.modelSelector")
+ return t("settings:validation.modelSelector")
}
break
case providerIdentifiers.githubCopilot:
@@ -151,62 +153,62 @@ function validateModelsAndKeysProvided(
apiConfiguration.vsCodeLmModelSelector?.vendor !== githubCopilotLanguageModel.vendor ||
(!apiConfiguration.vsCodeLmModelSelector.id && !apiConfiguration.vsCodeLmModelSelector.family)
) {
- return i18next.t("settings:validation.modelSelector")
+ return t("settings:validation.modelSelector")
}
break
case providerIdentifiers.fireworks:
if (!apiConfiguration.fireworksApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.friendli:
if (!apiConfiguration.friendliApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.qwenCode:
if (!apiConfiguration.qwenCodeOauthPath) {
- return i18next.t("settings:validation.qwenCodeOauthPath")
+ return t("settings:validation.qwenCodeOauthPath")
}
break
case providerIdentifiers.kimiCode:
if ((apiConfiguration.kimiCodeAuthMethod ?? "oauth") === "api-key" && !apiConfiguration.kimiCodeApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.vercelAiGateway:
if (!apiConfiguration.vercelAiGatewayApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.opencodeGo:
if (!apiConfiguration.opencodeGoApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.kenari:
if (!apiConfiguration.kenariApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.ioIntelligence:
if (!apiConfiguration.ioIntelligenceApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.nanogpt:
if (!apiConfiguration.nanoGptApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
case providerIdentifiers.zooGateway:
if (!apiConfiguration.zooSessionToken && !zooCodeIsAuthenticated) {
- return i18next.t("settings:validation.zooGatewaySignIn")
+ return t("settings:validation.zooGatewaySignIn")
}
break
case providerIdentifiers.baseten:
if (!apiConfiguration.basetenApiKey) {
- return i18next.t("settings:validation.apiKey")
+ return t("settings:validation.apiKey")
}
break
}
@@ -221,6 +223,7 @@ type ValidationError = {
function validateProviderAgainstOrganizationSettings(
apiConfiguration: ProviderSettings,
+ t: TFunction,
organizationAllowList?: OrganizationAllowList,
): ValidationError | undefined {
if (organizationAllowList && !organizationAllowList.allowAll) {
@@ -234,7 +237,7 @@ function validateProviderAgainstOrganizationSettings(
if (!providerConfig) {
return {
- message: i18next.t("settings:validation.providerNotAllowed", { provider }),
+ message: t("settings:validation.providerNotAllowed", { provider }),
code: "PROVIDER_NOT_ALLOWED",
}
}
@@ -245,7 +248,7 @@ function validateProviderAgainstOrganizationSettings(
if (modelId && !allowedModels.includes(modelId)) {
return {
- message: i18next.t("settings:validation.modelNotAllowed", {
+ message: t("settings:validation.modelNotAllowed", {
model: modelId,
provider,
}),
@@ -265,10 +268,11 @@ function validateProviderAgainstOrganizationSettings(
* provide valid ARNs without restriction. See issue #10108.
*
* @param arn The ARN string to validate
+ * @param t Translates the region mismatch message
* @param region Optional region to check against the ARN's region
* @returns An object with validation results: { isValid, arnRegion, errorMessage }
*/
-export function validateBedrockArn(arn: string, region?: string) {
+export function validateBedrockArn(arn: string, t: TFunction, region?: string) {
// Try to extract region from ARN for region mismatch warning.
// This is a permissive regex that attempts to find the region component
// without enforcing strict ARN format validation.
@@ -280,7 +284,7 @@ export function validateBedrockArn(arn: string, region?: string) {
return {
isValid: true,
arnRegion,
- errorMessage: i18next.t("settings:validation.arn.regionMismatch", { arnRegion, region }),
+ errorMessage: t("settings:validation.arn.regionMismatch", { arnRegion, region }),
}
}
@@ -290,6 +294,7 @@ export function validateBedrockArn(arn: string, region?: string) {
function validateDynamicProviderModelId(
apiConfiguration: ProviderSettings,
+ t: TFunction,
routerModels?: RouterModels,
): string | undefined {
const provider = apiConfiguration.apiProvider ?? ""
@@ -302,13 +307,13 @@ function validateDynamicProviderModelId(
const modelId = getModelId(apiConfiguration)
if (!modelId) {
- return i18next.t("settings:validation.modelId")
+ return t("settings:validation.modelId")
}
const models = routerModels?.[provider]
if (models && Object.keys(models).length > 1 && !Object.keys(models).includes(modelId)) {
- return i18next.t("settings:validation.modelAvailability", { modelId })
+ return t("settings:validation.modelAvailability", { modelId })
}
return undefined
@@ -320,6 +325,7 @@ function validateDynamicProviderModelId(
*/
export function getModelValidationError(
apiConfiguration: ProviderSettings,
+ t: TFunction,
routerModels?: RouterModels,
organizationAllowList?: OrganizationAllowList,
): string | undefined {
@@ -330,13 +336,13 @@ export function getModelValidationError(
apiModelId: modelId || "",
}
- const orgError = validateProviderAgainstOrganizationSettings(configWithModelId, organizationAllowList)
+ const orgError = validateProviderAgainstOrganizationSettings(configWithModelId, t, organizationAllowList)
if (orgError && orgError.code === "MODEL_NOT_ALLOWED") {
return orgError.message
}
- return validateDynamicProviderModelId(configWithModelId, routerModels)
+ return validateDynamicProviderModelId(configWithModelId, t, routerModels)
}
/**
@@ -351,18 +357,19 @@ export function getModelValidationError(
*/
export function validateApiConfigurationExcludingModelErrors(
apiConfiguration: ProviderSettings,
+ t: TFunction,
_routerModels?: RouterModels, // Keeping this for compatibility with the old function.
organizationAllowList?: OrganizationAllowList,
): string | undefined {
if (apiConfiguration.apiProvider !== providerIdentifiers.zooGateway) {
- const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration)
+ const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration, t)
if (keysAndIdsPresentErrorMessage) {
return keysAndIdsPresentErrorMessage
}
}
- const extraBodyError = validateOpenAiExtraBody(apiConfiguration)
+ const extraBodyError = validateOpenAiExtraBody(apiConfiguration, t)
if (extraBodyError) {
return extraBodyError
@@ -370,6 +377,7 @@ export function validateApiConfigurationExcludingModelErrors(
const organizationAllowListError = validateProviderAgainstOrganizationSettings(
apiConfiguration,
+ t,
organizationAllowList,
)