From 7d7285655d4e0b10124917f9d940573b2ec07c93 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 26 Apr 2026 14:03:39 +0300 Subject: [PATCH] Add i18n foundation and translations (#1027) * feat: add i18n foundation * feat: localize sessions sidebar * Localize multirun/scheduled tasks and fix dialog dropdown interactions * localize git sidebar surface and add zh-CN keys * feat(ui): localize context panel, diff/plan views, and context sidebar content * fix(config): resolve user config home via fs/home before embedded home * localize header/chat UI and complete model/worktree panel strings * localize worktree + github issue/pr dialog flows * localize settings sections and split settings i18n dictionaries * localize additional settings sections and sidebars * localize more settings pages and dialogs * fix settings select trigger localization * localize tunnel settings ui surface * localize additional settings sections * localize keyboard shortcuts labels in settings * localize terminal and utility dialogs surfaces * feat(i18n): localize remaining UI strings * Add Ukrainian locale * Add Spanish locale * Add Brazilian Portuguese locale * Polish locale translations --- .opencode/skills/locale-ui-patterns/SKILL.md | 128 + .../src/components/auth/SessionAuthGate.tsx | 69 +- .../chat/AgentMentionAutocomplete.tsx | 20 +- .../src/components/chat/ChangedFilesList.tsx | 6 +- .../ui/src/components/chat/ChatContainer.tsx | 10 +- .../ui/src/components/chat/ChatEmptyState.tsx | 6 +- .../src/components/chat/ChatErrorBoundary.tsx | 47 +- packages/ui/src/components/chat/ChatInput.tsx | 116 +- .../components/chat/CommandAutocomplete.tsx | 44 +- .../ui/src/components/chat/FileAttachment.tsx | 26 +- .../chat/FileMentionAutocomplete.tsx | 20 +- .../components/chat/MarkdownRendererImpl.tsx | 20 +- .../chat/MobileSessionStatusBar.tsx | 42 +- .../ui/src/components/chat/ModelControls.tsx | 219 +- .../src/components/chat/PendingChangesBar.tsx | 10 +- .../ui/src/components/chat/PermissionCard.tsx | 18 +- .../src/components/chat/PermissionRequest.tsx | 12 +- .../chat/PermissionToastActions.tsx | 30 +- .../ui/src/components/chat/QuestionCard.tsx | 32 +- .../components/chat/QueuedMessageChips.tsx | 8 +- packages/ui/src/components/chat/StatusRow.tsx | 37 +- .../ui/src/components/chat/TimelineDialog.tsx | 59 +- .../chat/TurnChangedFilesDropdown.tsx | 12 +- .../components/chat/UnifiedControlsDrawer.tsx | 14 +- .../chat/components/ScrollToBottomButton.tsx | 4 +- .../components/chat/message/MessageBody.tsx | 61 +- .../chat/message/TextSelectionMenu.tsx | 30 +- .../chat/message/ToolOutputDialog.tsx | 34 +- .../chat/message/parts/ToolPart.tsx | 19 +- .../components/chat/message/toolRenderers.tsx | 30 +- .../components/comments/InlineCommentCard.tsx | 12 +- .../comments/InlineCommentInput.tsx | 14 +- .../comments/useInlineCommentController.ts | 6 +- .../desktop/DesktopHostSwitcher.tsx | 216 +- .../components/desktop/OpenInAppButton.tsx | 14 +- .../components/layout/BottomTerminalDock.tsx | 12 +- .../ui/src/components/layout/ContextPanel.tsx | 45 +- .../components/layout/ContextSidebarTab.tsx | 51 +- packages/ui/src/components/layout/Header.tsx | 131 +- .../ui/src/components/layout/MainLayout.tsx | 4 +- .../layout/ProjectActionsButton.tsx | 47 +- .../components/layout/ProjectEditDialog.tsx | 52 +- .../ui/src/components/layout/RightSidebar.tsx | 4 +- .../components/layout/RightSidebarTabs.tsx | 10 +- packages/ui/src/components/layout/Sidebar.tsx | 4 +- .../layout/SidebarContextSummary.tsx | 62 - .../components/layout/SidebarFilesTree.tsx | 93 +- .../ui/src/components/layout/VSCodeLayout.tsx | 49 +- .../ui/src/components/mcp/McpDropdown.tsx | 42 +- .../src/components/multirun/AgentSelector.tsx | 4 +- .../components/multirun/BranchSelector.tsx | 14 +- .../components/multirun/ModelMultiSelect.tsx | 24 +- .../components/multirun/MultiRunLauncher.tsx | 66 +- .../components/onboarding/ChooserScreen.tsx | 60 +- .../onboarding/DesktopConnectionRecovery.tsx | 32 +- .../onboarding/LocalSetupScreen.tsx | 62 +- .../onboarding/RemoteConnectionForm.tsx | 68 +- .../onboarding/desktopRecoveryConfig.ts | 33 +- .../sections/SectionPlaceholder.tsx | 4 +- .../components/sections/agents/AgentsPage.tsx | 145 +- .../sections/agents/AgentsSidebar.tsx | 63 +- .../sections/agents/ModelSelector.tsx | 40 +- .../sections/commands/AgentSelector.tsx | 22 +- .../sections/commands/CommandsPage.tsx | 56 +- .../sections/commands/CommandsSidebar.tsx | 63 +- .../GitIdentityEditorDialog.tsx | 82 +- .../sections/git-identities/GitPage.tsx | 40 +- .../magic-prompts/MagicPromptsPage.tsx | 172 +- .../magic-prompts/MagicPromptsSidebar.tsx | 48 +- .../src/components/sections/mcp/McpPage.tsx | 450 ++-- .../components/sections/mcp/McpSidebar.tsx | 60 +- .../sections/openchamber/AboutSettings.tsx | 22 +- .../sections/openchamber/DefaultsSettings.tsx | 33 +- .../openchamber/DesktopNetworkSettings.tsx | 32 +- .../sections/openchamber/GitHubSettings.tsx | 64 +- .../sections/openchamber/GitSettings.tsx | 30 +- .../openchamber/KeyboardShortcutsSettings.tsx | 38 +- .../openchamber/NotificationSettings.tsx | 188 +- .../openchamber/OpenChamberVisualSettings.tsx | 357 +-- .../openchamber/OpenCodeCliSettings.tsx | 40 +- .../sections/openchamber/PasskeySettings.tsx | 54 +- .../openchamber/SessionRetentionSettings.tsx | 59 +- .../sections/openchamber/TunnelSettings.tsx | 286 ++- .../sections/openchamber/VoiceSettings.tsx | 144 +- .../openchamber/WorktreeSectionContent.tsx | 35 +- .../projects/ProjectActionsSection.tsx | 56 +- .../sections/projects/ProjectsPage.tsx | 54 +- .../sections/projects/ProjectsSidebar.tsx | 18 +- .../sections/providers/ProvidersPage.tsx | 167 +- .../sections/providers/ProvidersSidebar.tsx | 18 +- .../remote-instances/RemoteInstancesPage.tsx | 342 +-- .../RemoteInstancesSidebar.tsx | 61 +- .../shared/SettingsProjectSelector.tsx | 8 +- .../components/sections/skills/SkillsPage.tsx | 143 +- .../sections/skills/SkillsSidebar.tsx | 59 +- .../skills/catalog/AddCatalogDialog.tsx | 65 +- .../skills/catalog/InstallConflictsDialog.tsx | 35 +- .../skills/catalog/InstallFromRepoDialog.tsx | 123 +- .../skills/catalog/InstallSkillDialog.tsx | 61 +- .../skills/catalog/SkillsCatalogPage.tsx | 52 +- .../sections/usage/PaceIndicator.tsx | 28 +- .../components/sections/usage/UsagePage.tsx | 46 +- .../sections/usage/UsageSidebar.tsx | 26 +- .../components/session/BranchPickerDialog.tsx | 86 +- .../session/DirectoryExplorerDialog.tsx | 33 +- .../src/components/session/DirectoryTree.tsx | 34 +- .../session/GitHubIntegrationDialog.tsx | 64 +- .../session/GitHubIssuePickerDialog.tsx | 88 +- .../session/GitHubPrPickerDialog.tsx | 58 +- .../components/session/NewWorktreeDialog.tsx | 216 +- .../session/ProjectNotesTodoPanel.tsx | 130 +- .../session/SaveProjectPlanDialog.tsx | 16 +- .../session/ScheduledTaskEditorDialog.tsx | 177 +- .../session/ScheduledTasksDialog.tsx | 168 +- .../src/components/session/SessionDialogs.tsx | 170 +- .../components/session/SessionFolderItem.tsx | 24 +- .../src/components/session/SessionSidebar.tsx | 64 +- .../src/components/session/TodoSendDialog.tsx | 19 +- .../session/sidebar/BulkActionBar.tsx | 22 +- .../session/sidebar/ConfirmDialogs.tsx | 90 +- .../session/sidebar/SessionGroupSection.tsx | 61 +- .../session/sidebar/SessionNodeItem.tsx | 72 +- .../sidebar/SidebarActivitySections.tsx | 16 +- .../session/sidebar/SidebarFooter.tsx | 17 +- .../session/sidebar/SidebarHeader.tsx | 56 +- .../session/sidebar/SidebarProjectsList.tsx | 6 +- .../sidebar/hooks/useSessionActions.ts | 48 +- .../sidebar/hooks/useSessionGrouping.ts | 16 +- .../session/sidebar/sortableItems.tsx | 22 +- .../components/terminal/TerminalViewport.tsx | 6 +- packages/ui/src/components/ui/AboutDialog.tsx | 37 +- .../ui/src/components/ui/CommandPalette.tsx | 85 +- .../src/components/ui/ContextUsageDisplay.tsx | 22 +- .../ui/src/components/ui/ErrorBoundary.tsx | 60 +- packages/ui/src/components/ui/HelpDialog.tsx | 89 +- .../ui/src/components/ui/JsonTreeView.tsx | 14 +- .../ui/src/components/ui/MemoryDebugPanel.tsx | 83 +- .../ui/src/components/ui/OpenChamberLogo.tsx | 4 +- .../components/ui/OpenCodeStatusDialog.tsx | 16 +- .../ui/src/components/ui/QuickOpenDialog.tsx | 16 +- .../ui/src/components/ui/UpdateDialog.tsx | 44 +- packages/ui/src/components/ui/dialog.tsx | 4 +- .../ui/src/components/ui/dropdown-menu.tsx | 54 +- .../ui/src/components/ui/number-input.tsx | 10 +- packages/ui/src/components/ui/select.tsx | 62 +- .../src/components/ui/sortable-tabs-strip.tsx | 4 +- packages/ui/src/components/ui/textarea.tsx | 14 +- packages/ui/src/components/views/DiffView.tsx | 100 +- .../ui/src/components/views/FilesView.tsx | 223 +- packages/ui/src/components/views/GitView.tsx | 160 +- .../src/components/views/GoToLineDialog.tsx | 19 +- .../src/components/views/MultiRunWindow.tsx | 6 +- packages/ui/src/components/views/PlanView.tsx | 50 +- .../ui/src/components/views/SettingsView.tsx | 107 +- .../src/components/views/SettingsWindow.tsx | 4 +- .../ui/src/components/views/TerminalView.tsx | 85 +- .../views/agent-manager/AgentGroupDetail.tsx | 65 +- .../agent-manager/AgentManagerEmptyState.tsx | 58 +- .../agent-manager/AgentManagerSidebar.tsx | 69 +- .../components/views/git/AIHighlightsBox.tsx | 8 +- .../views/git/BranchIntegrationSection.tsx | 64 +- .../components/views/git/BranchSelector.tsx | 26 +- .../ui/src/components/views/git/ChangeRow.tsx | 8 +- .../components/views/git/ChangesSection.tsx | 26 +- .../src/components/views/git/CommitInput.tsx | 6 +- .../components/views/git/CommitSection.tsx | 30 +- .../components/views/git/ConflictDialog.tsx | 32 +- .../components/views/git/GitEmptyState.tsx | 10 +- .../ui/src/components/views/git/GitHeader.tsx | 11 +- .../components/views/git/HistoryCommitRow.tsx | 10 +- .../components/views/git/HistorySection.tsx | 16 +- .../views/git/InProgressOperationBanner.tsx | 22 +- .../views/git/IntegrateCommitsSection.tsx | 68 +- .../views/git/PullRequestSection.tsx | 226 +- .../src/components/views/git/StashDialog.tsx | 29 +- .../src/components/views/git/SyncActions.tsx | 32 +- .../views/git/WorktreeBranchDisplay.tsx | 8 +- .../components/voice/VoiceStatusIndicator.tsx | 23 +- packages/ui/src/lib/exportSession.ts | 6 +- packages/ui/src/lib/i18n/context.tsx | 31 + packages/ui/src/lib/i18n/index.ts | 4 + .../ui/src/lib/i18n/messages/en.settings.ts | 1506 ++++++++++++ packages/ui/src/lib/i18n/messages/en.ts | 2057 +++++++++++++++++ .../ui/src/lib/i18n/messages/es.settings.ts | 1506 ++++++++++++ packages/ui/src/lib/i18n/messages/es.ts | 2056 ++++++++++++++++ .../src/lib/i18n/messages/pt-BR.settings.ts | 1506 ++++++++++++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 2056 ++++++++++++++++ .../ui/src/lib/i18n/messages/uk.settings.ts | 1506 ++++++++++++ packages/ui/src/lib/i18n/messages/uk.ts | 2056 ++++++++++++++++ .../src/lib/i18n/messages/zh-CN.settings.ts | 1506 ++++++++++++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 2056 ++++++++++++++++ packages/ui/src/lib/i18n/react-context.ts | 14 + packages/ui/src/lib/i18n/runtime.ts | 108 + packages/ui/src/lib/i18n/store.ts | 90 + packages/ui/src/lib/i18n/useI18n.ts | 11 + packages/ui/src/lib/openchamberConfig.ts | 34 +- packages/ui/src/lib/utils.ts | 9 +- packages/ui/src/main.tsx | 19 +- 198 files changed, 24173 insertions(+), 4365 deletions(-) create mode 100644 .opencode/skills/locale-ui-patterns/SKILL.md delete mode 100644 packages/ui/src/components/layout/SidebarContextSummary.tsx create mode 100644 packages/ui/src/lib/i18n/context.tsx create mode 100644 packages/ui/src/lib/i18n/index.ts create mode 100644 packages/ui/src/lib/i18n/messages/en.settings.ts create mode 100644 packages/ui/src/lib/i18n/messages/en.ts create mode 100644 packages/ui/src/lib/i18n/messages/es.settings.ts create mode 100644 packages/ui/src/lib/i18n/messages/es.ts create mode 100644 packages/ui/src/lib/i18n/messages/pt-BR.settings.ts create mode 100644 packages/ui/src/lib/i18n/messages/pt-BR.ts create mode 100644 packages/ui/src/lib/i18n/messages/uk.settings.ts create mode 100644 packages/ui/src/lib/i18n/messages/uk.ts create mode 100644 packages/ui/src/lib/i18n/messages/zh-CN.settings.ts create mode 100644 packages/ui/src/lib/i18n/messages/zh-CN.ts create mode 100644 packages/ui/src/lib/i18n/react-context.ts create mode 100644 packages/ui/src/lib/i18n/runtime.ts create mode 100644 packages/ui/src/lib/i18n/store.ts create mode 100644 packages/ui/src/lib/i18n/useI18n.ts diff --git a/.opencode/skills/locale-ui-patterns/SKILL.md b/.opencode/skills/locale-ui-patterns/SKILL.md new file mode 100644 index 00000000..ff57255b --- /dev/null +++ b/.opencode/skills/locale-ui-patterns/SKILL.md @@ -0,0 +1,128 @@ +--- +name: locale-ui-patterns +description: Use when creating or modifying OpenChamber UI text, labels, buttons, placeholders, aria labels, empty states, toasts, dialogs, settings copy, navigation labels, or any user-facing strings. +--- + +# Locale UI Patterns + +## Core Rule + +User-facing UI text must go through `@/lib/i18n`; do not hardcode English strings in components. + +Use this skill for any React UI change that adds or edits visible text, accessible labels, placeholders, tooltips, toasts, dialogs, settings labels, navigation labels, or empty/error states. + +## Required Flow + +1. Add or reuse a key in `packages/ui/src/lib/i18n/messages/en.ts`. +2. Add the same key to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`. +3. In components, call `const { t } = useI18n()` from `@/lib/i18n` and render `t('key')`. +4. For locale names or language picker labels, use `label(locale)` from `useI18n()`. +5. Keep locale state in `packages/ui/src/lib/i18n/*`; do not add locale fields to broad stores like `useUIStore`. +6. Do not remount the app to update language. Components must re-render through `useI18n()`. + +## Component Usage Rules + +- Import from `@/lib/i18n`, not deep files. +- Keep `t(...)` calls inside React render/hook scope so locale changes re-render text. +- Do not resolve translated text at module scope. +- For static option arrays, store `labelKey` / `descriptionKey`; resolve with `t(...)` inside the component. +- For non-React helpers, pass translated strings in from the component or pass `t` explicitly. + +## Key Style + +Use stable semantic keys, not English text as keys. + +Keys should describe location + UI role + meaning. They should not encode current copy wording. + +Use existing nearby naming when extending a surface. If no nearby pattern exists, choose a short path that mirrors the UI ownership. + +Namespaces like `layout.*`, `settings.*`, `chat.*`, `git.*`, `session.*`, `toast.*`, and `dialog.*` are examples, not a fixed exhaustive list. + +Good: +```ts +'settings.appearance.language.label': 'Language' +'layout.mainTab.chat': 'Chat' +'chat.input.placeholder': 'Ask OpenChamber...' +``` + +Bad: +```ts +'Language': 'Language' +'chatLabel': 'Chat' +'askOpenChamberDotDotDot': 'Ask OpenChamber...' +``` + +Avoid overly generic keys unless the text is truly global and context-independent. Prefer specific keys when button meaning can vary by surface. + +## Parameters + +Use `{name}` placeholders for dynamic values. + +```ts +'toast.language.changed': 'Language changed to {language}' +``` + +```tsx +t('toast.language.changed', { language: label(locale) }) +``` + +Do not pass grammar fragments as params. Never use params like `{suffix}`, `{plural}`, `{article}`, `{prefix}`, `{dateSuffix}`, or pieces of words/sentences. + +Bad: +```tsx +t('dialog.delete.description', { count, suffix: count === 1 ? '' : 's' }) +``` + +Good: +```tsx +count === 1 + ? t('dialog.delete.descriptionSingle', { count }) + : t('dialog.delete.descriptionPlural', { count }) +``` + +Plural/count-dependent text must use separate complete-message keys unless all supported locales can use one identical complete sentence. Placeholders are only for real values (`{count}`, `{name}`, `{path}`), not grammar. + +Optional clauses must also be complete-message keys. Do not build a sentence by injecting a translated phrase into another translated sentence. + +Bad: +```tsx +t('dialog.delete.description', { + dateLabel: date ? t('dialog.delete.dateSuffix', { date }) : '', +}) +``` + +Good: +```tsx +date + ? t('dialog.delete.descriptionWithDate', { count, date }) + : t('dialog.delete.description', { count }) +``` + +## What Counts As UI Text + +- Button and menu labels +- Settings labels and descriptions +- Placeholder text +- Tooltip content +- Dialog titles/descriptions/actions +- Toast title/description/action labels +- Empty/error/loading states +- `aria-label`, `title`, image `alt` text when user-facing + +## Exceptions + +Do not translate: + +- Product names: `OpenChamber`, `OpenCode`, `GitHub` +- Protocol/tool acronyms: `MCP`, `SSE`, `WebSocket`, `API` +- Model/provider names +- File paths, command names, environment variables +- User/generated content + +## Review Checklist + +- No new hardcoded user-facing English in changed UI files. +- Every new key exists in all dictionaries. +- No locale state added to broad/shared stores. +- No full app remount for locale changes. +- Locale switch preserves current UI state. diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 4bd16ec8..779a8f5c 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -10,6 +10,7 @@ import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/pers import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence'; import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher'; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; +import { useI18n } from '@/lib/i18n'; import { authenticateWithPasskey, cancelPasskeyCeremony, @@ -89,6 +90,7 @@ const LoadingScreen: React.FC = () => ( ); const ErrorScreen: React.FC = ({ onRetry, errorType = 'network', retryAfter }) => { + const { t } = useI18n(); const isRateLimit = errorType === 'rate-limit'; const minutes = retryAfter ? Math.ceil(retryAfter / 60) : 1; @@ -97,16 +99,18 @@ const ErrorScreen: React.FC = ({ onRetry, errorType = 'network

- {isRateLimit ? 'Too many attempts' : 'Unable to reach server'} + {isRateLimit ? t('sessionAuth.error.rateLimitTitle') : t('sessionAuth.error.networkTitle')}

{isRateLimit - ? `Please wait ${minutes} minute${minutes > 1 ? 's' : ''} before trying again.` - : "We couldn't verify the UI session. Check that the service is running and try again."} + ? (minutes > 1 + ? t('sessionAuth.error.rateLimitDescriptionPlural', { minutes }) + : t('sessionAuth.error.rateLimitDescriptionSingle', { minutes })) + : t('sessionAuth.error.networkDescription')}

