From e5added6da4cf6b50d817d5f8f691540c9b2d198 Mon Sep 17 00:00:00 2001 From: ming Date: Thu, 9 Jul 2026 16:29:52 +0800 Subject: [PATCH] feat: add Chinese (zh) i18n localization support - Install i18next + react-i18next - Create i18n configuration and zh.json translation file (~400+ keys) - Wrap main.tsx with i18n import - Replace all hardcoded English UI text with {t('key')} calls across: - All page components (Login, Register, Setup, Repos, Schedules, etc.) - Navigation components (Sidebar, TabBar, Drawer, Sheets) - Repository components (Cards, Dialogs, Lists, Worktrees) - Session components (List, Cards, Permissions, Questions) - Settings components (General, Git, Account, Providers, MCP, etc.) - Schedule components (Jobs, Prompts, Timing, Skills, RunHistory) - Message components (PromptInput, MessagePart, Thread, etc.) - Model selectors, Source control, File browser, Notifications - All placeholder, aria-label, title attributes also localized - FormLabel and SelectItem text in settings dialogs localized Over 50+ components updated with Chinese translations. --- frontend/package.json | 2 + frontend/src/components/PwaUpdatePrompt.tsx | 11 +- frontend/src/components/VersionNotifier.tsx | 10 +- .../src/components/agent/AgentQuickSelect.tsx | 4 +- .../src/components/message/CodePreview.tsx | 10 +- .../message/EditableUserMessage.tsx | 13 +- .../components/message/FloatingTTSButton.tsx | 14 +- .../src/components/message/MessagePart.tsx | 21 +- .../src/components/message/MessageThread.tsx | 13 +- .../components/message/SessionTodoDisplay.tsx | 18 +- frontend/src/components/message/TextPart.tsx | 20 +- .../src/components/message/ToolCallPart.tsx | 49 +- .../message/UserMessageActionButtons.tsx | 4 +- .../src/components/model/ApiKeyDialog.tsx | 27 +- .../src/components/model/ModelQuickSelect.tsx | 58 +- .../components/model/ModelSelectDialog.tsx | 50 +- .../components/navigation/DesktopSidebar.tsx | 8 +- .../components/navigation/MobileSheetHost.tsx | 4 +- .../components/navigation/MobileTabBar.tsx | 26 +- .../src/components/navigation/MoreDrawer.tsx | 12 +- .../navigation/NotificationsSheet.tsx | 24 +- .../navigation/RepoQuickSwitchSheet.tsx | 16 +- .../navigation/SessionMoreButton.tsx | 4 +- .../navigation/moreDrawerItems.test.ts | 36 +- .../components/navigation/moreDrawerItems.ts | 78 +- .../src/components/repo/AddRepoDialog.tsx | 70 +- .../src/components/repo/RenameRepoDialog.tsx | 10 +- frontend/src/components/repo/RepoCard.tsx | 6 +- frontend/src/components/repo/RepoList.tsx | 14 +- .../src/components/repo/RepoListControls.tsx | 51 +- .../src/components/repo/RepoLspDialog.tsx | 4 +- .../src/components/repo/RepoMcpDialog.tsx | 18 +- .../src/components/repo/RepoMcpServerList.tsx | 59 +- .../src/components/repo/RepoRowActions.tsx | 56 +- .../src/components/repo/RepoSkillsDialog.tsx | 18 +- .../repo/ResetPermissionsDialog.tsx | 18 +- .../components/repo/SwitchConfigDialog.tsx | 28 +- .../src/components/repo/WorkspaceManager.tsx | 34 +- .../src/components/schedules/GeneralTab.tsx | 45 +- .../src/components/schedules/JobDetailTab.tsx | 55 +- frontend/src/components/schedules/JobsTab.tsx | 9 +- .../src/components/schedules/PromptTab.tsx | 14 +- .../schedules/PromptTemplateDialog.tsx | 36 +- .../components/schedules/RunDetailPanel.tsx | 20 +- .../components/schedules/RunHistoryCards.tsx | 14 +- .../components/schedules/RunHistoryTab.tsx | 11 +- .../schedules/ScheduleJobDialog.tsx | 30 +- .../components/schedules/ScheduleTabMenu.tsx | 9 +- .../src/components/schedules/SkillsTab.tsx | 15 +- .../src/components/schedules/TimingTab.tsx | 37 +- .../src/components/session/SessionList.tsx | 28 +- .../components/settings/AccountSettings.tsx | 72 +- .../settings/AddMcpServerDialog.tsx | 68 +- .../src/components/settings/AgentDialog.tsx | 92 +- .../src/components/settings/CommandDialog.tsx | 34 +- .../settings/CreateConfigDialog.tsx | 30 +- .../settings/DirectoryFilesList.tsx | 28 +- .../components/settings/GeneralSettings.tsx | 43 +- .../settings/GitCredentialDialog.tsx | 76 +- .../src/components/settings/GitSettings.tsx | 62 +- .../components/settings/KeyboardShortcuts.tsx | 30 +- .../settings/ManagerTokenSettings.tsx | 10 +- .../src/components/settings/McpManager.tsx | 26 +- .../components/settings/McpOAuthDialog.tsx | 63 +- .../src/components/settings/McpServerCard.tsx | 44 +- .../settings/NotificationSettings.tsx | 62 +- .../settings/OAuthAuthorizeDialog.tsx | 22 +- .../settings/OAuthCallbackDialog.tsx | 34 +- .../settings/OpenCodeConfigEditor.tsx | 28 +- .../settings/OpenCodeConfigManager.tsx | 84 +- .../settings/OpenCodeModelDialog.tsx | 145 ++- .../settings/OpenCodeServerAuthSettings.tsx | 22 +- .../components/settings/ProviderSettings.tsx | 44 +- .../src/components/settings/STTSettings.tsx | 20 +- .../settings/ServerEnvVarsSettings.tsx | 27 +- .../settings/ServerHealthStatus.tsx | 12 +- .../components/settings/SettingsDialog.tsx | 40 +- .../src/components/settings/SkillDialog.tsx | 32 +- .../settings/SkillInstallDialog.tsx | 48 +- .../src/components/settings/SkillsEditor.tsx | 26 +- .../src/components/settings/TTSSettings.tsx | 111 +- .../settings/VersionSelectDialog.tsx | 20 +- .../components/skills/SkillLibraryList.tsx | 16 +- .../components/source-control/BranchesTab.tsx | 32 +- .../components/source-control/ChangesTab.tsx | 12 +- .../source-control/GitFlatFileItem.tsx | 6 +- .../source-control/SourceControlPanel.tsx | 16 +- frontend/src/components/ui/discard-dialog.tsx | 16 +- frontend/src/i18n/index.ts | 16 + frontend/src/i18n/zh.json | 1017 +++++++++++++++++ frontend/src/main.tsx | 1 + frontend/src/pages/AssistantRedirect.tsx | 14 +- frontend/src/pages/GlobalSchedules.tsx | 156 +-- frontend/src/pages/Login.tsx | 12 +- frontend/src/pages/Register.tsx | 26 +- frontend/src/pages/RepoDetail.tsx | 21 +- frontend/src/pages/Repos.tsx | 10 +- frontend/src/pages/Schedules.tsx | 32 +- frontend/src/pages/SessionDetail.tsx | 20 +- frontend/src/pages/Setup.tsx | 16 +- frontend/src/pages/Workspace.tsx | 7 +- pnpm-lock.yaml | 115 +- 102 files changed, 2769 insertions(+), 1488 deletions(-) create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/zh.json diff --git a/frontend/package.json b/frontend/package.json index cd5c45be5..b603b6f80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,11 +36,13 @@ "date-fns": "^4.1.0", "diff": "^8.0.2", "highlight.js": "^11.11.1", + "i18next": "^26.3.5", "lucide-react": "^0.546.0", "mermaid": "^11.12.2", "react": "^19.1.1", "react-dom": "^19.1.1", "react-hook-form": "^7.65.0", + "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", "rehype-highlight": "^7.0.2", diff --git a/frontend/src/components/PwaUpdatePrompt.tsx b/frontend/src/components/PwaUpdatePrompt.tsx index 90871141e..163c4a965 100644 --- a/frontend/src/components/PwaUpdatePrompt.tsx +++ b/frontend/src/components/PwaUpdatePrompt.tsx @@ -1,21 +1,24 @@ +import { useTranslation } from 'react-i18next' import { useEffect } from 'react' import { showToast } from '@/lib/toast' import { onServiceWorkerUpdate, offServiceWorkerUpdate } from '@/lib/serviceWorker' export function PwaUpdatePrompt() { + const { t } = useTranslation() + useEffect(() => { onServiceWorkerUpdate(() => { - showToast.info('New build deployed', { - description: 'Refresh to load the latest changes.', + showToast.info(t('pwa.updatePrompt'), { + description: t('pwa.refreshDescription'), action: { - label: 'Refresh', + label: t('pwa.update'), onClick: () => window.location.reload(), }, duration: Infinity, }) }) return () => offServiceWorkerUpdate() - }, []) + }, [t]) return null } diff --git a/frontend/src/components/VersionNotifier.tsx b/frontend/src/components/VersionNotifier.tsx index e8b98bdb2..a2ab795a6 100644 --- a/frontend/src/components/VersionNotifier.tsx +++ b/frontend/src/components/VersionNotifier.tsx @@ -1,8 +1,10 @@ +import { useTranslation } from 'react-i18next' import { useEffect, useRef } from 'react' import { showToast } from '@/lib/toast' import { useVersionCheck } from '@/hooks/useVersionCheck' export function VersionNotifier() { + const { t } = useTranslation() const { data, isSuccess } = useVersionCheck() const hasNotifiedRef = useRef(false) @@ -11,16 +13,16 @@ export function VersionNotifier() { if (data.updateAvailable && data.latestVersion && data.releaseUrl) { hasNotifiedRef.current = true - showToast.info(`OpenCode Manager v${data.latestVersion} is available`, { - description: 'A new version is ready to install.', + showToast.info(t('versionNotifier.newVersionInfo', { version: data.latestVersion }), { + description: t('versionNotifier.readyToInstall'), action: { - label: 'View Release', + label: t('versionNotifier.viewRelease'), onClick: () => window.open(data.releaseUrl ?? '', '_blank'), }, duration: 10000, }) } - }, [isSuccess, data]) + }, [isSuccess, data, t]) return null } diff --git a/frontend/src/components/agent/AgentQuickSelect.tsx b/frontend/src/components/agent/AgentQuickSelect.tsx index 26fcc2b3f..c210945c5 100644 --- a/frontend/src/components/agent/AgentQuickSelect.tsx +++ b/frontend/src/components/agent/AgentQuickSelect.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { useMemo } from 'react' import { Check } from 'lucide-react' @@ -55,6 +56,7 @@ export function AgentQuickSelect({ onAgentChange, isBashMode = false, }: AgentQuickSelectProps) { + const { t } = useTranslation() const { data: agents = [] } = useAgents(opcodeUrl, directory) const primaryAgents = useMemo(() => { @@ -72,7 +74,7 @@ export function AgentQuickSelect({ const styleVars = isBashMode ? bashStyleVars : getAgentStyleVars(currentAgent, findAgentColor(agents, currentAgent)) - const displayName = isBashMode ? 'Bash' : capitalize(currentAgent) + const displayName = isBashMode ? t('agent.bash') : capitalize(currentAgent) const buttonContent = ( )} @@ -103,9 +105,9 @@ export function CodePreview({ fileName, content }: CodePreviewProps) { onClick={() => setShowMore(false)} className="mx-3 my-2 px-3 py-1.5 bg-muted hover:bg-muted/70 border border-border/50 rounded text-xs text-muted-foreground" > - Show less + {t('code.showLess')} )} ) -} \ No newline at end of file +} diff --git a/frontend/src/components/message/EditableUserMessage.tsx b/frontend/src/components/message/EditableUserMessage.tsx index 62ab586b2..9d66b902f 100644 --- a/frontend/src/components/message/EditableUserMessage.tsx +++ b/frontend/src/components/message/EditableUserMessage.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { memo, useState, useRef, useEffect } from 'react' import { Send, X, Pencil, Loader2 } from 'lucide-react' import { useRefreshMessage } from '@/hooks/useRemoveMessage' @@ -24,6 +25,7 @@ export const EditableUserMessage = memo(function EditableUserMessage({ onCancel, model }: EditableUserMessageProps) { + const { t } = useTranslation() const [editedContent, setEditedContent] = useState(content) const textareaRef = useRef(null) const isMobile = useMobile() @@ -89,13 +91,13 @@ export const EditableUserMessage = memo(function EditableUserMessage({ onKeyDown={handleKeyDown} onFocus={() => setIsEditingMessage(true)} className="w-full p-3 rounded-lg bg-background border border-primary/50 focus:border-primary focus:ring-1 focus:ring-primary outline-none resize-none min-h-[60px] text-[16px] md:text-sm" - placeholder="Edit your message..." + placeholder={t('session.editMessagePlaceholder')} disabled={refreshMessage.isPending} />
- Press Cmd+Enter to send, Esc to cancel + {t('session.editMessageHint')}
@@ -105,7 +107,7 @@ export const EditableUserMessage = memo(function EditableUserMessage({ className="flex items-center gap-1 px-3 py-1.5 rounded text-sm border border-muted-foreground/40 bg-secondary text-secondary-foreground hover:bg-secondary/80 hover:border-muted-foreground/60 transition-colors disabled:opacity-50" > - Cancel + {t('common.cancel')}
@@ -137,6 +139,7 @@ export const ClickableUserMessage = memo(function ClickableUserMessage({ onClick, isEditable }: ClickableUserMessageProps) { + const { t } = useTranslation() const isMobile = useMobile() if (!isEditable) { @@ -151,7 +154,7 @@ export const ClickableUserMessage = memo(function ClickableUserMessage({ )} {isQueued && ( - QUEUED + {t('session.queued')} )} @@ -332,6 +334,7 @@ export const MessageThread = memo(function MessageThread({ onUndoMessage, model }: MessageThreadProps) { + const { t } = useTranslation() const [editingUserMessageId, setEditingUserMessageId] = useState(null) const [editingForAssistantId, setEditingForAssistantId] = useState(null) const sessionStatus = useSessionStatusForSession(sessionID) @@ -396,7 +399,7 @@ export const MessageThread = memo(function MessageThread({ : parsed?.todos ? parsed.todos as Todo[] : [] } catch (_) { - console.warn('Failed to parse todo output:', _) + // Silent parse failure } } @@ -419,7 +422,7 @@ export const MessageThread = memo(function MessageThread({ if (!messages || messages.length === 0) { return (
- No messages yet. Start a conversation below. + {t('session.noMessages')}
) } diff --git a/frontend/src/components/message/SessionTodoDisplay.tsx b/frontend/src/components/message/SessionTodoDisplay.tsx index f88941337..8f592e2ac 100644 --- a/frontend/src/components/message/SessionTodoDisplay.tsx +++ b/frontend/src/components/message/SessionTodoDisplay.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { useState, useMemo, useEffect, useRef } from 'react' import { ChevronDown, ChevronRight, X } from 'lucide-react' import { TodoItem } from './TodoItem' @@ -14,6 +15,7 @@ const todoSignature = (todos: Todo[]) => todos.map((t) => `${t.id}:${t.status}`).join(',') export function SessionTodoDisplay({ sessionID }: SessionTodoDisplayProps) { + const { t } = useTranslation() const todos = useSessionTodosForSession(sessionID) const [isCollapsed, setIsCollapsed] = useState(true) const [isDismissed, setIsDismissed] = useState(false) @@ -85,7 +87,7 @@ export function SessionTodoDisplay({ sessionID }: SessionTodoDisplayProps) {
- Tasks: {stats.completed}/{stats.total} complete + {t('session.tasksProgress', { completed: stats.completed, total: stats.total })}
@@ -113,7 +115,7 @@ export function SessionTodoDisplay({ sessionID }: SessionTodoDisplayProps) { > - Tasks: {stats.completed}/{stats.total} complete + {t('session.tasksProgress', { completed: stats.completed, total: stats.total })}
- {renderGroup('In Progress', inProgress)} - {renderGroup('Pending', pending)} - {renderGroup('Completed', completedTodos)} + {renderGroup(t('session.inProgress'), inProgress)} + {renderGroup(t('common.pending'), pending)} + {renderGroup(t('common.completed'), completedTodos)}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/message/TextPart.tsx b/frontend/src/components/message/TextPart.tsx index 721c0dfaf..32acd71b2 100644 --- a/frontend/src/components/message/TextPart.tsx +++ b/frontend/src/components/message/TextPart.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import React, { useEffect, useState, useId, useCallback } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -21,6 +22,7 @@ interface MermaidBlockProps { } function MermaidBlock({ code }: MermaidBlockProps) { + const { t } = useTranslation() const [svg, setSvg] = useState(null) const [error, setError] = useState(null) const [isExpanded, setIsExpanded] = useState(false) @@ -53,7 +55,7 @@ function MermaidBlock({ code }: MermaidBlockProps) { } } catch (err: unknown) { if (currentAttempt === renderAttempt.current) { - let message = 'Failed to render diagram' + let message = t('mermaid.renderFailed') if (err instanceof Error) { message = err.message } else if (typeof err === 'string') { @@ -65,7 +67,7 @@ function MermaidBlock({ code }: MermaidBlockProps) { setSvg(null) } } - }, [code, uniqueId]) + }, [code, uniqueId, t]) useEffect(() => { renderDiagram() @@ -77,14 +79,14 @@ function MermaidBlock({ code }: MermaidBlockProps) {
- Mermaid Error + {t('mermaid.error')}
{error}
             {code}
           
- +
) } @@ -108,11 +110,11 @@ function MermaidBlock({ code }: MermaidBlockProps) { - +
@@ -128,7 +130,7 @@ function MermaidBlock({ code }: MermaidBlockProps) { @@ -150,6 +152,7 @@ interface CodeBlockProps { } function CodeBlock({ children, className, ...props }: CodeBlockProps) { + const { t } = useTranslation() const extractTextContent = (node: React.ReactNode): string => { if (typeof node === 'string') return node if (typeof node === 'number') return node.toString() @@ -170,7 +173,7 @@ function CodeBlock({ children, className, ...props }: CodeBlockProps) {
         {children}
       
- + ) } @@ -185,6 +188,7 @@ function isMermaidBlockComplete(text: string): boolean { } export function TextPart({ part }: TextPartProps) { + const { t } = useTranslation() const mermaidComplete = React.useMemo(() => { return part.text ? isMermaidBlockComplete(part.text) : false }, [part.text]) diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 77ceae93a..aeb2cc956 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { useState, useRef, useEffect } from 'react' import type { components } from '@/api/opencode-types' import { useSettings } from '@/hooks/useSettings' @@ -27,6 +28,7 @@ function getTaskSessionId(part: ToolPart): string | undefined { } function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (filePath: string) => void }) { + const { t } = useTranslation() const jsonString = JSON.stringify(json, null, 2) const references = detectFileReferences(jsonString) @@ -50,7 +52,7 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi onFileClick?.(ref.filePath) }} className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer underline decoration-dotted" - title={`Click to open ${ref.filePath}`} + title={t('tooltip.openFile', { file: ref.filePath })} > {ref.fullMatch} @@ -67,6 +69,7 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi } export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCallPartProps) { + const { t } = useTranslation() const { preferences } = useSettings() const { userBashCommands } = useUserBash() const taskSessionId = part.tool === 'task' ? getTaskSessionId(part) : undefined @@ -156,7 +159,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal if (part.tool === 'task') { const sessionId = taskSessionId - const description = previewText || 'Sub-agent task' + const description = previewText || t('message.subAgentTask') const status = part.state.status const isPending = status === 'pending' @@ -183,7 +186,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal {isCompleted && } {isError && } {description} - sub-agent + {t('message.subAgent')} {sessionId && } ) @@ -193,7 +196,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal @@ -211,7 +214,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal if (part.state.status === 'error') { return (
- Error updating tasks: {part.state.error} + {t('message.todoError')}: {part.state.error}
) } @@ -243,7 +246,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
             {output}
           
- + ) @@ -284,7 +287,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal } }} className="text-blue-600 dark:text-blue-400 text-xs truncate hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer underline decoration-dotted" - title={`Click to open ${previewText}`} + title={t('tooltip.openFile', { file: previewText })} > {previewText} @@ -301,14 +304,14 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal onChildSessionClick?.(sessionId) }} className="text-blue-600 dark:text-blue-400 text-xs hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer underline decoration-dotted flex items-center gap-1" - title="View subagent session" + title={t('session.viewSubagent')} > - View Session + {t('session.viewSubagent')} ) : null })()} - ({isWaitingPermission ? 'awaiting permission' : isWaitingQuestion ? 'awaiting answer' : part.state.status}) + ({isWaitingPermission ? t('session.awaitingPermission') : isWaitingQuestion ? t('session.awaitingAnswer') : part.state.status}) {expanded && ( @@ -320,7 +323,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal - Preparing tool call... + {t('message.preparingToolCall')} )} @@ -329,10 +332,10 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal {part.tool === 'bash' ? (
-
Command:
+
{t('message.command')}:
@@ -340,16 +343,16 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
- {isWaitingPermission ? 'Waiting for permission...' : 'Running...'} + {isWaitingPermission ? t('session.waitingForPermission') : t('message.running')}
) : (
-
Input:
+
{t('message.input')}:
- {isWaitingPermission ? 'Waiting for permission...' : 'Running...'} + {isWaitingPermission ? t('session.waitingForPermission') : t('message.running')}
)} @@ -361,10 +364,10 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal {part.tool === 'bash' ? (
-
Command:
+
{t('message.command')}:
@@ -373,24 +376,24 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
) : (
-
Input:
+
{t('message.input')}:
)}
-
Output:
+
{t('message.output')}:
                     {part.state.status === 'completed' ? part.state.output : ''}
                   
{part.state.status === 'completed' && part.state.output && ( - + )}
{part.state.time && (
- Duration: {((part.state.time.end - part.state.time.start) / 1000).toFixed(2)}s + {t('message.duration')}: {((part.state.time.end - part.state.time.start) / 1000).toFixed(2)}s
)} @@ -398,7 +401,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal {part.state.status === 'error' && (
-
Error:
+
{t('common.error')}:
                 {part.state.error}
               
diff --git a/frontend/src/components/message/UserMessageActionButtons.tsx b/frontend/src/components/message/UserMessageActionButtons.tsx index 6d3764fd6..d2d321d0c 100644 --- a/frontend/src/components/message/UserMessageActionButtons.tsx +++ b/frontend/src/components/message/UserMessageActionButtons.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { memo } from 'react' import { X, Loader2 } from 'lucide-react' import { useUndoMessage } from '@/hooks/useUndoMessage' @@ -20,6 +21,7 @@ export const UserMessageActionButtons = memo(function UserMessageActionButtons({ userMessageContent, onUndo }: UserMessageActionButtonsProps) { + const { t } = useTranslation() const isMobile = useMobile() const undoMessage = useUndoMessage({ opcodeUrl, @@ -42,7 +44,7 @@ export const UserMessageActionButtons = memo(function UserMessageActionButtons({ onClick={handleUndo} disabled={undoMessage.isPending} className="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50" - title="Undo this message" + title={t('session.undoMessage')} > {undoMessage.isPending ? ( diff --git a/frontend/src/components/model/ApiKeyDialog.tsx b/frontend/src/components/model/ApiKeyDialog.tsx index 3eafd2d5a..590ddd9df 100644 --- a/frontend/src/components/model/ApiKeyDialog.tsx +++ b/frontend/src/components/model/ApiKeyDialog.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, @@ -29,6 +30,7 @@ export function ApiKeyDialog({ onSuccess, mode = 'add', }: ApiKeyDialogProps) { + const { t } = useTranslation(); const [apiKey, setApiKey] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -44,12 +46,11 @@ export function ApiKeyDialog({ setApiKey(""); onSuccess(); } catch (err) { - setError("Failed to save API key. Please try again."); - console.error("Failed to set API key:", err); + setError(t('settings.saveApiKeyFailed')); } finally { setIsSubmitting(false); } - }, [provider, apiKey, onSuccess]); + }, [provider, apiKey, onSuccess, t]); const handleClose = useCallback(() => { setApiKey(""); @@ -68,23 +69,23 @@ export function ApiKeyDialog({ - {isEditMode ? `Update ${provider.name} API Key` : `Connect ${provider.name}`} + {isEditMode ? t('settings.updateApiKey', { name: provider.name }) : t('settings.connectProvider', { name: provider.name })} {isEditMode - ? `Enter a new API key for ${provider.name}.` - : `Enter your API key to use models from ${provider.name}.` + ? t('settings.enterNewApiKey', { name: provider.name }) + : t('settings.enterApiKey', { name: provider.name }) }
- + setApiKey(e.target.value)} onKeyDown={(e) => { @@ -95,7 +96,7 @@ export function ApiKeyDialog({ autoFocus />

- Environment variable: {envVarName} + {t('settings.environmentVariable')}: {envVarName}

@@ -111,23 +112,23 @@ export function ApiKeyDialog({ className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" > - Get an API key + {t('settings.getApiKey')} )}
diff --git a/frontend/src/components/model/ModelQuickSelect.tsx b/frontend/src/components/model/ModelQuickSelect.tsx index bf74e9d0c..67f2b1ef8 100644 --- a/frontend/src/components/model/ModelQuickSelect.tsx +++ b/frontend/src/components/model/ModelQuickSelect.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, ChevronLeft, ChevronRight, Clock, Search, Star, Trash2, X } from 'lucide-react' import { useModelSelection } from '@/hooks/useModelSelection' @@ -187,6 +188,7 @@ export function ModelQuickSelect({ disabled, children, }: ModelQuickSelectProps) { + const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const [showAllModels, setShowAllModels] = useState(false) const [searchQuery, setSearchQuery] = useState('') @@ -346,7 +348,7 @@ export function ModelQuickSelect({ if (favoriteModelsWithNames.length > 0) { sections.push({ - title: 'Favorites', + title: t('common.favorites'), icon: , models: favoriteModelsWithNames, }) @@ -354,7 +356,7 @@ export function ModelQuickSelect({ if (recentModelsWithNames.length > 0) { sections.push({ - title: 'Recent', + title: t('common.recent'), icon: , models: recentModelsWithNames, }) @@ -362,14 +364,14 @@ export function ModelQuickSelect({ if (sections.length === 0 && quickModels.length > 0) { sections.push({ - title: 'Models', + title: t('model.models'), icon: , models: quickModels, }) } return sections - }, [favoriteModelsWithNames, quickModels, recentModelsWithNames]) + }, [favoriteModelsWithNames, quickModels, recentModelsWithNames, t]) const selectedProviderModels = useMemo(() => { if (!selectedProviderId) return [] @@ -437,7 +439,7 @@ export function ModelQuickSelect({ const context = item.model?.limit?.context if (context) { const formattedContext = context >= 1000000 ? `${(context / 1000000).toFixed(1)}M` : context.toLocaleString() - return `${item.providerName} · ${formattedContext} context` + return `${item.providerName} · ${formattedContext} ${t('model.context')}` } return item.providerName @@ -475,7 +477,7 @@ export function ModelQuickSelect({ removeRecentModel({ providerID: item.providerID, modelID: item.modelID }) }} className="rounded-full p-1.5 text-white/30 hover:bg-white/10 hover:text-white/70" - aria-label="Remove from recent" + aria-label={t('model.removeFromRecent')} > @@ -487,7 +489,7 @@ export function ModelQuickSelect({ toggleFavorite({ providerID: item.providerID, modelID: item.modelID }) }} className="rounded-full p-1.5 text-white/50 transition-opacity hover:bg-white/10 hover:text-white" - aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'} + aria-label={isFavorite ? t('model.removeFromFavorites') : t('model.addToFavorites')} > @@ -508,7 +510,7 @@ export function ModelQuickSelect({ > {provider.label} - {provider.count} {provider.count === 1 ? 'model' : 'models'} + {provider.count} {provider.count === 1 ? t('model.model') : t('model.models')} @@ -520,9 +522,9 @@ export function ModelQuickSelect({ return ( <> - Variant + {t('model.variant')} clearVariant()} className={!currentVariant ? 'text-orange-500' : ''}> - Default + {t('common.default')} {!currentVariant && } {availableVariants.map(variant => ( @@ -535,7 +537,7 @@ export function ModelQuickSelect({ ) } - const selectedVariantLabel = currentVariant ? currentVariant : 'Default' + const selectedVariantLabel = currentVariant ? currentVariant : t('common.default') const handleMoreModelsBack = () => { if (selectedProviderId) { @@ -547,8 +549,8 @@ export function ModelQuickSelect({ setShowAllModels(false) } - const selectedModelLabel = selectedModelItem ? getPrimaryLabel(selectedModelItem) : 'Select model' - const selectedModelDescription = selectedModelItem ? getDescription(selectedModelItem) : 'Choose a model' + const selectedModelLabel = selectedModelItem ? getPrimaryLabel(selectedModelItem) : t('model.selectModel') + const selectedModelDescription = selectedModelItem ? getDescription(selectedModelItem) : t('model.selectOption') return ( <> @@ -560,7 +562,7 @@ export function ModelQuickSelect({ onClose={() => handleOpenChange(false)} heightClass="h-[70dvh] max-h-[720px]" className="z-[300] border-white/10 bg-zinc-950 text-white shadow-2xl md:mx-auto md:max-w-lg" - ariaLabel="Select model" + ariaLabel={t('model.selectModel')} >
{showAllModels ? ( @@ -569,7 +571,7 @@ export function ModelQuickSelect({ type="button" onClick={handleMoreModelsBack} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white/80 hover:bg-white/10" - aria-label="Back to quick models" + aria-label={t('model.backToQuick')} > @@ -578,7 +580,7 @@ export function ModelQuickSelect({ setSearchQuery(event.target.value)} - placeholder={selectedProviderId ? 'Search models...' : 'Search providers...'} + placeholder={selectedProviderId ? t('search.models') : t('search.providers')} className="h-9 border-white/10 bg-white/5 pl-9 text-sm text-white placeholder:text-white/40" autoComplete="off" name="model-search" @@ -591,7 +593,7 @@ export function ModelQuickSelect({ type="button" onClick={() => handleOpenChange(false)} className="flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white/80 hover:bg-white/10" - aria-label="Close model selector" + aria-label={t('model.closeSelector')} > @@ -611,7 +613,7 @@ export function ModelQuickSelect({ type="button" disabled={!model && !hasVariants} className="flex h-9 w-24 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/5 px-1.5 text-sm font-medium capitalize text-white/70 hover:bg-white/10 disabled:opacity-30" - aria-label={`Current variant: ${selectedVariantLabel}`} + aria-label={`${t('model.currentVariant')}: ${selectedVariantLabel}`} > {selectedVariantLabel} @@ -633,7 +635,7 @@ export function ModelQuickSelect({
{connectedProviderItems.length > 0 && ( <> -

Connected

+

{t('common.connected')}

{connectedProviderItems.map(provider => ( ))} {availableProviderItems.length > 0 &&
} @@ -654,7 +656,7 @@ export function ModelQuickSelect({ )} {availableProviderItems.length > 0 && ( <> -

Available

+

{t('common.available')}

{availableProviderItems.map(provider => ( ))} @@ -684,7 +686,7 @@ export function ModelQuickSelect({ itemHeight={MODEL_OPTION_ROW_HEIGHT} renderItem={(item) => renderModelOption(item)} getKey={(item) => item.key} - emptyLabel="No models found" + emptyLabel={t('model.noModelsFound')} className="px-4 pb-4 pt-2" resetKey={`${selectedProviderId ?? 'all'}:${deferredSearchQuery}`} /> @@ -698,7 +700,7 @@ export function ModelQuickSelect({ itemHeight={MODEL_OPTION_ROW_HEIGHT} renderItem={(item) => renderModelOption(item)} getKey={(item) => item.key} - emptyLabel="No models found" + emptyLabel={t('model.noModelsFound')} className="px-4 pb-4" resetKey={`${selectedProviderId}:${deferredSearchQuery}`} /> @@ -707,20 +709,20 @@ export function ModelQuickSelect({
{connectedProviderItems.length > 0 && ( <> -

Connected

+

{t('common.connected')}

{connectedProviderItems.map(renderProviderOption)} {availableProviderItems.length > 0 &&
} )} {availableProviderItems.length > 0 && ( <> -

Available

+

{t('common.available')}

{availableProviderItems.map(renderProviderOption)} )}
{filteredProviderItems.length === 0 && ( -
No providers found
+
{t('model.noProvidersFound')}
)}
)} @@ -748,7 +750,7 @@ export function ModelQuickSelect({ onClick={() => setShowAllModels(true)} className="flex w-full items-center justify-between bg-card px-6 py-4 text-left text-sm font-semibold text-white transition-colors hover:bg-accent active:bg-card" > - More models + {t('model.moreModels')}
diff --git a/frontend/src/components/model/ModelSelectDialog.tsx b/frontend/src/components/model/ModelSelectDialog.tsx index 966115c70..43d9439d9 100644 --- a/frontend/src/components/model/ModelSelectDialog.tsx +++ b/frontend/src/components/model/ModelSelectDialog.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useCallback, memo } from "react"; +import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, @@ -38,6 +39,7 @@ interface SearchInputProps { } function SearchInput({ onSearch, initialValue = "" }: SearchInputProps) { + const { t } = useTranslation(); const [value, setValue] = useState(initialValue); useEffect(() => { @@ -54,7 +56,7 @@ function SearchInput({ onSearch, initialValue = "" }: SearchInputProps) {
setValue(e.target.value)} className="pl-10 md:text-sm" @@ -84,20 +86,21 @@ const ModelCard = memo(function ModelCard({ onSelect, onToggleFavorite, }: ModelCardProps) { + const { t } = useTranslation(); const capabilities = useMemo(() => { const caps = []; - if (model.reasoning) caps.push("Reasoning"); - if (model.tool_call) caps.push("Tools"); - if (model.attachment) caps.push("Files"); + if (model.reasoning) caps.push(t('model.reasoning')); + if (model.tool_call) caps.push(t('model.tools')); + if (model.attachment) caps.push(t('model.files')); return caps; - }, [model.reasoning, model.tool_call, model.attachment]); + }, [model.reasoning, model.tool_call, model.attachment, t]); const statusBadge = useMemo(() => { - if (model.experimental) return Experimental; - if (model.status === "alpha") return Alpha; - if (model.status === "beta") return Beta; + if (model.experimental) return {t('model.experimental')}; + if (model.status === "alpha") return {t('model.alpha')}; + if (model.status === "beta") return {t('model.beta')}; return null; - }, [model.experimental, model.status]); + }, [model.experimental, model.status, t]); return (
@@ -160,18 +163,18 @@ const ModelCard = memo(function ModelCard({
{model.limit?.context && (
- Context: + {t('settings.contextLimit')}: {model.limit.context >= 1000000 ? `${(model.limit.context / 1000000).toFixed(1)}M` : model.limit.context.toLocaleString() - } tokens + } {t('model.tokens')}
)} {model.cost && (
- Cost: + {t('settings.cost')}: ${model.cost.input.toFixed(4)}/1K
)} @@ -203,6 +206,8 @@ const ModelGrid = memo(function ModelGrid({ recentModels = [], showRecent = false, }: ModelGridProps) { + const { t } = useTranslation(); + if (loading) { return (
@@ -214,7 +219,7 @@ const ModelGrid = memo(function ModelGrid({ if (models.length === 0 && (!showRecent || (favoriteModels.length === 0 && recentModels.length === 0))) { return (
- No models found + {t('model.noModelsFound')}
); } @@ -225,7 +230,7 @@ const ModelGrid = memo(function ModelGrid({

- Favorite Models + {t('model.favoriteModels')}

{favoriteModels.map(({ model, provider, modelKey }) => ( @@ -248,7 +253,7 @@ const ModelGrid = memo(function ModelGrid({

- Recent Models + {t('model.recentModels')}

{recentModels.map(({ model, provider, modelKey }) => ( @@ -271,7 +276,7 @@ const ModelGrid = memo(function ModelGrid({
{showRecent && (favoriteModels.length > 0 || recentModels.length > 0) && (

- All Models + {t('model.allModels')}

)}
@@ -305,6 +310,8 @@ const ProviderSidebar = memo(function ProviderSidebar({ selectedProvider, onSelect, }: ProviderSidebarProps) { + const { t } = useTranslation(); + return (
@@ -314,7 +321,7 @@ const ProviderSidebar = memo(function ProviderSidebar({ onClick={() => onSelect("")} className="w-full justify-start text-sm" > - All Providers + {t('model.allProviders')} {providers.length > 0 && ( @@ -343,6 +350,7 @@ export function ModelSelectDialog({ opcodeUrl, directory, }: ModelSelectDialogProps) { + const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(""); const [selectedProvider, setSelectedProvider] = useState(""); @@ -455,7 +463,7 @@ export function ModelSelectDialog({ - {selectedProvider && selectedProviderData ? `Select Model - ${selectedProviderData.name}` : 'Select Model'} + {selectedProvider && selectedProviderData ? `${t('model.selectModel')} - ${selectedProviderData.name}` : t('model.selectModel')} @@ -470,7 +478,7 @@ export function ModelSelectDialog({
setSearchQuery(e.target.value)} autoFocus @@ -116,8 +118,8 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr className="flex-shrink-0" > - Repo - Add repository + {t('repo.repo')} + {t('repo.addRepo')}
@@ -131,7 +133,7 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr ) : filteredRepos.length === 0 ? (
-

No repos found

+

{t('repo.noRepos')}

) : (
diff --git a/frontend/src/components/navigation/SessionMoreButton.tsx b/frontend/src/components/navigation/SessionMoreButton.tsx index 41a56b8f2..f6bb424ab 100644 --- a/frontend/src/components/navigation/SessionMoreButton.tsx +++ b/frontend/src/components/navigation/SessionMoreButton.tsx @@ -1,8 +1,10 @@ +import { useTranslation } from 'react-i18next' import { MoreVertical } from 'lucide-react' import { Button } from '@/components/ui/button' import { useMobileTabBar } from '@/hooks/useMobileTabBar' export function SessionMoreButton() { + const { t } = useTranslation() const { open } = useMobileTabBar() return ( @@ -11,7 +13,7 @@ export function SessionMoreButton() { size="sm" onClick={() => open('more')} className="md:hidden h-10 w-10 p-0 text-foreground border-border hover:bg-accent" - aria-label="More" + aria-label={t('nav.more')} > diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index 6ea63961c..9486c538a 100644 --- a/frontend/src/components/navigation/moreDrawerItems.test.ts +++ b/frontend/src/components/navigation/moreDrawerItems.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest' import { buildMoreItems, buildNavModel } from './moreDrawerItems' +const t = (key: string) => key + describe('buildMoreItems', () => { it('returns Settings + Logout + All Schedules + Files for root path', () => { - const items = buildMoreItems('/') + const items = buildMoreItems('/', t) expect(items).toHaveLength(4) expect(items[0].key).toBe('all-schedules') expect(items[1].key).toBe('files') @@ -12,7 +14,7 @@ describe('buildMoreItems', () => { }) it('returns repo-specific items for /repos/:id', () => { - const items = buildMoreItems('/repos/42') + const items = buildMoreItems('/repos/42', t) expect(items).toHaveLength(8) expect(items[0].key).toBe('files') expect(items[0].dialog).toBe('files') @@ -32,7 +34,7 @@ describe('buildMoreItems', () => { }) it('returns session-specific items for /repos/:id/sessions/:sid', () => { - const items = buildMoreItems('/repos/42/sessions/abc') + const items = buildMoreItems('/repos/42/sessions/abc', t) expect(items).toHaveLength(9) expect(items[0].key).toBe('files') expect(items[1].key).toBe('mcp') @@ -48,7 +50,7 @@ describe('buildMoreItems', () => { }) it('returns assistant workspace items for /repos/:id/assistant', () => { - const items = buildMoreItems('/repos/42/assistant') + const items = buildMoreItems('/repos/42/assistant', t) expect(items).toHaveLength(8) expect(items[0].key).toBe('files') expect(items[0].dialog).toBe('files') @@ -62,21 +64,21 @@ describe('buildMoreItems', () => { }) it('returns only Settings + Logout for /schedules', () => { - const items = buildMoreItems('/schedules') + const items = buildMoreItems('/schedules', t) expect(items).toHaveLength(2) expect(items[0].key).toBe('settings') expect(items[1].key).toBe('logout') }) it('returns only Settings + Logout for /repos/:id/schedules', () => { - const items = buildMoreItems('/repos/42/schedules') + const items = buildMoreItems('/repos/42/schedules', t) expect(items).toHaveLength(2) expect(items[0].key).toBe('settings') expect(items[1].key).toBe('logout') }) it('returns only Settings + Logout for unknown paths', () => { - const items = buildMoreItems('/unknown/path') + const items = buildMoreItems('/unknown/path', t) expect(items).toHaveLength(2) expect(items[0].key).toBe('settings') expect(items[1].key).toBe('logout') @@ -85,7 +87,7 @@ describe('buildMoreItems', () => { describe('buildNavModel', () => { it('returns new-repo primary CTA for root path', () => { - const model = buildNavModel('/') + const model = buildNavModel('/', t) expect(model.primary).toHaveLength(2) expect(model.primary[0].key).toBe('new-repo') expect(model.primary[0].onSelect).toBe('new-repo') @@ -94,7 +96,7 @@ describe('buildNavModel', () => { }) it('returns new-session and assistant primary CTAs for repo detail', () => { - const model = buildNavModel('/repos/5') + const model = buildNavModel('/repos/5', t) expect(model.primary).toHaveLength(2) expect(model.primary[0].key).toBe('new-session') expect(model.primary[0].onSelect).toBe('new-session') @@ -103,7 +105,7 @@ describe('buildNavModel', () => { }) it('returns new-session and assistant primary CTAs for session detail', () => { - const model = buildNavModel('/repos/5/sessions/abc') + const model = buildNavModel('/repos/5/sessions/abc', t) expect(model.primary).toHaveLength(2) expect(model.primary[0].key).toBe('new-session') expect(model.primary[0].onSelect).toBe('new-session') @@ -114,7 +116,7 @@ describe('buildNavModel', () => { }) it('returns new-session and assistant primary CTAs for assistant workspace', () => { - const model = buildNavModel('/repos/5/assistant') + const model = buildNavModel('/repos/5/assistant', t) expect(model.primary).toHaveLength(2) expect(model.primary[0].key).toBe('new-session') expect(model.primary[0].onSelect).toBe('new-session') @@ -125,7 +127,7 @@ describe('buildNavModel', () => { }) it('returns new-session and assistant primary CTAs for canonical /assistant', () => { - const model = buildNavModel('/assistant') + const model = buildNavModel('/assistant', t) expect(model.primary).toHaveLength(2) expect(model.primary[0].key).toBe('new-session') expect(model.primary[0].onSelect).toBe('new-session') @@ -136,14 +138,14 @@ describe('buildNavModel', () => { }) it('returns new-schedule primary CTA for schedules routes', () => { - const model1 = buildNavModel('/schedules') + const model1 = buildNavModel('/schedules', t) expect(model1.primary).toHaveLength(2) expect(model1.primary[0].key).toBe('new-schedule') expect(model1.primary[0].onSelect).toBe('new-schedule') expect(model1.primary[1].key).toBe('assistant') expect(model1.primary[1].to).toBe('/assistant') - const model2 = buildNavModel('/repos/5/schedules') + const model2 = buildNavModel('/repos/5/schedules', t) expect(model2.primary).toHaveLength(2) expect(model2.primary[0].key).toBe('new-schedule') expect(model2.primary[0].onSelect).toBe('new-schedule') @@ -152,15 +154,15 @@ describe('buildNavModel', () => { }) it('returns assistant primary for unknown routes', () => { - const model = buildNavModel('/unknown/path') + const model = buildNavModel('/unknown/path', t) expect(model.primary).toHaveLength(1) expect(model.primary[0].key).toBe('assistant') expect(model.primary[0].to).toBe('/assistant') }) it('preserves backwards compatibility with buildMoreItems', () => { - const model = buildNavModel('/repos/42') - const items = buildMoreItems('/repos/42') + const model = buildNavModel('/repos/42', t) + const items = buildMoreItems('/repos/42', t) expect(model.items).toEqual(items) }) }) diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 23d451cf2..be72edc09 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -25,43 +25,45 @@ export interface NavModel { items: MoreDrawerItem[] } -function getAssistantNavItem(_pathname: string, variant: NavPrimaryCta['variant'] = 'secondary'): NavPrimaryCta { +type TFunction = (key: string) => string + +function getAssistantNavItem(_pathname: string, t: TFunction, variant: NavPrimaryCta['variant'] = 'secondary'): NavPrimaryCta { return { key: 'assistant', - label: 'Assistant', + label: t('assistant.assistant'), icon: Bot, to: getAssistantPath(), variant, } } -function getBaseItems(): MoreDrawerItem[] { +function getBaseItems(t: TFunction): MoreDrawerItem[] { return [ - { key: 'settings', label: 'Settings', icon: Settings }, - { key: 'logout', label: 'Logout', icon: LogOut }, + { key: 'settings', label: t('nav.settings'), icon: Settings }, + { key: 'logout', label: t('nav.logout'), icon: LogOut }, ] } -export function buildNavModel(pathname: string): NavModel { - const baseItems = getBaseItems() +export function buildNavModel(pathname: string, t: TFunction): NavModel { + const baseItems = getBaseItems(t) const repoDetailMatch = /^\/repos\/(\d+)$/.exec(pathname) if (repoDetailMatch) { const id = repoDetailMatch[1] const items: MoreDrawerItem[] = [ - { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, + { key: 'files', label: t('fileBrowser.open'), icon: Folder, dialog: 'files' }, { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, - { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, - { key: 'reset-permissions', label: 'Reset Permissions', icon: ShieldOff, dialog: 'resetPermissions', danger: true }, - { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: `/repos/${id}/schedules` }, - { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, + { key: 'skills', label: t('settings.skills'), icon: Sparkles, dialog: 'skills' }, + { key: 'reset-permissions', label: t('repo.resetPermissions'), icon: ShieldOff, dialog: 'resetPermissions', danger: true }, + { key: 'schedules', label: t('nav.schedules'), icon: CalendarClock, to: `/repos/${id}/schedules` }, + { key: 'source-control', label: t('repo.sourceControl'), icon: GitCommitHorizontal, dialog: 'sourceControl' }, ...baseItems, ] return { primary: [ - { key: 'new-session', label: 'New Session', icon: SquarePlus, onSelect: 'new-session', variant: 'primary' }, - getAssistantNavItem(pathname), + { key: 'new-session', label: t('nav.newSession'), icon: SquarePlus, onSelect: 'new-session', variant: 'primary' }, + getAssistantNavItem(pathname, t), ], items, } @@ -70,20 +72,20 @@ export function buildNavModel(pathname: string): NavModel { const sessionDetailMatch = /^\/repos\/(\d+)\/sessions\/[^/]+$/.exec(pathname) if (sessionDetailMatch) { const items: MoreDrawerItem[] = [ - { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, + { key: 'files', label: t('fileBrowser.open'), icon: Folder, dialog: 'files' }, { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, - { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, - { key: 'lsp', label: 'LSP', icon: Code2, dialog: 'lsp' }, - { key: 'reset-permissions', label: 'Reset Permissions', icon: ShieldOff, dialog: 'resetPermissions', danger: true }, - { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: `/repos/${sessionDetailMatch[1]}/schedules` }, - { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, + { key: 'skills', label: t('settings.skills'), icon: Sparkles, dialog: 'skills' }, + { key: 'lsp', label: t('mcp.lspServers'), icon: Code2, dialog: 'lsp' }, + { key: 'reset-permissions', label: t('repo.resetPermissions'), icon: ShieldOff, dialog: 'resetPermissions', danger: true }, + { key: 'schedules', label: t('nav.schedules'), icon: CalendarClock, to: `/repos/${sessionDetailMatch[1]}/schedules` }, + { key: 'source-control', label: t('repo.sourceControl'), icon: GitCommitHorizontal, dialog: 'sourceControl' }, ...baseItems, ] return { primary: [ - { key: 'new-session', label: 'New Session', icon: SquarePlus, onSelect: 'new-session', variant: 'primary' }, - getAssistantNavItem(pathname), + { key: 'new-session', label: t('nav.newSession'), icon: SquarePlus, onSelect: 'new-session', variant: 'primary' }, + getAssistantNavItem(pathname, t), ], items, } @@ -91,19 +93,19 @@ export function buildNavModel(pathname: string): NavModel { if (isAssistantPath(pathname)) { const items: MoreDrawerItem[] = [ - { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, + { key: 'files', label: t('fileBrowser.open'), icon: Folder, dialog: 'files' }, { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, - { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, - { key: 'reset-permissions', label: 'Reset Permissions', icon: ShieldOff, dialog: 'resetPermissions', danger: true }, - { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: '/repos/0/schedules' }, - { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, + { key: 'skills', label: t('settings.skills'), icon: Sparkles, dialog: 'skills' }, + { key: 'reset-permissions', label: t('repo.resetPermissions'), icon: ShieldOff, dialog: 'resetPermissions', danger: true }, + { key: 'schedules', label: t('nav.schedules'), icon: CalendarClock, to: '/repos/0/schedules' }, + { key: 'source-control', label: t('repo.sourceControl'), icon: GitCommitHorizontal, dialog: 'sourceControl' }, ...baseItems, ] return { primary: [ - { key: 'new-session', label: 'New Session', icon: SquarePlus, onSelect: 'new-session', variant: 'primary' }, - getAssistantNavItem(pathname, 'secondary'), + { key: 'new-session', label: t('nav.newSession'), icon: SquarePlus, onSelect: 'new-session', variant: 'primary' }, + getAssistantNavItem(pathname, t, 'secondary'), ], items, } @@ -112,8 +114,8 @@ export function buildNavModel(pathname: string): NavModel { if (pathname === '/schedules' || /^\/repos\/\d+\/schedules$/.test(pathname)) { return { primary: [ - { key: 'new-schedule', label: 'New Schedule', icon: Clock, onSelect: 'new-schedule', variant: 'primary' }, - getAssistantNavItem(pathname), + { key: 'new-schedule', label: t('schedule.create'), icon: Clock, onSelect: 'new-schedule', variant: 'primary' }, + getAssistantNavItem(pathname, t), ], items: baseItems, } @@ -122,12 +124,12 @@ export function buildNavModel(pathname: string): NavModel { if (pathname === '/') { return { primary: [ - { key: 'new-repo', label: 'New Repo', icon: Plus, onSelect: 'new-repo', variant: 'primary' }, - getAssistantNavItem(pathname), + { key: 'new-repo', label: t('nav.newRepo'), icon: Plus, onSelect: 'new-repo', variant: 'primary' }, + getAssistantNavItem(pathname, t), ], items: [ - { key: 'all-schedules', label: 'All Schedules', icon: CalendarClock, to: '/schedules' }, - { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, + { key: 'all-schedules', label: t('repo.allSchedules'), icon: CalendarClock, to: '/schedules' }, + { key: 'files', label: t('fileBrowser.open'), icon: Folder, dialog: 'files' }, ...baseItems, ], } @@ -135,12 +137,12 @@ export function buildNavModel(pathname: string): NavModel { return { primary: [ - getAssistantNavItem(pathname), + getAssistantNavItem(pathname, t), ], items: baseItems, } } -export function buildMoreItems(pathname: string): MoreDrawerItem[] { - return buildNavModel(pathname).items +export function buildMoreItems(pathname: string, t: TFunction): MoreDrawerItem[] { + return buildNavModel(pathname, t).items } diff --git a/frontend/src/components/repo/AddRepoDialog.tsx b/frontend/src/components/repo/AddRepoDialog.tsx index d8a0254a6..79712a34c 100644 --- a/frontend/src/components/repo/AddRepoDialog.tsx +++ b/frontend/src/components/repo/AddRepoDialog.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query' import { listRepos, createRepo, discoverRepos } from '@/api/repos' import { Button } from '@/components/ui/button' @@ -18,6 +19,7 @@ interface AddRepoDialogProps { } export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { + const { t } = useTranslation() const [repoType, setRepoType] = useState<'remote' | 'local' | 'folder'>('remote') const [repoUrl, setRepoUrl] = useState('') const [localPath, setLocalPath] = useState('') @@ -95,23 +97,23 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { if (result.mode === 'discover') { const summary = [ - result.discoveredCount > 0 ? `${result.discoveredCount} new` : null, - result.existingCount > 0 ? `${result.existingCount} existing` : null, + result.discoveredCount > 0 ? `${result.discoveredCount} ${t('repo.repo')}` : null, + result.existingCount > 0 ? `${result.existingCount} ${t('common.existing')}` : null, ].filter(Boolean).join(', ') if (result.errors.length > 0) { - showToast.warning('Repository discovery completed with issues', { - description: `${summary || 'No repos imported'}. ${result.errors[0]?.error || 'Some folders could not be imported.'}`, + showToast.warning(t('repo.discoveryWithIssues'), { + description: `${summary || t('repo.noRepos')}. ${result.errors[0]?.error || t('common.failed')}.`, }) } else if (result.discoveredCount === 0 && result.existingCount === 0) { - showToast.info('No Git repositories found in that folder') + showToast.info(t('repo.noGitRepos')) } else { - showToast.success('Repository discovery complete', { + showToast.success(t('repo.discoveryComplete'), { description: summary, }) } } else { - showToast.success('Repository added') + showToast.success(t('repo.repositoryAdded')) } onOpenChange(false) @@ -146,52 +148,52 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { - Add Repository + {t('repo.addTitle')}
- + setRepoType(value as 'remote' | 'local' | 'folder')}> - Remote - Local - Folder + {t('repo.remote')} + {t('repo.local')} + {t('repo.folder')}
{repoType === 'remote' ? (
- + handleRepoUrlChange(e.target.value)} disabled={mutation.isPending} className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" />

- Full URL or shorthand format (owner/repo for GitHub) + {t('repo.fullUrlHint')}

) : repoType === 'local' ? (
- + setLocalPath(e.target.value)} disabled={mutation.isPending} className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" />

- Directory name for a new repo, or an absolute path to link an existing Git repo + {t('repo.localPathHint')}

) : (
- +

- Scans the folder for nested Git repositories and links each one + {t('repo.folderScans')}

)} {showDirectoryName && (
- + handleDirectoryNameChange(e.target.value)} disabled={mutation.isPending} @@ -221,25 +223,25 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {

) : directoryCollision ? (

- A repository named '{directoryName}' already exists. + {t('repo.cloneDirError')} '{directoryName}'. {directoryCollision.repoUrl && directoryCollision.repoUrl !== repoUrl ? ` (${directoryCollision.repoUrl})` : '' } - {' '}Choose a different directory name to clone this fork. + {' '}{t('repo.chooseDirName')}.

) : (

- Custom directory name for the cloned repository + {t('repo.directoryNameHint')}

)}
)}
- + setBranch(e.target.value)} disabled={mutation.isPending || repoType === 'folder'} @@ -247,10 +249,10 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { />

{repoType === 'folder' - ? 'Links each repository on its current branch' + ? t('repo.folderScans') : branch - ? `Uses '${branch}' branch` - : 'Uses default branch' + ? `${t('repo.useDefaultBranch')} '${branch}'` + : t('repo.useDefaultBranch') }

@@ -267,10 +269,10 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { />

- Auto-accept the SSH host key for self-hosted or internal servers + {t('repo.skipSSHDesc') || 'Auto-accept the SSH host key for self-hosted or internal servers'}

@@ -284,10 +286,10 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { {mutation.isPending ? ( <> - {repoType === 'local' ? 'Linking...' : repoType === 'folder' ? 'Discovering...' : 'Cloning...'} + {repoType === 'local' ? t('repo.linking') : repoType === 'folder' ? t('repo.discovering') : t('repo.clone')} ) : ( - repoType === 'folder' ? 'Discover Repositories' : 'Add Repository' + repoType === 'folder' ? t('repo.discoverRepos') : t('repo.addRepo') )} {mutation.isError && ( diff --git a/frontend/src/components/repo/RenameRepoDialog.tsx b/frontend/src/components/repo/RenameRepoDialog.tsx index 0a2237d8e..721c061dc 100644 --- a/frontend/src/components/repo/RenameRepoDialog.tsx +++ b/frontend/src/components/repo/RenameRepoDialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { X, Check } from 'lucide-react' @@ -18,6 +19,7 @@ export function RenameRepoDialog({ onClose, onSave, }: RenameRepoDialogProps) { + const { t } = useTranslation() const [editName, setEditName] = useState(currentName) const inputRef = useRef(null) @@ -43,7 +45,7 @@ export function RenameRepoDialog({ - Rename repository + {t('repo.renameTitle')}
@@ -59,7 +61,7 @@ export function RenameRepoDialog({ {editName && (
diff --git a/frontend/src/components/repo/RepoCard.tsx b/frontend/src/components/repo/RepoCard.tsx index 70fde9acf..4a9f7b2c9 100644 --- a/frontend/src/components/repo/RepoCard.tsx +++ b/frontend/src/components/repo/RepoCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Loader2, GitBranch, FolderOpen, AlertCircle } from "lucide-react"; @@ -45,6 +46,7 @@ export function RepoCard({ hasSelectedRepos = false, selectionMode = false, }: RepoCardProps) { + const { t } = useTranslation(); const navigate = useNavigate(); const [actionsOpen, setActionsOpen] = useState(false); @@ -109,13 +111,13 @@ export function RepoCard({ {isCloning ? ( - Cloning... + {t('repo.clone')} ) : ( <> - {branchToDisplay || "main"} + {branchToDisplay || t('common.default')} {isDirty && ( diff --git a/frontend/src/components/repo/RepoList.tsx b/frontend/src/components/repo/RepoList.tsx index 38345c3d5..85164c719 100644 --- a/frontend/src/components/repo/RepoList.tsx +++ b/frontend/src/components/repo/RepoList.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next" import { useState, useMemo } from "react" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { DndContext, closestCenter, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core" @@ -36,7 +37,7 @@ function formatActivityLabel(timestamp: number): string { const days = Math.floor(hours / 24) if (days > 0) { - return days === 1 ? '1d ago' : `${days}d ago` + return days === 1 ? t('common.date') + ' ago' : `${days}d ago` } if (hours > 0) { return hours === 1 ? '1h ago' : `${hours}h ago` @@ -44,7 +45,7 @@ function formatActivityLabel(timestamp: number): string { if (minutes > 0) { return minutes === 1 ? '1m ago' : `${minutes}m ago` } - return 'just now' + return t('common.latest') } interface RepoCardWrapperProps { @@ -147,6 +148,7 @@ function StaticRepoCard({ } export function RepoList() { + const { t } = useTranslation() const queryClient = useQueryClient() const isMobile = useMobile() const { preferences, updateSettings } = useSettings() @@ -332,8 +334,8 @@ export function RepoList() { case !!error: return (
- Failed to load repositories:{" "} - {error instanceof Error ? error.message : "Unknown error"} + Failed to load repositories:{t('common.error')} + {error instanceof Error ? error.message : t('repo.unknownError')}
) @@ -342,7 +344,7 @@ export function RepoList() {

- No repositories yet. Add one to get started. + {t('repo.noReposYet')}

) @@ -427,7 +429,7 @@ export function RepoList() {

- {sections[0]?.emptyMessage || `No repositories found${searchQuery ? ` matching "${searchQuery}"` : ''}`} + {sections[0]?.emptyMessage || `${t('repo.noReposFound')}${searchQuery ? ` "${searchQuery}"` : ''}`}

) diff --git a/frontend/src/components/repo/RepoListControls.tsx b/frontend/src/components/repo/RepoListControls.tsx index e780c04c7..288dc4613 100644 --- a/frontend/src/components/repo/RepoListControls.tsx +++ b/frontend/src/components/repo/RepoListControls.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { useState } from "react"; import { Search, SlidersHorizontal, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -33,20 +34,6 @@ interface RepoListControlsProps { onSelectionModeChange: (enabled: boolean) => void; } -const FILTER_OPTIONS: { value: RepoFilterMode; label: string }[] = [ - { value: "all", label: "All" }, - { value: "recent", label: "Recent" }, - { value: "attention", label: "Changes" }, - { value: "worktrees", label: "Worktrees" }, - { value: "local", label: "Local" }, -]; - -const SORT_OPTIONS: { value: RepoSortMode; label: string }[] = [ - { value: "recent", label: "Recent" }, - { value: "manual", label: "Manual" }, - { value: "name", label: "Name" }, -]; - export function RepoListControls({ searchQuery, onSearchChange, @@ -66,18 +53,33 @@ export function RepoListControls({ selectionMode, onSelectionModeChange, }: RepoListControlsProps) { + const { t } = useTranslation(); const isMobile = useMobile(); const [showMenu, setShowMenu] = useState(false); + const FILTER_OPTIONS: { value: RepoFilterMode; label: string }[] = [ + { value: "all", label: t("repo.allFilter") }, + { value: "recent", label: t("repo.recent") }, + { value: "attention", label: t("repo.changes") }, + { value: "worktrees", label: t("repo.worktrees") }, + { value: "local", label: t("repo.localFilter") }, + ]; + + const SORT_OPTIONS: { value: RepoSortMode; label: string }[] = [ + { value: "recent", label: t("repo.recent") }, + { value: "manual", label: t("repo.manualSort") }, + { value: "name", label: t("repo.nameSort") }, + ]; + const currentSortLabel = - SORT_OPTIONS.find((s) => s.value === sortMode)?.label ?? "Recent"; + SORT_OPTIONS.find((s) => s.value === sortMode)?.label ?? t("repo.recent"); const inSelectionMode = selectedCount > 0; const getDeleteLabel = () => { if (hasLocalRepos && !hasClonedRepos) { - return "Unlink"; + return t("repo.unlink"); } - return "Delete"; + return t("repo.delete"); }; if (inSelectionMode) { @@ -85,7 +87,7 @@ export function RepoListControls({
- {selectedCount} selected + {selectedCount} {t("common.selected")}
); } - diff --git a/frontend/src/components/repo/RepoLspDialog.tsx b/frontend/src/components/repo/RepoLspDialog.tsx index 5b709af95..6c6f58450 100644 --- a/frontend/src/components/repo/RepoLspDialog.tsx +++ b/frontend/src/components/repo/RepoLspDialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { RepoLspServerList } from './RepoLspServerList' import { useLSPStatus } from '@/hooks/useLSPStatus' @@ -10,13 +11,14 @@ interface RepoLspDialogProps { } export function RepoLspDialog({ open, onOpenChange, opcodeUrl, directory }: RepoLspDialogProps) { + const { t } = useTranslation() const { isLoading, data } = useLSPStatus(opcodeUrl, directory) return ( - LSP Servers + {t('mcp.lspServers')} diff --git a/frontend/src/components/repo/RepoMcpDialog.tsx b/frontend/src/components/repo/RepoMcpDialog.tsx index 37f42212e..6eed9e4a6 100644 --- a/frontend/src/components/repo/RepoMcpDialog.tsx +++ b/frontend/src/components/repo/RepoMcpDialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { useState, useEffect, useCallback } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { RepoMcpServerList } from './RepoMcpServerList' @@ -15,6 +16,7 @@ interface RepoMcpDialogProps { } export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogProps) { + const { t } = useTranslation() const queryClient = useQueryClient() const [localStatus, setLocalStatus] = useState>({}) const [mcpServers, setMcpServers] = useState>({}) @@ -59,12 +61,12 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr } }, onSuccess: async () => { - showToast.success('MCP server updated for this location') + showToast.success(t('mcp.serverUpdated')) await fetchStatus() invalidateSessionCaches(queryClient) }, onError: (error) => { - showToast.error(error instanceof Error ? error.message : 'Failed to update MCP server') + showToast.error(error instanceof Error ? error.message : t('mcp.failedToUpdate')) }, }) @@ -74,13 +76,13 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr await mcpApi.removeAuthDirectory(serverId, directory) }, onSuccess: async () => { - showToast.success('Authentication removed for this location') + showToast.success(t('mcp.authRemoved')) setRemoveAuthConfirmServer(null) await fetchStatus() invalidateSessionCaches(queryClient) }, onError: (error) => { - showToast.error(error instanceof Error ? error.message : 'Failed to remove authentication') + showToast.error(error instanceof Error ? error.message : t('mcp.failedToRemove')) }, }) @@ -140,9 +142,9 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr - MCP for This Location + {t('mcp.mcpForLocation')} - Toggle MCP servers for this repository + {t('mcp.toggleServers')} @@ -167,8 +169,8 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr } }} onCancel={() => setRemoveAuthConfirmServer(null)} - title="Remove Authentication" - description="This will remove the OAuth credentials for this MCP server at this location. You will need to re-authenticate to use this server here again." + title={t('mcp.removeAuthTitle')} + description={t('mcp.removeAuthDesc')} itemName={removeAuthConfirmServer ? getDisplayName(removeAuthConfirmServer) : ''} isDeleting={removeAuthMutation.isPending} /> diff --git a/frontend/src/components/repo/RepoMcpServerList.tsx b/frontend/src/components/repo/RepoMcpServerList.tsx index fa7c1bc8e..8c14994c6 100644 --- a/frontend/src/components/repo/RepoMcpServerList.tsx +++ b/frontend/src/components/repo/RepoMcpServerList.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { Switch } from '@/components/ui/switch' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -36,6 +37,8 @@ export function RepoMcpServerList({ onAuthClick, onRemoveAuthClick, }: RepoMcpServerListProps) { + const { t } = useTranslation() + const getDisplayName = (serverId: string): string => { const name = serverId.replace(/[-_]/g, ' ') return name.charAt(0).toUpperCase() + name.slice(1) @@ -44,21 +47,21 @@ export function RepoMcpServerList({ const getDescription = (serverConfig: McpServerConfig): string => { if (serverConfig.type === 'local' && serverConfig.command) { const command = serverConfig.command.join(' ') - if (command.includes('filesystem')) return 'File system access' - if (command.includes('git')) return 'Git repository operations' - if (command.includes('sqlite')) return 'SQLite database access' - if (command.includes('postgres')) return 'PostgreSQL database access' - if (command.includes('brave-search')) return 'Web search via Brave' - if (command.includes('github')) return 'GitHub repository access' - if (command.includes('slack')) return 'Slack integration' - if (command.includes('puppeteer')) return 'Web automation' - if (command.includes('fetch')) return 'HTTP requests' - if (command.includes('memory')) return 'Persistent memory' + if (command.includes('filesystem')) return t('repo.fileSystemAccess') + if (command.includes('git')) return t('repo.gitOperations') + if (command.includes('sqlite')) return t('repo.sqliteAccess') + if (command.includes('postgres')) return t('repo.postgresAccess') + if (command.includes('brave-search')) return t('repo.webSearch') + if (command.includes('github')) return t('repo.githubAccess') + if (command.includes('slack')) return t('repo.slackIntegration') + if (command.includes('puppeteer')) return t('repo.webAutomation') + if (command.includes('fetch')) return t('repo.httpRequests') + if (command.includes('memory')) return t('repo.persistentMemory') return `Local: ${command}` } else if (serverConfig.type === 'remote' && serverConfig.url) { return serverConfig.url } - return 'MCP server' + return t('repo.mcpServer') } const getStatusBadge = (status?: McpStatus) => { @@ -66,24 +69,24 @@ export function RepoMcpServerList({ switch (status.status) { case 'connected': - return Connected + return {t('mcp.connected')} case 'disabled': - return Disabled + return {t('mcp.disabled')} case 'failed': return ( - Failed + {t('mcp.failed')} ) case 'needs_auth': return ( - Needs Auth + {t('mcp.needsAuth')} ) default: - return Unknown + return {t('mcp.unknown')} } } @@ -92,13 +95,13 @@ export function RepoMcpServerList({ {hasFetchedStatus && serverIds.length === 0 ? (
-

No MCP servers configured for this location

-

Add them in Settings or in the project's opencode.json

+

{t('mcp.noServersLocation')}

+

{t('mcp.addInSettings')}

) : isLoadingStatus ? (
- Loading... + {t('mcp.loading')}
) : (
@@ -130,21 +133,21 @@ export function RepoMcpServerList({ @@ -153,13 +156,13 @@ export function RepoMcpServerList({ {showAuthButton && ( onAuthClick(serverId)}> - Authenticate + {t('mcp.authenticate')} )} {connectedWithOAuth && ( onAuthClick(serverId)}> - Re-authenticate + {t('mcp.reAuthenticate')} )} {connectedWithOAuth && ( @@ -170,7 +173,7 @@ export function RepoMcpServerList({ disabled={removeAuthMutation.isPending} > - {removeAuthMutation.isPending ? 'Removing...' : 'Remove Auth'} + {removeAuthMutation.isPending ? t('mcp.removing') : t('mcp.removeAuthAction')} )} @@ -181,7 +184,7 @@ export function RepoMcpServerList({ )}

- {serverConfig ? getDescription(serverConfig) : 'MCP server'} + {serverConfig ? getDescription(serverConfig) : t('repo.mcpServer')}

{failed && status.status === 'failed' && (
@@ -200,7 +203,7 @@ export function RepoMcpServerList({ size="sm" > - Auth + {t('mcp.authAction')} ) : ( { - showToast.error(error instanceof Error ? error.message : 'Rename failed') + showToast.error(error instanceof Error ? error.message : t('repo.renameFailed')) }, }) @@ -97,14 +99,14 @@ export function RepoRowActions({ } const canCreateWorktree = isReady && !repo.isWorktree && Boolean(repo.repoUrl) - const deleteLabel = repo.isLocal ? 'Unlink Repository' : 'Delete Repository' + const deleteLabel = repo.isLocal ? t('repo.unlinkRepo') : t('repo.deleteRepo') const handleDownload = async (options: { includeGit?: boolean; includePaths?: string[] }) => { try { await downloadRepo(repo.id, repoName, options) - showToast.success('Download complete') + showToast.success(t('repo.downloadComplete')) } catch (error: unknown) { - showToast.error(error instanceof Error ? error.message : 'Download failed') + showToast.error(error instanceof Error ? error.message : t('repo.downloadFailed')) } } @@ -114,11 +116,11 @@ export function RepoRowActions({ - Rename Repository + {t('tooltip.rename')} - Source Control + {t('tooltip.sourceControl')} {canCreateWorktree && ( - Create Worktree + {t('tooltip.createWorktree')} )} - Download Repository + {t('tooltip.downloadRepo')} @@ -302,8 +304,8 @@ export function RepoRowActions({ open={showDownloadDialog} onOpenChange={(open) => { setShowDownloadDialog(open); onActionsOpenChange?.(open); }} onDownload={handleDownload} - title="Download Repository" - description="This will create a ZIP archive of the entire repository." + title={t('repo.download')} + description={t('repo.download')} itemName={repoName} targetPath={repo.fullPath} /> diff --git a/frontend/src/components/repo/RepoSkillsDialog.tsx b/frontend/src/components/repo/RepoSkillsDialog.tsx index 06d5a61b9..7f755f0d4 100644 --- a/frontend/src/components/repo/RepoSkillsDialog.tsx +++ b/frontend/src/components/repo/RepoSkillsDialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Download } from 'lucide-react' @@ -32,6 +33,7 @@ export function RepoSkillsDialog({ directory, onSkillLoaded, }: RepoSkillsDialogProps) { + const { t } = useTranslation() const queryClient = useQueryClient() const [installDialogOpen, setInstallDialogOpen] = useState(false) const { deleteSkill, setDeleteSkill, confirmDelete, isDeleting } = useDeleteSkill() @@ -68,14 +70,14 @@ export function RepoSkillsDialog({
- Skills + {t('settings.skills')} - {canLoad ? 'Search and load a skill into the current session' : 'Skills available for this repository'} + {canLoad ? t('settings.searchSkills') : t('settings.skills')}
@@ -85,9 +87,9 @@ export function RepoSkillsDialog({ data={data} error={error as Error | null} primaryAction={canLoad ? { label: 'Load', onClick: handleLoad } : undefined} - rowActions={[{ label: 'Delete', onClick: setDeleteSkill, destructive: true }]} - emptyTitle="No skills found" - emptyHint="Install a skill or add one to .opencode/skills//SKILL.md." + rowActions={[{ label: t('common.delete'), onClick: setDeleteSkill, destructive: true }]} + emptyTitle={t('common.noResults')} + emptyHint={t('settings.searchSkills')} maxHeightClassName="max-h-[55vh]" />
@@ -105,8 +107,8 @@ export function RepoSkillsDialog({ onOpenChange={(isOpen) => !isOpen && setDeleteSkill(null)} onConfirm={confirmDelete} onCancel={() => setDeleteSkill(null)} - title="Delete Skill" - description="Delete this managed skill directory and bundled files? This action cannot be undone." + title={t('settings.deleteSkill')} + description={t('settings.deleteSkill')} itemName={deleteSkill?.name} isDeleting={isDeleting} /> diff --git a/frontend/src/components/repo/ResetPermissionsDialog.tsx b/frontend/src/components/repo/ResetPermissionsDialog.tsx index 3708d24c8..6895d9f60 100644 --- a/frontend/src/components/repo/ResetPermissionsDialog.tsx +++ b/frontend/src/components/repo/ResetPermissionsDialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { resetRepoPermissions } from "@/api/repos"; import { @@ -23,14 +24,15 @@ export function ResetPermissionsDialog({ onOpenChange, repoId, }: ResetPermissionsDialogProps) { + const { t } = useTranslation(); const resetPermissionsMutation = useMutation({ mutationFn: () => resetRepoPermissions(repoId), onSuccess: () => { - showToast.success("Permissions reset successfully"); + showToast.success(t("repo.resetPermissions") + " " + t("common.success")); onOpenChange(false); }, onError: () => { - showToast.error("Failed to reset permissions"); + showToast.error(t("repo.resetPermissions") + " " + t("common.failed")); }, }); @@ -38,11 +40,9 @@ export function ResetPermissionsDialog({ - Reset Permissions + {t("repo.resetPermissions")} - This will clear all "Allow Always" permissions for this repository. - You will be prompted again for permission when opencode needs to perform - actions like running commands or editing files. + {t("settings.permissions")} @@ -51,7 +51,7 @@ export function ResetPermissionsDialog({ onClick={() => onOpenChange(false)} disabled={resetPermissionsMutation.isPending} > - Cancel + {t("common.cancel")} diff --git a/frontend/src/components/repo/SwitchConfigDialog.tsx b/frontend/src/components/repo/SwitchConfigDialog.tsx index 744c90f83..84b13b092 100644 --- a/frontend/src/components/repo/SwitchConfigDialog.tsx +++ b/frontend/src/components/repo/SwitchConfigDialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { useState, useEffect } from 'react' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -22,6 +23,7 @@ export function SwitchConfigDialog({ currentConfigName, onConfigSwitched, }: SwitchConfigDialogProps) { + const { t } = useTranslation() const [configs, setConfigs] = useState([]) const [selectedConfig, setSelectedConfig] = useState('') const [loading, setLoading] = useState(true) @@ -39,7 +41,7 @@ export function SwitchConfigDialog({ setConfigs(response.configs || []) setSelectedConfig(currentConfigName || '') } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load configs') + setError(err instanceof Error ? err.message : t('common.failed')) } finally { setLoading(false) } @@ -50,7 +52,7 @@ export function SwitchConfigDialog({ const handleSwitch = async () => { if (!selectedConfig) { - setError('Please select a config') + setError(t('repo.selectConfig')) return } @@ -66,7 +68,7 @@ export function SwitchConfigDialog({ onConfigSwitched(selectedConfig) onOpenChange(false) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to switch config') + setError(err instanceof Error ? err.message : t('common.failed')) } finally { setSwitching(false) } @@ -76,30 +78,30 @@ export function SwitchConfigDialog({ - Switch Config + {t('repo.switchConfig')} - Select a different OpenCode configuration for this repository + {t('repo.switchConfig')}
{currentConfigName && (
- Current config: {currentConfigName} + {t('repo.currentConfig')}: {currentConfigName}
)} {loading ? (
- Loading configs... + {t('common.loading')}
) : configs.length === 0 ? ( -
No configs available
+
{t('repo.noConfigs')}
) : ( setSearchQuery(event.target.value)} - placeholder="Search workspaces..." + placeholder={t('repo.searchWorkspaces')} className="h-10 w-full rounded-md border border-border bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:border-primary" />
@@ -199,7 +201,7 @@ export function WorkspaceManager({ > {label} - {isActive && Selected} + {isActive && {t('workspace.selected')}} {workspace.fullPath && ( {workspace.fullPath} @@ -217,7 +219,7 @@ export function WorkspaceManager({ checked={isChecked} disabled={isDeleting} onCheckedChange={(checked) => toggle(workspaceId, checked === true)} - aria-label={`Select workspace ${label}`} + aria-label={`${t('workspace.selectAll')} ${label}`} /> {label} @@ -231,7 +233,7 @@ export function WorkspaceManager({ })} {filteredWorkspaces.length === 0 && (
- No workspaces found + {t('workspace.noWorkspaces')}
)}
@@ -244,11 +246,11 @@ export function WorkspaceManager({ onOpenChange={setConfirmOpen} onConfirm={handleConfirm} onCancel={() => setConfirmOpen(false)} - title={selectedCount === 1 ? 'Delete Workspace' : 'Delete Workspaces'} + title={selectedCount === 1 ? t('workspace.title') : t('workspace.title')} description={ selectedCount === 1 - ? 'Are you sure you want to delete this OpenCode workspace? This removes the workspace and its sessions in OpenCode.' - : `Are you sure you want to delete ${selectedCount} OpenCode workspaces? This removes the workspaces and their sessions in OpenCode.` + ? t('workspace.title') + : t('workspace.title') } isDeleting={isDeleting} /> diff --git a/frontend/src/components/schedules/GeneralTab.tsx b/frontend/src/components/schedules/GeneralTab.tsx index b3be2af0e..931515001 100644 --- a/frontend/src/components/schedules/GeneralTab.tsx +++ b/frontend/src/components/schedules/GeneralTab.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { Combobox, type ComboboxOption } from '@/components/ui/combobox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -73,51 +74,53 @@ export function GeneralTab({ bashDenyPatterns, onBashDenyPatternsChange, }: GeneralTabProps) { + const { t } = useTranslation() + return (
{showRepoSelector && !isEditing && (
- + onRepoChange?.(value ? Number(value) : undefined)} options={repoOptions} - placeholder="Select a repository" + placeholder={t('repo.selectRepo')} allowCustomValue={false} />
)}
- + onNameChange(event.target.value)} - placeholder="Nightly repo health check" + placeholder={t('schedule.nightlyHealth')} />
- + onDescriptionChange(event.target.value)} - placeholder="What this job checks or produces" + placeholder={t('schedule.whatItChecks')} />
- + @@ -125,14 +128,14 @@ export function GeneralTab({
- - + +
@@ -142,8 +145,8 @@ export function GeneralTab({
-

Enabled

- +

{t('common.enabled')}

+
@@ -152,14 +155,14 @@ export function GeneralTab({
- - + +
-

Permissions

+

{t('settings.permissions')}

-

Allow access outside the working directory

- +

{t('repo.allowOutsideDir')}

+
@@ -181,8 +184,8 @@ export function GeneralTab({
- - + +