@@ -126,6 +130,7 @@ interface ErrorScreenProps { } export const SessionAuthGate: React.FC = ({ children }) => { + const { t } = useI18n(); const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []); const skipAuth = vscodeRuntime; const showHostSwitcher = React.useMemo(() => isDesktopShell() && !vscodeRuntime, [vscodeRuntime]); @@ -339,14 +344,14 @@ export const SessionAuthGate: React.FC = ({ children }) => if (enrollPasskey && supportsPasskeys) { try { await registerPasskeyForCurrentSession(); - toast.success('Passkey added'); + toast.success(t('sessionAuth.toast.passkeyAdded')); setState('authenticated'); return; } catch (error) { if (isPasskeyCeremonyAbort(error)) { - toast.message('Passkey setup canceled'); + toast.message(t('sessionAuth.toast.passkeySetupCanceled')); } else { - const message = error instanceof Error ? error.message : 'Passkey setup failed.'; + const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySetupFailed'); toast.error(message); } setState('authenticated'); @@ -359,7 +364,7 @@ export const SessionAuthGate: React.FC = ({ children }) => if (response.status === 401) { console.warn('[Frontend Auth] Login failed: Invalid password'); - setErrorMessage('Incorrect password. Try again.'); + setErrorMessage(t('sessionAuth.error.incorrectPassword')); setIsTunnelLocked(false); setState('locked'); return; @@ -375,18 +380,18 @@ export const SessionAuthGate: React.FC = ({ children }) => } console.error('[Frontend Auth] Login failed: Unexpected response', response.status); - setErrorMessage('Unexpected response from server.'); + setErrorMessage(t('sessionAuth.error.unexpectedResponse')); setIsTunnelLocked(false); setState('error'); } catch (error) { console.warn('Failed to submit UI password:', error); - setErrorMessage('Network error. Check connection and retry.'); + setErrorMessage(t('sessionAuth.error.networkRetry')); setIsTunnelLocked(false); setState('error'); } finally { setIsSubmitting(false); } - }, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, trustDevice]); + }, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, t, trustDevice]); const handlePasskeyUnlock = React.useCallback(async () => { if (isSubmitting || !supportsPasskeys) { @@ -411,14 +416,14 @@ export const SessionAuthGate: React.FC = ({ children }) => if (isPasskeyCeremonyAbort(error)) { setErrorMessage(''); } else { - const message = error instanceof Error ? error.message : 'Passkey sign-in was cancelled.'; + const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySignInCanceled'); setErrorMessage(message); } } finally { setActivePasskeyAction(null); setIsPasskeyBusy(false); } - }, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, trustDevice]); + }, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, t, trustDevice]); const handlePasskeySetupOnly = React.useCallback(async () => { if (isSubmitting || isTunnelLocked || !supportsPasskeys) { @@ -432,7 +437,7 @@ export const SessionAuthGate: React.FC = ({ children }) => if (state !== 'authenticated') { if (!password) { - setErrorMessage('Enter your password to add a passkey.'); + setErrorMessage(t('sessionAuth.error.enterPasswordForPasskey')); return; } await handlePasswordUnlock(true); @@ -442,16 +447,16 @@ export const SessionAuthGate: React.FC = ({ children }) => setErrorMessage(''); try { await registerPasskeyForCurrentSession(); - toast.success('Passkey added'); + toast.success(t('sessionAuth.toast.passkeyAdded')); } catch (error) { if (isPasskeyCeremonyAbort(error)) { - toast.message('Passkey setup canceled'); + toast.message(t('sessionAuth.toast.passkeySetupCanceled')); return; } - const message = error instanceof Error ? error.message : 'Passkey setup failed.'; + const message = error instanceof Error ? error.message : t('sessionAuth.error.passkeySetupFailed'); toast.error(message); } - }, [cancelActivePasskey, handlePasswordUnlock, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, state, supportsPasskeys]); + }, [cancelActivePasskey, handlePasswordUnlock, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, state, supportsPasskeys, t]); const canOfferPasskeySetup = supportsPasskeys && passkeyStatus.enabled; const canUsePasskey = canOfferPasskeySetup && passkeyStatus.hasPasskeys; @@ -474,12 +479,12 @@ export const SessionAuthGate: React.FC = ({ children }) =>

- {isTunnelLocked ? 'Tunnel access required' : 'Unlock OpenChamber'} + {isTunnelLocked ? t('sessionAuth.locked.tunnelTitle') : t('sessionAuth.locked.unlockTitle')}

{isTunnelLocked - ? 'Open this tunnel using the one-time connect link from the desktop app.' - : 'This session is password-protected.'} + ? t('sessionAuth.locked.tunnelDescription') + : t('sessionAuth.locked.passwordDescription')}

@@ -498,7 +503,9 @@ export const SessionAuthGate: React.FC = ({ children }) => ) : ( )} - {isPasskeyBusy && activePasskeyAction === 'auth' ? 'Cancel passkey' : 'Use passkey'} + {isPasskeyBusy && activePasskeyAction === 'auth' + ? t('sessionAuth.actions.cancelPasskey') + : t('sessionAuth.actions.usePasskey')} )}
@@ -509,7 +516,7 @@ export const SessionAuthGate: React.FC = ({ children }) => ref={passwordInputRef} type="password" autoComplete="current-password" - placeholder="Enter password" + placeholder={t('sessionAuth.password.placeholder')} value={password} onChange={(event) => { setPassword(event.target.value); @@ -527,7 +534,7 @@ export const SessionAuthGate: React.FC = ({ children }) => type="submit" size="icon" disabled={!password || isSubmitting} - aria-label={isSubmitting ? 'Unlocking' : 'Unlock'} + aria-label={isSubmitting ? t('sessionAuth.actions.unlockingAria') : t('sessionAuth.actions.unlockAria')} > {isSubmitting ? ( @@ -543,11 +550,11 @@ export const SessionAuthGate: React.FC = ({ children }) => checked={trustDevice} onChange={setTrustDevice} disabled={isSubmitting} - ariaLabel="Trust this device" + ariaLabel={t('sessionAuth.actions.trustDeviceAria')} className="size-4" iconClassName="size-4" /> - Trust this device + {t('sessionAuth.actions.trustDevice')}
) : ( @@ -566,11 +575,11 @@ export const SessionAuthGate: React.FC = ({ children }) => checked={trustDevice} onChange={setTrustDevice} disabled={isSubmitting} - ariaLabel="Trust this device" + ariaLabel={t('sessionAuth.actions.trustDeviceAria')} className="size-4" iconClassName="size-4" /> - Trust this device + {t('sessionAuth.actions.trustDevice')} )} {errorMessage && ( @@ -585,7 +594,7 @@ export const SessionAuthGate: React.FC = ({ children }) =>

- Use Local if remote is unreachable. + {t('sessionAuth.locked.hostSwitcherHint')}

)} diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx index 9400a62f..98cc461b 100644 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx @@ -3,6 +3,7 @@ import { cn, fuzzyMatch } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { useI18n } from '@/lib/i18n'; interface AgentInfo { name: string; @@ -40,6 +41,7 @@ export const AgentMentionAutocomplete = React.forwardRef { + const { t } = useI18n(); const containerRef = React.useRef(null); const [selectedIndex, setSelectedIndex] = React.useState(0); const [agents, setAgents] = React.useState([]); @@ -157,7 +159,7 @@ export const AgentMentionAutocomplete = React.forwardRef#{agent.name} {isSystem ? ( - system + {t('chat.agentMentionAutocomplete.badge.system')} ) : agent.scope ? ( ([ + { id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') }, + { id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') }, + { id: 'files' as const, label: t('chat.autocomplete.tabs.files') }, + ]), [t]); + return (
- {([ - { id: 'commands' as const, label: 'Commands' }, - { id: 'agents' as const, label: 'Agents' }, - { id: 'files' as const, label: 'Files' }, - ]).map((tab) => ( + {tabs.map((tab) => (
); diff --git a/packages/ui/src/components/chat/ChangedFilesList.tsx b/packages/ui/src/components/chat/ChangedFilesList.tsx index 4c20be4c..bc8f670a 100644 --- a/packages/ui/src/components/chat/ChangedFilesList.tsx +++ b/packages/ui/src/components/chat/ChangedFilesList.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { type ChangedFileEntry, getDisplayPath, getFileStats } from './changedFiles'; +import { useI18n } from '@/lib/i18n'; interface ChangedFilesListProps { files: ChangedFileEntry[]; @@ -9,10 +10,11 @@ interface ChangedFilesListProps { } export const ChangedFilesList: React.FC = ({ files, currentDirectory, onOpenFile }) => { + const { t } = useI18n(); return ( <>
- Changed files + {t('chat.changedFiles.title')} {files.length}
@@ -26,7 +28,7 @@ export const ChangedFilesList: React.FC = ({ files, curre key={`${file.path}:${index}`} type="button" className="relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none text-left hover:bg-interactive-hover" - title={`Open ${file.path}`} + title={t('chat.changedFiles.actions.openFileTitle', { path: file.path })} onClick={() => onOpenFile(file)} > diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 9934d046..f06c2a61 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -40,6 +40,7 @@ import { import { useSync } from '@/sync/use-sync'; import { usePlanDetection } from '@/hooks/usePlanDetection'; import { getAllSyncSessions } from '@/sync/sync-refs'; +import { useI18n } from '@/lib/i18n'; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const EMPTY_PERMISSIONS: PermissionRequest[] = []; @@ -231,6 +232,7 @@ const HYDRATING_SKELETON_ITEMS: Array<{ ]; export const ChatContainer: React.FC = () => { + const { t } = useI18n(); // Session UI state const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); @@ -432,11 +434,13 @@ export const ChatContainer: React.FC = () => { size="xs" onClick={handleReturnToParentSession} className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95" - aria-label="Return to parent session" - title={parentSession.title?.trim() ? `Return to: ${parentSession.title}` : 'Return to parent session'} + aria-label={t('chat.container.returnToParent.aria')} + title={parentSession.title?.trim() + ? t('chat.container.returnToParent.titleNamed', { title: parentSession.title }) + : t('chat.container.returnToParent.title')} > - Parent + {t('chat.container.returnToParent.label')} ) : null; diff --git a/packages/ui/src/components/chat/ChatEmptyState.tsx b/packages/ui/src/components/chat/ChatEmptyState.tsx index bb673dfd..e5ad6e95 100644 --- a/packages/ui/src/components/chat/ChatEmptyState.tsx +++ b/packages/ui/src/components/chat/ChatEmptyState.tsx @@ -2,8 +2,10 @@ import React from 'react'; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useGlobalSyncStore } from '@/sync/global-sync-store'; +import { useI18n } from '@/lib/i18n'; const ChatEmptyState: React.FC = () => { + const { t } = useI18n(); const { currentTheme } = useThemeSystem(); const initError = useGlobalSyncStore((s) => s.error); @@ -14,13 +16,13 @@ const ChatEmptyState: React.FC = () => { {initError ? (
- OpenCode is not reachable + {t('chat.emptyState.opencodeUnreachable')} {initError.message}
) : ( - Start a new chat + {t('chat.emptyState.startNewChat')} )}
); diff --git a/packages/ui/src/components/chat/ChatErrorBoundary.tsx b/packages/ui/src/components/chat/ChatErrorBoundary.tsx index c2fc26a3..571884b9 100644 --- a/packages/ui/src/components/chat/ChatErrorBoundary.tsx +++ b/packages/ui/src/components/chat/ChatErrorBoundary.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { RiChat3Line, RiRestartLine } from '@remixicon/react'; import { Button } from '../ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { useI18n } from '@/lib/i18n'; interface ChatErrorBoundaryState { hasError: boolean; @@ -14,8 +15,21 @@ interface ChatErrorBoundaryProps { sessionId?: string; } -export class ChatErrorBoundary extends React.Component { - constructor(props: ChatErrorBoundaryProps) { +interface ChatErrorBoundaryTexts { + title: string; + description: string; + sessionLabel: string; + detailsSummary: string; + resetAction: string; + persistentHint: string; +} + +interface ChatErrorBoundaryViewProps extends ChatErrorBoundaryProps { + texts: ChatErrorBoundaryTexts; +} + +class ChatErrorBoundaryView extends React.Component { + constructor(props: ChatErrorBoundaryViewProps) { super(props); this.state = { hasError: false }; } @@ -44,23 +58,23 @@ export class ChatErrorBoundary extends React.Component - Chat Error + {this.props.texts.title}

- The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data. + {this.props.texts.description}

{this.props.sessionId && (
- Session: {this.props.sessionId} + {this.props.texts.sessionLabel}: {this.props.sessionId}
)} {this.state.error && (
- Error details + {this.props.texts.detailsSummary}
                     {this.state.error.toString()}
                   
@@ -70,12 +84,12 @@ export class ChatErrorBoundary extends React.Component
- If the problem persists, try refreshing the page. + {this.props.texts.persistentHint}
@@ -86,3 +100,20 @@ export class ChatErrorBoundary extends React.Component + ); +} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 34e86c40..cefd2f2b 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -72,6 +72,7 @@ import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; import { usePermissionStore } from '@/stores/permissionStore'; import { extractGitChangedFiles } from './changedFiles'; +import { useI18n } from '@/lib/i18n'; const MAX_VISIBLE_TEXTAREA_LINES = 8; const EMPTY_QUEUE: QueuedMessage[] = []; @@ -249,6 +250,7 @@ type ComposerAttachmentControlsProps = { }; const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) { + const { t } = useI18n(); const { isMobile, isVSCode, @@ -280,8 +282,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl } }} onClick={handleOpenCommandMenu} - title="Commands" - aria-label="Commands" + title={t('chat.chatInput.actions.commands')} + aria-label={t('chat.chatInput.actions.commands')} > @@ -301,8 +303,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl type="button" className={footerIconButtonClass} onClick={handlePickLocalFiles} - title="Attach files" - aria-label="Attach files" + title={t('chat.chatInput.actions.attachFiles')} + aria-label={t('chat.chatInput.actions.attachFiles')} > @@ -312,8 +314,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl @@ -325,7 +327,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl }} > - Attach files + {t('chat.chatInput.actions.attachFiles')} { @@ -333,7 +335,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl }} > - Link GitHub Issue + {t('chat.chatInput.actions.linkGithubIssue')} { @@ -341,7 +343,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl }} > - Link GitHub PR + {t('chat.chatInput.actions.linkGithubPr')} @@ -353,8 +355,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl type="button" onClick={onOpenSettings} className={footerIconButtonClass} - title="Model and agent settings" - aria-label="Model and agent settings" + title={t('chat.chatInput.actions.modelAgentSettings')} + aria-label={t('chat.chatInput.actions.modelAgentSettings')} > @@ -379,6 +381,7 @@ type PermissionAutoAcceptButtonProps = { }; const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) { + const { t } = useI18n(); const { footerIconButtonClass, iconSizeClass, @@ -389,11 +392,11 @@ const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButto } = props; const ariaLabel = permissionAutoAcceptEnabled - ? 'Disable permission auto-accept' - : 'Enable permission auto-accept'; + ? t('chat.chatInput.permissionAutoAccept.disable') + : t('chat.chatInput.permissionAutoAccept.enable'); const tooltipLabel = permissionAutoAcceptEnabled - ? 'Permission auto-accept: on' - : 'Permission auto-accept: off'; + ? t('chat.chatInput.permissionAutoAccept.on') + : t('chat.chatInput.permissionAutoAccept.off'); const button = ( @@ -561,7 +566,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C 'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1', currentSessionId ? 'text-primary hover:text-primary' : 'opacity-30' )} - aria-label="Queue message" + aria-label={t('chat.chatInput.actions.queueMessageAria')} > @@ -573,7 +578,7 @@ const ComposerActionButtons = React.memo(function ComposerActionButtons(props: C footerIconButtonClass, 'text-[var(--status-error)] hover:text-[var(--status-error)]' )} - aria-label="Stop generating" + aria-label={t('chat.chatInput.actions.stopGeneratingAria')} > @@ -691,6 +696,7 @@ const loadConfirmedMentions = (sessionId: string | null): Set => { }; const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom }) => { + const { t } = useI18n(); // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); // Track initial session ID (captured at mount time for draft restoration) @@ -1489,7 +1495,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo providerID: configState.currentProviderId || '', }); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to compact session'); + toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed')); } return; } @@ -1518,7 +1524,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ); scrollToBottom?.({ instant: true, force: true }); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to generate summary'); + toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.summaryFailed')); } return; } @@ -1540,7 +1546,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ); scrollToBottom?.({ instant: true, force: true }); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to review changes'); + toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.reviewFailed')); } return; } @@ -1603,7 +1609,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo normalized === 'failed to send message'; if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) { - toast.error('Attachments are too large to send. Please try reducing the number or size of images.'); + toast.error(t('chat.chatInput.toast.attachmentsTooLarge')); if (allAttachments.length > 0) { useInputStore.setState({ attachedFiles: allAttachments }); } @@ -1613,7 +1619,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (isSoftNetworkError) { if (allAttachments.length > 0) { useInputStore.setState({ attachedFiles: allAttachments }); - toast.error('Failed to send attachments. Try fewer files or smaller images.'); + toast.error(t('chat.chatInput.toast.sendAttachmentsFailed')); } return; } @@ -1621,7 +1627,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (allAttachments.length > 0) { useInputStore.setState({ attachedFiles: allAttachments }); } - toast.error(rawMessage || 'Message failed to send. Attachments restored.'); + toast.error(rawMessage || t('chat.chatInput.toast.messageSendFailed')); }); if (!isMobile) { @@ -2329,7 +2335,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo await addAttachedFile(file); } catch (error) { console.error('Clipboard image attach failed', error); - toast.error(error instanceof Error ? error.message : 'Failed to attach image from clipboard'); + toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.clipboardAttachFailed')); } } }, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]); @@ -2640,7 +2646,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } setPendingInputText(mentions.join(' '), 'append-inline'); - toast.success(`Added ${mentions.length} file mention${mentions.length > 1 ? 's' : ''}`); + toast.success(t('chat.chatInput.toast.addedFileMentions', { count: mentions.length })); }, [normalizeDroppedPath, setPendingInputText, toProjectRelativeMentionPath]); const handleDragEnter = (e: React.DragEvent) => { @@ -2756,7 +2762,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo await addAttachedFile(file); } catch (error) { console.error('File attach failed', error); - toast.error(error instanceof Error ? error.message : 'Failed to attach file'); + toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed')); } } } @@ -2864,7 +2870,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo await addAttachedFile(file); } catch (error) { console.error('Failed to attach dropped file:', path, error); - toast.error(`Failed to attach ${path.split(/[\\/]/).pop() || 'file'}`); + toast.error(t('chat.chatInput.toast.attachNamedFailed', { + name: path.split(/[\\/]/).pop() || t('chat.chatInput.fileFallback'), + })); } } } @@ -2898,7 +2906,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo await addAttachedFile(file); } catch (error) { console.error('File attach failed', error); - toast.error(error instanceof Error ? error.message : 'Failed to attach file'); + toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.attachFileFailed')); } } }, [addAttachedFile]); @@ -2914,7 +2922,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const summary = skipped .map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`) .join('\n'); - toast.error(`Some files were skipped:\n${summary}`); + toast.error(t('chat.chatInput.toast.someFilesSkipped', { summary })); } const asFiles = picked @@ -2943,7 +2951,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } catch (error) { console.error('VS Code file pick failed', error); - toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code'); + toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed')); } }, [attachFiles]); @@ -3249,13 +3257,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const handlePermissionAutoAcceptToggle = React.useCallback(() => { if (!permissionScopeSessionId) { - toast.error('Open a session first'); + toast.error(t('chat.chatInput.toast.openSessionFirst')); return; } const nextEnabled = !permissionAutoAcceptEnabled; setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => { - toast.error('Failed to toggle permission auto-accept'); + toast.error(t('chat.chatInput.toast.togglePermissionAutoAcceptFailed')); }); }, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]); @@ -3311,7 +3319,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo borderColor: currentTheme?.colors?.interactive?.border, }} > - Review comments: + {t('chat.chatInput.reviewComments')} {draftCount} @@ -3337,7 +3345,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo #{linkedIssue.number} {linkedIssue.author && ( - by {linkedIssue.author.login} + {t('chat.chatInput.linked.byAuthor', { author: linkedIssue.author.login })} )} @@ -3350,7 +3358,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors" - aria-label="Open issue in browser" + aria-label={t('chat.chatInput.linked.issue.openInBrowserAria')} > @@ -3360,7 +3368,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setLinkedIssue(null); }} className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer" - aria-label="Remove linked issue" + aria-label={t('chat.chatInput.linked.issue.removeAria')} > @@ -3383,9 +3391,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo /> )} - PR #{linkedPr.number} + {t('chat.chatInput.linked.pr.number', { number: linkedPr.number })} {linkedPr.author && ( - by {linkedPr.author.login} + {t('chat.chatInput.linked.byAuthor', { author: linkedPr.author.login })} )} @@ -3401,7 +3409,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors" - aria-label="Open pull request in browser" + aria-label={t('chat.chatInput.linked.pr.openInBrowserAria')} > @@ -3411,7 +3419,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setLinkedPr(null); }} className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer" - aria-label="Remove linked pull request" + aria-label={t('chat.chatInput.linked.pr.removeAria')} > @@ -3458,13 +3466,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent" > - {selectedDraftBranchLabel ?? 'Branch'} + {selectedDraftBranchLabel ?? t('chat.chatInput.branch')} {projectRootBranchOption ? ( - Project root + {t('chat.chatInput.projectRoot')} {projectRootBranchOption.label} @@ -3473,14 +3481,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo {projectRootBranchOption ? : null}
- Worktrees + {t('chat.chatInput.worktrees')}
{worktreeBranchOptions.map((option) => ( @@ -3530,13 +3538,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo type="button" className={iconButtonBaseClass} onClick={() => handlePickLocalFiles()} - title="Attach files" - aria-label="Attach files" + title={t('chat.chatInput.actions.attachFiles')} + aria-label={t('chat.chatInput.actions.attachFiles')} > -

{isInternalDrag ? 'Drop to insert as mention' : 'Drop files here to attach'}

+

+ {isInternalDrag ? t('chat.chatInput.drop.insertMention') : t('chat.chatInput.drop.attachFiles')} +

)} @@ -3666,9 +3676,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }} placeholder={currentSessionId || newSessionDraftOpen ? inputMode === 'shell' - ? "Enter shell command..." - : "@ for files/agents; / for commands; ! for shell" - : "Select or create a session to start chatting"} + ? t('chat.chatInput.placeholder.shell') + : t('chat.chatInput.placeholder.chat') + : t('chat.chatInput.placeholder.selectSession')} disabled={!currentSessionId && !newSessionDraftOpen} autoCorrect={isMobile ? "on" : "off"} autoCapitalize={isMobile ? "sentences" : "off"} diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index 20076bf2..52d20ee4 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -6,6 +6,7 @@ import { useSessionMessages } from '@/sync/sync-context'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { useI18n } from '@/lib/i18n'; type CommandSource = 'openchamber' | 'opencode'; @@ -47,6 +48,7 @@ export const CommandAutocomplete = React.forwardRef { + const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessionMessages = useSessionMessages(currentSessionId ?? ''); const hasMessagesInCurrentSession = sessionMessages.length > 0; @@ -107,23 +109,23 @@ export const CommandAutocomplete = React.forwardRef { setSelectedIndex(0); @@ -266,9 +268,9 @@ export const CommandAutocomplete = React.forwardRef
{([ - { id: 'commands' as const, label: 'Commands' }, - { id: 'agents' as const, label: 'Agents' }, - { id: 'files' as const, label: 'Files' }, + { id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') }, + { id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') }, + { id: 'files' as const, label: t('chat.autocomplete.tabs.files') }, ]).map((tab) => (
)} )}
- ↑↓ navigate • Enter select • Esc close + {t('chat.autocomplete.keyboardHint')}
); diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 6cafbbe6..d874c2e0 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -9,10 +9,12 @@ import { openExternalUrl } from '@/lib/url'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { useI18n } from '@/lib/i18n'; import type { ToolPopupContent } from './message/types'; export const FileAttachmentButton = memo(() => { + const { t } = useI18n(); const fileInputRef = useRef(null); const addAttachedFile = useInputStore((state) => state.addAttachedFile); const isMobile = useUIStore((state) => state.isMobile); @@ -27,7 +29,7 @@ export const FileAttachmentButton = memo(() => { await addAttachedFile(file); } catch (error) { console.error('File attach failed', error); - toast.error(error instanceof Error ? error.message : 'Failed to attach file'); + toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.attachFailed')); } } }; @@ -50,8 +52,8 @@ export const FileAttachmentButton = memo(() => { const skipped = Array.isArray(data?.skipped) ? data.skipped : []; if (skipped.length > 0) { - const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`).join('\n'); - toast.error(`Some files were skipped:\n${summary}`); + const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || t('chat.fileAttachment.fileFallback')}: ${s?.reason || t('chat.fileAttachment.skippedFallback')}`).join('\n'); + toast.error(t('chat.fileAttachment.toast.someFilesSkipped', { summary })); } const asFiles = picked @@ -67,7 +69,7 @@ export const FileAttachmentButton = memo(() => { bytes[i] = binary.charCodeAt(i); } const blob = new Blob([bytes], { type: mime }); - return new File([blob], file.name || 'file', { type: mime }); + return new File([blob], file.name || t('chat.fileAttachment.fileFallback'), { type: mime }); } catch (err) { console.error('Failed to decode VS Code picked file', err); return null; @@ -80,7 +82,7 @@ export const FileAttachmentButton = memo(() => { } } catch (error) { console.error('VS Code file pick failed', error); - toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code'); + toast.error(error instanceof Error ? error.message : t('chat.fileAttachment.toast.vscodePickFailed')); } }; @@ -103,13 +105,13 @@ export const FileAttachmentButton = memo(() => { 'hover:bg-muted text-muted-foreground', buttonSizeClass )} - aria-label="Attach files" + aria-label={t('chat.fileAttachment.actions.attachAria')} > -

Attach files

+

{t('chat.fileAttachment.actions.attach')}

@@ -124,6 +126,7 @@ interface ImagePreviewProps { } const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => { + const { t } = useI18n(); const isLocalImagePreview = file.source !== 'server' && file.mimeType.startsWith('image/') && @@ -163,7 +166,7 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => { onRemove(); }} className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer" - aria-label={`Remove ${displayName}`} + aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })} > @@ -182,8 +185,8 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => { @@ -199,6 +202,7 @@ interface FileChipProps { } const FileChip = memo(({ file, onRemove }: FileChipProps) => { + const { t } = useI18n(); const getFileExtension = (filename: string): string => { const parts = filename.split('.'); return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; @@ -245,7 +249,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => { onRemove(); }} className="flex items-center justify-center h-5 w-5 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer" - aria-label={`Remove ${displayName}`} + aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })} > diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 3c44235a..f3bf02bd 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -11,6 +11,7 @@ import type { ProjectFileSearchHit } from '@/lib/opencode/client'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; +import { useI18n } from '@/lib/i18n'; type FileInfo = ProjectFileSearchHit; type AgentInfo = { @@ -48,6 +49,7 @@ export const FileMentionAutocomplete = React.forwardRef { + const { t } = useI18n(); const currentDirectory = useChatSearchDirectory() ?? ''; const activeProjectId = useProjectsStore((state) => state.activeProjectId); const activeProjectPath = useProjectsStore( @@ -446,6 +448,12 @@ export const FileMentionAutocomplete = React.forwardRef ([ + { id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') }, + { id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') }, + { id: 'files' as const, label: t('chat.autocomplete.tabs.files') }, + ]), [t]); + return (
- {([ - { id: 'commands' as const, label: 'Commands' }, - { id: 'agents' as const, label: 'Agents' }, - { id: 'files' as const, label: 'Files' }, - ]).map((tab) => ( + {tabs.map((tab) => (
)}
)}
- ↑↓ navigate • Enter select • Esc close + {t('chat.autocomplete.keyboardHint')}
); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 55c4339a..cc7472d7 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -15,6 +15,7 @@ import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; +import { useI18n } from '@/lib/i18n'; import { isExternalHttpUrl, openExternalUrl } from '@/lib/url'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; @@ -176,6 +177,7 @@ const downloadFile = (filename: string, content: string, mimeType: string) => { // Table copy button with dropdown const TableCopyButton: React.FC<{ tableRef: React.RefObject }> = ({ tableRef }) => { + const { t } = useI18n(); const [copied, setCopied] = React.useState(false); const [showMenu, setShowMenu] = React.useState(false); const menuRef = React.useRef(null); @@ -224,7 +226,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject setShowMenu(!showMenu)} className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors" - title="Copy table" + title={t('markdownRenderer.table.actions.copyTitle')} > {copied ? : } @@ -250,6 +252,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject }> = ({ tableRef }) => { + const { t } = useI18n(); const [showMenu, setShowMenu] = React.useState(false); const menuRef = React.useRef(null); @@ -273,7 +276,7 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject setShowMenu(!showMenu)} className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors" - title="Download table" + title={t('markdownRenderer.table.actions.downloadTitle')} > @@ -325,6 +328,7 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> }; const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => { + const { t } = useI18n(); const currentTheme = useCurrentMermaidTheme(); const { isMobile } = useDeviceInfo(); const [copied, setCopied] = React.useState(false); @@ -393,7 +397,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou setDownloaded(true); setTimeout(() => setDownloaded(false), 2000); } catch { - toast.error('Failed to download diagram'); + toast.error(t('markdownRenderer.mermaid.toast.downloadFailed')); } }; @@ -414,7 +418,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou @@ -438,7 +442,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou @@ -461,14 +465,14 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index 32c0f461..d9f48f99 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -55,6 +55,7 @@ import { useDrawerSwipe } from '@/hooks/useDrawerSwipe'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useNotificationStore } from '@/sync/notification-store'; +import { useI18n } from '@/lib/i18n'; interface MobileSessionStatusBarProps { onSessionSwitch?: (sessionId: string) => void; @@ -745,6 +746,7 @@ function ProjectEditPanel({ onDelete, homeDirectory, }: ProjectEditPanelProps) { + const { t } = useI18n(); const [localProjects, setLocalProjects] = React.useState(projects); React.useEffect(() => { @@ -797,10 +799,10 @@ function ProjectEditPanel({ - Drag items to reorder, or use arrows to move. Tap edit to change details. + {t('chat.mobileStatus.editProjects.footer')}

} > @@ -832,7 +834,7 @@ function ProjectEditPanel({ {localProjects.length === 0 && (
- No projects to edit + {t('chat.mobileStatus.editProjects.empty')}
)} @@ -958,6 +960,7 @@ function ProjectBar({ onRemoveProject, homeDirectory }: ProjectBarProps) { + const { t } = useI18n(); const scrollRef = React.useRef(null); const [editPanelOpen, setEditPanelOpen] = React.useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); @@ -1012,12 +1015,12 @@ function ProjectBar({ if (projects.length === 0) { return (
- No projects + {t('chat.mobileStatus.projects.empty')} @@ -1093,7 +1096,7 @@ function ProjectBar({ type="button" onClick={onAddProject} className="flex items-center justify-center !py-1.5 px-2 rounded-md border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 shrink-0 !min-h-0" - aria-label="Add project" + aria-label={t('chat.mobileStatus.projects.addAria')} > @@ -1102,17 +1105,17 @@ function ProjectBar({ - Remove Project + {t('chat.mobileStatus.projects.removeTitle')} - Are you sure you want to remove {projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}? + {t('chat.mobileStatus.projects.removeDescriptionPrefix')} {projectToDelete?.label || formatDirectoryName(projectToDelete?.path || '', homeDirectory)}? @@ -1176,6 +1179,7 @@ function CollapsedView({ contextUsage: SessionContextUsage | null; childIndicators?: Array<{ session: Session; isRunning: boolean }>; }) { + const { t } = useI18n(); const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); return ( @@ -1219,7 +1223,7 @@ function CollapsedView({ }} className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-center" > - New + {t('chat.mobileStatus.new')}
@@ -1283,6 +1287,7 @@ function ExpandedView({ homeDirectory: string | null; childIndicators?: Array<{ session: Session; isRunning: boolean }>; }) { + const { t } = useI18n(); const containerRef = React.useRef(null); const [collapsedHeight, setCollapsedHeight] = React.useState(null); const [hasMeasured, setHasMeasured] = React.useState(false); @@ -1376,7 +1381,7 @@ function ExpandedView({ }} className="flex items-center gap-0.5 px-2 py-1 text-[12px] leading-tight !min-h-0 rounded border border-[var(--primary-base)]/60 bg-[var(--primary-base)]/5 text-[var(--primary-base)]/80 hover:text-[var(--primary-base)] hover:bg-[var(--primary-base)]/10 self-start" > - New + {t('chat.mobileStatus.new')} @@ -1400,7 +1405,7 @@ function ExpandedView({ > {displaySessions.length === 0 ? (
- No sessions in this project + {t('chat.mobileStatus.noSessionsInProject')}
) : ( displaySessions.map((session) => ( @@ -1424,6 +1429,7 @@ function ExpandedView({ export const MobileSessionStatusBar: React.FC = ({ onSessionSwitch, }) => { + const { t } = useI18n(); const { currentTheme } = useThemeSystem(); const sessions = useSessions(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); @@ -1457,7 +1463,7 @@ export const MobileSessionStatusBar: React.FC = ({ const currentSession = sessions.find((s) => s.id === currentSessionId); const currentSessionTitle = currentSession ? getSessionTitle(currentSession) - : '← Swipe here to open sidebars →'; + : t('chat.mobileStatus.swipeHint'); // Calculate current session's child indicators const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId); @@ -1522,19 +1528,19 @@ export const MobileSessionStatusBar: React.FC = ({ if (result.success && result.path) { const added = addProject(result.path, { id: result.projectId }); if (!added) { - toast.error('Failed to add project', { - description: 'Please select a valid directory.', + toast.error(t('chat.mobileStatus.toast.addProjectFailed'), { + description: t('chat.mobileStatus.toast.selectValidDirectory'), }); } } else if (result.error && result.error !== 'Directory selection cancelled') { - toast.error('Failed to select directory', { + toast.error(t('chat.mobileStatus.toast.selectDirectoryFailed'), { description: result.error, }); } }) .catch((error) => { console.error('Failed to select directory:', error); - toast.error('Failed to select directory'); + toast.error(t('chat.mobileStatus.toast.selectDirectoryFailed')); }); }; diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 0569ea02..d338e288 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -55,6 +55,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useModelLists } from '@/hooks/useModelLists'; import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; import type { MobileControlsPanel } from './mobileControlsUtils'; +import { useI18n } from '@/lib/i18n'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type IconComponent = ComponentType; @@ -293,6 +294,7 @@ export const ModelControls: React.FC = ({ onMobilePanelSelection, onAgentPanelSelection, }) => { + const { t } = useI18n(); const providers = useConfigStore((state) => state.providers); const currentProviderId = useConfigStore((state) => state.currentProviderId); const currentModelId = useConfigStore((state) => state.currentModelId); @@ -545,9 +547,29 @@ export const ModelControls: React.FC = ({ const currentMetadata = currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined; - const currentCapabilityIcons = getCapabilityIcons(currentMetadata); - const inputModalityIcons = getModalityIcons(currentMetadata, 'input'); - const outputModalityIcons = getModalityIcons(currentMetadata, 'output'); + const localizeMetaLabel = React.useCallback((label: string) => { + if (label === 'Tool calling') return t('chat.modelControls.capability.toolCalling'); + if (label === 'Reasoning') return t('chat.modelControls.capability.reasoning'); + if (label === 'Text') return t('chat.modelControls.modality.text'); + if (label === 'Image') return t('chat.modelControls.modality.image'); + if (label === 'Video') return t('chat.modelControls.modality.video'); + if (label === 'Audio') return t('chat.modelControls.modality.audio'); + if (label === 'PDF') return t('chat.modelControls.modality.pdf'); + return label; + }, [t]); + + const currentCapabilityIcons = React.useMemo( + () => getCapabilityIcons(currentMetadata).map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), + [currentMetadata, localizeMetaLabel], + ); + const inputModalityIcons = React.useMemo( + () => getModalityIcons(currentMetadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), + [currentMetadata, localizeMetaLabel], + ); + const outputModalityIcons = React.useMemo( + () => getModalityIcons(currentMetadata, 'output').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), + [currentMetadata, localizeMetaLabel], + ); // Compute from current model each render to avoid stale variants // in draft/session transitions. @@ -1148,14 +1170,14 @@ export const ModelControls: React.FC = ({
{}
-
Provider
+
{t('chat.modelControls.provider')}
{getProviderDisplayName()}
{} {currentCapabilityIcons.length > 0 && (
-
Capabilities
+
{t('chat.modelControls.capabilities')}
{currentCapabilityIcons.map(({ key, icon, label }) => (
@@ -1170,11 +1192,11 @@ export const ModelControls: React.FC = ({ {} {(inputModalityIcons.length > 0 || outputModalityIcons.length > 0) && (
-
Modalities
+
{t('chat.modelControls.modalities')}
{inputModalityIcons.length > 0 && (
- Input + {t('chat.modelControls.input')}
{inputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} input`, `input-${key}`))}
@@ -1182,7 +1204,7 @@ export const ModelControls: React.FC = ({ )} {outputModalityIcons.length > 0 && (
- Output + {t('chat.modelControls.output')}
{outputModalityIcons.map(({ key, icon, label }) => renderIconBadge(icon, `${label} output`, `output-${key}`))}
@@ -1194,14 +1216,14 @@ export const ModelControls: React.FC = ({ {}
-
Limits
+
{t('chat.modelControls.limits')}
- Context + {t('chat.modelControls.context')} {formatTokens(currentMetadata?.limit?.context)}
- Output + {t('chat.modelControls.output')} {formatTokens(currentMetadata?.limit?.output)}
@@ -1209,14 +1231,14 @@ export const ModelControls: React.FC = ({ {}
-
Metadata
+
{t('chat.modelControls.metadata')}
- Knowledge + {t('chat.modelControls.knowledge')} {formatKnowledge(currentMetadata?.knowledge)}
- Release + {t('chat.modelControls.release')} {formatDate(currentMetadata?.release_date)}
@@ -1239,12 +1261,12 @@ export const ModelControls: React.FC = ({ const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask'; if (hasCustom) { - return { mode: 'ask', label: 'Custom' }; + return { mode: 'ask', label: t('chat.modelControls.permissionLabel.custom') }; } - if (action === 'allow') return { mode: 'allow', label: 'Allow' }; - if (action === 'deny') return { mode: 'deny', label: 'Deny' }; - return { mode: 'ask', label: 'Ask' }; + if (action === 'allow') return { mode: 'allow', label: t('chat.modelControls.permissionLabel.allow') }; + if (action === 'deny') return { mode: 'deny', label: t('chat.modelControls.permissionLabel.deny') }; + return { mode: 'ask', label: t('chat.modelControls.permissionLabel.ask') }; }; const editPermissionSummary = summarizePermission('edit'); @@ -1267,16 +1289,22 @@ export const ModelControls: React.FC = ({ {}
-
Mode
+
{t('chat.modelControls.mode')}
- {currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'} + {currentAgent.mode === 'primary' + ? t('chat.modelControls.modeValue.primary') + : currentAgent.mode === 'subagent' + ? t('chat.modelControls.modeValue.subagent') + : currentAgent.mode === 'all' + ? t('chat.modelControls.modeValue.all') + : t('chat.modelControls.modeValue.none')}
{} {(hasModelConfig || hasTemperatureOrTopP) && (
-
Model
+
{t('chat.modelControls.model')}
{hasModelConfig && (
{currentAgent.model!.providerID} / {currentAgent.model!.modelID} @@ -1286,13 +1314,13 @@ export const ModelControls: React.FC = ({
{currentAgent.temperature !== undefined && (
- Temperature + {t('chat.modelControls.temperature')} {currentAgent.temperature}
)} {currentAgent.topP !== undefined && (
- Top P + {t('chat.modelControls.topP')} {currentAgent.topP}
)} @@ -1304,10 +1332,10 @@ export const ModelControls: React.FC = ({ {}
-
Permissions
+
{t('chat.modelControls.permissions')}
- Edit + {t('chat.modelControls.edit')}
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')} @@ -1316,7 +1344,7 @@ export const ModelControls: React.FC = ({
- Bash + {t('chat.modelControls.bash')}
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')} @@ -1325,7 +1353,7 @@ export const ModelControls: React.FC = ({
- WebFetch + {t('chat.modelControls.webFetch')}
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')} @@ -1340,7 +1368,7 @@ export const ModelControls: React.FC = ({ {hasCustomPrompt && (
- Custom Prompt + {t('chat.modelControls.customPrompt')}
@@ -1408,7 +1436,7 @@ export const ModelControls: React.FC = ({
@@ -1417,7 +1445,7 @@ export const ModelControls: React.FC = ({ setMobileModelQuery(event.target.value)} - placeholder="Search providers or models" + placeholder={t('chat.modelControls.searchProvidersOrModels')} className="pl-7 h-9 rounded-xl border-border/40 bg-[var(--surface-elevated)] typography-meta" /> {mobileModelQuery && ( @@ -1425,7 +1453,7 @@ export const ModelControls: React.FC = ({ type="button" onClick={() => setMobileModelQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" - aria-label="Clear search" + aria-label={t('chat.modelControls.clearSearch')} > @@ -1444,7 +1472,7 @@ export const ModelControls: React.FC = ({
- Favorites + {t('chat.modelControls.favorites')}
{favoriteModelsList.map(({ model, providerID, modelID }) => { @@ -1490,7 +1518,7 @@ export const ModelControls: React.FC = ({
- Recent + {t('chat.modelControls.recent')}
{recentModelsList.map(({ model, providerID, modelID }) => { @@ -1556,7 +1584,7 @@ export const ModelControls: React.FC = ({ {provider.name} {isActiveProvider && ( - Current + {t('chat.modelControls.current')} )}
{isExpanded ? ( @@ -1571,8 +1599,8 @@ export const ModelControls: React.FC = ({ {providerModels.map((model: ProviderModel) => { const isSelected = isActiveProvider && model.id === currentModelId; const metadata = getModelMetadata(provider.id, model.id!); - const capabilityIcons = getCapabilityIcons(metadata).slice(0, 3); - const inputIcons = getModalityIcons(metadata, 'input'); + const capabilityIcons = getCapabilityIcons(metadata).slice(0, 3).map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })); + const inputIcons = getModalityIcons(metadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })); return (
= ({ ? "text-primary" : "text-muted-foreground" )} - aria-label={isFavoriteModel(provider.id as string, model.id as string) ? "Unfavorite" : "Favorite"} - title={isFavoriteModel(provider.id as string, model.id as string) ? "Remove from favorites" : "Add to favorites"} + aria-label={isFavoriteModel(provider.id as string, model.id as string) + ? t('chat.modelControls.unfavoriteAria') + : t('chat.modelControls.favoriteAria')} + title={isFavoriteModel(provider.id as string, model.id as string) + ? t('chat.modelControls.removeFromFavorites') + : t('chat.modelControls.addToFavorites')} > {isFavoriteModel(provider.id as string, model.id as string) ? ( @@ -1682,7 +1714,7 @@ export const ModelControls: React.FC = ({
@@ -1730,7 +1762,7 @@ export const ModelControls: React.FC = ({
@@ -1788,22 +1820,22 @@ export const ModelControls: React.FC = ({ {getProviderDisplayName()}
- Capabilities + {t('chat.modelControls.capabilities')}
{currentCapabilityIcons.length > 0 ? ( currentCapabilityIcons.map(({ key, icon, label }) => renderIconBadge(icon, label, `cap-${key}`) ) ) : ( - + {t('chat.modelControls.modeValue.none')} )}
- Modalities + {t('chat.modelControls.modalities')}
- Input + {t('chat.modelControls.input')}
{inputModalityIcons.length > 0 ? inputModalityIcons.map(({ key, icon, label }) => @@ -1813,7 +1845,7 @@ export const ModelControls: React.FC = ({
- Output + {t('chat.modelControls.output')}
{outputModalityIcons.length > 0 ? outputModalityIcons.map(({ key, icon, label }) => @@ -1825,7 +1857,7 @@ export const ModelControls: React.FC = ({
- Cost ($/1M tokens) + {t('chat.modelControls.costPerMillion')} {costRows.map((row) => (
{row.label} @@ -1834,7 +1866,7 @@ export const ModelControls: React.FC = ({ ))}
- Limits + {t('chat.modelControls.limits')} {limitRows.map((row) => (
{row.label} @@ -1843,19 +1875,19 @@ export const ModelControls: React.FC = ({ ))}
- Metadata + {t('chat.modelControls.metadata')}
- Knowledge + {t('chat.modelControls.knowledge')} {formatKnowledge(currentMetadata.knowledge)}
- Release + {t('chat.modelControls.release')} {formatDate(currentMetadata.release_date)}
) : ( -
Model metadata unavailable.
+
{t('chat.modelControls.metadataUnavailable')}
)} ); @@ -1872,11 +1904,12 @@ export const ModelControls: React.FC = ({ const metadata = getModelMetadata(providerID, modelID); const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({ ...icon, + label: localizeMetaLabel(icon.label), id: `cap-${icon.key}`, })); const modalityIcons = [ - ...getModalityIcons(metadata, 'input'), - ...getModalityIcons(metadata, 'output'), + ...getModalityIcons(metadata, 'input').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), + ...getModalityIcons(metadata, 'output').map((icon) => ({ ...icon, label: localizeMetaLabel(icon.label) })), ]; const uniqueModalityIcons = Array.from( new Map(modalityIcons.map((icon) => [icon.key, icon])).values() @@ -2006,8 +2039,12 @@ export const ModelControls: React.FC = ({ "model-favorite-button flex h-4 w-4 items-center justify-center hover:text-primary/80", isFavorite ? "text-primary" : "text-muted-foreground" )} - aria-label={isFavorite ? "Unfavorite" : "Favorite"} - title={isFavorite ? "Remove from favorites" : "Add to favorites"} + aria-label={isFavorite + ? t('chat.modelControls.unfavoriteAria') + : t('chat.modelControls.favoriteAria')} + title={isFavorite + ? t('chat.modelControls.removeFromFavorites') + : t('chat.modelControls.addToFavorites')} > {isFavorite ? ( @@ -2229,7 +2266,7 @@ export const ModelControls: React.FC = ({ setDesktopModelQuery(e.target.value)} onKeyDown={handleModelKeyDown} @@ -2260,14 +2297,14 @@ export const ModelControls: React.FC = ({ - Add new provider + {t('chat.modelControls.addNewProvider')}
{!hasResults && (
- No models found + {t('chat.modelControls.noModelsFound')}
)} @@ -2278,7 +2315,7 @@ export const ModelControls: React.FC = ({ className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30" > - Favorites + {t('chat.modelControls.favorites')} {filteredFavorites.map(({ model, providerID, modelID }) => { const idx = currentFlatIndex++; @@ -2295,7 +2332,7 @@ export const ModelControls: React.FC = ({ className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30" > - Recent + {t('chat.modelControls.recent')} {filteredRecents.map(({ model, providerID, modelID }) => { const idx = currentFlatIndex++; @@ -2340,7 +2377,11 @@ export const ModelControls: React.FC = ({ forceExpandProviders ? 'cursor-default' : 'cursor-pointer' )} aria-expanded={isExpanded} - title={forceExpandProviders ? undefined : (isExpanded ? 'Collapse provider' : 'Expand provider')} + title={forceExpandProviders + ? undefined + : (isExpanded + ? t('chat.modelControls.collapseProvider') + : t('chat.modelControls.expandProvider'))} >
= ({ {/* Keyboard hints footer */}
- ↑↓ navigate{highlightedSupportsThinking ? ' • ←→ thinking' : ''} • Enter select • Esc close + {t('chat.modelControls.keyboardHint', { + thinking: highlightedSupportsThinking ? ` • ${t('chat.modelControls.keyboardHintThinking')}` : '', + })}
@@ -2415,7 +2458,7 @@ export const ModelControls: React.FC = ({ if (!currentAgent) { return ( -
No agent selected.
+
{t('chat.modelControls.noAgentSelected')}
); } @@ -2430,12 +2473,12 @@ export const ModelControls: React.FC = ({ const action = resolveWildcardPermissionAction(rules, permissionName) ?? 'ask'; if (hasCustom) { - return { mode: 'ask', label: 'Custom' }; - } + return { mode: 'ask', label: t('chat.modelControls.permissionLabel.custom') }; + } - if (action === 'allow') return { mode: 'allow', label: 'Allow' }; - if (action === 'deny') return { mode: 'deny', label: 'Deny' }; - return { mode: 'ask', label: 'Ask' }; + if (action === 'allow') return { mode: 'allow', label: t('chat.modelControls.permissionLabel.allow') }; + if (action === 'deny') return { mode: 'deny', label: t('chat.modelControls.permissionLabel.deny') }; + return { mode: 'ask', label: t('chat.modelControls.permissionLabel.ask') }; }; const editPermissionSummary = summarizePermission('edit'); @@ -2455,33 +2498,39 @@ export const ModelControls: React.FC = ({
- Mode + {t('chat.modelControls.mode')} - {currentAgent.mode === 'primary' ? 'Primary' : currentAgent.mode === 'subagent' ? 'Subagent' : currentAgent.mode === 'all' ? 'All' : '—'} + {currentAgent.mode === 'primary' + ? t('chat.modelControls.modeValue.primary') + : currentAgent.mode === 'subagent' + ? t('chat.modelControls.modeValue.subagent') + : currentAgent.mode === 'all' + ? t('chat.modelControls.modeValue.all') + : t('chat.modelControls.modeValue.none')}
{(hasModelConfig || hasTemperatureOrTopP) && (
- Model + {t('chat.modelControls.model')} {hasModelConfig ? ( {currentAgent.model!.providerID} / {currentAgent.model!.modelID} ) : ( - + {t('chat.modelControls.modeValue.none')} )} {hasTemperatureOrTopP && (
{currentAgent.temperature !== undefined && (
- Temperature + {t('chat.modelControls.temperature')} {currentAgent.temperature}
)} {currentAgent.topP !== undefined && (
- Top P + {t('chat.modelControls.topP')} {currentAgent.topP}
)} @@ -2492,9 +2541,9 @@ export const ModelControls: React.FC = ({
- Permissions + {t('chat.modelControls.permissions')}
- Edit + {t('chat.modelControls.edit')}
{renderEditModeIcon(editPermissionSummary.mode, 'h-3.5 w-3.5')} @@ -2503,7 +2552,7 @@ export const ModelControls: React.FC = ({
- Bash + {t('chat.modelControls.bash')}
{renderEditModeIcon(bashPermissionSummary.mode, 'h-3.5 w-3.5')} @@ -2512,7 +2561,7 @@ export const ModelControls: React.FC = ({
- WebFetch + {t('chat.modelControls.webFetch')}
{renderEditModeIcon(webfetchPermissionSummary.mode, 'h-3.5 w-3.5')} @@ -2524,7 +2573,7 @@ export const ModelControls: React.FC = ({ {hasCustomPrompt && (
- Custom Prompt + {t('chat.modelControls.customPrompt')}
)} @@ -2538,7 +2587,7 @@ export const ModelControls: React.FC = ({ return null; } - const displayVariant = currentVariant ?? 'Default'; + const displayVariant = currentVariant ?? t('chat.modelControls.default'); const isDefault = !currentVariant; const colorClass = isDefault ? 'text-muted-foreground' : 'text-[color:var(--status-info)]'; @@ -2594,10 +2643,10 @@ export const ModelControls: React.FC = ({ - Thinking + {t('chat.modelControls.thinking')} handleVariantSelect(undefined)}>
- Default + {t('chat.modelControls.default')} {isDefault && }
@@ -2667,7 +2716,7 @@ export const ModelControls: React.FC = ({ setAgentSearchQuery(e.target.value)} onKeyDown={(e) => { @@ -2688,7 +2737,7 @@ export const ModelControls: React.FC = ({ >
- Reset to default + {t('chat.modelControls.resetToDefault')}
diff --git a/packages/ui/src/components/chat/PendingChangesBar.tsx b/packages/ui/src/components/chat/PendingChangesBar.tsx index 4c66bab4..89fb1f6b 100644 --- a/packages/ui/src/components/chat/PendingChangesBar.tsx +++ b/packages/ui/src/components/chat/PendingChangesBar.tsx @@ -14,8 +14,10 @@ import { } from './changedFiles'; import { ChangedFilesList } from './ChangedFilesList'; import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './changedFilesPopover'; +import { useI18n } from '@/lib/i18n'; export const PendingChangesBar: React.FC = React.memo(() => { + const { t } = useI18n(); const [isExpanded, setIsExpanded] = React.useState(false); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); const runtime = React.useContext(RuntimeAPIContext); @@ -102,7 +104,9 @@ export const PendingChangesBar: React.FC = React.memo(() => { }; const fileCount = gitChangedFiles.length; - const labelHead = `${fileCount} file${fileCount !== 1 ? 's' : ''}`; + const labelHead = fileCount === 1 + ? t('chat.pendingChanges.fileCountSingle', { count: fileCount }) + : t('chat.pendingChanges.fileCountPlural', { count: fileCount }); return (
@@ -113,7 +117,9 @@ export const PendingChangesBar: React.FC = React.memo(() => { > {labelHead} - changed in workspace + + {t('chat.pendingChanges.changedInWorkspace')} + {totalAdded > 0 ? +{totalAdded} : null} {totalRemoved > 0 ? -{totalRemoved} : null} diff --git a/packages/ui/src/components/chat/PermissionCard.tsx b/packages/ui/src/components/chat/PermissionCard.tsx index 0bebd5c5..f973a677 100644 --- a/packages/ui/src/components/chat/PermissionCard.tsx +++ b/packages/ui/src/components/chat/PermissionCard.tsx @@ -10,6 +10,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { DiffPreview, WritePreview } from './DiffPreview'; +import { useI18n } from '@/lib/i18n'; interface PermissionCardProps { permission: PermissionRequest; @@ -62,6 +63,7 @@ export const PermissionCard: React.FC = ({ permission, onResponse }) => { + const { t } = useI18n(); const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); const respondToPermission = sessionActions.respondToPermission;; @@ -123,12 +125,12 @@ export const PermissionCard: React.FC = ({ )} {workingDir && (
- Working Directory: {workingDir} + {t('chat.permissionCard.workingDirectory')} {workingDir}
)} {timeout && (
- Timeout: {timeout}ms + {t('chat.permissionCard.timeout')} {timeout}ms
)} {} @@ -215,7 +217,7 @@ export const PermissionCard: React.FC = ({ <> {url && (
-
Request:
+
{t('chat.permissionCard.request')}
{method} @@ -228,7 +230,7 @@ export const PermissionCard: React.FC = ({ )} {headers && Object.keys(headers).length > 0 && (
-
Headers:
+
{t('chat.permissionCard.headers')}
= ({ )} {body && (
-
Body:
+
{t('chat.permissionCard.body')}
= ({ )} {genericContent && (
-
Action:
+
{t('chat.permissionCard.action')}
                 {String(genericContent)}
@@ -302,7 +304,7 @@ export const PermissionCard: React.FC = ({
         {}
         {Object.keys(permission.metadata).length > 0 && !genericContent && !description && (
           
-
Details:
+
{t('chat.permissionCard.details')}
                 {JSON.stringify(permission.metadata, null, 2)}
@@ -343,7 +345,7 @@ export const PermissionCard: React.FC = ({
           
{permission.patterns.length > 0 && (
-
Patterns:
+
{t('chat.permissionCard.patterns')}
{permission.patterns.join(", ")} diff --git a/packages/ui/src/components/chat/PermissionRequest.tsx b/packages/ui/src/components/chat/PermissionRequest.tsx index 402fb9cb..335d62c4 100644 --- a/packages/ui/src/components/chat/PermissionRequest.tsx +++ b/packages/ui/src/components/chat/PermissionRequest.tsx @@ -3,6 +3,7 @@ import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission'; import * as sessionActions from '@/sync/session-actions'; +import { useI18n } from '@/lib/i18n'; interface PermissionRequestProps { permission: PermissionRequestPayload; @@ -13,6 +14,7 @@ export const PermissionRequest: React.FC = ({ permission, onResponse }) => { + const { t } = useI18n(); const [isResponding, setIsResponding] = React.useState(false); const [hasResponded, setHasResponded] = React.useState(false); const respondToPermission = sessionActions.respondToPermission;; @@ -42,7 +44,7 @@ export const PermissionRequest: React.FC = ({
- Permission required: + {t('chat.permissionRequest.required')} {command} @@ -70,7 +72,7 @@ export const PermissionRequest: React.FC = ({ }} > - Once + {t('chat.permissionRequest.actions.once')} {isResponding && ( @@ -125,4 +127,4 @@ export const PermissionRequest: React.FC = ({
); -}; \ No newline at end of file +}; diff --git a/packages/ui/src/components/chat/PermissionToastActions.tsx b/packages/ui/src/components/chat/PermissionToastActions.tsx index af61d175..60ebf802 100644 --- a/packages/ui/src/components/chat/PermissionToastActions.tsx +++ b/packages/ui/src/components/chat/PermissionToastActions.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; interface PermissionToastActionsProps { sessionTitle: string; @@ -27,10 +28,11 @@ export const PermissionToastActions: React.FC = ({ onAlways, onDeny, }) => { + const { t } = useI18n(); const [isBusy, setIsBusy] = React.useState(false); - const actionContext = sessionTitle.trim().length > 0 ? ` for ${sessionTitle}` : ''; - const sessionPreview = truncateToastText(sessionTitle, 64) || 'Session'; - const permissionPreview = truncateToastText(permissionBody, 120) || 'Permission details unavailable'; + const hasSessionTitle = sessionTitle.trim().length > 0; + const sessionPreview = truncateToastText(sessionTitle, 64) || t('chat.permissionToast.sessionFallback'); + const permissionPreview = truncateToastText(permissionBody, 120) || t('chat.permissionToast.permissionFallback'); const handleAction = async (action: () => Promise | void) => { if (isBusy || disabled) return; @@ -46,13 +48,13 @@ export const PermissionToastActions: React.FC = ({

- Session:{' '} + {t('chat.permissionToast.labels.session')}{' '} {sessionPreview}

- Permission:{' '} + {t('chat.permissionToast.labels.permission')}{' '} {permissionPreview} @@ -63,7 +65,9 @@ export const PermissionToastActions: React.FC = ({

diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx index d3e9adfe..8dc74e2e 100644 --- a/packages/ui/src/components/chat/QuestionCard.tsx +++ b/packages/ui/src/components/chat/QuestionCard.tsx @@ -8,6 +8,7 @@ import type { QuestionRequest } from '@/types/question'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions } from '@/sync/sync-context'; import * as sessionActions from '@/sync/session-actions'; +import { useI18n } from '@/lib/i18n'; interface QuestionCardProps { question: QuestionRequest; @@ -17,6 +18,7 @@ type TabKey = string; const SUMMARY_TAB = 'summary'; export const QuestionCard: React.FC = ({ question }) => { + const { t } = useI18n(); const respondToQuestion = sessionActions.respondToQuestion; const rejectQuestion = sessionActions.rejectQuestion;; const sessions = useSessions(); @@ -59,21 +61,21 @@ export const QuestionCard: React.FC = ({ question }) => { })); // Add summary tab when multiple questions if (questions.length > 1) { - questionTabs.push({ value: SUMMARY_TAB, label: 'Summary' }); + questionTabs.push({ value: SUMMARY_TAB, label: t('chat.questionCard.summaryTab') }); } return questionTabs; - }, [questions]); + }, [questions, t]); // Helper to get answer display for a question index const getAnswerDisplay = React.useCallback((index: number): string => { const isCustom = Boolean(customMode[index]); if (isCustom) { const value = (customText[index] ?? '').trim(); - return value || '(no answer)'; + return value || t('chat.questionCard.noAnswer'); } const answers = selectedOptions[index] ?? []; - return answers.length > 0 ? answers.join(', ') : '(no answer)'; - }, [customMode, customText, selectedOptions]); + return answers.length > 0 ? answers.join(', ') : t('chat.questionCard.noAnswer'); + }, [customMode, customText, selectedOptions, t]); const isMultiple = Boolean(activeQuestion?.multiple); const selectedForActive = selectedOptions[activeIndex] ?? []; @@ -197,10 +199,10 @@ export const QuestionCard: React.FC = ({ question }) => {
- Input needed + {t('chat.questionCard.inputNeeded')} {isFromSubagent ? ( - From subagent + {t('chat.questionCard.fromSubagent')} ) : null} {activeHeader ? ( @@ -249,7 +251,7 @@ export const QuestionCard: React.FC = ({ question }) => {
{questions.map((q, index) => { const answer = getAnswerDisplay(index); - const hasAnswer = answer !== '(no answer)'; + const hasAnswer = answer !== t('chat.questionCard.noAnswer'); return ( @@ -380,7 +382,7 @@ export const QuestionCard: React.FC = ({ question }) => { el.style.height = `${Math.min(Math.max(el.scrollHeight, minHeight), maxHeight)}px`; setCustomText((prev) => ({ ...prev, [activeIndex]: el.value })); }} - placeholder="Your answer" + placeholder={t('chat.questionCard.yourAnswer')} disabled={isResponding} rows={2} className="w-full bg-transparent border border-border/30 focus:border-primary rounded px-2 py-1 outline-none typography-meta text-foreground placeholder:text-muted-foreground/50 transition-colors resize-none overflow-hidden" @@ -406,7 +408,7 @@ export const QuestionCard: React.FC = ({ question }) => { )} > {requiredSatisfied ? : } - {requiredSatisfied ? 'Submit' : 'Next'} + {requiredSatisfied ? t('chat.questionCard.submit') : t('chat.questionCard.next')} {isResponding ? ( diff --git a/packages/ui/src/components/chat/QueuedMessageChips.tsx b/packages/ui/src/components/chat/QueuedMessageChips.tsx index 31fd5cce..45fbecd7 100644 --- a/packages/ui/src/components/chat/QueuedMessageChips.tsx +++ b/packages/ui/src/components/chat/QueuedMessageChips.tsx @@ -3,6 +3,7 @@ import { RiCloseLine, RiMessage2Line } from '@remixicon/react'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInputStore } from '@/sync/input-store'; +import { useI18n } from '@/lib/i18n'; interface QueuedMessageChipProps { message: QueuedMessage; @@ -11,6 +12,7 @@ interface QueuedMessageChipProps { } const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => { + const { t } = useI18n(); const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue); // Get first line of message, truncated @@ -38,11 +40,11 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi Queued {attachmentCount > 0 && ( - +{attachmentCount} file{attachmentCount > 1 ? 's' : ''} + {t('chat.queuedMessage.attachments', { count: attachmentCount })} )} - {firstLine || '(empty)'} + {firstLine || t('chat.queuedMessage.empty')} { @@ -50,7 +52,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChi removeFromQueue(sessionId, message.id); }} className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer" - aria-label="Remove from queue" + aria-label={t('chat.queuedMessage.removeAria')} > diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 039e5ff2..7a329b6e 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -22,6 +22,7 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore"; import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder"; import { isVSCodeRuntime } from "@/lib/desktop"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useI18n } from "@/lib/i18n"; const statusConfig: Record = { in_progress: { @@ -50,17 +51,17 @@ const priorityIcon: Record = { low:
@@ -154,6 +158,7 @@ export const StatusRow: React.FC = ({ agentName, leftAccessory, }) => { + const { t } = useI18n(); const [isExpanded, setIsExpanded] = React.useState(false); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const todosRecord = useDirectorySync((state) => state.todo); @@ -235,7 +240,7 @@ export const StatusRow: React.FC = ({ type="button" onClick={onAbort} className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0" - aria-label="Stop generating" + aria-label={t('chat.statusRow.actions.stopGeneratingAria')} >