From 6855d63ec6757cdb64f2b0259803cdff7dd8c52b Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 15:33:51 +0300
Subject: [PATCH] feat(i18n): add localization foundation
Refs https://github.com/777genius/agent-teams-ai/issues/139
---
i18next.config.ts | 29 +
package.json | 7 +
pnpm-lock.yaml | 930 ++-
scripts/i18n/validate.ts | 145 +
.../renderer/ui/GraphActivityHud.tsx | 8 +-
.../renderer/ui/GraphBlockingEdgePopover.tsx | 14 +-
.../renderer/ui/GraphMemberLogPreviewHud.tsx | 10 +-
.../renderer/ui/GraphNodePopover.tsx | 61 +-
.../renderer/ui/GraphProvisioningHud.tsx | 10 +-
.../localization/contracts/appLocale.ts | 19 +
src/features/localization/contracts/index.ts | 11 +
.../localization/contracts/namespaces.ts | 13 +
.../core/application/resolveRuntimeLocale.ts | 15 +
.../validateTranslationCatalogs.ts | 7 +
.../localization/core/domain/catalogPolicy.ts | 203 +
.../localization/core/domain/localePolicy.ts | 43 +
src/features/localization/index.ts | 11 +
.../adapters/browserSystemLocaleAdapter.ts | 3 +
.../composition/createI18nextInstance.ts | 35 +
.../composition/localizationResources.ts | 34 +
.../renderer/hooks/useAppTranslation.ts | 17 +
.../renderer/hooks/useLocaleFormatters.ts | 54 +
.../localization/renderer/i18next.d.ts | 10 +
src/features/localization/renderer/index.ts | 4 +
.../renderer/locales/en/common.json | 900 +++
.../renderer/locales/en/dashboard.json | 197 +
.../renderer/locales/en/errors.json | 3 +
.../renderer/locales/en/extensions.json | 684 +++
.../renderer/locales/en/report.json | 217 +
.../renderer/locales/en/settings.json | 983 +++
.../renderer/locales/en/team.json | 2415 ++++++++
.../renderer/locales/ru/common.json | 900 +++
.../renderer/locales/ru/dashboard.json | 197 +
.../renderer/locales/ru/errors.json | 3 +
.../renderer/locales/ru/extensions.json | 684 +++
.../renderer/locales/ru/report.json | 217 +
.../renderer/locales/ru/settings.json | 983 +++
.../renderer/locales/ru/team.json | 2415 ++++++++
.../localization/renderer/resources.d.ts | 5402 +++++++++++++++++
.../renderer/ui/AppLanguageSelect.tsx | 64 +
.../renderer/ui/LocalizationProvider.tsx | 40 +
.../adapters/MemberLogStreamSection.tsx | 14 +-
.../renderer/ui/ExecutionLogStreamView.tsx | 4 +-
.../ui/MemberRuntimeProcessLogsPanel.tsx | 13 +-
.../renderer/ui/MemberWorkSyncDetails.tsx | 31 +-
.../renderer/ui/MemberWorkSyncStatusPanel.tsx | 11 +-
.../renderer/ui/RecentProjectCard.tsx | 23 +-
.../renderer/ui/RecentProjectsSection.tsx | 25 +-
.../renderer/ui/RunningTeamsSection.tsx | 4 +-
.../ui/RuntimeProviderManagementPanelView.tsx | 215 +-
.../renderer/ui/TmuxInstallerBannerView.tsx | 41 +-
src/main/ipc/configValidation.ts | 8 +
.../services/infrastructure/ConfigManager.ts | 4 +
src/renderer/App.tsx | 20 +-
src/renderer/components/chat/AIChatGroup.tsx | 4 +-
src/renderer/components/chat/ChatHistory.tsx | 12 +-
.../components/chat/ChatHistoryEmptyState.tsx | 11 +-
.../components/chat/CompactBoundary.tsx | 19 +-
src/renderer/components/chat/ContextBadge.tsx | 63 +-
.../components/chat/DisplayItemList.tsx | 13 +-
.../components/chat/LastOutputDisplay.tsx | 8 +-
.../DirectoryTree/DirectoryTreeNode.tsx | 6 +-
.../components/ClaudeMdFilesSection.tsx | 5 +-
.../components/ClaudeMdSection.tsx | 6 +-
.../components/CollapsibleSection.tsx | 5 +-
.../components/FlatInjectionList.tsx | 4 +-
.../components/MentionedFilesSection.tsx | 5 +-
.../components/RankedInjectionList.tsx | 4 +-
.../components/SessionContextHeader.tsx | 44 +-
.../components/SessionContextHelpTooltip.tsx | 22 +-
.../components/TaskCoordinationSection.tsx | 5 +-
.../components/ThinkingTextSection.tsx | 5 +-
.../components/ToolOutputsSection.tsx | 5 +-
.../components/UserMessagesSection.tsx | 5 +-
.../chat/SessionContextPanel/index.tsx | 8 +-
.../items/ClaudeMdItem.tsx | 4 +-
.../items/MentionedFileItem.tsx | 12 +-
.../items/TaskCoordinationItem.tsx | 12 +-
.../items/ThinkingTextItem.tsx | 14 +-
.../items/ToolBreakdownItem.tsx | 5 +-
.../items/ToolOutputItem.tsx | 12 +-
.../items/UserMessageItem.tsx | 10 +-
.../components/chat/SystemChatGroup.tsx | 4 +-
.../components/chat/UserChatGroup.tsx | 18 +-
.../components/chat/items/ExecutionTrace.tsx | 18 +-
.../components/chat/items/LinkedToolItem.tsx | 14 +-
.../components/chat/items/MetricsPill.tsx | 8 +-
.../components/chat/items/SubagentItem.tsx | 39 +-
.../chat/items/TeammateMessageItem.tsx | 12 +-
.../items/linkedTool/DefaultToolViewer.tsx | 16 +-
.../chat/items/linkedTool/EditToolViewer.tsx | 6 +-
.../chat/items/linkedTool/ReadToolViewer.tsx | 8 +-
.../chat/items/linkedTool/SkillToolViewer.tsx | 8 +-
.../items/linkedTool/ToolErrorDisplay.tsx | 5 +-
.../chat/items/linkedTool/WriteToolViewer.tsx | 10 +-
.../chat/items/linkedTool/renderHelpers.tsx | 33 +-
src/renderer/components/chat/session-panel.ts | 1 +
.../chat/viewers/CodeBlockViewer.tsx | 6 +-
.../components/chat/viewers/DiffViewer.tsx | 8 +-
.../chat/viewers/MarkdownViewer.tsx | 45 +-
.../chat/viewers/MermaidDiagram.tsx | 4 +-
.../common/CliInstallWarningBanner.tsx | 4 +-
.../components/common/ConfirmDialog.tsx | 4 +-
.../common/ContextSwitchOverlay.tsx | 10 +-
src/renderer/components/common/CopyButton.tsx | 6 +-
.../components/common/ErrorBoundary.tsx | 57 +-
.../components/common/ExportDropdown.tsx | 6 +-
.../components/common/OngoingIndicator.tsx | 5 +-
.../common/ProviderActivityStatusStrip.tsx | 15 +-
.../components/common/RepositoryDropdown.tsx | 10 +-
.../components/common/TokenUsageDisplay.tsx | 66 +-
.../components/common/UpdateBanner.tsx | 8 +-
.../components/common/UpdateDialog.tsx | 18 +-
.../components/common/WorkspaceIndicator.tsx | 4 +-
.../components/dashboard/CliStatusBanner.tsx | 639 +-
.../dashboard/DashboardUpdateBanner.tsx | 6 +-
.../components/dashboard/DashboardView.tsx | 13 +-
.../components/dashboard/WebPreviewBanner.tsx | 11 +-
.../dashboard/WindowsAdministratorBanner.tsx | 9 +-
.../extensions/ExtensionStoreView.tsx | 170 +-
.../extensions/apikeys/ApiKeyCard.tsx | 4 +-
.../extensions/apikeys/ApiKeyFormDialog.tsx | 76 +-
.../extensions/apikeys/ApiKeysPanel.tsx | 24 +-
.../extensions/common/InstallButton.tsx | 14 +-
.../extensions/mcp/CustomMcpServerDialog.tsx | 60 +-
.../extensions/mcp/McpServerCard.tsx | 22 +-
.../extensions/mcp/McpServerDetailDialog.tsx | 69 +-
.../extensions/mcp/McpServersPanel.tsx | 77 +-
.../extensions/plugins/PluginCard.tsx | 4 +-
.../extensions/plugins/PluginDetailDialog.tsx | 58 +-
.../extensions/plugins/PluginsPanel.tsx | 82 +-
.../extensions/skills/SkillDetailDialog.tsx | 94 +-
.../extensions/skills/SkillEditorDialog.tsx | 168 +-
.../extensions/skills/SkillImportDialog.tsx | 88 +-
.../extensions/skills/SkillReviewDialog.tsx | 45 +-
.../extensions/skills/SkillsPanel.tsx | 152 +-
.../components/layout/CustomTitleBar.tsx | 16 +-
src/renderer/components/layout/MoreMenu.tsx | 26 +-
.../components/layout/PaneContent.tsx | 23 +-
src/renderer/components/layout/PaneView.tsx | 4 +-
.../components/layout/SessionTabContent.tsx | 12 +-
src/renderer/components/layout/Sidebar.tsx | 12 +-
.../components/layout/SortableTab.tsx | 10 +-
src/renderer/components/layout/TabBar.tsx | 8 +-
.../components/layout/TabBarActions.tsx | 26 +-
src/renderer/components/layout/TabBarRow.tsx | 6 +-
.../components/layout/TabContextMenu.tsx | 29 +-
.../components/layout/TeamTabSectionNav.tsx | 20 +-
.../notifications/NotificationRow.tsx | 13 +-
.../notifications/NotificationsView.tsx | 48 +-
.../components/report/SessionReportTab.tsx | 6 +-
.../report/sections/CostSection.tsx | 79 +-
.../report/sections/ErrorSection.tsx | 25 +-
.../report/sections/FrictionSection.tsx | 22 +-
.../components/report/sections/GitSection.tsx | 17 +-
.../report/sections/InsightsSection.tsx | 34 +-
.../report/sections/KeyTakeawaysSection.tsx | 5 +-
.../report/sections/OverviewSection.tsx | 24 +-
.../report/sections/QualitySection.tsx | 56 +-
.../report/sections/SubagentSection.tsx | 22 +-
.../report/sections/TimelineSection.tsx | 22 +-
.../report/sections/TokenSection.tsx | 30 +-
.../report/sections/ToolSection.tsx | 20 +-
.../runtime/CodexLoginLinkCopyButton.tsx | 19 +-
.../runtime/ProviderModelBadges.tsx | 29 +-
.../ProviderRuntimeBackendSelector.tsx | 85 +-
.../runtime/ProviderRuntimeSettingsDialog.tsx | 641 +-
.../runtime/providerConnectionUi.ts | 521 +-
.../components/schedules/SchedulesView.tsx | 72 +-
.../components/search/CommandPalette.tsx | 64 +-
src/renderer/components/search/SearchBar.tsx | 22 +-
.../components/AddTriggerForm.tsx | 14 +-
.../components/ColorPaletteSelector.tsx | 8 +-
.../components/DynamicConfigSection.tsx | 44 +-
.../components/GeneralInfoSection.tsx | 14 +-
.../components/IgnorePatternsSection.tsx | 13 +-
.../components/ModeSelector.tsx | 8 +-
.../components/RepositoryScopeSection.tsx | 13 +-
.../components/TriggerCardHeader.tsx | 25 +-
.../components/TriggerConfiguration.tsx | 61 +-
.../components/TriggerPreview.tsx | 22 +-
.../hooks/useTriggerForm.ts | 19 +-
.../NotificationTriggerSettings/index.tsx | 16 +-
.../NotificationTriggerSettings/types.ts | 1 +
.../utils/constants.ts | 70 +-
.../components/settings/SettingsTabs.tsx | 37 +-
.../components/settings/SettingsView.tsx | 12 +-
.../settings/hooks/useSettingsConfig.ts | 2 +
.../settings/hooks/useSettingsHandlers.ts | 10 +
.../settings/sections/AdvancedSection.tsx | 40 +-
.../settings/sections/CliStatusSection.tsx | 107 +-
.../settings/sections/ConfigEditorDialog.tsx | 34 +-
.../settings/sections/ConnectionSection.tsx | 73 +-
.../settings/sections/GeneralSection.tsx | 192 +-
.../sections/NotificationsSection.tsx | 195 +-
.../settings/sections/WorkspaceSection.tsx | 68 +-
.../sidebar/DateGroupedSessions.tsx | 78 +-
.../components/sidebar/GlobalTaskList.tsx | 72 +-
.../sidebar/SessionFiltersPopover.tsx | 10 +-
.../components/sidebar/SessionItem.tsx | 18 +-
.../components/sidebar/SidebarTaskItem.tsx | 52 +-
.../components/sidebar/TaskContextMenu.tsx | 17 +-
.../components/sidebar/TaskFiltersPopover.tsx | 46 +-
.../components/sidebar/taskFiltersState.ts | 16 +-
.../team/ClaudeLogsFilterPopover.tsx | 24 +-
.../components/team/ClaudeLogsPanel.tsx | 34 +-
.../components/team/ClaudeLogsSection.tsx | 10 +-
.../team/LiveRuntimeStatusBridge.tsx | 4 +-
.../team/LiveRuntimeStatusSection.tsx | 39 +-
.../components/team/ProcessesSection.tsx | 22 +-
.../team/ProvisioningProgressBlock.tsx | 45 +-
src/renderer/components/team/RoleSelect.tsx | 65 +-
src/renderer/components/team/TaskTooltip.tsx | 6 +-
.../components/team/TeamChangesSection.tsx | 76 +-
.../components/team/TeamDetailView.tsx | 571 +-
.../components/team/TeamEmptyState.tsx | 14 +-
.../components/team/TeamListFilterPopover.tsx | 16 +-
src/renderer/components/team/TeamListView.tsx | 136 +-
.../components/team/TeamSessionsSection.tsx | 32 +-
.../components/team/TeamTaskStatusSummary.tsx | 13 +-
.../team/ToolApprovalDiffPreview.tsx | 12 +-
.../components/team/ToolApprovalSheet.tsx | 16 +-
.../team/activity/ActiveTasksBlock.tsx | 4 +-
.../components/team/activity/ActivityItem.tsx | 128 +-
.../team/activity/ActivityTimeline.tsx | 66 +-
.../team/activity/LeadThoughtsGroup.tsx | 18 +-
.../team/activity/MessageExpandDialog.tsx | 11 +-
.../team/activity/PendingRepliesBlock.tsx | 20 +-
.../team/activity/ReplyQuoteBlock.tsx | 6 +-
.../team/activity/ThoughtBodyContent.tsx | 6 +-
.../team/attachments/AttachmentDisplay.tsx | 4 +-
.../team/attachments/DropZoneOverlay.tsx | 5 +-
.../attachments/SourceMessageAttachments.tsx | 5 +-
.../components/team/context-metric-alias.ts | 2 +
.../team/dialogs/AddMemberDialog.tsx | 10 +-
.../team/dialogs/AdvancedCliSection.tsx | 30 +-
.../dialogs/AnthropicExtraUsageWarning.tsx | 34 +-
.../dialogs/AnthropicFastModeSelector.tsx | 25 +-
.../team/dialogs/CodexFastModeSelector.tsx | 18 +-
.../team/dialogs/CodexReconnectPrompt.tsx | 7 +-
.../team/dialogs/CreateTaskDialog.tsx | 75 +-
.../team/dialogs/CreateTeamDialog.tsx | 176 +-
.../team/dialogs/EditTeamDialog.tsx | 147 +-
.../team/dialogs/EffortLevelSelector.tsx | 7 +-
.../team/dialogs/GlobalTaskDetailDialog.tsx | 4 +-
.../team/dialogs/LaunchTeamDialog.tsx | 213 +-
.../team/dialogs/LimitContextCheckbox.tsx | 66 +-
.../team/dialogs/MembersJsonEditor.tsx | 4 +-
.../dialogs/OpenCodeContextConfigHint.tsx | 26 +-
.../team/dialogs/OptionalSettingsSection.tsx | 4 +-
.../team/dialogs/ProjectPathSelector.tsx | 63 +-
.../ProvisioningProviderStatusList.tsx | 258 +-
.../components/team/dialogs/ReviewDialog.tsx | 14 +-
.../team/dialogs/SendMessageDialog.tsx | 45 +-
.../team/dialogs/SkipPermissionsCheckbox.tsx | 96 +-
.../team/dialogs/StatusHistoryTimeline.tsx | 79 +-
.../team/dialogs/TaskAttachments.tsx | 10 +-
.../team/dialogs/TaskCommentAwaitingReply.tsx | 12 +-
.../team/dialogs/TaskCommentInput.tsx | 25 +-
.../team/dialogs/TaskCommentsSection.tsx | 52 +-
.../team/dialogs/TaskDetailDialog.tsx | 123 +-
.../team/dialogs/TeamModelSelector.tsx | 348 +-
.../TeammateRuntimeCompatibilityNotice.tsx | 5 +-
.../dialogs/ToolApprovalSettingsPanel.tsx | 64 +-
.../dialogs/WorktreeGitReadinessBanner.tsx | 19 +-
.../team/editor/EditorBinaryPlaceholder.tsx | 6 +-
.../team/editor/EditorEmptyState.tsx | 27 +-
.../team/editor/EditorErrorBoundary.tsx | 31 +-
.../team/editor/EditorErrorState.tsx | 6 +-
.../components/team/editor/EditorFileTree.tsx | 27 +-
.../team/editor/EditorImagePreview.tsx | 8 +-
.../team/editor/EditorSearchPanel.tsx | 28 +-
.../team/editor/EditorShortcutsHelp.tsx | 157 +-
.../team/editor/EditorStatusBar.tsx | 22 +-
.../components/team/editor/EditorTabBar.tsx | 4 +-
.../components/team/editor/EditorToolbar.tsx | 22 +-
.../components/team/editor/GoToLineDialog.tsx | 12 +-
.../components/team/editor/NewFileDialog.tsx | 28 +-
.../team/editor/ProjectEditorOverlay.tsx | 85 +-
.../team/editor/QuickOpenDialog.tsx | 12 +-
.../team/editor/SearchInFilesPanel.tsx | 32 +-
.../components/team/kanban/KanbanBoard.tsx | 39 +-
.../team/kanban/KanbanFilterPopover.tsx | 34 +-
.../team/kanban/KanbanGridLayout.tsx | 8 +-
.../team/kanban/KanbanSearchInput.tsx | 17 +-
.../team/kanban/KanbanSortPopover.tsx | 44 +-
.../components/team/kanban/KanbanTaskCard.tsx | 49 +-
.../components/team/kanban/TrashDialog.tsx | 21 +-
.../components/team/lead-load-guards.ts | 1 +
.../team/members/CurrentTaskIndicator.tsx | 4 +-
.../components/team/members/LeadModelRow.tsx | 27 +-
.../components/team/members/MemberCard.tsx | 27 +-
.../team/members/MemberDetailDialog.tsx | 26 +-
.../team/members/MemberDetailHeader.tsx | 4 +-
.../team/members/MemberDraftRow.tsx | 115 +-
.../team/members/MemberExecutionLog.tsx | 20 +-
.../team/members/MemberHoverCard.tsx | 4 +-
.../components/team/members/MemberList.tsx | 18 +-
.../components/team/members/MemberLogsTab.tsx | 33 +-
.../team/members/MemberMessagesTab.tsx | 28 +-
.../team/members/MemberStatsTab.tsx | 53 +-
.../team/members/MemberTasksTab.tsx | 4 +-
.../team/members/MembersEditorSection.tsx | 20 +-
.../members/SubagentRecentMessagesPreview.tsx | 12 +-
.../team/messages/ActionModeSelector.tsx | 4 +-
.../team/messages/MessageComposer.tsx | 92 +-
.../team/messages/MessagesFilterPopover.tsx | 24 +-
.../team/messages/MessagesPanel.tsx | 109 +-
.../team/messages/OpenCodeDeliveryWarning.tsx | 74 +-
.../components/team/messages/StatusBlock.tsx | 4 +-
.../components/team/provisioningSteps.ts | 8 +-
.../team/review/ChangeReviewDialog.tsx | 23 +-
.../team/review/ChangesLoadingAnimation.tsx | 34 +-
.../team/review/CodeMirrorDiffView.tsx | 22 +-
.../team/review/ConfidenceBadge.tsx | 21 +-
.../components/team/review/ConflictDialog.tsx | 18 +-
.../team/review/ContinuousScrollView.tsx | 4 +-
.../team/review/DiffErrorBoundary.tsx | 45 +-
.../team/review/FileEditTimeline.tsx | 5 +-
.../team/review/FileSectionDiff.tsx | 6 +-
.../team/review/FileSectionHeader.tsx | 108 +-
.../team/review/FileSectionPlaceholder.tsx | 52 +-
.../team/review/FullDiffLoadingBanner.tsx | 30 +-
.../team/review/KeyboardShortcutsHelp.tsx | 37 +-
.../components/team/review/ReviewFileTree.tsx | 35 +-
.../components/team/review/ReviewToolbar.tsx | 40 +-
.../team/review/ScopeWarningBanner.tsx | 100 +-
.../team/review/ViewedProgressBar.tsx | 8 +-
.../team/schedule/CronScheduleInput.tsx | 51 +-
.../team/schedule/ScheduleEmptyState.tsx | 26 +-
.../team/schedule/ScheduleRunLogDialog.tsx | 20 +-
.../team/schedule/ScheduleSection.tsx | 27 +-
.../team/schedule/ScheduleStatusBadge.tsx | 79 +-
.../team/session-injection-types.ts | 1 +
.../team/taskLogs/ExactTaskLogCard.tsx | 6 +-
.../team/taskLogs/ExactTaskLogsSection.tsx | 21 +-
.../taskLogs/ExecutionSessionsSection.tsx | 10 +-
.../team/taskLogs/TaskActivitySection.tsx | 20 +-
.../team/taskLogs/TaskLogStreamSection.tsx | 4 +-
.../components/team/tasks/TaskList.tsx | 35 +-
.../team/useTeamProvisioningPresentation.ts | 5 +-
.../components/terminal/TerminalModal.tsx | 35 +-
.../components/ui/ChipInteractionLayer.tsx | 13 +-
.../components/ui/ExpandableContent.tsx | 6 +-
src/renderer/components/ui/MemberSelect.tsx | 12 +-
.../components/ui/MentionSuggestionList.tsx | 18 +-
src/renderer/components/ui/dialog.tsx | 53 +-
.../components/ui/tiptap/TiptapBubbleMenu.tsx | 10 +-
src/renderer/hooks/useOptionalTabId.ts | 1 +
.../utils/teamProvisioningPresentation.ts | 551 +-
src/shared/types/notifications.ts | 2 +
.../localization/core/catalogPolicy.test.ts | 51 +
.../localization/core/localePolicy.test.ts | 30 +
test/main/ipc/configValidation.test.ts | 16 +
test/renderer/store/extensionsSlice.test.ts | 5 +-
355 files changed, 26205 insertions(+), 4964 deletions(-)
create mode 100644 i18next.config.ts
create mode 100644 scripts/i18n/validate.ts
create mode 100644 src/features/localization/contracts/appLocale.ts
create mode 100644 src/features/localization/contracts/index.ts
create mode 100644 src/features/localization/contracts/namespaces.ts
create mode 100644 src/features/localization/core/application/resolveRuntimeLocale.ts
create mode 100644 src/features/localization/core/application/validateTranslationCatalogs.ts
create mode 100644 src/features/localization/core/domain/catalogPolicy.ts
create mode 100644 src/features/localization/core/domain/localePolicy.ts
create mode 100644 src/features/localization/index.ts
create mode 100644 src/features/localization/renderer/adapters/browserSystemLocaleAdapter.ts
create mode 100644 src/features/localization/renderer/composition/createI18nextInstance.ts
create mode 100644 src/features/localization/renderer/composition/localizationResources.ts
create mode 100644 src/features/localization/renderer/hooks/useAppTranslation.ts
create mode 100644 src/features/localization/renderer/hooks/useLocaleFormatters.ts
create mode 100644 src/features/localization/renderer/i18next.d.ts
create mode 100644 src/features/localization/renderer/index.ts
create mode 100644 src/features/localization/renderer/locales/en/common.json
create mode 100644 src/features/localization/renderer/locales/en/dashboard.json
create mode 100644 src/features/localization/renderer/locales/en/errors.json
create mode 100644 src/features/localization/renderer/locales/en/extensions.json
create mode 100644 src/features/localization/renderer/locales/en/report.json
create mode 100644 src/features/localization/renderer/locales/en/settings.json
create mode 100644 src/features/localization/renderer/locales/en/team.json
create mode 100644 src/features/localization/renderer/locales/ru/common.json
create mode 100644 src/features/localization/renderer/locales/ru/dashboard.json
create mode 100644 src/features/localization/renderer/locales/ru/errors.json
create mode 100644 src/features/localization/renderer/locales/ru/extensions.json
create mode 100644 src/features/localization/renderer/locales/ru/report.json
create mode 100644 src/features/localization/renderer/locales/ru/settings.json
create mode 100644 src/features/localization/renderer/locales/ru/team.json
create mode 100644 src/features/localization/renderer/resources.d.ts
create mode 100644 src/features/localization/renderer/ui/AppLanguageSelect.tsx
create mode 100644 src/features/localization/renderer/ui/LocalizationProvider.tsx
create mode 100644 src/renderer/components/chat/session-panel.ts
create mode 100644 src/renderer/components/team/context-metric-alias.ts
create mode 100644 src/renderer/components/team/lead-load-guards.ts
create mode 100644 src/renderer/components/team/session-injection-types.ts
create mode 100644 src/renderer/hooks/useOptionalTabId.ts
create mode 100644 test/features/localization/core/catalogPolicy.test.ts
create mode 100644 test/features/localization/core/localePolicy.test.ts
diff --git a/i18next.config.ts b/i18next.config.ts
new file mode 100644
index 00000000..a8c56bbb
--- /dev/null
+++ b/i18next.config.ts
@@ -0,0 +1,29 @@
+import { defineConfig } from 'i18next-cli';
+
+import {
+ DEFAULT_TRANSLATION_NAMESPACE,
+ FALLBACK_APP_LOCALE,
+ RESOLVED_APP_LOCALES,
+} from './src/features/localization/contracts';
+
+export default defineConfig({
+ locales: [...RESOLVED_APP_LOCALES],
+ extract: {
+ defaultNS: DEFAULT_TRANSLATION_NAMESPACE,
+ input: ['src/**/*.{ts,tsx}'],
+ ignore: ['src/**/*.test.{ts,tsx}', 'src/**/__tests__/**'],
+ output: 'src/features/localization/renderer/locales/{{language}}/{{namespace}}.json',
+ primaryLanguage: FALLBACK_APP_LOCALE,
+ sort: true,
+ useTranslationNames: ['useTranslation', { name: 'useAppTranslation', nsArg: 0 }],
+ },
+ lint: {
+ ignore: ['src/**/*.test.{ts,tsx}', 'src/**/__tests__/**'],
+ },
+ types: {
+ basePath: `src/features/localization/renderer/locales/${FALLBACK_APP_LOCALE}`,
+ input: [`src/features/localization/renderer/locales/${FALLBACK_APP_LOCALE}/*.json`],
+ output: 'src/features/localization/renderer/i18next.d.ts',
+ resourcesFile: 'src/features/localization/renderer/resources.d.ts',
+ },
+});
diff --git a/package.json b/package.json
index ac084a39..f10e6bf4 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,10 @@
"test:semantic": "tsx test/test-semantic-steps.ts",
"test:noise": "tsx test/test-noise-filtering.ts",
"test:task-filtering": "tsx test/test-task-filtering.ts",
+ "i18n:extract": "i18next-cli extract --with-types",
+ "i18n:status": "i18next-cli status",
+ "i18n:validate": "tsx scripts/i18n/validate.ts",
+ "i18n:types": "i18next-cli types --quiet",
"test": "vitest run",
"test:ci": "vitest run --maxWorkers 1 --minWorkers 1",
"test:task-change-ledger": "vitest run test/main/services/team/TaskChangeLedgerReader.test.ts test/main/services/team/taskChangeLedgerFixtures.integration.test.ts test/main/services/team/ReviewApplierService.test.ts test/main/services/team/FileContentResolver.test.ts test/main/services/team/ChangeExtractorService.test.ts test/renderer/store/changeReviewSlice.test.ts test/renderer/utils/reviewKey.test.ts test/main/services/team/TeamLogSourceTracker.test.ts test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts",
@@ -164,6 +168,7 @@
"fast-json-stringify": "^6.4.0",
"fastify": "^5.8.5",
"highlight.js": "^11.11.1",
+ "i18next": "26.2.0",
"idb-keyval": "^6.2.2",
"isbinaryfile": "^6.0.0",
"json-schema-ref-resolver": "^3.0.0",
@@ -178,6 +183,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-grid-layout": "^2.2.2",
+ "react-i18next": "17.0.8",
"react-markdown": "^10.1.0",
"react-modal-sheet": "5.6.0",
"react-resizable": "^3.1.3",
@@ -232,6 +238,7 @@
"globals": "^17.2.0",
"happy-dom": "^20.9.0",
"husky": "^9.1.7",
+ "i18next-cli": "1.58.0",
"knip": "^5.82.1",
"lint-staged": "^16.2.7",
"postcss": "^8.5.10",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ba38e32b..2793d188 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -275,6 +275,9 @@ importers:
highlight.js:
specifier: ^11.11.1
version: 11.11.1
+ i18next:
+ specifier: 26.2.0
+ version: 26.2.0(typescript@5.9.3)
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
@@ -317,6 +320,9 @@ importers:
react-grid-layout:
specifier: ^2.2.2
version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react-i18next:
+ specifier: 17.0.8
+ version: 17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
@@ -377,7 +383,7 @@ importers:
version: 4.0.4
'@eslint-community/eslint-plugin-eslint-comments':
specifier: ^4.6.0
- version: 4.6.0(eslint@9.39.4(jiti@2.7.0))
+ version: 4.6.0(eslint@9.39.4(jiti@1.21.7))
'@eslint/js':
specifier: ^9.39.2
version: 9.39.2
@@ -410,10 +416,10 @@ importers:
version: 1.15.5
'@vitejs/plugin-react':
specifier: ^4.3.1
- version: 4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
+ version: 4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: ^3.1.4
- version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
+ version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
autoprefixer:
specifier: ^10.4.17
version: 10.4.23(postcss@8.5.10)
@@ -425,43 +431,43 @@ importers:
version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
electron-vite:
specifier: ^5.0.0
- version: 5.0.0(vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
+ version: 5.0.0(@swc/core@1.15.33)(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
eslint:
specifier: ^9.39.4
- version: 9.39.4(jiti@2.7.0)
+ version: 9.39.4(jiti@1.21.7)
eslint-config-prettier:
specifier: ^10.1.8
- version: 10.1.8(eslint@9.39.4(jiti@2.7.0))
+ version: 10.1.8(eslint@9.39.4(jiti@1.21.7))
eslint-import-resolver-typescript:
specifier: ^4.4.4
- version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
+ version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-boundaries:
specifier: ^5.3.1
- version: 5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ version: 5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-import:
specifier: ^2.32.0
- version: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ version: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-jsx-a11y:
specifier: ^6.10.2
- version: 6.10.2(eslint@9.39.4(jiti@2.7.0))
+ version: 6.10.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react:
specifier: ^7.37.5
- version: 7.37.5(eslint@9.39.4(jiti@2.7.0))
+ version: 7.37.5(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react-hooks:
specifier: ^7.0.1
- version: 7.0.1(eslint@9.39.4(jiti@2.7.0))
+ version: 7.0.1(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react-refresh:
specifier: ^0.4.26
- version: 0.4.26(eslint@9.39.4(jiti@2.7.0))
+ version: 0.4.26(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-security:
specifier: ^3.0.1
version: 3.0.1
eslint-plugin-simple-import-sort:
specifier: ^12.1.1
- version: 12.1.1(eslint@9.39.4(jiti@2.7.0))
+ version: 12.1.1(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-sonarjs:
specifier: ^3.0.6
- version: 3.0.6(eslint@9.39.4(jiti@2.7.0))
+ version: 3.0.6(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-tailwindcss:
specifier: ^3.18.2
version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))
@@ -474,6 +480,9 @@ importers:
husky:
specifier: ^9.1.7
version: 9.1.7
+ i18next-cli:
+ specifier: 1.58.0
+ version: 1.58.0(@types/node@25.0.7)(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(typescript@5.9.3)
knip:
specifier: ^5.82.1
version: 5.82.1(@types/node@25.0.7)(typescript@5.9.3)
@@ -500,13 +509,13 @@ importers:
version: 5.9.3
typescript-eslint:
specifier: ^8.54.0
- version: 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ version: 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
vite:
specifier: ^6.4.2
- version: 6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ version: 6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vitest:
specifier: ^3.1.4
- version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
agent-teams-controller: {}
@@ -612,7 +621,7 @@ importers:
version: 22.19.15
tsup:
specifier: ^8.5.1
- version: 8.5.1(jiti@2.7.0)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)
+ version: 8.5.1(@swc/core@1.15.33)(jiti@2.7.0)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -809,6 +818,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.29.2':
+ resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -978,6 +991,12 @@ packages:
'@colordx/core@5.4.3':
resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==}
+ '@croct/json5-parser@0.2.2':
+ resolution: {integrity: sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw==}
+
+ '@croct/json@2.1.0':
+ resolution: {integrity: sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==}
+
'@develar/schema-utils@2.6.5':
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
engines: {node: '>= 8.9.0'}
@@ -1877,6 +1896,140 @@ packages:
peerDependencies:
vue: '>=3'
+ '@inquirer/ansi@2.0.5':
+ resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+
+ '@inquirer/checkbox@5.1.5':
+ resolution: {integrity: sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/confirm@6.0.13':
+ resolution: {integrity: sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/core@11.1.10':
+ resolution: {integrity: sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/editor@5.1.2':
+ resolution: {integrity: sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/expand@5.0.14':
+ resolution: {integrity: sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/external-editor@3.0.0':
+ resolution: {integrity: sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/figures@2.0.5':
+ resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+
+ '@inquirer/input@5.0.13':
+ resolution: {integrity: sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/number@4.0.13':
+ resolution: {integrity: sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/password@5.0.13':
+ resolution: {integrity: sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/prompts@8.4.3':
+ resolution: {integrity: sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/rawlist@5.2.9':
+ resolution: {integrity: sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/search@4.1.9':
+ resolution: {integrity: sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/select@5.1.5':
+ resolution: {integrity: sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/type@4.0.5':
+ resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
'@intlify/bundle-utils@10.0.1':
resolution: {integrity: sha512-WkaXfSevtpgtUR4t8K2M6lbR7g03mtOxFeh+vXp5KExvPqS12ppaRj1QxzwRuRI5VUto54A22BjKoBMLyHILWQ==}
engines: {node: '>= 18'}
@@ -4102,6 +4255,99 @@ packages:
peerDependencies:
eslint: ^9.0.0 || ^10.0.0
+ '@swc/core-darwin-arm64@1.15.33':
+ resolution: {integrity: sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@swc/core-darwin-x64@1.15.33':
+ resolution: {integrity: sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@swc/core-linux-arm-gnueabihf@1.15.33':
+ resolution: {integrity: sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==}
+ engines: {node: '>=10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@swc/core-linux-arm64-gnu@1.15.33':
+ resolution: {integrity: sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-arm64-musl@1.15.33':
+ resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@swc/core-linux-ppc64-gnu@1.15.33':
+ resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==}
+ engines: {node: '>=10'}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-s390x-gnu@1.15.33':
+ resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==}
+ engines: {node: '>=10'}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-x64-gnu@1.15.33':
+ resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-x64-musl@1.15.33':
+ resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@swc/core-win32-arm64-msvc@1.15.33':
+ resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@swc/core-win32-ia32-msvc@1.15.33':
+ resolution: {integrity: sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==}
+ engines: {node: '>=10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@swc/core-win32-x64-msvc@1.15.33':
+ resolution: {integrity: sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/core@1.15.33':
+ resolution: {integrity: sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@swc/helpers': '>=0.5.17'
+ peerDependenciesMeta:
+ '@swc/helpers':
+ optional: true
+
+ '@swc/counter@0.1.3':
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
+
+ '@swc/types@0.1.26':
+ resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
+
'@szmarczak/http-timer@4.0.6':
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
@@ -5556,6 +5802,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ chalk@5.6.2:
+ resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
@@ -5571,6 +5821,9 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+ chardet@2.1.1:
+ resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
+
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -5622,6 +5875,10 @@ packages:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
+ cli-spinners@3.4.0:
+ resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==}
+ engines: {node: '>=18.20'}
+
cli-truncate@2.1.0:
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
engines: {node: '>=8'}
@@ -5630,6 +5887,10 @@ packages:
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
engines: {node: '>=20'}
+ cli-width@4.1.0:
+ resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
+ engines: {node: '>= 12'}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -7285,6 +7546,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+ html-parse-stringify@3.0.1:
+ resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -7349,6 +7613,23 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ i18next-cli@1.58.0:
+ resolution: {integrity: sha512-S5Nm6fE/HZzbuC5g8i2JQGq2CCVw7uSpDomY+PpqIglslEuQpje1rQ5r97ZDBdJebUZ4E5v7wf9FRLfGXDGjtQ==}
+ engines: {node: '>=22'}
+ hasBin: true
+
+ i18next-resources-for-ts@2.1.0:
+ resolution: {integrity: sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow==}
+ hasBin: true
+
+ i18next@26.2.0:
+ resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==}
+ peerDependencies:
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
iconv-corefoundation@1.1.7:
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
engines: {node: ^8.11.2 || >=10}
@@ -7414,6 +7695,15 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+ inquirer@13.4.3:
+ resolution: {integrity: sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -7545,6 +7835,10 @@ packages:
resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==}
engines: {node: '>=18'}
+ is-interactive@2.0.0:
+ resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
+ engines: {node: '>=12'}
+
is-map@2.0.3:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'}
@@ -7774,6 +8068,9 @@ packages:
resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ jsonc-parser@3.3.1:
+ resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
+
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
@@ -7944,6 +8241,10 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+ log-symbols@7.0.1:
+ resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==}
+ engines: {node: '>=18'}
+
log-update@6.1.0:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
@@ -8348,6 +8649,10 @@ packages:
multimath@2.0.0:
resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==}
+ mute-stream@3.0.0:
+ resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
mux-embed@5.18.1:
resolution: {integrity: sha512-ePsHjiEKY+FgrSBiMmaF+LOtTQSSBWv/1zqpREQFN96JE93xlsArT/MEi30yKOE06MgjOlL70YI750molu3y7g==}
@@ -8599,6 +8904,10 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
+ ora@9.4.0:
+ resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==}
+ engines: {node: '>=20'}
+
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
@@ -9324,6 +9633,22 @@ packages:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
+ react-i18next@17.0.8:
+ resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
+ peerDependencies:
+ i18next: '>= 26.2.0'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -9393,6 +9718,10 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
+ react@19.2.6:
+ resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
+ engines: {node: '>=0.10.0'}
+
read-binary-file-arch@1.0.6:
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
hasBin: true
@@ -9609,12 +9938,19 @@ packages:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
+ run-async@4.0.6:
+ resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==}
+ engines: {node: '>=0.12.0'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+ rxjs@7.8.2:
+ resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
safe-array-concat@1.1.3:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
@@ -9905,6 +10241,10 @@ packages:
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+ stdin-discarder@0.3.2:
+ resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
+ engines: {node: '>=18'}
+
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -10890,6 +11230,10 @@ packages:
jsdom:
optional: true
+ void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@@ -11445,6 +11789,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/runtime@7.29.2': {}
+
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -11494,10 +11840,10 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
- '@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))':
+ '@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))':
dependencies:
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
handlebars: 4.7.9
is-core-module: 2.16.1
micromatch: 4.0.8
@@ -11800,6 +12146,12 @@ snapshots:
'@colordx/core@5.4.3': {}
+ '@croct/json5-parser@0.2.2':
+ dependencies:
+ '@croct/json': 2.1.0
+
+ '@croct/json@2.1.0': {}
+
'@develar/schema-utils@2.6.5':
dependencies:
ajv: 6.14.0
@@ -12297,12 +12649,17 @@ snapshots:
'@esbuild/win32-x64@0.28.0':
optional: true
- '@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.4(jiti@2.7.0))':
+ '@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.4(jiti@1.21.7))':
dependencies:
escape-string-regexp: 4.0.0
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
ignore: 7.0.5
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
+ dependencies:
+ eslint: 9.39.4(jiti@1.21.7)
+ eslint-visitor-keys: 3.4.3
+
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))':
dependencies:
eslint: 9.39.4(jiti@2.7.0)
@@ -12492,6 +12849,125 @@ snapshots:
'@iconify/types': 2.0.0
vue: 3.5.30(typescript@5.9.3)
+ '@inquirer/ansi@2.0.5': {}
+
+ '@inquirer/checkbox@5.1.5(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/confirm@6.0.13(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/core@11.1.10(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ cli-width: 4.1.0
+ fast-wrap-ansi: 0.2.2
+ mute-stream: 3.0.0
+ signal-exit: 4.1.0
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/editor@5.1.2(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/external-editor': 3.0.0(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/expand@5.0.14(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/external-editor@3.0.0(@types/node@25.0.7)':
+ dependencies:
+ chardet: 2.1.1
+ iconv-lite: 0.7.2
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/figures@2.0.5': {}
+
+ '@inquirer/input@5.0.13(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/number@4.0.13(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/password@5.0.13(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/prompts@8.4.3(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/checkbox': 5.1.5(@types/node@25.0.7)
+ '@inquirer/confirm': 6.0.13(@types/node@25.0.7)
+ '@inquirer/editor': 5.1.2(@types/node@25.0.7)
+ '@inquirer/expand': 5.0.14(@types/node@25.0.7)
+ '@inquirer/input': 5.0.13(@types/node@25.0.7)
+ '@inquirer/number': 4.0.13(@types/node@25.0.7)
+ '@inquirer/password': 5.0.13(@types/node@25.0.7)
+ '@inquirer/rawlist': 5.2.9(@types/node@25.0.7)
+ '@inquirer/search': 4.1.9(@types/node@25.0.7)
+ '@inquirer/select': 5.1.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/rawlist@5.2.9(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/search@4.1.9(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/select@5.1.5(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/type@4.0.5(@types/node@25.0.7)':
+ optionalDependencies:
+ '@types/node': 25.0.7
+
'@intlify/bundle-utils@10.0.1(vue-i18n@10.0.8(vue@3.5.30(typescript@5.9.3)))':
dependencies:
'@intlify/message-compiler': 11.3.0
@@ -14879,6 +15355,66 @@ snapshots:
estraverse: 5.3.0
picomatch: 4.0.4
+ '@swc/core-darwin-arm64@1.15.33':
+ optional: true
+
+ '@swc/core-darwin-x64@1.15.33':
+ optional: true
+
+ '@swc/core-linux-arm-gnueabihf@1.15.33':
+ optional: true
+
+ '@swc/core-linux-arm64-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-arm64-musl@1.15.33':
+ optional: true
+
+ '@swc/core-linux-ppc64-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-s390x-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-x64-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-x64-musl@1.15.33':
+ optional: true
+
+ '@swc/core-win32-arm64-msvc@1.15.33':
+ optional: true
+
+ '@swc/core-win32-ia32-msvc@1.15.33':
+ optional: true
+
+ '@swc/core-win32-x64-msvc@1.15.33':
+ optional: true
+
+ '@swc/core@1.15.33':
+ dependencies:
+ '@swc/counter': 0.1.3
+ '@swc/types': 0.1.26
+ optionalDependencies:
+ '@swc/core-darwin-arm64': 1.15.33
+ '@swc/core-darwin-x64': 1.15.33
+ '@swc/core-linux-arm-gnueabihf': 1.15.33
+ '@swc/core-linux-arm64-gnu': 1.15.33
+ '@swc/core-linux-arm64-musl': 1.15.33
+ '@swc/core-linux-ppc64-gnu': 1.15.33
+ '@swc/core-linux-s390x-gnu': 1.15.33
+ '@swc/core-linux-x64-gnu': 1.15.33
+ '@swc/core-linux-x64-musl': 1.15.33
+ '@swc/core-win32-arm64-msvc': 1.15.33
+ '@swc/core-win32-ia32-msvc': 1.15.33
+ '@swc/core-win32-x64-msvc': 1.15.33
+
+ '@swc/counter@0.1.3': {}
+
+ '@swc/types@0.1.26':
+ dependencies:
+ '@swc/counter': 0.1.3
+
'@szmarczak/http-timer@4.0.6':
dependencies:
defer-to-connect: 2.0.1
@@ -15385,15 +15921,15 @@ snapshots:
'@types/node': 25.0.7
optional: true
- '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.54.0
- '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -15417,14 +15953,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -15477,13 +16013,13 @@ snapshots:
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
debug: 4.4.3
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -15535,17 +16071,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
+ '@typescript-eslint/scope-manager': 8.57.1
+ '@typescript-eslint/types': 8.57.1
+ '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
'@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
@@ -15658,7 +16206,7 @@ snapshots:
- rollup
- supports-color
- '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
+ '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@babel/core': 7.28.6
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
@@ -15666,7 +16214,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
- vite: 6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
@@ -15694,7 +16242,7 @@ snapshots:
vite: 7.3.3(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vue: 3.5.34(typescript@5.9.3)
- '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
+ '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -15709,7 +16257,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
@@ -15729,13 +16277,13 @@ snapshots:
optionalDependencies:
vite: 7.3.2(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
- '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
+ '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -16566,7 +17114,7 @@ snapshots:
dotenv: 17.3.1
exsolve: 1.0.8
giget: 2.0.0
- jiti: 2.6.1
+ jiti: 2.7.0
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 2.1.0
@@ -16661,6 +17209,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ chalk@5.6.2: {}
+
change-case@5.4.4: {}
character-entities-html4@2.1.0: {}
@@ -16671,6 +17221,8 @@ snapshots:
character-reference-invalid@2.0.1: {}
+ chardet@2.1.1: {}
+
check-error@2.1.3: {}
chokidar@3.6.0:
@@ -16721,6 +17273,8 @@ snapshots:
dependencies:
restore-cursor: 5.1.0
+ cli-spinners@3.4.0: {}
+
cli-truncate@2.1.0:
dependencies:
slice-ansi: 3.0.0
@@ -16732,6 +17286,8 @@ snapshots:
slice-ansi: 7.1.2
string-width: 8.2.0
+ cli-width@4.1.0: {}
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -17422,7 +17978,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- electron-vite@5.0.0(vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
+ electron-vite@5.0.0(@swc/core@1.15.33)(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0)
@@ -17430,7 +17986,9 @@ snapshots:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
- vite: 6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ optionalDependencies:
+ '@swc/core': 1.15.33
transitivePeerDependencies:
- supports-color
@@ -17741,9 +18299,9 @@ snapshots:
'@eslint/compat': 2.0.3(eslint@9.39.4(jiti@2.7.0))
eslint: 9.39.4(jiti@2.7.0)
- eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)):
+ eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)):
dependencies:
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-flat-config-utils@3.0.2:
dependencies:
@@ -17765,10 +18323,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)):
+ eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)):
dependencies:
debug: 4.4.3
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
get-tsconfig: 4.13.0
is-bun-module: 2.0.0
@@ -17776,8 +18334,8 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
- eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
+ eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
@@ -17785,24 +18343,24 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.7.0)
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.7.0)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
+ eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
- eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)):
dependencies:
- '@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ '@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
chalk: 4.1.2
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
micromatch: 4.0.8
transitivePeerDependencies:
- '@typescript-eslint/parser'
@@ -17814,6 +18372,26 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.7.0)
+ eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ '@package-json/types': 0.0.12
+ '@typescript-eslint/types': 8.57.1
+ comment-parser: 1.4.5
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@1.21.7)
+ eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
+ is-glob: 4.0.3
+ minimatch: 9.0.7
+ semver: 7.7.4
+ stable-hash-x: 0.2.0
+ unrs-resolver: 1.11.1
+ optionalDependencies:
+ '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ eslint-import-resolver-node: 0.3.9
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)):
dependencies:
'@package-json/types': 0.0.12
@@ -17833,7 +18411,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -17842,9 +18420,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -17856,7 +18434,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -17882,7 +18460,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -17892,7 +18470,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -17901,22 +18479,22 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
- eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@babel/core': 7.28.6
'@babel/parser': 7.28.6
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
hermes-parser: 0.25.1
zod: 4.3.6
zod-validation-error: 4.0.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@1.21.7)):
dependencies:
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
- eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -17924,7 +18502,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.2
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@@ -17953,16 +18531,16 @@ snapshots:
dependencies:
safe-regex: 2.1.1
- eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4(jiti@1.21.7)):
dependencies:
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
- eslint-plugin-sonarjs@3.0.6(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-sonarjs@3.0.6(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@eslint-community/regexpp': 4.12.2
builtin-modules: 3.3.0
bytes: 3.1.2
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
functional-red-black-tree: 1.0.1
jsx-ast-utils-x: 0.1.0
lodash.merge: 4.6.2
@@ -18033,6 +18611,47 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
+ eslint@9.39.4(jiti@1.21.7):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.2
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.5
+ '@eslint/js': 9.39.4
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.7
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.14.0
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.4
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 1.21.7
+ transitivePeerDependencies:
+ - supports-color
+
eslint@9.39.4(jiti@2.7.0):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
@@ -18890,6 +19509,10 @@ snapshots:
html-escaper@2.0.2: {}
+ html-parse-stringify@3.0.1:
+ dependencies:
+ void-elements: 3.1.0
+
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
@@ -18959,6 +19582,45 @@ snapshots:
husky@9.1.7: {}
+ i18next-cli@1.58.0(@types/node@25.0.7)(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(typescript@5.9.3):
+ dependencies:
+ '@croct/json5-parser': 0.2.2
+ '@swc/core': 1.15.33
+ chokidar: 5.0.0
+ commander: 14.0.3
+ execa: 9.6.1
+ glob: 13.0.6
+ i18next-resources-for-ts: 2.1.0
+ inquirer: 13.4.3(@types/node@25.0.7)
+ jiti: 2.7.0
+ jsonc-parser: 3.3.1
+ magic-string: 0.30.21
+ minimatch: 10.2.3
+ ora: 9.4.0
+ react: 19.2.6
+ react-i18next: 17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.6)(typescript@5.9.3)
+ yaml: 2.9.0
+ transitivePeerDependencies:
+ - '@swc/helpers'
+ - '@types/node'
+ - i18next
+ - react-dom
+ - react-native
+ - typescript
+
+ i18next-resources-for-ts@2.1.0:
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@swc/core': 1.15.33
+ chokidar: 5.0.0
+ yaml: 2.9.0
+ transitivePeerDependencies:
+ - '@swc/helpers'
+
+ i18next@26.2.0(typescript@5.9.3):
+ optionalDependencies:
+ typescript: 5.9.3
+
iconv-corefoundation@1.1.7:
dependencies:
cli-truncate: 2.1.0
@@ -19020,6 +19682,18 @@ snapshots:
inline-style-parser@0.2.7: {}
+ inquirer@13.4.3(@types/node@25.0.7):
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/prompts': 8.4.3(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ mute-stream: 3.0.0
+ run-async: 4.0.6
+ rxjs: 7.8.2
+ optionalDependencies:
+ '@types/node': 25.0.7
+
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -19154,6 +19828,8 @@ snapshots:
global-directory: 4.0.1
is-path-inside: 4.0.0
+ is-interactive@2.0.0: {}
+
is-map@2.0.3: {}
is-module@1.0.0: {}
@@ -19350,6 +20026,8 @@ snapshots:
espree: 9.6.1
semver: 7.7.4
+ jsonc-parser@3.3.1: {}
+
jsonfile@4.0.0:
optionalDependencies:
graceful-fs: 4.2.11
@@ -19563,6 +20241,11 @@ snapshots:
lodash@4.18.1: {}
+ log-symbols@7.0.1:
+ dependencies:
+ is-unicode-supported: 2.1.0
+ yoctocolors: 2.1.2
+
log-update@6.1.0:
dependencies:
ansi-escapes: 7.3.0
@@ -20194,6 +20877,8 @@ snapshots:
glur: 1.1.2
object-assign: 4.1.1
+ mute-stream@3.0.0: {}
+
mux-embed@5.18.1: {}
mz@2.7.0:
@@ -20659,6 +21344,17 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
+ ora@9.4.0:
+ dependencies:
+ chalk: 5.6.2
+ cli-cursor: 5.0.0
+ cli-spinners: 3.4.0
+ is-interactive: 2.0.0
+ is-unicode-supported: 2.1.0
+ log-symbols: 7.0.1
+ stdin-discarder: 0.3.2
+ string-width: 8.2.0
+
orderedmap@2.1.1: {}
own-keys@1.0.1:
@@ -21441,6 +22137,28 @@ snapshots:
react-resizable: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
resize-observer-polyfill: 1.5.1
+ react-i18next@17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ html-parse-stringify: 3.0.1
+ i18next: 26.2.0(typescript@5.9.3)
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ optionalDependencies:
+ react-dom: 19.2.4(react@19.2.4)
+ typescript: 5.9.3
+
+ react-i18next@17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.6)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ html-parse-stringify: 3.0.1
+ i18next: 26.2.0(typescript@5.9.3)
+ react: 19.2.6
+ use-sync-external-store: 1.6.0(react@19.2.6)
+ optionalDependencies:
+ react-dom: 19.2.4(react@19.2.4)
+ typescript: 5.9.3
+
react-is@16.13.1: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
@@ -21513,6 +22231,8 @@ snapshots:
react@19.2.4: {}
+ react@19.2.6: {}
+
read-binary-file-arch@1.0.6:
dependencies:
debug: 4.4.3
@@ -21812,12 +22532,18 @@ snapshots:
run-applescript@7.1.0: {}
+ run-async@4.0.6: {}
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
rw@1.3.3: {}
+ rxjs@7.8.2:
+ dependencies:
+ tslib: 2.8.1
+
safe-array-concat@1.1.3:
dependencies:
call-bind: 1.0.8
@@ -22120,6 +22846,8 @@ snapshots:
std-env@4.1.0: {}
+ stdin-discarder@0.3.2: {}
+
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -22525,7 +23253,7 @@ snapshots:
tsscmp@1.0.6: {}
- tsup@8.5.1(jiti@2.7.0)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0):
+ tsup@8.5.1(@swc/core@1.15.33)(jiti@2.7.0)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.2)
cac: 6.7.14
@@ -22545,6 +23273,7 @@ snapshots:
tinyglobby: 0.2.15
tree-kill: 1.2.2
optionalDependencies:
+ '@swc/core': 1.15.33
postcss: 8.5.10
typescript: 5.9.3
transitivePeerDependencies:
@@ -22614,13 +23343,13 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3):
+ typescript-eslint@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.7.0)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -22948,6 +23677,10 @@ snapshots:
dependencies:
react: 19.2.4
+ use-sync-external-store@1.6.0(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+
utf8-byte-length@1.0.5: {}
util-deprecate@1.0.2: {}
@@ -23009,13 +23742,13 @@ snapshots:
- tsx
- yaml
- vite-node@3.2.4(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
+ vite-node@3.2.4(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
- vite: 7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -23105,7 +23838,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
+ vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
@@ -23116,7 +23849,7 @@ snapshots:
optionalDependencies:
'@types/node': 25.0.7
fsevents: 2.3.3
- jiti: 2.7.0
+ jiti: 1.21.7
sass: 1.98.0
terser: 5.46.0
tsx: 4.21.0
@@ -23139,6 +23872,23 @@ snapshots:
tsx: 4.21.0
yaml: 2.9.0
+ vite@7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
+ dependencies:
+ esbuild: 0.27.4
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.10
+ rollup: 4.59.0
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 25.0.7
+ fsevents: 2.3.3
+ jiti: 1.21.7
+ sass: 1.98.0
+ terser: 5.46.0
+ tsx: 4.21.0
+ yaml: 2.9.0
+
vite@7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
esbuild: 0.27.4
@@ -23288,11 +24038,11 @@ snapshots:
- tsx
- yaml
- vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
+ vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
- '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
+ '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -23310,8 +24060,8 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
- vite: 7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
- vite-node: 3.2.4(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite-node: 3.2.4(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
@@ -23331,6 +24081,8 @@ snapshots:
- tsx
- yaml
+ void-elements@3.1.0: {}
+
vscode-uri@3.1.0: {}
vue-bundle-renderer@2.2.0:
diff --git a/scripts/i18n/validate.ts b/scripts/i18n/validate.ts
new file mode 100644
index 00000000..98abbbbd
--- /dev/null
+++ b/scripts/i18n/validate.ts
@@ -0,0 +1,145 @@
+import { readdir, readFile } from 'node:fs/promises';
+import path from 'node:path';
+import process from 'node:process';
+
+import {
+ FALLBACK_APP_LOCALE,
+ RESOLVED_APP_LOCALES,
+ TRANSLATION_NAMESPACES,
+} from '../../src/features/localization/contracts';
+import { validateTranslationCatalogs } from '../../src/features/localization/core/application/validateTranslationCatalogs';
+
+import type {
+ CatalogValidationIssue,
+ TranslationCatalogByNamespace,
+ TranslationCatalogsByLocale,
+ TranslationCatalogNode,
+} from '../../src/features/localization/core/application/validateTranslationCatalogs';
+
+const repoRoot = process.cwd();
+const localesRoot = path.join(repoRoot, 'src/features/localization/renderer/locales');
+
+const issues: CatalogValidationIssue[] = [];
+const catalogs = await readCatalogs(localesRoot, issues);
+
+validateConfiguredLocales(catalogs, issues);
+validateConfiguredNamespaces(catalogs, issues);
+issues.push(...validateTranslationCatalogs(catalogs, FALLBACK_APP_LOCALE));
+
+if (issues.length > 0) {
+ for (const issue of issues) {
+ console.error(`${issue.locale}/${issue.namespace}: ${issue.message}`);
+ }
+ process.exit(1);
+}
+
+console.log(
+ `i18n catalogs valid (${RESOLVED_APP_LOCALES.length} locale set, ${TRANSLATION_NAMESPACES.length} namespaces)`
+);
+
+async function readCatalogs(
+ root: string,
+ issuesOutput: CatalogValidationIssue[]
+): Promise {
+ const localeEntries = await readdir(root, { withFileTypes: true });
+ const result: TranslationCatalogsByLocale = {};
+
+ for (const localeEntry of localeEntries) {
+ if (!localeEntry.isDirectory()) continue;
+
+ const locale = localeEntry.name;
+ const localeDir = path.join(root, locale);
+ const namespaceEntries = await readdir(localeDir, { withFileTypes: true });
+ const localeCatalog: TranslationCatalogByNamespace = {};
+
+ for (const namespaceEntry of namespaceEntries) {
+ if (!namespaceEntry.isFile() || !namespaceEntry.name.endsWith('.json')) continue;
+
+ const namespace = namespaceEntry.name.slice(0, -'.json'.length);
+ const filePath = path.join(localeDir, namespaceEntry.name);
+ const parsed = JSON.parse(await readFile(filePath, 'utf8')) as unknown;
+
+ if (!isTranslationCatalogNode(parsed)) {
+ issuesOutput.push({
+ type: 'shape-mismatch',
+ locale,
+ namespace,
+ message: `Catalog "${locale}/${namespace}.json" must contain a JSON object of nested strings`,
+ });
+ continue;
+ }
+
+ localeCatalog[namespace] = parsed;
+ }
+
+ result[locale] = localeCatalog;
+ }
+
+ return result;
+}
+
+function validateConfiguredLocales(
+ catalogs: TranslationCatalogsByLocale,
+ issuesOutput: CatalogValidationIssue[]
+): void {
+ for (const locale of RESOLVED_APP_LOCALES) {
+ if (!catalogs[locale]) {
+ issuesOutput.push({
+ type: 'missing-namespace',
+ locale,
+ namespace: '*',
+ message: `Configured locale "${locale}" has no catalog directory`,
+ });
+ }
+ }
+
+ for (const locale of Object.keys(catalogs)) {
+ if (!RESOLVED_APP_LOCALES.includes(locale as (typeof RESOLVED_APP_LOCALES)[number])) {
+ issuesOutput.push({
+ type: 'extra-key',
+ locale,
+ namespace: '*',
+ message: `Catalog directory "${locale}" is not listed in RESOLVED_APP_LOCALES`,
+ });
+ }
+ }
+}
+
+function validateConfiguredNamespaces(
+ catalogs: TranslationCatalogsByLocale,
+ issuesOutput: CatalogValidationIssue[]
+): void {
+ for (const [locale, catalog] of Object.entries(catalogs)) {
+ for (const namespace of TRANSLATION_NAMESPACES) {
+ if (!catalog[namespace]) {
+ issuesOutput.push({
+ type: 'missing-namespace',
+ locale,
+ namespace,
+ message: `Configured namespace "${namespace}" is missing for locale "${locale}"`,
+ });
+ }
+ }
+
+ for (const namespace of Object.keys(catalog)) {
+ if (!TRANSLATION_NAMESPACES.includes(namespace as (typeof TRANSLATION_NAMESPACES)[number])) {
+ issuesOutput.push({
+ type: 'extra-key',
+ locale,
+ namespace,
+ message: `Catalog namespace "${namespace}" is not listed in TRANSLATION_NAMESPACES`,
+ });
+ }
+ }
+ }
+}
+
+function isTranslationCatalogNode(value: unknown): value is TranslationCatalogNode {
+ if (typeof value === 'string') return true;
+ if (!isPlainObject(value)) return false;
+ return Object.values(value).every(isTranslationCatalogNode);
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx
index b81c165e..a6315b25 100644
--- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ACTIVITY_LANE } from '@claude-teams/agent-graph';
+import { useAppTranslation } from '@features/localization/renderer';
import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext';
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
@@ -77,6 +78,7 @@ export const GraphActivityHud = ({
onOpenTaskDetail,
onOpenMemberProfile,
}: GraphActivityHudProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const worldLayerRef = useRef(null);
const shellRefs = useRef(new Map());
const connectorRefs = useRef(new Map());
@@ -552,12 +554,12 @@ export const GraphActivityHud = ({
>
- Activity
+ {t('agentGraph.activityHud.activity')}
{lane.entries.length === 0 && lane.overflowCount === 0 ? (
- No recent activity
+ {t('agentGraph.activityHud.noRecentActivity')}
) : null}
{lane.entries.map(renderLaneEntry)}
@@ -568,7 +570,7 @@ export const GraphActivityHud = ({
className={`${INTERACTIVE_ACTIVITY_CONTROL_CLASS} h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]`}
onClick={() => handleOpenOwnerActivity(lane.node)}
>
- +{lane.overflowCount} more
+ {t('agentGraph.activityHud.more', { count: lane.overflowCount })}
) : null}
diff --git a/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx b/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx
index 5b4d5c82..27db9063 100644
--- a/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -63,6 +64,7 @@ export const GraphBlockingEdgePopover = ({
onSelectNode,
onOpenTaskDetail,
}: GraphBlockingEdgePopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { teamData } = useGraphActivityContext(teamName);
const tasksById = useMemo(
() => new Map((teamData?.tasks ?? []).map((task) => [task.id, task] as const)),
@@ -102,7 +104,7 @@ export const GraphBlockingEdgePopover = ({
- Blocking Dependency
+ {t('agentGraph.blockingEdge.title')}
{relationCount > 1 && (
{sourceLabel}
{sourceHiddenTasks.length > 0 && (
)}
-
blocks
+
+ {t('agentGraph.blockingEdge.blocks')}
+
{targetLabel}
{targetHiddenTasks.length > 0 && (
)}
- Close
+ {t('agentGraph.blockingEdge.close')}
diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx
index 16c4e79a..51e32def 100644
--- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
AlertCircle,
Brain,
@@ -279,6 +280,7 @@ export const GraphMemberLogPreviewHud = ({
enabled = true,
onOpenMemberProfile,
}: GraphMemberLogPreviewHudProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const worldLayerRef = useRef(null);
const shellRefs = useRef(new Map());
const visibleKeyRef = useRef('');
@@ -607,7 +609,7 @@ export const GraphMemberLogPreviewHud = ({
- Logs
+ {t('agentGraph.logPreview.logs')}
{items.length > 0 ? (
@@ -617,10 +619,10 @@ export const GraphMemberLogPreviewHud = ({
type="button"
className={`${INTERACTIVE_LOG_CONTROL_CLASS} flex min-h-0 flex-1 rounded-md text-left text-[11px] text-slate-400/60`}
aria-busy="true"
- aria-label="Loading logs"
+ aria-label={t('agentGraph.logPreview.loading')}
onClick={() => openLogs(memberName)}
>
- Loading logs
+ {t('agentGraph.logPreview.loading')}
{renderLoadingSkeleton()}
) : (
@@ -638,7 +640,7 @@ export const GraphMemberLogPreviewHud = ({
className={`${INTERACTIVE_LOG_CONTROL_CLASS} h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]`}
onClick={() => openLogs(memberName)}
>
- +{preview.overflowCount} more
+ {t('agentGraph.logPreview.more', { count: preview.overflowCount })}
) : null}
diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx
index 2c2b3a4d..6ea2258d 100644
--- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx
@@ -6,6 +6,7 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -115,6 +116,7 @@ export const GraphNodePopover = ({
onViewChanges,
onDeleteTask,
}: GraphNodePopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
if (node.kind === 'member' || node.kind === 'lead') {
return (
{'\u{2194}'}
{extTeamName}
- External team
+
+ {t('agentGraph.popover.externalTeam')}
+
);
}
@@ -185,11 +189,15 @@ export const GraphNodePopover = ({
{node.processRegisteredBy && (
- Started by: {node.processRegisteredBy}
+ {t('agentGraph.popover.process.startedBy')}{' '}
+ {node.processRegisteredBy}
)}
{node.processRegisteredAt && (
-
At: {new Date(node.processRegisteredAt).toLocaleTimeString()}
+
+ {t('agentGraph.popover.process.at')}{' '}
+ {new Date(node.processRegisteredAt).toLocaleTimeString()}
+
)}
{node.exceptionLabel && (
- Open URL
+ {t('agentGraph.popover.process.openUrl')}
)}
@@ -229,6 +237,7 @@ const OverflowPopoverContent = ({
onClose: () => void;
onOpenTaskDetail?: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { teamData } = useGraphActivityContext(teamName);
const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task]));
const hiddenTasks = (node.overflowTaskIds ?? [])
@@ -238,14 +247,18 @@ const OverflowPopoverContent = ({
return (
-
Hidden tasks
+
+ {t('agentGraph.popover.overflow.hiddenTasks')}
+
{node.overflowCount ?? hiddenTasks.length}
{hiddenTasks.length === 0 ? (
-
No hidden tasks available.
+
+ {t('agentGraph.popover.overflow.empty')}
+
) : (
hiddenTasks.map((task) => {
const reviewer = resolveTaskReviewer(task, teamData?.kanbanState.tasks[task.id]);
@@ -303,6 +316,7 @@ const MemberPopoverContent = ({
onCreateTask?: (owner: string) => void;
onOpenTask?: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const memberName =
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
? node.domainRef.memberName
@@ -342,6 +356,7 @@ const MemberPopoverContent = ({
members: teamMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
+ t,
})
: null;
const launchPresentation = member
@@ -372,11 +387,11 @@ const MemberPopoverContent = ({
const fallbackSpawnStatusLabel =
node.spawnStatus && node.spawnStatus !== 'online'
? node.spawnStatus === 'waiting'
- ? 'waiting to start'
+ ? t('agentGraph.popover.member.spawn.waitingToStart')
: node.spawnStatus === 'spawning'
- ? 'starting'
+ ? t('agentGraph.popover.member.spawn.starting')
: node.spawnStatus === 'error'
- ? 'failed'
+ ? t('agentGraph.popover.member.spawn.failed')
: node.spawnStatus
: null;
const statusLabel =
@@ -385,13 +400,13 @@ const MemberPopoverContent = ({
launchPresentation?.presenceLabel ??
fallbackSpawnStatusLabel ??
(node.state === 'active'
- ? 'active'
+ ? t('agentGraph.popover.member.state.active')
: node.state === 'idle'
- ? 'idle'
+ ? t('agentGraph.popover.member.state.idle')
: node.state === 'terminated'
- ? 'offline'
+ ? t('agentGraph.popover.member.state.offline')
: node.state === 'tool_calling'
- ? 'running tool'
+ ? t('agentGraph.popover.member.state.runningTool')
: node.state);
const statusDotClass =
launchPresentation?.dotClass ??
@@ -464,7 +479,7 @@ const MemberPopoverContent = ({
variant="outline"
className="border-blue-500/30 px-1.5 py-0 text-[10px] text-blue-400"
>
- Lead
+ {t('agentGraph.popover.member.lead')}
)}
{(launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) &&
@@ -499,7 +514,9 @@ const MemberPopoverContent = ({
className="size-3 shrink-0 animate-spin"
style={{ color: node.color ?? '#66ccff' }}
/>
-
working on
+
+ {t('agentGraph.popover.member.workingOn')}
+
{node.activeTool.state === 'running'
- ? 'Running tool'
+ ? t('agentGraph.popover.member.activeTool.running')
: node.activeTool.state === 'error'
- ? 'Tool failed'
- : 'Tool finished'}
+ ? t('agentGraph.popover.member.activeTool.failed')
+ : t('agentGraph.popover.member.activeTool.finished')}
@@ -555,7 +572,7 @@ const MemberPopoverContent = ({
{node.recentTools && node.recentTools.length > 0 && (
- Recent tools
+ {t('agentGraph.popover.member.recentTools')}
{node.recentTools.slice(0, 5).map((tool) => {
@@ -594,7 +611,7 @@ const MemberPopoverContent = ({
onClose();
}}
>
-
Message
+
{t('agentGraph.popover.member.actions.message')}
- Profile
+ {t('agentGraph.popover.member.actions.profile')}
- Task
+ {t('agentGraph.popover.member.actions.task')}
diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx
index fd3bad8b..0700f332 100644
--- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
import { TeamProvisioningPanel } from '@renderer/components/team/TeamProvisioningPanel';
@@ -15,7 +16,6 @@ import {
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import type { CSSProperties } from 'react';
-const MINI_STEPS = DISPLAY_STEPS.map((step) => ({ key: step.key, label: step.label }));
const HUD_STEPPER_STYLE: CSSProperties = {
['--stepper-done' as string]: '#22c55e',
['--stepper-done-glow' as string]: 'rgba(34, 197, 94, 0.24)',
@@ -46,6 +46,8 @@ export const GraphProvisioningHud = ({
teamName,
enabled = true,
}: GraphProvisioningHudProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
+ const miniSteps = DISPLAY_STEPS.map((step) => ({ key: step.key, label: t(step.labelKey) }));
const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName);
const lastActiveStepRef = useRef(-1);
const [detailsOpen, setDetailsOpen] = useState(false);
@@ -88,7 +90,7 @@ export const GraphProvisioningHud = ({
>
- Launch details
+ {t('agentGraph.provisioning.launchDetails')}
- Detailed team launch progress, live output and CLI logs.
+ {t('agentGraph.provisioning.launchDetailsDescription')}
diff --git a/src/features/localization/contracts/appLocale.ts b/src/features/localization/contracts/appLocale.ts
new file mode 100644
index 00000000..aa3d490b
--- /dev/null
+++ b/src/features/localization/contracts/appLocale.ts
@@ -0,0 +1,19 @@
+export const APP_LOCALE_PREFERENCES = ['system', 'en', 'ru'] as const;
+
+export const RESOLVED_APP_LOCALES = ['en', 'ru'] as const;
+
+export type AppLocalePreference = (typeof APP_LOCALE_PREFERENCES)[number];
+
+export type ResolvedAppLocale = (typeof RESOLVED_APP_LOCALES)[number];
+
+export const DEFAULT_APP_LOCALE_PREFERENCE: AppLocalePreference = 'system';
+
+export const FALLBACK_APP_LOCALE: ResolvedAppLocale = 'en';
+
+export function isAppLocalePreference(value: unknown): value is AppLocalePreference {
+ return typeof value === 'string' && APP_LOCALE_PREFERENCES.includes(value as AppLocalePreference);
+}
+
+export function isResolvedAppLocale(value: unknown): value is ResolvedAppLocale {
+ return typeof value === 'string' && RESOLVED_APP_LOCALES.includes(value as ResolvedAppLocale);
+}
diff --git a/src/features/localization/contracts/index.ts b/src/features/localization/contracts/index.ts
new file mode 100644
index 00000000..3d29bb19
--- /dev/null
+++ b/src/features/localization/contracts/index.ts
@@ -0,0 +1,11 @@
+export type { AppLocalePreference, ResolvedAppLocale } from './appLocale';
+export {
+ APP_LOCALE_PREFERENCES,
+ DEFAULT_APP_LOCALE_PREFERENCE,
+ FALLBACK_APP_LOCALE,
+ isAppLocalePreference,
+ isResolvedAppLocale,
+ RESOLVED_APP_LOCALES,
+} from './appLocale';
+export type { TranslationNamespace } from './namespaces';
+export { DEFAULT_TRANSLATION_NAMESPACE, TRANSLATION_NAMESPACES } from './namespaces';
diff --git a/src/features/localization/contracts/namespaces.ts b/src/features/localization/contracts/namespaces.ts
new file mode 100644
index 00000000..f7b75a92
--- /dev/null
+++ b/src/features/localization/contracts/namespaces.ts
@@ -0,0 +1,13 @@
+export const TRANSLATION_NAMESPACES = [
+ 'common',
+ 'settings',
+ 'errors',
+ 'report',
+ 'dashboard',
+ 'extensions',
+ 'team',
+] as const;
+
+export type TranslationNamespace = (typeof TRANSLATION_NAMESPACES)[number];
+
+export const DEFAULT_TRANSLATION_NAMESPACE: TranslationNamespace = 'common';
diff --git a/src/features/localization/core/application/resolveRuntimeLocale.ts b/src/features/localization/core/application/resolveRuntimeLocale.ts
new file mode 100644
index 00000000..e8c8301d
--- /dev/null
+++ b/src/features/localization/core/application/resolveRuntimeLocale.ts
@@ -0,0 +1,15 @@
+import { resolveAppLocale } from '../domain/localePolicy';
+
+import type { AppLocalePreference, ResolvedAppLocale } from '../../contracts';
+
+export interface ResolveRuntimeLocaleInput {
+ readonly preference: AppLocalePreference;
+ readonly systemLocale: string | null;
+}
+
+export function resolveRuntimeLocale(input: ResolveRuntimeLocaleInput): ResolvedAppLocale {
+ return resolveAppLocale({
+ preference: input.preference,
+ systemLocale: input.systemLocale,
+ });
+}
diff --git a/src/features/localization/core/application/validateTranslationCatalogs.ts b/src/features/localization/core/application/validateTranslationCatalogs.ts
new file mode 100644
index 00000000..c8b6340c
--- /dev/null
+++ b/src/features/localization/core/application/validateTranslationCatalogs.ts
@@ -0,0 +1,7 @@
+export type {
+ CatalogValidationIssue,
+ TranslationCatalogByNamespace,
+ TranslationCatalogNode,
+ TranslationCatalogsByLocale,
+} from '../domain/catalogPolicy';
+export { validateCatalogCompleteness as validateTranslationCatalogs } from '../domain/catalogPolicy';
diff --git a/src/features/localization/core/domain/catalogPolicy.ts b/src/features/localization/core/domain/catalogPolicy.ts
new file mode 100644
index 00000000..9b36102e
--- /dev/null
+++ b/src/features/localization/core/domain/catalogPolicy.ts
@@ -0,0 +1,203 @@
+export type TranslationCatalogNode = string | { readonly [key: string]: TranslationCatalogNode };
+
+export interface CatalogValidationIssue {
+ readonly type:
+ | 'missing-namespace'
+ | 'missing-key'
+ | 'extra-key'
+ | 'shape-mismatch'
+ | 'empty-message'
+ | 'interpolation-mismatch';
+ readonly locale: string;
+ readonly namespace: string;
+ readonly key?: string;
+ readonly message: string;
+}
+
+export type TranslationCatalogByNamespace = Record;
+
+export type TranslationCatalogsByLocale = Record;
+
+export function validateCatalogCompleteness(
+ catalogsByLocale: TranslationCatalogsByLocale,
+ sourceLocale: string
+): CatalogValidationIssue[] {
+ const sourceCatalog = catalogsByLocale[sourceLocale];
+ if (!sourceCatalog) {
+ return [
+ {
+ type: 'missing-namespace',
+ locale: sourceLocale,
+ namespace: '*',
+ message: `Source locale "${sourceLocale}" is missing`,
+ },
+ ];
+ }
+
+ const issues: CatalogValidationIssue[] = [];
+ for (const [locale, localeCatalog] of Object.entries(catalogsByLocale)) {
+ compareLocaleCatalog(issues, locale, localeCatalog, sourceCatalog);
+ }
+ return issues;
+}
+
+function compareLocaleCatalog(
+ issues: CatalogValidationIssue[],
+ locale: string,
+ localeCatalog: TranslationCatalogByNamespace,
+ sourceCatalog: TranslationCatalogByNamespace
+): void {
+ for (const [namespace, sourceNamespaceCatalog] of Object.entries(sourceCatalog)) {
+ const targetNamespaceCatalog = localeCatalog[namespace];
+ if (!targetNamespaceCatalog) {
+ issues.push({
+ type: 'missing-namespace',
+ locale,
+ namespace,
+ message: `Locale "${locale}" is missing namespace "${namespace}"`,
+ });
+ continue;
+ }
+
+ compareCatalogNode(issues, {
+ locale,
+ namespace,
+ keyPath: [],
+ sourceNode: sourceNamespaceCatalog,
+ targetNode: targetNamespaceCatalog,
+ });
+ }
+
+ for (const namespace of Object.keys(localeCatalog)) {
+ if (!(namespace in sourceCatalog)) {
+ issues.push({
+ type: 'extra-key',
+ locale,
+ namespace,
+ message: `Locale "${locale}" has extra namespace "${namespace}"`,
+ });
+ }
+ }
+}
+
+interface CompareCatalogNodeInput {
+ readonly locale: string;
+ readonly namespace: string;
+ readonly keyPath: readonly string[];
+ readonly sourceNode: TranslationCatalogNode;
+ readonly targetNode: TranslationCatalogNode;
+}
+
+function compareCatalogNode(
+ issues: CatalogValidationIssue[],
+ input: CompareCatalogNodeInput
+): void {
+ const key = input.keyPath.join('.');
+
+ if (typeof input.sourceNode === 'string') {
+ validateStringNode(issues, input, key);
+ return;
+ }
+
+ if (typeof input.targetNode === 'string') {
+ issues.push({
+ type: 'shape-mismatch',
+ locale: input.locale,
+ namespace: input.namespace,
+ key,
+ message: `Expected object at "${input.namespace}:${key}"`,
+ });
+ return;
+ }
+
+ for (const [childKey, sourceChildNode] of Object.entries(input.sourceNode)) {
+ if (!(childKey in input.targetNode)) {
+ const missingKey = [...input.keyPath, childKey].join('.');
+ issues.push({
+ type: 'missing-key',
+ locale: input.locale,
+ namespace: input.namespace,
+ key: missingKey,
+ message: `Missing key "${input.namespace}:${missingKey}" for locale "${input.locale}"`,
+ });
+ continue;
+ }
+
+ compareCatalogNode(issues, {
+ locale: input.locale,
+ namespace: input.namespace,
+ keyPath: [...input.keyPath, childKey],
+ sourceNode: sourceChildNode,
+ targetNode: input.targetNode[childKey],
+ });
+ }
+
+ for (const childKey of Object.keys(input.targetNode)) {
+ if (!(childKey in input.sourceNode)) {
+ const extraKey = [...input.keyPath, childKey].join('.');
+ issues.push({
+ type: 'extra-key',
+ locale: input.locale,
+ namespace: input.namespace,
+ key: extraKey,
+ message: `Extra key "${input.namespace}:${extraKey}" for locale "${input.locale}"`,
+ });
+ }
+ }
+}
+
+function validateStringNode(
+ issues: CatalogValidationIssue[],
+ input: CompareCatalogNodeInput,
+ key: string
+): void {
+ const sourceMessage = input.sourceNode;
+ if (typeof sourceMessage !== 'string') {
+ return;
+ }
+
+ if (typeof input.targetNode !== 'string') {
+ issues.push({
+ type: 'shape-mismatch',
+ locale: input.locale,
+ namespace: input.namespace,
+ key,
+ message: `Expected string at "${input.namespace}:${key}"`,
+ });
+ return;
+ }
+
+ if (input.targetNode.trim().length === 0) {
+ issues.push({
+ type: 'empty-message',
+ locale: input.locale,
+ namespace: input.namespace,
+ key,
+ message: `Empty message at "${input.namespace}:${key}" for locale "${input.locale}"`,
+ });
+ }
+
+ const sourceVariables = extractInterpolationVariables(sourceMessage);
+ const targetVariables = extractInterpolationVariables(input.targetNode);
+ if (!hasSameItems(sourceVariables, targetVariables)) {
+ issues.push({
+ type: 'interpolation-mismatch',
+ locale: input.locale,
+ namespace: input.namespace,
+ key,
+ message: `Interpolation variables differ at "${input.namespace}:${key}" for locale "${input.locale}"`,
+ });
+ }
+}
+
+export function extractInterpolationVariables(message: string): readonly string[] {
+ const variables = new Set();
+ for (const match of message.matchAll(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g)) {
+ variables.add(match[1]);
+ }
+ return [...variables].sort();
+}
+
+function hasSameItems(left: readonly string[], right: readonly string[]): boolean {
+ return left.length === right.length && left.every((item, index) => item === right[index]);
+}
diff --git a/src/features/localization/core/domain/localePolicy.ts b/src/features/localization/core/domain/localePolicy.ts
new file mode 100644
index 00000000..28151706
--- /dev/null
+++ b/src/features/localization/core/domain/localePolicy.ts
@@ -0,0 +1,43 @@
+import {
+ FALLBACK_APP_LOCALE,
+ isAppLocalePreference,
+ isResolvedAppLocale,
+ RESOLVED_APP_LOCALES,
+} from '../../contracts';
+
+import type { AppLocalePreference, ResolvedAppLocale } from '../../contracts';
+
+export interface LocaleResolutionInput {
+ readonly preference: unknown;
+ readonly systemLocale?: string | null;
+ readonly supportedLocales?: readonly ResolvedAppLocale[];
+ readonly fallbackLocale?: ResolvedAppLocale;
+}
+
+export function normalizeAppLocalePreference(value: unknown): AppLocalePreference {
+ return isAppLocalePreference(value) ? value : 'system';
+}
+
+export function extractPrimaryLocaleSubtag(locale: string | null | undefined): string | null {
+ const trimmed = locale?.trim();
+ if (!trimmed) return null;
+
+ const normalized = trimmed.replace('_', '-').toLowerCase();
+ const primary = normalized.split('-')[0]?.trim();
+ return primary || null;
+}
+
+export function resolveAppLocale(input: LocaleResolutionInput): ResolvedAppLocale {
+ const supportedLocales = input.supportedLocales ?? RESOLVED_APP_LOCALES;
+ const fallbackLocale = input.fallbackLocale ?? FALLBACK_APP_LOCALE;
+ const preference = normalizeAppLocalePreference(input.preference);
+
+ if (preference !== 'system') {
+ return supportedLocales.includes(preference) ? preference : fallbackLocale;
+ }
+
+ const primarySystemLocale = extractPrimaryLocaleSubtag(input.systemLocale);
+ return isResolvedAppLocale(primarySystemLocale) && supportedLocales.includes(primarySystemLocale)
+ ? primarySystemLocale
+ : fallbackLocale;
+}
diff --git a/src/features/localization/index.ts b/src/features/localization/index.ts
new file mode 100644
index 00000000..555be93b
--- /dev/null
+++ b/src/features/localization/index.ts
@@ -0,0 +1,11 @@
+export type { AppLocalePreference, ResolvedAppLocale, TranslationNamespace } from './contracts';
+export {
+ APP_LOCALE_PREFERENCES,
+ DEFAULT_APP_LOCALE_PREFERENCE,
+ FALLBACK_APP_LOCALE,
+ isAppLocalePreference,
+ isResolvedAppLocale,
+ RESOLVED_APP_LOCALES,
+ TRANSLATION_NAMESPACES,
+} from './contracts';
+export { normalizeAppLocalePreference, resolveAppLocale } from './core/domain/localePolicy';
diff --git a/src/features/localization/renderer/adapters/browserSystemLocaleAdapter.ts b/src/features/localization/renderer/adapters/browserSystemLocaleAdapter.ts
new file mode 100644
index 00000000..5fa9acff
--- /dev/null
+++ b/src/features/localization/renderer/adapters/browserSystemLocaleAdapter.ts
@@ -0,0 +1,3 @@
+export function getBrowserSystemLocale(): string | null {
+ return globalThis.navigator?.language ?? null;
+}
diff --git a/src/features/localization/renderer/composition/createI18nextInstance.ts b/src/features/localization/renderer/composition/createI18nextInstance.ts
new file mode 100644
index 00000000..8a0339eb
--- /dev/null
+++ b/src/features/localization/renderer/composition/createI18nextInstance.ts
@@ -0,0 +1,35 @@
+import { initReactI18next } from 'react-i18next';
+
+import i18next from 'i18next';
+
+import {
+ DEFAULT_TRANSLATION_NAMESPACE,
+ FALLBACK_APP_LOCALE,
+ RESOLVED_APP_LOCALES,
+ TRANSLATION_NAMESPACES,
+} from '../../contracts';
+
+import { localizationResources } from './localizationResources';
+
+export function createI18nextInstance(initialLocale = FALLBACK_APP_LOCALE): typeof i18next {
+ const instance = i18next.createInstance();
+
+ void instance.use(initReactI18next).init({
+ debug: false,
+ defaultNS: DEFAULT_TRANSLATION_NAMESPACE,
+ fallbackLng: FALLBACK_APP_LOCALE,
+ initAsync: false,
+ interpolation: {
+ escapeValue: false,
+ },
+ lng: initialLocale,
+ ns: [...TRANSLATION_NAMESPACES],
+ resources: localizationResources,
+ returnEmptyString: false,
+ supportedLngs: [...RESOLVED_APP_LOCALES],
+ });
+
+ return instance;
+}
+
+export const appI18n = createI18nextInstance();
diff --git a/src/features/localization/renderer/composition/localizationResources.ts b/src/features/localization/renderer/composition/localizationResources.ts
new file mode 100644
index 00000000..c7ef2394
--- /dev/null
+++ b/src/features/localization/renderer/composition/localizationResources.ts
@@ -0,0 +1,34 @@
+import { RESOLVED_APP_LOCALES, TRANSLATION_NAMESPACES } from '../../contracts';
+
+import type { ResolvedAppLocale, TranslationNamespace } from '../../contracts';
+
+type TranslationResource = Record;
+type TranslationResources = Record<
+ ResolvedAppLocale,
+ Record
+>;
+
+const catalogModules = import.meta.glob('../locales/*/*.json', {
+ eager: true,
+ import: 'default',
+});
+
+export const localizationResources = buildLocalizationResources();
+
+function buildLocalizationResources(): TranslationResources {
+ const resources = {} as TranslationResources;
+
+ for (const locale of RESOLVED_APP_LOCALES) {
+ resources[locale] = {} as Record;
+
+ for (const namespace of TRANSLATION_NAMESPACES) {
+ const resource = catalogModules[`../locales/${locale}/${namespace}.json`];
+ if (!resource) {
+ throw new Error(`Missing i18n catalog: ${locale}/${namespace}.json`);
+ }
+ resources[locale][namespace] = resource;
+ }
+ }
+
+ return resources;
+}
diff --git a/src/features/localization/renderer/hooks/useAppTranslation.ts b/src/features/localization/renderer/hooks/useAppTranslation.ts
new file mode 100644
index 00000000..d3c98ef1
--- /dev/null
+++ b/src/features/localization/renderer/hooks/useAppTranslation.ts
@@ -0,0 +1,17 @@
+import { useTranslation } from 'react-i18next';
+
+import type { TranslationNamespace } from '../../contracts';
+import type { TFunction } from 'i18next';
+
+export interface AppTranslationApi {
+ readonly t: TFunction;
+ readonly resolvedLanguage: string | undefined;
+}
+
+export function useAppTranslation(namespace: TranslationNamespace): AppTranslationApi {
+ const { i18n, t } = useTranslation(namespace);
+ return {
+ t,
+ resolvedLanguage: i18n.resolvedLanguage,
+ };
+}
diff --git a/src/features/localization/renderer/hooks/useLocaleFormatters.ts b/src/features/localization/renderer/hooks/useLocaleFormatters.ts
new file mode 100644
index 00000000..d6a0fcb5
--- /dev/null
+++ b/src/features/localization/renderer/hooks/useLocaleFormatters.ts
@@ -0,0 +1,54 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { FALLBACK_APP_LOCALE } from '../../contracts';
+
+export interface LocaleFormatters {
+ readonly date: (value: Date | string | number, options?: Intl.DateTimeFormatOptions) => string;
+ readonly time: (value: Date | string | number, options?: Intl.DateTimeFormatOptions) => string;
+ readonly dateTime: (
+ value: Date | string | number,
+ options?: Intl.DateTimeFormatOptions
+ ) => string;
+ readonly number: (value: number, options?: Intl.NumberFormatOptions) => string;
+ readonly currency: (
+ value: number,
+ currency: string,
+ options?: Intl.NumberFormatOptions
+ ) => string;
+}
+
+export function useLocaleFormatters(): LocaleFormatters {
+ const { i18n } = useTranslation();
+ const locale = i18n.resolvedLanguage || i18n.language || FALLBACK_APP_LOCALE;
+
+ return useMemo(
+ () => ({
+ date: (value, options) =>
+ new Intl.DateTimeFormat(locale, options ?? { dateStyle: 'medium' }).format(
+ normalizeDate(value)
+ ),
+ time: (value, options) =>
+ new Intl.DateTimeFormat(locale, options ?? { hour: '2-digit', minute: '2-digit' }).format(
+ normalizeDate(value)
+ ),
+ dateTime: (value, options) =>
+ new Intl.DateTimeFormat(
+ locale,
+ options ?? { dateStyle: 'medium', timeStyle: 'short' }
+ ).format(normalizeDate(value)),
+ number: (value, options) => new Intl.NumberFormat(locale, options).format(value),
+ currency: (value, currency, options) =>
+ new Intl.NumberFormat(locale, {
+ currency,
+ style: 'currency',
+ ...options,
+ }).format(value),
+ }),
+ [locale]
+ );
+}
+
+function normalizeDate(value: Date | string | number): Date {
+ return value instanceof Date ? value : new Date(value);
+}
diff --git a/src/features/localization/renderer/i18next.d.ts b/src/features/localization/renderer/i18next.d.ts
new file mode 100644
index 00000000..b1127ae9
--- /dev/null
+++ b/src/features/localization/renderer/i18next.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated by i18next-cli, because it was not existing. You can edit it based on your needs: https://www.i18next.com/overview/typescript#custom-type-options
+import type Resources from './resources';
+
+declare module 'i18next' {
+ interface CustomTypeOptions {
+ enableSelector: false;
+ defaultNS: 'common';
+ resources: Resources;
+ }
+}
diff --git a/src/features/localization/renderer/index.ts b/src/features/localization/renderer/index.ts
new file mode 100644
index 00000000..c2ea035b
--- /dev/null
+++ b/src/features/localization/renderer/index.ts
@@ -0,0 +1,4 @@
+export { useAppTranslation } from './hooks/useAppTranslation';
+export { useLocaleFormatters } from './hooks/useLocaleFormatters';
+export { AppLanguageSelect } from './ui/AppLanguageSelect';
+export { LocalizationProvider } from './ui/LocalizationProvider';
diff --git a/src/features/localization/renderer/locales/en/common.json b/src/features/localization/renderer/locales/en/common.json
new file mode 100644
index 00000000..0b020a1c
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/common.json
@@ -0,0 +1,900 @@
+{
+ "actions": {
+ "cancel": "Cancel",
+ "close": "Close",
+ "copied": "Copied",
+ "copyUrl": "Copy URL",
+ "open": "Open",
+ "reveal": "Reveal",
+ "retry": "Retry",
+ "save": "Save",
+ "showLess": "Show less",
+ "showMore": "Show more",
+ "refresh": "Refresh",
+ "reset": "Reset",
+ "copyToClipboard": "Copy to clipboard",
+ "moreActions": "More actions",
+ "closeDialog": "Close dialog",
+ "goToDashboard": "Go to Dashboard",
+ "or": "or",
+ "hide": "Hide",
+ "resetSelection": "Reset selection"
+ },
+ "code": {
+ "line": "line {{line}}",
+ "lines": "lines {{from}}-{{to}}",
+ "moreLines": "({{count}} more lines...)",
+ "moreLines_few": "({{count}} more lines...)",
+ "moreLines_many": "({{count}} more lines...)",
+ "moreLines_one": "({{count}} more line...)",
+ "moreLines_other": "({{count}} more lines...)",
+ "code": "Code",
+ "preview": "Preview",
+ "markdownPreview": "Markdown Preview",
+ "linesParenthesized": "(lines {{from}}-{{to}})",
+ "mermaidSyntaxError": "Mermaid syntax error"
+ },
+ "contextBadge": {
+ "badge": "Context",
+ "breakdown": {
+ "text": "Text",
+ "thinking": "Thinking"
+ },
+ "detailsAria": "Context injection details",
+ "sectionSummary": "{{title}} ({{count}}) ~{{tokens}} tokens",
+ "sections": {
+ "claudeMdFiles": "CLAUDE.md Files",
+ "mentionedFiles": "Mentioned Files",
+ "taskCoordination": "Task Coordination",
+ "thinkingText": "Thinking + Text",
+ "toolOutputs": "Tool Outputs",
+ "userMessages": "User Messages"
+ },
+ "title": "New Context Injected In This Turn",
+ "tokenCount": "~{{tokens}} tokens",
+ "totalNewTokens": "Total new tokens",
+ "turn": "Turn {{turn}}",
+ "sectionSummary_few": "{{title}} ({{count}}) ~{{tokens}} tokens",
+ "sectionSummary_many": "{{title}} ({{count}}) ~{{tokens}} tokens",
+ "sectionSummary_one": "{{title}} ({{count}}) ~{{tokens}} tokens",
+ "sectionSummary_other": "{{title}} ({{count}}) ~{{tokens}} tokens"
+ },
+ "locales": {
+ "emptyMessage": "No language found.",
+ "names": {
+ "en": "English",
+ "ru": "Russian",
+ "system": "System"
+ },
+ "searchPlaceholder": "Search language...",
+ "selectPlaceholder": "Select app language...",
+ "systemWithResolved": "System - {{locale}}"
+ },
+ "members": {
+ "emptyMessage": "No members found.",
+ "searchPlaceholder": "Search members...",
+ "unassigned": "Unassigned",
+ "teammateFallback": "teammate"
+ },
+ "providerRuntime": {
+ "codex": {
+ "install": {
+ "checking": "Checking",
+ "downloading": "Downloading",
+ "installCli": "Install Codex CLI",
+ "installing": "Installing",
+ "retryInstall": "Retry install"
+ }
+ }
+ },
+ "search": {
+ "noMatchingSuggestions": "No matching suggestions",
+ "searching": "Searching...",
+ "searchingFiles": "Searching files...",
+ "findInConversation": "Find in conversation...",
+ "resultCount": "{{current}} of {{total}}",
+ "resultCountCapped": "{{current}} of {{total}}+",
+ "noResults": "No results",
+ "previousResultShortcut": "Previous result (Shift+Enter)",
+ "nextResultShortcut": "Next result (Enter)",
+ "closeShortcut": "Close (Esc)",
+ "nothingFound": "Nothing found",
+ "placeholder": "Search..."
+ },
+ "schedules": {
+ "actions": {
+ "addSchedule": "Add Schedule",
+ "clearFilters": "Clear filters",
+ "createSchedule": "Create Schedule",
+ "delete": "Delete",
+ "edit": "Edit",
+ "pause": "Pause",
+ "resume": "Resume",
+ "runNow": "Run now"
+ },
+ "empty": {
+ "description": "Create a schedule on any team to automate Claude task execution with cron expressions. Schedules from all teams will appear here.",
+ "noMatches": "No schedules match the current filters",
+ "title": "No scheduled tasks"
+ },
+ "filters": {
+ "allTeams": "All teams"
+ },
+ "item": {
+ "loadingRunHistory": "Loading run history...",
+ "nextRun": "Next: {{value}}",
+ "noRunsYet": "No runs yet"
+ },
+ "loading": "Loading schedules...",
+ "searchPlaceholder": "Search schedules...",
+ "status": {
+ "active": "Active",
+ "all": "All",
+ "disabled": "Disabled",
+ "paused": "Paused"
+ },
+ "title": "Schedules"
+ },
+ "sessions": {
+ "actions": {
+ "hide": "Hide",
+ "pin": "Pin",
+ "unhide": "Unhide"
+ },
+ "empty": {
+ "noMatchingSessions": "No matching sessions",
+ "noMatchingSessionsDescription": "This project has no matching sessions yet.",
+ "noMatchingSessionsFiltered": "Try another query or reset the provider filter.",
+ "noSessions": "No sessions found",
+ "noSessionsDescription": "This project has no sessions yet",
+ "selectProject": "Select a project to view sessions"
+ },
+ "errors": {
+ "loading": "Error loading sessions"
+ },
+ "loadedMatchingMore": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "loadingMore": "Loading more sessions...",
+ "pinned": "Pinned",
+ "scrollToLoadMore": "Scroll to load more",
+ "search": {
+ "clear": "Clear session search",
+ "placeholder": "Search sessions..."
+ },
+ "selection": {
+ "cancel": "Cancel selection",
+ "exitMode": "Exit selection mode",
+ "hideSelected": "Hide selected sessions",
+ "pinSelected": "Pin selected sessions",
+ "selectSessions": "Select sessions",
+ "selected": "{{count}} selected",
+ "unhideSelected": "Unhide selected sessions",
+ "selected_few": "{{count}} selected",
+ "selected_many": "{{count}} selected",
+ "selected_one": "{{count}} selected",
+ "selected_other": "{{count}} selected"
+ },
+ "sort": {
+ "byContext": "By Context",
+ "byContextTooltip": "Sort by context consumption",
+ "byRecentTooltip": "Sort by recent",
+ "contextLoadedOnly": "Context sorting only ranks loaded sessions."
+ },
+ "title": "Sessions",
+ "visibility": {
+ "hideHidden": "Hide hidden sessions",
+ "showHidden": "Show hidden sessions"
+ },
+ "worktree": {
+ "switch": "Switch Worktree"
+ },
+ "loadedMatchingMore_few": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "loadedMatchingMore_many": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "loadedMatchingMore_one": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "loadedMatchingMore_other": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "failedToLoad": "Failed to load session",
+ "loading": "Loading session...",
+ "filter": {
+ "title": "Filter sessions"
+ },
+ "count": "{{count}} sessions",
+ "count_one": "{{count}} session",
+ "count_other": "{{count}} sessions",
+ "count_few": "{{count}} sessions",
+ "count_many": "{{count}} sessions",
+ "inProgress": "Session is in progress..."
+ },
+ "states": {
+ "loading": "Loading...",
+ "offline": "Offline",
+ "online": "Online",
+ "unknown": "Unknown",
+ "error": "Error"
+ },
+ "markdown": {
+ "imageFallback": "[Image: {{label}}]",
+ "largeContentNotice": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.",
+ "largeContentTitle": "Large content is shown as raw to prevent UI freeze",
+ "raw": "Raw",
+ "rawPreview": "Raw preview",
+ "renderMarkdown": "Render markdown",
+ "showAll": "Show all",
+ "showMore": "Show more",
+ "showRaw": "Show raw",
+ "showingChars": "Showing {{shown}} / {{total}} chars",
+ "largeContentNotice_few": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.",
+ "largeContentNotice_many": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.",
+ "largeContentNotice_one": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.",
+ "largeContentNotice_other": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive."
+ },
+ "terminal": {
+ "checkOutputForDetails": "Check terminal output above for details",
+ "closingInSeconds": "Closing in {{count}}s...",
+ "closingInSeconds_few": "Closing in {{count}}s...",
+ "closingInSeconds_many": "Closing in {{count}}s...",
+ "closingInSeconds_one": "Closing in {{count}}s...",
+ "closingInSeconds_other": "Closing in {{count}}s...",
+ "completedSuccessfully": "Completed successfully",
+ "exitCode": "(exit code {{code}})",
+ "processFailed": "Process failed",
+ "title": "Terminal"
+ },
+ "tokens": {
+ "accumulatedWithoutDuplication": "Accumulated across entire session without duplication",
+ "cacheRead": "Cache Read",
+ "cacheWrite": "Cache Write",
+ "costUsd": "Cost (USD)",
+ "inputTokens": "Input Tokens",
+ "model": "Model",
+ "outputTokens": "Output Tokens",
+ "phase": "Phase {{phase}}/{{total}}",
+ "promptInputShare": "{{percent}}% of prompt input",
+ "taskCoordination": "Task Coordination",
+ "thinkingText": "Thinking + Text",
+ "toolOutputs": "Tool Outputs",
+ "total": "Total",
+ "userMessages": "User Messages",
+ "visibleContext": "Visible Context",
+ "includesClaudeMd": "incl. CLAUDE.md ×{{count}}",
+ "claudeMd": "CLAUDE.md",
+ "mentionedFiles": "@files",
+ "percentValue": "({{percent}}%)",
+ "approxTokens": "~{{tokens}} tokens",
+ "approxTokensParenthesized": "(~{{tokens}})"
+ },
+ "list": {
+ "actions": {
+ "copyTeam": "Copy team",
+ "createTeam": "Create Team",
+ "deleteForever": "Delete forever",
+ "deletePermanently": "Delete permanently",
+ "deleteTeam": "Delete team",
+ "launching": "Launching...",
+ "launchTeam": "Launch team",
+ "relaunchTeam": "Relaunch team",
+ "restore": "Restore",
+ "restoreTeam": "Restore team",
+ "retry": "Retry",
+ "stopTeam": "Stop team",
+ "stopping": "Stopping..."
+ },
+ "status": {
+ "active": "Active",
+ "deleted": "Deleted",
+ "launching": "Launching...",
+ "offline": "Offline",
+ "partialFailure": "Launch failed partway",
+ "partialPending": "Bootstrap pending",
+ "partialSkipped": "Launch skipped member",
+ "running": "Running"
+ },
+ "partial": {
+ "pending": "Last launch is still reconciling.",
+ "skipped": "Last launch has skipped teammates.",
+ "skippedWithCount": "Last launch skipped {{count}}/{{expected}} teammate.",
+ "skippedWithCount_few": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "skippedWithCount_many": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "skippedWithCount_one": "Last launch skipped {{count}}/{{expected}} teammate.",
+ "skippedWithCount_other": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "stopped": "Last launch stopped before all teammates joined.",
+ "stoppedWithCount": "Last launch stopped before {{count}}/{{expected}} teammate joined.",
+ "stoppedWithCount_few": "Last launch stopped before {{count}}/{{expected}} teammates joined.",
+ "stoppedWithCount_many": "Last launch stopped before {{count}}/{{expected}} teammates joined.",
+ "stoppedWithCount_one": "Last launch stopped before {{count}}/{{expected}} teammate joined.",
+ "stoppedWithCount_other": "Last launch stopped before {{count}}/{{expected}} teammates joined."
+ },
+ "noDescription": "No description",
+ "solo": "Solo",
+ "membersCount": "Members: {{count}}",
+ "membersCount_few": "Members: {{count}}",
+ "membersCount_many": "Members: {{count}}",
+ "membersCount_one": "Member: {{count}}",
+ "membersCount_other": "Members: {{count}}",
+ "all": "All",
+ "moreCount": "+{{count}} more",
+ "moreCount_one": "+{{count}} more",
+ "moreCount_other": "+{{count}} more",
+ "moreCount_few": "+{{count}} more",
+ "moreCount_many": "+{{count}} more"
+ },
+ "runtimeProvider": {
+ "defaults": {
+ "scopeDescriptionAllProjects": "Default for every project that does not have its own OpenCode override.",
+ "scopeDescriptionProject": "Override only the selected project. Running teams are not changed.",
+ "setAllProjectsDefault": "Set all-projects default",
+ "setProjectDefault": "Set project default",
+ "validationContext": "Validation context",
+ "projectOverrideContext": "Project override context",
+ "selectProjectHint": "Select a project before testing local models or saving defaults.",
+ "allProjectsHint": "Tests use {{project}}. Default applies unless a project has an override.",
+ "projectHint": "Saving overrides only {{project}}."
+ }
+ },
+ "sessionContext": {
+ "header": {
+ "title": "Context",
+ "closePanel": "Close panel",
+ "phase": "Phase:",
+ "current": "Current",
+ "view": "View:",
+ "category": "Category",
+ "bySize": "By Size"
+ },
+ "metrics": {
+ "unavailable": "Unavailable",
+ "contextUsed": "Context Used",
+ "promptInput": "Prompt Input",
+ "visibleContext": "Visible Context",
+ "ofContext": "of context",
+ "ofPrompt": "of prompt",
+ "codexTelemetryUnavailable": "Codex prompt-side usage is not exposed by the current runtime telemetry yet, so Prompt Input and Context Used stay unavailable instead of showing a fake zero.",
+ "sessionCost": "Session Cost:",
+ "parentPlus": "parent +",
+ "subagents": "subagents",
+ "details": "details"
+ },
+ "help": {
+ "contextUsed": {
+ "title": "Context Used",
+ "description": "Prompt input plus output tokens currently occupying the model's context window."
+ },
+ "promptInput": {
+ "title": "Prompt Input",
+ "description": "Tokens sent to the model before generation. For Claude this includes `input_tokens + cache_creation_input_tokens + cache_read_input_tokens`."
+ },
+ "visibleContext": {
+ "title": "Visible Context",
+ "description": "The inspectable subset of prompt input: files, CLAUDE.md, tool outputs, user messages, and similar injections that you can optimize directly."
+ },
+ "availability": {
+ "title": "Availability",
+ "description": "If a provider runtime does not expose prompt-side usage yet, the panel shows metrics as unavailable instead of pretending they are zero."
+ }
+ },
+ "items": {
+ "turn": "@Turn {{turn}}",
+ "tokensApprox": "~{{tokens}} tokens",
+ "toolsCount": "{{count}} tools",
+ "toolsCount_one": "{{count}} tool",
+ "toolsCount_other": "{{count}} tools",
+ "toolsCount_few": "{{count}} tools",
+ "toolsCount_many": "{{count}} tools",
+ "itemsCount": "{{count}} items",
+ "itemsCount_one": "{{count}} item",
+ "itemsCount_other": "{{count}} items",
+ "itemsCount_few": "{{count}} items",
+ "itemsCount_many": "{{count}} items",
+ "missing": "missing",
+ "thinking": "Thinking",
+ "text": "Text"
+ },
+ "empty": "No context injections detected in this session",
+ "view": {
+ "grouped": "Grouped",
+ "flat": "Flat"
+ },
+ "claudeMdFiles": "CLAUDE.md Files",
+ "mentionedFiles": "Mentioned Files"
+ },
+ "chat": {
+ "subagent": {
+ "fallbackName": "Subagent",
+ "shutdownConfirmed": "Shutdown confirmed",
+ "summary": {
+ "tools": "{{count}} tools",
+ "tools_one": "{{count}} tool",
+ "tools_other": "{{count}} tools",
+ "tools_few": "{{count}} tools",
+ "tools_many": "{{count}} tools"
+ },
+ "meta": {
+ "type": "Type",
+ "duration": "Duration",
+ "model": "Model",
+ "id": "ID"
+ },
+ "metrics": {
+ "contextWindow": "Context Window",
+ "contextUsage": "Context Usage",
+ "mainContext": "Main Context",
+ "totalOutput": "Total Output",
+ "turns": "({{count}} turns)",
+ "turns_one": "({{count}} turn)",
+ "turns_other": "({{count}} turns)",
+ "subagentContext": "Subagent Context",
+ "phase": "Phase {{phase}}",
+ "turns_few": "({{count}} turns)",
+ "turns_many": "({{count}} turns)"
+ },
+ "trace": {
+ "title": "Execution Trace"
+ }
+ },
+ "user": {
+ "you": "You",
+ "showMore": "Show more",
+ "showLess": "Show less",
+ "backgroundTask": "Background task",
+ "exitCode": "exit {{code}}",
+ "imagesAttached": "{{count}} images attached",
+ "imagesAttached_one": "{{count}} image attached",
+ "imagesAttached_few": "{{count}} images attached",
+ "imagesAttached_many": "{{count}} images attached",
+ "imagesAttached_other": "{{count}} images attached"
+ },
+ "compact": {
+ "toggle": "Toggle compacted content",
+ "contextCompacted": "Context compacted",
+ "freedTokens": "({{tokens}} freed)",
+ "phase": "Phase {{phase}}",
+ "conversationCompacted": "Conversation Compacted",
+ "summary": "Previous messages were summarized to save context. The full conversation history is preserved in the session file.",
+ "compacted": "Compacted"
+ },
+ "executionTrace": {
+ "empty": "No execution items",
+ "nested": "Nested: {{name}}",
+ "input": "Input"
+ },
+ "items": {
+ "empty": "No items to display"
+ },
+ "tools": {
+ "teammateSpawned": "Teammate spawned",
+ "shutdownRequested": "Shutdown requested ->",
+ "noResultReceived": "No result received",
+ "duration": "Duration: {{duration}}",
+ "result": "Result",
+ "write": {
+ "createdFile": "Created file",
+ "wroteToFile": "Wrote to file"
+ },
+ "skill": {
+ "instructions": "Skill Instructions",
+ "unknown": "Unknown Skill"
+ }
+ },
+ "lastOutput": {
+ "requestInterrupted": "Request interrupted by user",
+ "planReadyForApproval": "Plan Ready for Approval"
+ },
+ "empty": {
+ "icon": "💬",
+ "title": "No conversation history",
+ "description": "This session does not contain any messages yet."
+ },
+ "context": {
+ "remainingPercent": "({{percent}}% left)",
+ "count": "Context ({{count}})",
+ "count_one": "Context ({{count}})",
+ "count_other": "Context ({{count}})",
+ "count_few": "Context ({{count}})",
+ "count_many": "Context ({{count}})"
+ },
+ "scrollToBottom": "Scroll to bottom",
+ "bottom": "Bottom",
+ "teammateMessage": {
+ "message": "Message",
+ "resent": "Resent",
+ "fallback": "Teammate message"
+ },
+ "system": {
+ "label": "System"
+ }
+ },
+ "tmuxInstaller": {
+ "summaryTitle": "tmux is not installed",
+ "detectedOs": "Detected OS: {{os}}",
+ "runtimePath": "Runtime path: {{path}}",
+ "phase": "Phase: {{phase}}",
+ "actions": {
+ "cancel": "Cancel",
+ "manualGuide": "Manual guide",
+ "hideSetupSteps": "Hide setup steps",
+ "showSetupSteps": "Show setup steps ({{count}})",
+ "showSetupSteps_one": "Show setup step ({{count}})",
+ "showSetupSteps_other": "Show setup steps ({{count}})",
+ "recheck": "Re-check",
+ "showSetupSteps_few": "Show setup steps ({{count}})",
+ "showSetupSteps_many": "Show setup steps ({{count}})"
+ },
+ "installerProgress": "Installer progress",
+ "input": {
+ "placeholder": "Send input to the installer",
+ "send": "Send input",
+ "passwordNotice": "Password input is sent directly to the installer terminal and is not added to the log output."
+ },
+ "details": {
+ "show": "Show details",
+ "hide": "Hide details"
+ }
+ },
+ "commandPalette": {
+ "noRecentActivity": "No recent activity",
+ "sessionsCount": "{{count}} sessions",
+ "sessionsCount_one": "{{count}} session",
+ "sessionsCount_other": "{{count}} sessions",
+ "mode": {
+ "searchProjects": "Search projects",
+ "searchAcrossProjects": "Search across all projects",
+ "searchInProject": "Search in project"
+ },
+ "currentProject": "Current project",
+ "global": "Global",
+ "placeholders": {
+ "projects": "Search projects...",
+ "conversations": "Search conversations..."
+ },
+ "empty": {
+ "noProjectsForQuery": "No projects found for \"{{query}}\"",
+ "noProjects": "No projects found",
+ "minChars": "Type at least 2 characters to search",
+ "noFastResults": "No fast results in recent sessions for \"{{query}}\"",
+ "noResults": "No results found for \"{{query}}\""
+ },
+ "footer": {
+ "projectsCount": "{{count}} projects",
+ "projectsCount_one": "{{count}} project",
+ "projectsCount_other": "{{count}} projects",
+ "results": "{{count}} {{speed}}results",
+ "results_one": "{{count}} {{speed}}result",
+ "results_other": "{{count}} {{speed}}results",
+ "resultsAcrossProjects": "{{count}} {{speed}}results across all projects",
+ "resultsAcrossProjects_one": "{{count}} {{speed}}result across all projects",
+ "resultsAcrossProjects_other": "{{count}} {{speed}}results across all projects",
+ "fastPrefix": "fast ",
+ "typeToSearch": "Type to search",
+ "navigate": "navigate",
+ "select": "select",
+ "open": "open",
+ "global": "global",
+ "close": "close",
+ "results_few": "{{count}} {{speed}}results",
+ "results_many": "{{count}} {{speed}}results",
+ "resultsAcrossProjects_few": "{{count}} {{speed}}results across all projects",
+ "resultsAcrossProjects_many": "{{count}} {{speed}}results across all projects",
+ "projectsCount_few": "{{count}} projects",
+ "projectsCount_many": "{{count}} projects",
+ "upDownKey": "↑↓",
+ "escapeKey": "esc"
+ },
+ "sessionsCount_few": "{{count}} sessions",
+ "sessionsCount_many": "{{count}} sessions"
+ },
+ "tasksPanel": {
+ "title": "Tasks",
+ "searchPlaceholder": "Search tasks...",
+ "pinned": "Pinned",
+ "groupByLabel": "Group by:",
+ "groupByAria": "Group by",
+ "groupModes": {
+ "none": "None",
+ "project": "Project",
+ "time": "Time"
+ },
+ "showArchived": "Show archived",
+ "hideArchived": "Hide archived",
+ "empty": {
+ "noMatchingTasks": "No matching tasks",
+ "noTasks": "No tasks found"
+ },
+ "teamLabel": "Team: {{team}}",
+ "showMore": "Show more",
+ "showLess": "Show less",
+ "deleteConfirm": {
+ "title": "Delete task",
+ "message": "Move task #{{taskId}} to trash?",
+ "confirmLabel": "Delete",
+ "cancelLabel": "Cancel"
+ },
+ "deleteFailed": {
+ "title": "Failed to delete task",
+ "fallbackMessage": "An unexpected error occurred",
+ "confirmLabel": "OK"
+ },
+ "sort": {
+ "byTime": "By time",
+ "byUnread": "By unread",
+ "byProject": "By project",
+ "byTeam": "By team"
+ }
+ },
+ "toolViewer": {
+ "input": "Input",
+ "replaceAll": "(replace all)",
+ "noInputRecorded": "No input recorded for this tool call.",
+ "agent": {
+ "action": "action",
+ "teammate": "teammate",
+ "team": "team",
+ "runtime": "runtime",
+ "type": "type",
+ "startupInstructionsHidden": "Startup instructions are hidden in the UI."
+ }
+ },
+ "taskContextMenu": {
+ "unpin": "Unpin",
+ "pin": "Pin",
+ "rename": "Rename",
+ "markUnread": "Mark as unread",
+ "unarchive": "Unarchive",
+ "archive": "Archive",
+ "deleteTask": "Delete task"
+ },
+ "updateDialog": {
+ "closeDialog": "Close dialog",
+ "updateAvailable": "Update available",
+ "updateReady": "Update Ready",
+ "noReleaseNotes": "No release notes available.",
+ "viewOnGitHub": "View on GitHub",
+ "later": "Later",
+ "restartNow": "Restart now",
+ "download": "Download"
+ },
+ "errorBoundary": {
+ "title": "Something went wrong",
+ "description": "An unexpected error occurred in the application. You can try reloading the page or resetting the error state.",
+ "componentStack": "Component Stack",
+ "tryAgain": "Try Again",
+ "copied": "Copied",
+ "copyErrorDetails": "Copy Error Details",
+ "reportBugOnGitHub": "Report Bug on GitHub",
+ "reloadApp": "Reload App",
+ "diagnosticsNotice": "GitHub bug reports and copied diagnostics include the error message, stack traces, app version, active tab, selected team, task context, and environment details."
+ },
+ "runtimeBackendSelector": {
+ "label": "Runtime backend",
+ "resolved": "Resolved: {{backend}}",
+ "current": "Current",
+ "recommended": "Recommended",
+ "unavailable": "Unavailable",
+ "cannotSelectYet": "This backend cannot be selected yet.",
+ "auto": "Auto",
+ "autoCurrently": "Auto (currently: {{backend}})",
+ "audience": {
+ "internal": "Internal"
+ },
+ "states": {
+ "locked": "Locked",
+ "disabled": "Disabled",
+ "authRequired": "Auth required",
+ "runtimeMissing": "Runtime missing",
+ "degraded": "Degraded",
+ "unavailable": "Unavailable"
+ }
+ },
+ "providerModelBadges": {
+ "checking": "Checking",
+ "unavailable": "Unavailable",
+ "checkFailed": "Check failed",
+ "free": "Free",
+ "freeTooltip": "Reported by OpenCode metadata. Availability and limits may change."
+ },
+ "taskFilters": {
+ "status": "Status",
+ "clearAll": "Clear all",
+ "selectAll": "Select all",
+ "team": "Team",
+ "allTeams": "All teams",
+ "searchTeams": "Search teams...",
+ "noTeamsFound": "No teams found",
+ "project": "Project",
+ "allProjects": "All Projects",
+ "searchProjects": "Search projects...",
+ "noProjects": "No projects",
+ "comments": "Comments",
+ "apply": "Apply",
+ "read": {
+ "all": "All",
+ "unread": "Unread",
+ "read": "Read"
+ },
+ "statusOptions": {
+ "todo": "TODO",
+ "inProgress": "IN PROGRESS",
+ "needsFix": "NEEDS FIXES",
+ "done": "DONE",
+ "review": "REVIEW",
+ "approved": "APPROVED"
+ }
+ },
+ "sessionItem": {
+ "totalContext": "Total Context: {{tokens}} tokens",
+ "context": "Context: {{tokens}}",
+ "phase": "Phase {{phase}}:",
+ "compactedTo": "(compacted to {{tokens}})"
+ },
+ "notifications": {
+ "row": {
+ "team": "team",
+ "subagent": "subagent",
+ "markAsRead": "Mark as read",
+ "delete": "Delete",
+ "viewInSession": "View in session"
+ },
+ "title": "Notifications",
+ "loading": "Loading notifications...",
+ "actions": {
+ "markFilteredAsRead": "Mark filtered as read",
+ "markAllAsRead": "Mark all as read",
+ "markFilteredRead": "Mark filtered read",
+ "markAllRead": "Mark all read",
+ "clearFilteredNotifications": "Clear filtered notifications",
+ "clearAllNotifications": "Clear all notifications",
+ "clickToConfirm": "Click to confirm",
+ "clearFiltered": "Clear filtered",
+ "clearAll": "Clear all"
+ },
+ "counts": {
+ "unreadInFilter": "{{count}} unread in filter",
+ "unreadInFilter_one": "{{count}} unread in filter",
+ "unreadInFilter_few": "{{count}} unread in filter",
+ "unreadInFilter_many": "{{count}} unread in filter",
+ "unreadInFilter_other": "{{count}} unread in filter",
+ "inFilter": "{{count}} in filter",
+ "inFilter_one": "{{count}} in filter",
+ "inFilter_few": "{{count}} in filter",
+ "inFilter_many": "{{count}} in filter",
+ "inFilter_other": "{{count}} in filter",
+ "unread": "{{count}} unread",
+ "unread_one": "{{count}} unread",
+ "unread_few": "{{count}} unread",
+ "unread_many": "{{count}} unread",
+ "unread_other": "{{count}} unread",
+ "total": "{{count}} total",
+ "total_one": "{{count}} total",
+ "total_few": "{{count}} total",
+ "total_many": "{{count}} total",
+ "total_other": "{{count}} total"
+ },
+ "filters": {
+ "other": "Other"
+ },
+ "empty": {
+ "noMatching": "No matching notifications",
+ "noNotifications": "No notifications",
+ "tryDifferentFilter": "Try a different filter",
+ "allCaughtUp": "You're all caught up!"
+ }
+ },
+ "updates": {
+ "restartToUpdate": "Restart to update",
+ "updateApp": "Update app",
+ "downloadedRestartTooltip": "Update downloaded, restart to apply",
+ "newVersionAvailable": "New version available",
+ "updatingApp": "Updating app",
+ "updateReady": "Update ready",
+ "restartNow": "Restart now"
+ },
+ "layout": {
+ "github": "GitHub",
+ "discord": "Discord",
+ "expandSidebar": "Expand sidebar",
+ "collapseSidebarShortcut": "Collapse sidebar ({{shortcut}})",
+ "sidebarView": "Sidebar view",
+ "resizeSidebar": "Resize sidebar",
+ "closeTab": "Close tab",
+ "openedFromSearch": "Opened from search",
+ "pinnedSession": "Pinned session",
+ "jumpToSection": "Jump to section",
+ "newTab": "New tab",
+ "newTabDashboard": "New tab (Dashboard)",
+ "refreshSession": "Refresh session",
+ "refreshSessionWithShortcut": "Refresh Session ({{shortcut}})",
+ "loadingTab": "Loading tab",
+ "menu": {
+ "teams": "Teams",
+ "settings": "Settings",
+ "extensions": "Extensions",
+ "search": "Search",
+ "schedules": "Schedules",
+ "docs": "Docs",
+ "exportMarkdown": "Export as Markdown",
+ "exportJson": "Export as JSON",
+ "exportPlainText": "Export as Plain Text",
+ "analyzeSession": "Analyze Session"
+ },
+ "tabMenu": {
+ "closeTabs": "Close {{count}} Tabs",
+ "closeTabs_one": "Close {{count}} Tab",
+ "closeTabs_few": "Close {{count}} Tabs",
+ "closeTabs_many": "Close {{count}} Tabs",
+ "closeTabs_other": "Close {{count}} Tabs",
+ "closeTab": "Close Tab",
+ "closeOtherTabs": "Close Other Tabs",
+ "splitRight": "Split Right",
+ "splitLeft": "Split Left",
+ "pinToSidebar": "Pin to Sidebar",
+ "unpinFromSidebar": "Unpin from Sidebar",
+ "hideFromSidebar": "Hide from Sidebar",
+ "unhideFromSidebar": "Unhide from Sidebar",
+ "closeAllTabs": "Close All Tabs"
+ },
+ "sections": {
+ "team": "Team",
+ "sessions": "Sessions",
+ "kanban": "Kanban",
+ "claudeLogs": "Claude Logs",
+ "messages": "Messages"
+ }
+ },
+ "editorFormatting": {
+ "bold": "Bold",
+ "italic": "Italic",
+ "strike": "Strike",
+ "code": "Code"
+ },
+ "diff": {
+ "changed": "Changed",
+ "noChangesDetected": "No changes detected"
+ },
+ "codexLogin": {
+ "copyLoginLinkAndCode": "Copy ChatGPT login link and code",
+ "copyLoginLink": "Copy ChatGPT login link",
+ "copyFailed": "Copy failed",
+ "copyLinkAndCode": "Copy link + code",
+ "copyLink": "Copy link",
+ "enterCodeOnLoginPage": "Enter this code on the ChatGPT login page"
+ },
+ "window": {
+ "minimize": "Minimize",
+ "maximize": "Maximize",
+ "restore": "Restore"
+ },
+ "context": {
+ "local": "Local",
+ "switchingTo": "Switching to {{workspace}}",
+ "loadingWorkspace": "Loading workspace",
+ "switchWorkspace": "Switch Workspace"
+ },
+ "repositories": {
+ "noneAvailable": "No repositories available",
+ "remove": "Remove repository"
+ },
+ "export": {
+ "session": "Export session",
+ "sessionTitle": "Export Session"
+ },
+ "brand": {
+ "claude": "Claude"
+ },
+ "sessionReport": {
+ "noSessionData": "No session data available",
+ "title": "Session Report"
+ },
+ "sessionFilters": {
+ "project": {
+ "selectProject": "Select Project"
+ }
+ },
+ "tasks": {
+ "date": {
+ "updatedPrefix": "upd",
+ "updatedYesterday": "upd yesterday",
+ "yesterday": "Yesterday"
+ },
+ "reviewState": {
+ "needsFix": "Needs Fixes"
+ },
+ "unassigned": "unassigned"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/dashboard.json b/src/features/localization/renderer/locales/en/dashboard.json
new file mode 100644
index 00000000..7fe4d83e
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/dashboard.json
@@ -0,0 +1,197 @@
+{
+ "cliStatus": {
+ "actions": {
+ "alreadyLoggedIn": "Already logged in?",
+ "becomeSponsor": "Become a sponsor",
+ "cancel": "Cancel",
+ "checkNow": "Check now",
+ "checkUpdates": "Check for Updates",
+ "checking": "Checking...",
+ "connect": "Connect",
+ "extensions": "Extensions",
+ "login": "Login",
+ "manage": "Manage",
+ "manageProviders": "Manage Providers",
+ "plan": "Plan",
+ "recheck": "Re-check",
+ "recheckProvider": "Re-check {{provider}}",
+ "retry": "Retry",
+ "updateTo": "Update to v{{version}}",
+ "useCode": "Use code"
+ },
+ "atlas": {
+ "alt": "Atlas Cloud",
+ "description": "Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access.",
+ "openCodeProvider": "OpenCode provider",
+ "plan": "Atlas Cloud coding plan",
+ "sponsor": "Sponsor"
+ },
+ "errors": {
+ "checkStatusFailed": "Failed to check CLI status",
+ "installationFailed": "Installation failed",
+ "refreshFailed": "Failed to check for updates. Check your network connection and try again.",
+ "runtimeUpdatedRefreshFailed": "Runtime updated, but failed to refresh provider status."
+ },
+ "hints": {
+ "backgroundStatus": "{{runtime}} status will be checked in the background.",
+ "codexApiKeyFallback": "{{hint}} API key fallback is available if you switch auth mode.",
+ "codexAutoApiKey": "{{hint}} Auto will keep using the API key until ChatGPT is connected.",
+ "codexFinishLogin": "Finish ChatGPT login in the browser. Enter the shown code if prompted.",
+ "codexNoActiveLogin": "Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login.",
+ "codexNoActiveManagedSession": "Usage limits appear only after Codex CLI sees an active ChatGPT account. Local Codex account data exists, but no active managed session is selected right now.",
+ "codexReconnectNeeded": "Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect.",
+ "firstCheckSlow": "First check may take up to 30 seconds",
+ "loginRequiredForTeams": "Browsing sessions and projects works without login. Login is only needed to run agent teams.",
+ "troubleshootTitle": "If you're sure you're logged in, try these steps:"
+ },
+ "installer": {
+ "checkingLatest": "Checking latest version...",
+ "downloading": "Downloading {{runtime}}...",
+ "installing": "Installing {{runtime}}...",
+ "success": "Successfully installed {{runtime}} v{{version}}",
+ "verifying": "Verifying checksum..."
+ },
+ "labels": {
+ "apiKeyRequired": "API key required",
+ "comingSoon": "Coming soon",
+ "collapseProviderDetails": "Collapse provider details",
+ "expandProviderDetails": "Expand provider details",
+ "generateLink": "Generate link",
+ "loadingRateLimits": "Rate limits loading",
+ "loggedOut": "Provider logged out",
+ "loginAuthFailed": "Authentication failed",
+ "loginAuthUpdated": "Authentication updated",
+ "loginComplete": "Login complete",
+ "loginFailed": "Login failed",
+ "loginTitle": "Login",
+ "logoutFailed": "Logout failed",
+ "logoutTitle": "Logout",
+ "notLoggedIn": "Not logged in",
+ "openLogin": "Open login",
+ "providerActionRequired": "Provider action required",
+ "resets": "resets {{time}}",
+ "runtimeLoginTitle": "{{runtime}} Login"
+ },
+ "loading": {
+ "aiProviders": "Checking AI Providers...",
+ "claudeCli": "Checking Claude CLI..."
+ },
+ "provider": {
+ "authenticated": "Authenticated",
+ "backend": "Backend: {{backend}}",
+ "checkingAuthentication": "Checking authentication...",
+ "checkingProviders": "Checking providers...",
+ "configuredLocalCount": "{{count}} configured local",
+ "configuredLocalCount_few": "{{count}} configured local",
+ "configuredLocalCount_many": "{{count}} configured local",
+ "configuredLocalCount_one": "{{count}} configured local",
+ "configuredLocalCount_other": "{{count}} configured local",
+ "configuredLocalTitle": "Local OpenCode routes imported from your OpenCode config.",
+ "connectedCount": "Providers: {{connected}}/{{denominator}} connected",
+ "freeModels": "Free models",
+ "freeModelsTitle": "OpenCode includes free model options such as Big Pickle when available in your setup. OpenRouter through OpenCode can also expose free models, but not every OpenCode/OpenRouter model is free. Availability and limits may change.",
+ "loadingModels": "Loading models...",
+ "modelsUnavailable": "Models unavailable for this runtime build",
+ "runtime": "Runtime: {{runtime}}",
+ "verifiedCount": "{{count}} verified",
+ "verifiedCount_few": "{{count}} verified",
+ "verifiedCount_many": "{{count}} verified",
+ "verifiedCount_one": "{{count}} verified",
+ "verifiedCount_other": "{{count}} verified",
+ "verifiedTitle": "OpenCode routes with a successful execution proof."
+ },
+ "runtime": {
+ "configuredHealthCheckFailed": "The configured {{runtime}} failed its startup health check.",
+ "configuredNotFound": "The configured {{runtime}} was not found.",
+ "foundButFailed": "{{runtime}} was found but failed to start",
+ "healthCheckFailedDescription": "The app found the configured {{runtime}}, but its startup health check failed. Repair or reinstall it, then retry.",
+ "install": "Install {{runtime}}",
+ "installRequiredDescription": "{{runtime}} is required for team provisioning and session management. Install it to get started.",
+ "isRequired": "{{runtime}} is required",
+ "reinstall": "Reinstall {{runtime}}"
+ },
+ "runtimeInstall": {
+ "checking": "Checking",
+ "codexTitle": "Install Codex CLI into app data",
+ "downloading": "Downloading",
+ "downloadingPercent": "Downloading {{percent}}%",
+ "install": "Install",
+ "installing": "Installing",
+ "openCodeTitle": "Install OpenCode runtime into app data",
+ "retryInstall": "Retry install"
+ },
+ "troubleshoot": {
+ "again": "again",
+ "authStatusCommand": "your configured CLI auth status command",
+ "checkLoggedIn": "- check if it shows \"Logged in\"",
+ "click": "Click",
+ "loginCommand": "the runtime login command",
+ "logoutCommand": "the runtime logout command",
+ "openTerminal": "Open your terminal and run:",
+ "reloginPrefix": "If it says logged in but the app doesn't see it, try:",
+ "sameRuntime": "Make sure the CLI in your terminal is the same runtime the app uses",
+ "statusCacheHint": "- sometimes the status is cached for a few seconds",
+ "then": "then"
+ },
+ "warnings": {
+ "multipleApiKeysMissing": "One or more providers are set to API key mode, but no API key is configured. Open Manage Providers to add keys or switch the connection mode.",
+ "multipleApiKeysNeedAttention": "One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.",
+ "notAuthenticated": "{{runtime}} is installed but you are not authenticated. Login is required for team provisioning and AI features.",
+ "singleApiKeyMissing": "{{provider}} is set to API key mode, but no API key is configured. Open Manage Providers to add a key or switch the connection mode.",
+ "singleApiKeyNeedsAttention": "{{provider}} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode."
+ }
+ },
+ "recentProjects": {
+ "selectFolderTitle": "Select a project folder",
+ "selectFolder": "Select Folder",
+ "failedToLoad": "Failed to load projects",
+ "retry": "Retry",
+ "noProjects": "No projects found",
+ "noMatches": "No matches for \"{{query}}\"",
+ "noRecentProjects": "No recent projects found",
+ "emptyDescription": "Recent Claude and Codex activity will appear here.",
+ "loadMore": "Load more",
+ "card": {
+ "deleted": "Deleted",
+ "projectFolderMissing": "Project folder no longer exists",
+ "taskCounts": {
+ "active": "{{count}} active",
+ "active_one": "{{count}} active",
+ "active_other": "{{count}} active",
+ "active_few": "{{count}} active",
+ "active_many": "{{count}} active",
+ "pending": "{{count}} pending",
+ "pending_one": "{{count}} pending",
+ "pending_other": "{{count}} pending",
+ "pending_few": "{{count}} pending",
+ "pending_many": "{{count}} pending",
+ "done": "{{count}} done",
+ "done_one": "{{count}} done",
+ "done_other": "{{count}} done",
+ "done_few": "{{count}} done",
+ "done_many": "{{count}} done"
+ }
+ },
+ "title": "Recent Projects",
+ "searchResults": "Search Results",
+ "searchPlaceholder": "Search projects..."
+ },
+ "actions": {
+ "selectTeam": "Select Team",
+ "or": "or",
+ "clearSearch": "Clear search"
+ },
+ "windowsAdmin": {
+ "title": "Windows Administrator mode recommended",
+ "description": "OpenCode runtime checks can time out when Agent Teams AI is not elevated. Restart the app with Run as administrator before launching OpenCode teams."
+ },
+ "webPreview": {
+ "title": "Open the desktop app for full functionality",
+ "description": "The browser version is still in development. Project actions, integrations, and live status updates may be limited here. Use the desktop app to access all features reliably."
+ },
+ "updateBanner": {
+ "newVersionAvailable": "New version available",
+ "restartNow": "Restart now",
+ "viewDetails": "View details"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/errors.json b/src/features/localization/renderer/locales/en/errors.json
new file mode 100644
index 00000000..027abdd1
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/errors.json
@@ -0,0 +1,3 @@
+{
+ "fallback": "Something went wrong."
+}
diff --git a/src/features/localization/renderer/locales/en/extensions.json b/src/features/localization/renderer/locales/en/extensions.json
new file mode 100644
index 00000000..acfcea20
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/extensions.json
@@ -0,0 +1,684 @@
+{
+ "store": {
+ "actions": {
+ "addCustom": "Add Custom",
+ "openDashboard": "Open Dashboard",
+ "refreshCatalog": "Refresh catalog"
+ },
+ "capabilities": {
+ "mcp": "MCP: {{status}}",
+ "plugins": "Plugins: {{status}}",
+ "skills": "Skills: {{status}}"
+ },
+ "desktopOnly": "Available in the desktop app only.",
+ "provider": {
+ "checkingStatus": "Checking provider status...",
+ "connected": "Connected",
+ "loading": "Loading...",
+ "needsSetup": "Needs setup",
+ "readyToConfigure": "Ready to configure",
+ "unsupported": "Unsupported"
+ },
+ "runtime": {
+ "checkingAvailabilityDescription": "Extensions need the configured runtime to manage plugins, MCP servers, skills, and provider connections.",
+ "checkingAvailabilityTitle": "Checking extensions runtime availability",
+ "failedToStartDescription": "Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.",
+ "failedToStartTitle": "The configured runtime was found but failed to start",
+ "multimodelCapabilitiesDescription": "Provider support can differ by section. Plugins are shown only where the runtime explicitly declares support.",
+ "multimodelCapabilitiesTitle": "Multimodel runtime capabilities",
+ "needsSignInDescription": "{{runtime}} was found{{version}}, but plugin installs are disabled until you sign in from the Dashboard.",
+ "needsSignInTitle": "{{runtime}} needs sign-in",
+ "notAvailableDescription": "Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.",
+ "notAvailableTitle": "The configured runtime is not available",
+ "readyDescription": "Plugins can be installed from this page{{versionSuffix}}.",
+ "readyTitle": "{{runtime}} is ready",
+ "requiredForMutations": "The configured runtime is required to install or uninstall extensions. Install or repair it from the Dashboard."
+ },
+ "sessionsRestartWarning": "Running sessions won't pick up extension changes until restarted.",
+ "tabs": {
+ "apiKeys": {
+ "description": "Secret keys for online services. Add them here so plugins, servers, and integrations can connect and work.",
+ "label": "API Keys"
+ },
+ "mcpServers": {
+ "description": "Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.",
+ "label": "MCP Servers"
+ },
+ "plugins": {
+ "description": "Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported. Broader provider support is in development.",
+ "label": "Plugins"
+ },
+ "skills": {
+ "description": "Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.",
+ "label": "Skills"
+ }
+ },
+ "title": "Extensions"
+ },
+ "pluginsPanel": {
+ "activeFilters": "{{count}} active",
+ "browseByFit": "Browse by fit",
+ "capabilities": "Capabilities",
+ "categories": "Categories",
+ "clearAllFilters": "Clear all filters",
+ "clearFilters": "Clear filters",
+ "counts": {
+ "capabilities": "{{count}} capabilities",
+ "categories": "{{count}} categories",
+ "plugins": "{{count}} plugins",
+ "capabilities_few": "{{count}} capabilities",
+ "capabilities_many": "{{count}} capabilities",
+ "capabilities_one": "{{count}} capabilities",
+ "capabilities_other": "{{count}} capabilities",
+ "categories_few": "{{count}} categories",
+ "categories_many": "{{count}} categories",
+ "categories_one": "{{count}} categories",
+ "categories_other": "{{count}} categories",
+ "plugins_few": "{{count}} plugins",
+ "plugins_many": "{{count}} plugins",
+ "plugins_one": "{{count}} plugins",
+ "plugins_other": "{{count}} plugins"
+ },
+ "empty": {
+ "description": "Check back later for new plugins",
+ "filteredDescription": "Try adjusting your search or filter criteria",
+ "filteredTitle": "No plugins match your filters",
+ "title": "No plugins available"
+ },
+ "filterDescription": "Narrow the catalog by category, capability, or installed state.",
+ "installedOnly": "Installed only",
+ "providerSupportNotice": "Plugin support is currently guaranteed for Anthropic (Claude) sessions only. We're working to support plugins across all agents.",
+ "resultsUpdateInstantly": "Results update instantly as you refine filters.",
+ "searchPlaceholder": "Search plugins...",
+ "selectedCount": "{{count}} selected",
+ "showing": "Showing {{shown}} of {{total}} plugins",
+ "sort": {
+ "category": "Category",
+ "nameAsc": "Name A-Z",
+ "nameDesc": "Name Z-A",
+ "popular": "Popular"
+ },
+ "activeFilters_few": "{{count}} active",
+ "activeFilters_many": "{{count}} active",
+ "activeFilters_one": "{{count}} active",
+ "activeFilters_other": "{{count}} active",
+ "selectedCount_few": "{{count}} selected",
+ "selectedCount_many": "{{count}} selected",
+ "selectedCount_one": "{{count}} selected",
+ "selectedCount_other": "{{count}} selected"
+ },
+ "customMcp": {
+ "actions": {
+ "add": "Add",
+ "cancel": "Cancel",
+ "install": "Install",
+ "installing": "Installing..."
+ },
+ "description": "Add a server manually without the catalog.",
+ "errors": {
+ "installFailed": "Install failed",
+ "invalidServerName": "Invalid server name. Use alphanumeric characters, dashes, underscores, dots.",
+ "npmPackageRequired": "npm package name is required",
+ "serverNameRequired": "Server name is required",
+ "serverUrlRequired": "Server URL is required"
+ },
+ "fields": {
+ "environmentVariables": "Environment Variables",
+ "headers": "Headers",
+ "npmPackage": "npm Package",
+ "scope": "Scope",
+ "serverName": "Server Name",
+ "serverUrl": "Server URL",
+ "transport": "Transport",
+ "transportType": "Transport Type",
+ "versionOptional": "Version (optional)"
+ },
+ "title": "Add Custom MCP Server",
+ "transport": {
+ "httpSse": "HTTP / SSE",
+ "stdio": "Stdio (npm)"
+ },
+ "placeholders": {
+ "headerName": "Header-Name",
+ "envVarName": "ENV_VAR_NAME",
+ "serverName": "my-server",
+ "latest": "latest",
+ "value": "value",
+ "serverUrl": "https://api.example.com/mcp"
+ }
+ },
+ "mcpDetail": {
+ "auth": {
+ "remoteMayNeedHeaders": "Remote MCP servers may still require custom headers or API keys even when the registry does not describe them. If connection fails after install, check the provider docs.",
+ "required": "This server requires authentication"
+ },
+ "diagnostics": {
+ "launchTarget": "Launch Target"
+ },
+ "form": {
+ "autoFilled": "Auto-filled",
+ "environmentVariables": "Environment Variables",
+ "headers": "Headers",
+ "scope": "Scope",
+ "serverName": "Server Name"
+ },
+ "install": {
+ "httpTransport": "HTTP: {{transport}}",
+ "manualSetupDescription": "This server requires manual setup. Check the repository for installation instructions.",
+ "manualSetupRequired": "Manual setup required",
+ "npmPackage": "npm: {{package}}",
+ "manage": "Manage Installation",
+ "install": "Install Server"
+ },
+ "links": {
+ "glama": "Glama",
+ "repository": "Repository",
+ "website": "Website"
+ },
+ "metadata": {
+ "author": "Author",
+ "githubStars": "GitHub Stars",
+ "hosting": "Hosting",
+ "installType": "Install Type",
+ "license": "License",
+ "published": "Published",
+ "source": "Source",
+ "updated": "Updated",
+ "version": "Version"
+ },
+ "scope": {
+ "local": "Local",
+ "project": "Project"
+ },
+ "tools": {
+ "title": "Tools ({{count}})",
+ "title_few": "Tools ({{count}})",
+ "title_many": "Tools ({{count}})",
+ "title_one": "Tools ({{count}})",
+ "title_other": "Tools ({{count}})"
+ },
+ "placeholders": {
+ "serverName": "my-server"
+ }
+ },
+ "skillEditor": {
+ "actions": {
+ "cancel": "Cancel",
+ "createSkill": "Create Skill",
+ "preparing": "Preparing...",
+ "reviewAndCreate": "Review And Create",
+ "reviewAndSave": "Review And Save",
+ "saveSkill": "Save Skill"
+ },
+ "advanced": {
+ "customDescription": "This skill uses a custom markdown format, so edit it directly here.",
+ "customTitle": "2. SKILL.md editor",
+ "description": "Most people can skip this. Open it only if you want direct control over the raw markdown file.",
+ "hide": "Hide Advanced Editor",
+ "resetFromStructuredFields": "Reset From Structured Fields",
+ "show": "Show Advanced Editor",
+ "title": "4. Advanced SKILL.md editor"
+ },
+ "basics": {
+ "description": "Give this skill a clear name, choose who can use it, and decide where it should live.",
+ "title": "1. Basics"
+ },
+ "description": {
+ "create": "Describe the workflow in plain language, review the files that will be created, then save it.",
+ "edit": "Update this skill, review the resulting file changes, then save it."
+ },
+ "extraFiles": {
+ "addedFiles": "Added files:",
+ "assets": "Assets",
+ "assetsDescription": "Add screenshots or bundled media only if they help explain the workflow.",
+ "description": "Add supporting docs, scripts, or assets only if this skill really needs them.",
+ "lockedForEdits": "Root and folder are locked for edits",
+ "optionalDescription": "Add starter files that will be included in the review and written together with `SKILL.md`.",
+ "optionalTitle": "Optional files",
+ "references": "References",
+ "referencesDescription": "Add supporting docs, links, or examples the runtime can look at.",
+ "scripts": "Scripts",
+ "scriptsDescription": "Add helper commands or setup notes. Review carefully before sharing this skill.",
+ "title": "3. Extra files"
+ },
+ "fields": {
+ "compatibility": "Compatibility",
+ "description": "Description",
+ "folderName": "Folder name",
+ "folderNameHint": "We suggest this automatically from the skill name so review works right away.",
+ "invocation": "How it should be used",
+ "license": "License",
+ "name": "Skill name",
+ "notes": "Extra notes or guardrails",
+ "root": "Where to store it",
+ "scope": "Who can use it",
+ "steps": "Main steps to follow",
+ "whenToUse": "When to reach for this"
+ },
+ "instructions": {
+ "description": "These sections generate the skill file for you, so you do not need to edit markdown unless you want to.",
+ "locked": "Structured fields are locked because you switched to manual `SKILL.md` editing below.",
+ "title": "2. Instructions"
+ },
+ "invocation": {
+ "auto": "Can be used automatically",
+ "manualOnly": "Only when you ask for it"
+ },
+ "placeholders": {
+ "description": "What this skill helps with",
+ "name": "Write concise skill name",
+ "notes": "Example: Call out missing tests, regressions, and risky assumptions.",
+ "steps": "1. Inspect the relevant files.\n2. Explain the main risk first.\n3. Suggest the safest fix.",
+ "whenToUse": "Example: Use this when the task is a code review or bug triage request.",
+ "license": "MIT",
+ "compatibility": "claude-code, cursor"
+ },
+ "review": {
+ "creating": "Creating a skill",
+ "hint": "Review the file changes first, then confirm save in the next step.",
+ "saving": "Saving this skill"
+ },
+ "root": {
+ "codexOnly": " - Codex only",
+ "shared": " - Shared"
+ },
+ "scope": {
+ "project": "Project: {{project}}",
+ "projectUnavailable": "Project unavailable",
+ "user": "User"
+ },
+ "title": {
+ "create": "Create skill",
+ "edit": "Edit skill"
+ }
+ },
+ "skillDetail": {
+ "actions": {
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "deleteSkill": "Delete Skill",
+ "deleting": "Deleting...",
+ "editSkill": "Edit Skill",
+ "openFolder": "Open Folder",
+ "openSkillFile": "Open SKILL.md",
+ "retry": "Retry"
+ },
+ "badges": {
+ "assets": "Assets",
+ "autoUse": "Auto use",
+ "hasScripts": "Has scripts",
+ "manualUse": "Manual use",
+ "references": "References",
+ "storedIn": "Stored in {{root}}"
+ },
+ "deleteDialog": {
+ "description": "Delete this skill and move it to Trash?",
+ "descriptionWithName": "Delete \"{{name}}\" and move it to Trash? You can restore it later from Trash if needed.",
+ "title": "Delete skill?"
+ },
+ "descriptionFallback": "Inspect discovered skill metadata and raw instructions.",
+ "errors": {
+ "deleteFailed": "Failed to delete skill",
+ "loadFailed": "Unable to load this skill."
+ },
+ "files": {
+ "advancedDetails": "Advanced file details",
+ "assets": "Assets",
+ "references": "References",
+ "scripts": "Scripts",
+ "storedAt": "Stored at"
+ },
+ "includes": {
+ "assets": "assets",
+ "instructionsOnly": "Just the skill instructions",
+ "references": "references",
+ "scripts": "scripts"
+ },
+ "invocation": {
+ "auto": "Runs automatically when it matches the task.",
+ "manualOnly": "Only runs when you explicitly ask for it."
+ },
+ "issues": {
+ "bundledScripts": "This skill includes bundled scripts",
+ "reviewCarefully": "Review this skill carefully before using it"
+ },
+ "loading": "Loading skill details...",
+ "scope": {
+ "personal": "Your personal skills",
+ "projectOnly": "This project only"
+ },
+ "summary": {
+ "howUsed": "How it is used",
+ "included": "What comes with it",
+ "whoCanUse": "Who can use it"
+ },
+ "titleFallback": "Skill details"
+ },
+ "skillsPanel": {
+ "actions": {
+ "createSkill": "Create Skill",
+ "import": "Import"
+ },
+ "badges": {
+ "assets": "Assets",
+ "hasScripts": "Has scripts",
+ "needsAttention": "Needs attention",
+ "references": "References",
+ "storedIn": "Stored in {{root}}"
+ },
+ "configuredRuntime": "the configured runtime",
+ "counts": {
+ "codexOnly": "{{count}} Codex only",
+ "personal": "{{count}} personal",
+ "project": "{{count}} project",
+ "shared": "{{count}} shared",
+ "total": "{{count}} total",
+ "codexOnly_few": "{{count}} Codex only",
+ "codexOnly_many": "{{count}} Codex only",
+ "codexOnly_one": "{{count}} Codex only",
+ "codexOnly_other": "{{count}} Codex only",
+ "personal_few": "{{count}} personal",
+ "personal_many": "{{count}} personal",
+ "personal_one": "{{count}} personal",
+ "personal_other": "{{count}} personal",
+ "project_few": "{{count}} project",
+ "project_many": "{{count}} project",
+ "project_one": "{{count}} project",
+ "project_other": "{{count}} project",
+ "shared_few": "{{count}} shared",
+ "shared_many": "{{count}} shared",
+ "shared_one": "{{count}} shared",
+ "shared_other": "{{count}} shared",
+ "total_few": "{{count}} total",
+ "total_many": "{{count}} total",
+ "total_one": "{{count}} total",
+ "total_other": "{{count}} total"
+ },
+ "empty": {
+ "noMatches": "No skills match your search",
+ "noMatchesDescription": "Try a different search term or switch filters.",
+ "noSkills": "No skills yet",
+ "noSkillsDescription": "Create your first skill to teach a repeatable workflow, or import one you already use."
+ },
+ "filters": {
+ "all": "All skills",
+ "codexOnly": "Codex only",
+ "hasScripts": "Has scripts",
+ "needsAttention": "Needs attention",
+ "personal": "Personal",
+ "project": "Project",
+ "shared": "Shared"
+ },
+ "hero": {
+ "codexAvailable": "Use `.codex` when a skill should stay Codex-only.",
+ "codexUnavailable": "Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.",
+ "description": "Skills are reusable instructions that help the runtime handle the same kind of task more consistently.",
+ "guidance": "Use personal skills for habits you want everywhere. Use project skills for workflows that only make sense inside one codebase.",
+ "personalContext": "You are seeing only your personal skills right now.",
+ "projectContext": "You are seeing skills for {{project}} plus your personal skills.",
+ "title": "Teach repeatable work"
+ },
+ "invocation": {
+ "auto": "Runs automatically when it fits",
+ "manualOnly": "Only runs when you explicitly ask for it"
+ },
+ "loading": {
+ "loading": "Loading skills...",
+ "refreshing": "Refreshing skills..."
+ },
+ "runtimeAudience": "Shared skills in `.claude`, `.cursor`, and `.agents` are available to {{audience}}. Skills stored in `.codex` stay Codex-only when Codex support is available.",
+ "scope": {
+ "project": "This project",
+ "user": "Personal"
+ },
+ "searchPlaceholder": "Search by skill name or what it helps with...",
+ "sections": {
+ "personal": {
+ "description": "Habits and instructions you want available everywhere.",
+ "title": "Personal skills"
+ },
+ "project": {
+ "description": "Workflows that only make sense for this codebase.",
+ "title": "Project skills"
+ }
+ },
+ "sort": {
+ "label": "Sort skills",
+ "name": "Name",
+ "recent": "Recent"
+ },
+ "status": {
+ "hasScripts": "Includes scripts, so review it carefully",
+ "needsAttention": "Needs attention before you rely on it",
+ "ready": "Ready to use"
+ },
+ "success": {
+ "created": "Skill created successfully.",
+ "imported": "Skill imported successfully.",
+ "saved": "Skill saved successfully."
+ }
+ },
+ "pluginDetail": {
+ "unknown": "Unknown",
+ "metadata": {
+ "author": "Author",
+ "category": "Category",
+ "source": "Source",
+ "version": "Version",
+ "capabilities": "Capabilities",
+ "installs": "Installs"
+ },
+ "scope": {
+ "label": "Scope:",
+ "options": {
+ "user": "User (global)",
+ "project": "Project (shared)",
+ "local": "Local (gitignored)"
+ }
+ },
+ "links": {
+ "homepage": "Homepage",
+ "contact": "Contact"
+ },
+ "readme": {
+ "loading": "Loading README...",
+ "empty": "No README available."
+ }
+ },
+ "skillImport": {
+ "title": "Import skill",
+ "description": "Pick an existing skill folder, review what will be copied, then import it into one of your supported skill locations.",
+ "steps": {
+ "chooseFolder": {
+ "title": "1. Choose a skill folder",
+ "description": "This should be a folder that already contains a `SKILL.md`, `Skill.md`, or `skill.md` file."
+ },
+ "location": {
+ "title": "2. Decide where it belongs",
+ "description": "Personal skills work everywhere. Project skills only show up for one codebase."
+ }
+ },
+ "fields": {
+ "sourceFolder": "Source folder",
+ "destinationFolderName": "Destination folder name",
+ "audience": "Who can use it",
+ "storage": "Where to store it"
+ },
+ "placeholders": {
+ "defaultFolderName": "Defaults to source folder name"
+ },
+ "actions": {
+ "browse": "Browse",
+ "cancel": "Cancel",
+ "preparing": "Preparing...",
+ "reviewAndImport": "Review And Import",
+ "importSkill": "Import Skill",
+ "backToImport": "Back To Import"
+ },
+ "scope": {
+ "user": "User",
+ "project": "Project: {{project}}",
+ "projectUnavailable": "Project unavailable"
+ },
+ "rootSuffix": {
+ "codexOnly": " - Codex only",
+ "shared": " - Shared"
+ },
+ "reviewHint": "Review the copied files first, then confirm the import in the next step.",
+ "reviewLabel": "Importing this skill",
+ "errors": {
+ "missingSkillFile": "This folder does not look like a skill yet. It needs a SKILL.md, Skill.md, or skill.md file.",
+ "symbolicLinks": "This folder contains symbolic links. Import the real files instead of links.",
+ "tooManyFiles": "This skill folder is too large to import at once. Remove extra files and try again.",
+ "tooLarge": "This skill folder is too large to import safely. Trim large assets and try again.",
+ "invalidFolderName": "Pick a simpler destination folder name using letters, numbers, dots, dashes, or underscores.",
+ "mustBeDirectory": "Choose a folder to import, not a single file.",
+ "reviewFailed": "Failed to review import changes",
+ "importFailed": "Failed to import skill"
+ }
+ },
+ "mcpPanel": {
+ "sort": {
+ "nameAsc": "Name A→Z",
+ "nameDesc": "Name Z→A",
+ "toolsDesc": "Most tools"
+ },
+ "health": {
+ "title": "MCP Health Status",
+ "checkingViaRuntime": "Checking installed MCP servers via {{runtime}} ...",
+ "lastChecked": "Last checked {{time}}",
+ "description": "Run diagnostics from this page to verify installed MCP connectivity.",
+ "checking": "Checking...",
+ "checkStatus": "Check Status"
+ },
+ "diagnostics": {
+ "title": "Runtime MCP Diagnostics",
+ "serversCount": "{{count}} servers",
+ "serversCount_one": "{{count}} server",
+ "serversCount_other": "{{count}} servers",
+ "waiting": "Waiting for diagnostics results...",
+ "disableReasons": {
+ "checkingRuntimeStatus": "Checking runtime status...",
+ "checkingRuntimeAvailability": "Checking runtime availability...",
+ "runtimeFailedToStart": "The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.",
+ "runtimeRequired": "The configured runtime is required. Install or repair it from the Dashboard."
+ },
+ "serversCount_few": "{{count}} servers",
+ "serversCount_many": "{{count}} servers"
+ },
+ "searchPlaceholder": "Search MCP servers...",
+ "runtime": {
+ "notAvailable": "{{runtime}} not available",
+ "notInstalled": "{{runtime}} not installed",
+ "requiredDescription": "MCP health checks require {{runtime}}. Go to the Dashboard to install or repair it."
+ },
+ "empty": {
+ "searchTitle": "No servers found",
+ "title": "No MCP servers available",
+ "searchDescription": "Try a different search term",
+ "description": "Check back later for new servers"
+ },
+ "loadMore": "Load more"
+ },
+ "apiKeys": {
+ "description": "Securely store API keys for auto-filling when installing MCP servers.",
+ "storage": {
+ "osKeychain": "Keys are encrypted via {{backend}} and stored with restricted file permissions (owner-only).",
+ "localEncryption": "OS keychain unavailable - keys are encrypted locally with AES-256. For stronger protection, install a keyring service (gnome-keyring, kwallet)."
+ },
+ "actions": {
+ "add": "Add API Key",
+ "addFirst": "Add your first key",
+ "edit": "Edit"
+ },
+ "empty": {
+ "title": "No API keys saved",
+ "description": "Add keys to auto-fill environment variables when installing MCP servers."
+ },
+ "form": {
+ "addTitle": "Add API Key",
+ "editTitle": "Edit API Key",
+ "addDescription": "Store an API key for auto-filling in MCP server installations.",
+ "editDescription": "Update the key details. You must re-enter the value.",
+ "keychainUnavailable": "OS keychain unavailable - keys encrypted with AES-256 locally. Install gnome-keyring for OS-level protection.",
+ "name": "Name",
+ "namePlaceholder": "e.g. OpenAI Production",
+ "environmentVariableName": "Environment Variable Name",
+ "envVarPlaceholder": "e.g. OPENAI_API_KEY",
+ "value": "Value",
+ "reenterValue": "Re-enter key value",
+ "valuePlaceholder": "sk-...",
+ "scope": "Scope",
+ "userScopeLabel": "User (global)",
+ "projectScopeLabel": "Project: {{project}}",
+ "projectUnavailable": "Project unavailable",
+ "boundTo": "Bound to {{path}}",
+ "cancel": "Cancel",
+ "saving": "Saving...",
+ "update": "Update",
+ "save": "Save",
+ "errors": {
+ "invalidEnvVarFormat": "Use letters, digits, underscores. Must start with a letter or underscore.",
+ "nameRequired": "Name is required",
+ "envVarRequired": "Environment variable name is required",
+ "invalidEnvVar": "Invalid environment variable name",
+ "valueRequired": "Key value is required",
+ "projectScopeRequiresProject": "Project-scoped API keys require an active project",
+ "saveFailed": "Failed to save"
+ }
+ }
+ },
+ "skillReview": {
+ "title": "Review skill changes",
+ "description": "{{reviewLabel}} previews the filesystem changes first. Nothing is written until you confirm below.",
+ "noPreview": "No preview available.",
+ "confirmPromptPrefix": "Review the diff below, then use",
+ "confirmPromptSuffix": "to apply these changes.",
+ "noChanges": "No file changes detected yet.",
+ "binaryBadge": "binary",
+ "binaryPreviewHidden": "Binary file preview is not shown. The file will be copied as-is.",
+ "summary": {
+ "fileChanges": "{{count}} file changes",
+ "fileChanges_one": "{{count}} file change",
+ "fileChanges_other": "{{count}} file changes",
+ "new": "{{count}} new",
+ "updated": "{{count}} updated",
+ "removed": "{{count}} removed",
+ "binary": "{{count}} binary",
+ "fileChanges_few": "{{count}} file changes",
+ "fileChanges_many": "{{count}} file changes"
+ }
+ },
+ "mcpCard": {
+ "toolsCount": "{{count}} tools",
+ "toolsCount_one": "{{count}} tool",
+ "toolsCount_other": "{{count}} tools",
+ "envCount": "{{count}} envs",
+ "envCount_one": "{{count}} env",
+ "envCount_other": "{{count}} envs",
+ "auth": "Auth",
+ "byAuthor": "by {{author}}",
+ "hosting": {
+ "remote": "Remote",
+ "local": "Local",
+ "both": "Both"
+ },
+ "toolsCount_few": "{{count}} tools",
+ "toolsCount_many": "{{count}} tools",
+ "envCount_few": "{{count}} envs",
+ "envCount_many": "{{count}} envs",
+ "repository": "Repository",
+ "website": "Website"
+ },
+ "installButton": {
+ "installing": "Installing...",
+ "removing": "Removing...",
+ "done": "Done",
+ "retry": "Retry",
+ "uninstall": "Uninstall",
+ "install": "Install"
+ },
+ "pluginCard": {
+ "official": "Official"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/report.json b/src/features/localization/renderer/locales/en/report.json
new file mode 100644
index 00000000..6a68920e
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/report.json
@@ -0,0 +1,217 @@
+{
+ "cost": {
+ "breakdownTitle": "Cost Breakdown (per 1M tokens)",
+ "cacheRead": "Cache Read",
+ "cacheWrite": "Cache Write",
+ "cost": "Cost",
+ "input": "Input",
+ "noCommits": "no commits",
+ "noLinesChanged": "no lines changed",
+ "output": "Output",
+ "parent": "Parent: {{cost}}",
+ "parentCost": "Parent Cost",
+ "perCommit": "Per Commit",
+ "perCommitFormula": "total cost ÷ {{count}} commit",
+ "perCommitFormula_few": "total cost ÷ {{count}} commits",
+ "perCommitFormula_many": "total cost ÷ {{count}} commits",
+ "perCommitFormula_one": "total cost ÷ {{count}} commit",
+ "perCommitFormula_other": "total cost ÷ {{count}} commits",
+ "perLineChanged": "Per Line Changed",
+ "perLineFormula": "total cost ÷ {{count}} line",
+ "perLineFormula_few": "total cost ÷ {{count}} lines",
+ "perLineFormula_many": "total cost ÷ {{count}} lines",
+ "perLineFormula_one": "total cost ÷ {{count}} line",
+ "perLineFormula_other": "total cost ÷ {{count}} lines",
+ "subagent": "Subagent: {{cost}}",
+ "subagentCost": "Subagent Cost",
+ "title": "Cost Analysis",
+ "total": "Total"
+ },
+ "insights": {
+ "agent": "agent",
+ "agent_few": "agents",
+ "agent_many": "agents",
+ "agent_one": "agent",
+ "agent_other": "agents",
+ "agentTree": "Agent Tree ({{count}} {{unit}})",
+ "background": "(background)",
+ "bashCommands": "Bash Commands",
+ "outOfScopeFindings": "Out-of-Scope Findings ({{count}})",
+ "questionsAsked": "Questions Asked ({{count}})",
+ "repeated": "Repeated",
+ "skillsInvoked": "Skills Invoked ({{count}})",
+ "taskDispatches": "Task Dispatches ({{count}})",
+ "tasksCreated": "Tasks Created ({{count}})",
+ "teamMode": "Team Mode",
+ "teams": "Teams: {{teams}}",
+ "title": "Session Insights",
+ "total": "Total",
+ "unique": "Unique",
+ "skillsInvoked_few": "Skills Invoked ({{count}})",
+ "skillsInvoked_many": "Skills Invoked ({{count}})",
+ "skillsInvoked_one": "Skills Invoked ({{count}})",
+ "skillsInvoked_other": "Skills Invoked ({{count}})",
+ "taskDispatches_few": "Task Dispatches ({{count}})",
+ "taskDispatches_many": "Task Dispatches ({{count}})",
+ "taskDispatches_one": "Task Dispatches ({{count}})",
+ "taskDispatches_other": "Task Dispatches ({{count}})",
+ "tasksCreated_few": "Tasks Created ({{count}})",
+ "tasksCreated_many": "Tasks Created ({{count}})",
+ "tasksCreated_one": "Tasks Created ({{count}})",
+ "tasksCreated_other": "Tasks Created ({{count}})",
+ "questionsAsked_few": "Questions Asked ({{count}})",
+ "questionsAsked_many": "Questions Asked ({{count}})",
+ "questionsAsked_one": "Questions Asked ({{count}})",
+ "questionsAsked_other": "Questions Asked ({{count}})",
+ "agentTree_few": "Agent Tree ({{count}} {{unit}})",
+ "agentTree_many": "Agent Tree ({{count}} {{unit}})",
+ "agentTree_one": "Agent Tree ({{count}} {{unit}})",
+ "agentTree_other": "Agent Tree ({{count}} {{unit}})",
+ "outOfScopeFindings_few": "Out-of-Scope Findings ({{count}})",
+ "outOfScopeFindings_many": "Out-of-Scope Findings ({{count}})",
+ "outOfScopeFindings_one": "Out-of-Scope Findings ({{count}})",
+ "outOfScopeFindings_other": "Out-of-Scope Findings ({{count}})",
+ "keyTakeaways": "Key Takeaways"
+ },
+ "quality": {
+ "chars": "chars",
+ "corrections": "Corrections",
+ "failed": "failed",
+ "fileReadRedundancy": "File Read Redundancy",
+ "firstMessage": "First Message",
+ "firstRun": "First Run",
+ "frictionRate": "Friction Rate",
+ "lastRun": "Last Run",
+ "messagesBeforeWork": "Messages Before Work",
+ "passed": "passed",
+ "promptQuality": "Prompt Quality",
+ "readsPerUniqueFile": "Reads/Unique File",
+ "snapshot": "snapshot",
+ "snapshot_few": "snapshots",
+ "snapshot_many": "snapshots",
+ "snapshot_one": "snapshot",
+ "snapshot_other": "snapshots",
+ "startupOverhead": "Startup Overhead",
+ "testProgression": "Test Progression",
+ "title": "Quality Signals",
+ "tokensBeforeWork": "Tokens Before Work",
+ "totalReads": "Total Reads",
+ "uniqueFiles": "Unique Files",
+ "userMessages": "User Messages",
+ "percentOfTotal": "% of Total"
+ },
+ "tokens": {
+ "apiCalls": "API Calls",
+ "cacheCreate": "Cache Create",
+ "cacheEfficiency": "Cache Efficiency",
+ "cacheRead": "Cache Read",
+ "cacheReadPct": "Cache Read %",
+ "coldStart": "Cold Start",
+ "cost": "Cost",
+ "input": "Input",
+ "model": "Model",
+ "no": "No",
+ "output": "Output",
+ "readWriteRatio": "R/W Ratio",
+ "title": "Token Usage",
+ "total": "Total",
+ "yes": "Yes"
+ },
+ "subagents": {
+ "title": "Subagents",
+ "metrics": {
+ "count": "Count",
+ "totalTokens": "Total Tokens",
+ "totalDuration": "Total Duration",
+ "totalCost": "Total Cost"
+ },
+ "table": {
+ "description": "Description",
+ "type": "Type",
+ "tokens": "Tokens",
+ "duration": "Duration",
+ "cost": "Cost"
+ }
+ },
+ "overview": {
+ "title": "Overview",
+ "yes": "Yes",
+ "no": "No",
+ "metrics": {
+ "duration": "Duration",
+ "messages": "Messages",
+ "contextUsage": "Context Usage",
+ "compactions": "Compactions",
+ "branch": "Branch",
+ "subagents": "Subagents",
+ "project": "Project",
+ "sessionId": "Session ID"
+ }
+ },
+ "timeline": {
+ "title": "Timeline & Activity",
+ "idleAnalysis": "Idle Analysis",
+ "metrics": {
+ "idleGaps": "Idle Gaps",
+ "totalIdle": "Total Idle",
+ "activeTime": "Active Time",
+ "idlePercent": "Idle %"
+ },
+ "modelSwitches": "Model Switches ({{count}})",
+ "modelSwitches_one": "Model Switches ({{count}})",
+ "modelSwitches_other": "Model Switches ({{count}})",
+ "messageNumber": "msg #{{number}}",
+ "keyEvents": "Key Events",
+ "modelSwitches_few": "Model Switches ({{count}})",
+ "modelSwitches_many": "Model Switches ({{count}})"
+ },
+ "tools": {
+ "title": "Tool Usage",
+ "summary": "{{formattedCount}} total calls across {{toolCount}} tools",
+ "columns": {
+ "tool": "Tool",
+ "calls": "Calls",
+ "errors": "Errors",
+ "successPercent": "Success %",
+ "health": "Health"
+ }
+ },
+ "git": {
+ "title": "Git Activity",
+ "commits": "Commits",
+ "pushes": "Pushes",
+ "linesAdded": "Lines Added",
+ "linesRemoved": "Lines Removed",
+ "branchesCreated": "Branches Created"
+ },
+ "friction": {
+ "title": "Friction Signals",
+ "rate": "Friction Rate: {{rate}}%",
+ "correctionsCount": "{{count}} corrections",
+ "correctionsCount_one": "{{count}} correction",
+ "corrections": "Corrections",
+ "thrashingSignals": "Thrashing Signals",
+ "repeatedBashCommands": "Repeated Bash Commands",
+ "reworkedFiles": "Reworked Files (3+ edits)",
+ "correctionsCount_few": "{{count}} corrections",
+ "correctionsCount_many": "{{count}} corrections",
+ "correctionsCount_other": "{{count}} corrections"
+ },
+ "errors": {
+ "title": "Errors",
+ "permissionDenied": "Permission Denied",
+ "messageIndex": "msg #{{index}}",
+ "input": "Input",
+ "error": "Error",
+ "count": "{{count}} errors",
+ "count_one": "{{count}} error",
+ "permissionDenialCount": "{{count}} permission denials",
+ "permissionDenialCount_one": "{{count}} permission denial",
+ "count_few": "{{count}} errors",
+ "count_many": "{{count}} errors",
+ "count_other": "{{count}} errors",
+ "permissionDenialCount_few": "{{count}} permission denials",
+ "permissionDenialCount_many": "{{count}} permission denials",
+ "permissionDenialCount_other": "{{count}} permission denials"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/settings.json b/src/features/localization/renderer/locales/en/settings.json
new file mode 100644
index 00000000..e9b1876b
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/settings.json
@@ -0,0 +1,983 @@
+{
+ "tabs": {
+ "advanced": {
+ "description": "Power-user options: export/import config, reset defaults, and raw configuration editing.",
+ "label": "Advanced"
+ },
+ "general": {
+ "description": "Core app preferences like theme, language, display density, and startup behavior.",
+ "label": "General"
+ },
+ "infoAriaLabel": "What is {{label}}?",
+ "notifications": {
+ "description": "Control when and how you get notified about agent activity, task completions, and errors.",
+ "label": "Notifications"
+ }
+ },
+ "view": {
+ "description": "Manage your app preferences",
+ "loading": "Loading settings...",
+ "title": "Settings"
+ },
+ "runtimeProvider": {
+ "actions": {
+ "cancel": "Cancel",
+ "test": "Test"
+ },
+ "defaults": {
+ "allProjects": "All projects",
+ "allProjectsHint": "Tests use {{project}}. Default applies unless a project has an override.",
+ "loadingContexts": "Loading contexts...",
+ "projectHint": "Saving overrides only {{project}}.",
+ "projectOverrideContext": "Project override context",
+ "scopeDescriptionAllProjects": "Default for every project that does not have its own OpenCode override.",
+ "scopeDescriptionProject": "Override only the selected project. Running teams are not changed.",
+ "selectProjectContext": "Select project context",
+ "selectProjectHint": "Select a project before testing local models or saving defaults.",
+ "selectValidationContext": "Select validation context",
+ "setAllProjectsDefault": "Set all-projects default",
+ "setProjectDefault": "Set project default",
+ "thisProject": "This project",
+ "title": "OpenCode defaults",
+ "validationContext": "Validation context"
+ },
+ "diagnostics": {
+ "copied": "Diagnostics copied",
+ "copiedShort": "Copied",
+ "copy": "Copy diagnostics",
+ "hints": "Hints",
+ "likelyCause": "Likely cause:"
+ },
+ "models": {
+ "alreadyDefault": "This is already the selected OpenCode default.",
+ "empty": "No models found.",
+ "emptyFree": "No free models found.",
+ "emptyRecommended": "No recommended models found.",
+ "emptyRecommendedFree": "No recommended free models found.",
+ "freeOnly": "Free only",
+ "launchableDescription": "Routes you can test or use in the team picker: local config, free built-in models, and current default.",
+ "launchableTitle": "Launchable OpenCode models",
+ "loadingRoutes": "Loading OpenCode model routes...",
+ "noRoutesMatch": "No OpenCode model routes match \"{{query}}\".",
+ "noneReported": "No launchable OpenCode model routes were reported yet. Configure a local route in OpenCode or use the Providers tab to inspect catalog providers.",
+ "recommendedOnly": "Recommended only",
+ "searchPlaceholder": "Search models",
+ "selectProjectBeforeTesting": "Select a project context before testing models.",
+ "selectProjectBeforeTestingDefaults": "Select a project context before testing or saving OpenCode defaults.",
+ "useInTeamPicker": "Use in team picker"
+ },
+ "providers": {
+ "catalog": "OpenCode provider catalog",
+ "countFallback": "OpenCode providers",
+ "description": "{{count}}. Connected and recommended providers are shown first.",
+ "loadMore": "Load more providers",
+ "loading": "Loading OpenCode providers",
+ "noMatches": "No providers match that search.",
+ "noneReported": "No OpenCode providers reported by the managed runtime.",
+ "recommended": "Recommended",
+ "refreshCatalog": "Refresh catalog",
+ "searchPlaceholder": "Search providers",
+ "description_few": "{{count}}. Connected and recommended providers are shown first.",
+ "description_many": "{{count}}. Connected and recommended providers are shown first.",
+ "description_one": "{{count}}. Connected and recommended providers are shown first.",
+ "description_other": "{{count}}. Connected and recommended providers are shown first."
+ },
+ "setup": {
+ "loading": "Loading provider setup..."
+ },
+ "summary": {
+ "defaultModel": "OpenCode default: {{model}}",
+ "loading": "Loading managed OpenCode runtime, connected providers, and model defaults...",
+ "source": "Source: {{source}}",
+ "title": "OpenCode runtime"
+ },
+ "tabs": {
+ "models": "Models",
+ "providers": "Providers"
+ },
+ "modelRoutes": {
+ "searchPlaceholder": "Search model routes"
+ },
+ "badges": {
+ "usedInTeamPicker": "Used in team picker",
+ "free": "free",
+ "local": "local",
+ "configured": "configured",
+ "connected": "connected",
+ "verified": "verified",
+ "needsTest": "needs test",
+ "failed": "failed",
+ "unknown": "unknown",
+ "default": "default"
+ },
+ "compatibleEndpoint": {
+ "baseUrlPlaceholder": "http://localhost:1234"
+ }
+ },
+ "general": {
+ "agentLanguage": {
+ "description": "Language for agent communication",
+ "descriptionWithDetected": "Language for agent communication (detected: {{detected}})",
+ "emptyMessage": "No language found.",
+ "label": "Language",
+ "searchPlaceholder": "Search language...",
+ "selectPlaceholder": "Select language...",
+ "title": "Agent Language"
+ },
+ "appLanguage": {
+ "description": "Language for the application interface.",
+ "label": "Language",
+ "title": "App Language"
+ },
+ "appearance": {
+ "autoExpandAIGroups": {
+ "description": "Automatically expand each response turn when opening a transcript or receiving a new message",
+ "label": "Expand AI responses by default"
+ },
+ "nativeTitleBar": {
+ "description": "Use the default system window frame instead of the custom title bar",
+ "label": "Use native title bar",
+ "restartConfirm": {
+ "confirmLabel": "Restart",
+ "message": "The app needs to restart to apply the title bar change. Restart now?",
+ "title": "Restart required"
+ }
+ },
+ "theme": {
+ "description": "Choose your preferred color theme",
+ "label": "Theme",
+ "options": {
+ "dark": "Dark",
+ "light": "Light",
+ "system": "System"
+ }
+ },
+ "title": "Appearance"
+ },
+ "browserAccess": {
+ "serverMode": {
+ "description": "Start an HTTP server to access the UI from a browser or embed in iframes",
+ "label": "Enable server mode"
+ },
+ "title": "Browser Access"
+ },
+ "localClaudeRoot": {
+ "actions": {
+ "selectFolder": "Select Folder",
+ "selectFolderManually": "Select Folder Manually",
+ "useAutoDetect": "Use Auto-Detect",
+ "useFolder": "Use Folder",
+ "usePath": "Use Path",
+ "useThisPath": "Use This Path",
+ "useWsl": "Using Linux/WSL?"
+ },
+ "confirm": {
+ "noProjectsDir": {
+ "message": "This folder does not contain a \"projects\" directory. Continue anyway?",
+ "title": "No projects directory found"
+ },
+ "notClaudeDir": {
+ "message": "This folder is named \"{{folderName}}\", not \".claude\". Continue anyway?",
+ "title": "Selected folder is not .claude"
+ },
+ "noWslPaths": {
+ "message": "Could not find WSL distros with Claude data automatically. Select folder manually?",
+ "title": "No WSL Claude paths found"
+ },
+ "wslNoProjectsDir": {
+ "message": "\"{{path}}\" does not contain a \"projects\" directory. Continue anyway?",
+ "title": "WSL path missing projects directory"
+ }
+ },
+ "current": {
+ "autoDetected": "Auto-detected: {{path}}",
+ "autoDetectedPath": "Using auto-detected path",
+ "customPath": "Using custom path",
+ "label": "Current Local Root"
+ },
+ "description": "Choose which local folder is treated as your Claude data root",
+ "errors": {
+ "detectWslFailed": "Failed to detect WSL Claude root paths",
+ "loadFailed": "Failed to load local Claude root settings",
+ "updateFailed": "Failed to update Claude root"
+ },
+ "title": "Local Claude Root",
+ "wslModal": {
+ "closeAriaLabel": "Close WSL path modal",
+ "description": "Detected WSL distributions and Claude root candidates",
+ "noProjectsDir": "No projects directory detected",
+ "title": "Select WSL Claude Root"
+ }
+ },
+ "privacy": {
+ "telemetry": {
+ "description": "Help improve the app by sending anonymous crash and performance data",
+ "label": "Send crash reports"
+ },
+ "title": "Privacy"
+ },
+ "server": {
+ "runningOn": "Running on",
+ "standaloneModeDescription": "Running in standalone mode. The HTTP server is always active. System notifications are not available - notification triggers are logged in-app only.",
+ "title": "Server"
+ },
+ "startup": {
+ "launchAtLogin": {
+ "description": "Automatically start the app when you log in",
+ "label": "Launch at login"
+ },
+ "showDockIcon": {
+ "description": "Display the app icon in the dock (macOS)",
+ "label": "Show dock icon"
+ },
+ "title": "Startup"
+ }
+ },
+ "notifications": {
+ "dev": {
+ "descriptionPrefix": "Notifications may not work in development mode. macOS identifies the app as \"Electron\" (bundle ID",
+ "descriptionSuffix": ") instead of the production app name. Check System Settings > Notifications > Electron to verify permissions.",
+ "title": "Dev Mode"
+ },
+ "ignoredRepositories": {
+ "description": "Notifications from these repositories will be ignored",
+ "empty": "No repositories ignored",
+ "selectPlaceholder": "Select repository to ignore...",
+ "title": "Ignored Repositories"
+ },
+ "settings": {
+ "enabled": {
+ "description": "Show system notifications for errors and events",
+ "label": "Enable System Notifications"
+ },
+ "sound": {
+ "description": "Play a sound when notifications appear",
+ "label": "Play sound"
+ },
+ "subagentErrors": {
+ "description": "Detect and notify about errors in subagent sessions",
+ "label": "Include subagent errors"
+ },
+ "title": "Notification Settings"
+ },
+ "snooze": {
+ "clear": "Clear Snooze",
+ "description": "Temporarily pause notifications",
+ "descriptionWithTime": "Snoozed until {{time}}",
+ "label": "Snooze notifications",
+ "options": {
+ "15": "15 minutes",
+ "30": "30 minutes",
+ "60": "1 hour",
+ "120": "2 hours",
+ "240": "4 hours",
+ "-1": "Until tomorrow"
+ },
+ "selectDuration": "Select duration..."
+ },
+ "taskCompletion": {
+ "description": "Get native OS notifications when Claude finishes tasks - sounds, banners, and Dock/taskbar badges. Works on macOS, Linux, and Windows.",
+ "installPlugin": "Install claude-notifications-go plugin",
+ "title": "Task Completion Notifications"
+ },
+ "team": {
+ "allTasksCompleted": {
+ "description": "Notify when every task in a team reaches completed status",
+ "label": "All tasks completed"
+ },
+ "autoResumeOnRateLimit": {
+ "description": "When Claude reports a reset time, schedule a follow-up nudge for the team lead after the limit resets",
+ "label": "Auto-resume after rate limit"
+ },
+ "clarifications": {
+ "description": "Show native OS notifications when a task needs your input",
+ "label": "Task clarification notifications"
+ },
+ "crossTeamMessage": {
+ "description": "Notify when a message arrives from another team",
+ "label": "Cross-team message notifications"
+ },
+ "leadInbox": {
+ "description": "Notify when teammates send messages to the team lead",
+ "label": "Lead inbox notifications"
+ },
+ "statusChange": {
+ "description": "Show native OS notifications when a task's status changes",
+ "label": "Task status change notifications",
+ "onlySolo": {
+ "description": "Notify only when the team has no teammates",
+ "label": "Only in Solo mode"
+ },
+ "statuses": {
+ "description": "Which target statuses trigger a notification",
+ "label": "Notify on these statuses",
+ "options": {
+ "approved": "Approved",
+ "completed": "Completed",
+ "deleted": "Deleted",
+ "in_progress": "Started",
+ "needsFix": "Needs Fixes",
+ "pending": "Pending",
+ "review": "Review"
+ }
+ }
+ },
+ "taskComments": {
+ "description": "Show native OS notifications when agents comment on tasks",
+ "label": "Task comment notifications"
+ },
+ "taskCreated": {
+ "description": "Show native OS notifications when a new task is created",
+ "label": "Task created notifications"
+ },
+ "teamLaunched": {
+ "description": "Notify when a team finishes launching and is ready",
+ "label": "Team launched notifications"
+ },
+ "title": "Team Notifications",
+ "toolApproval": {
+ "description": "Notify when a tool needs your approval (Allow/Deny) while the app is not focused",
+ "label": "Tool approval notifications"
+ },
+ "userInbox": {
+ "description": "Notify when teammates send messages to you",
+ "label": "User inbox notifications"
+ }
+ },
+ "test": {
+ "action": "Send Test",
+ "description": "Send a test notification to verify delivery",
+ "failedToSend": "Failed to send test notification",
+ "label": "Test notification",
+ "sending": "Sending...",
+ "sent": "Sent!",
+ "unknownError": "Unknown error"
+ }
+ },
+ "advanced": {
+ "about": {
+ "appIconAlt": "App icon",
+ "description": "Assemble AI agent teams that work autonomously in parallel, communicate across teams, and manage tasks on a kanban board - with built-in code review, live process monitoring, and full tool visibility.",
+ "standalone": "Standalone",
+ "title": "About",
+ "version": "Version {{version}}"
+ },
+ "configuration": {
+ "editConfig": "Edit Config",
+ "exportConfig": "Export Config",
+ "importConfig": "Import Config",
+ "openInEditor": "Open in Editor",
+ "resetToDefaults": "Reset to Defaults",
+ "title": "Configuration"
+ },
+ "updates": {
+ "available": "v{{version}} available",
+ "check": "Check for Updates",
+ "checking": "Checking...",
+ "ready": "Update ready",
+ "unknownVersion": "unknown",
+ "upToDate": "Up to date"
+ },
+ "appName": "Agent Teams AI"
+ },
+ "configEditor": {
+ "errors": {
+ "loadFailed": "Failed to load config",
+ "saveFailed": "Failed to save config"
+ },
+ "footer": {
+ "autoSave": "Changes auto-save after editing",
+ "toClose": "to close",
+ "escapeKey": "Esc"
+ },
+ "loading": "Loading config...",
+ "status": {
+ "invalidJson": "Invalid JSON",
+ "saveFailed": "Save failed",
+ "saved": "Saved",
+ "saving": "Saving..."
+ },
+ "title": "Edit Configuration"
+ },
+ "notificationTriggers": {
+ "add": {
+ "cancel": "Cancel",
+ "submit": "Add Trigger",
+ "title": "Add Custom Trigger"
+ },
+ "builtin": {
+ "description": "Default triggers that come with the application. You can enable or disable them and customize their patterns.",
+ "title": "Built-in Triggers"
+ },
+ "card": {
+ "builtinBadge": "Builtin",
+ "collapseAriaLabel": "Collapse",
+ "deleteAriaLabel": "Delete trigger",
+ "editNameAriaLabel": "Edit name",
+ "expandAriaLabel": "Expand"
+ },
+ "color": {
+ "customHexTitle": "Custom hex color",
+ "invalidHex": "Invalid hex"
+ },
+ "configuration": {
+ "alertIfGreaterThan": "Alert if >",
+ "emptyPatternHint": "Leave empty to match all content. Uses JavaScript regex syntax.",
+ "errorStatusDescription": "Triggers when a tool execution reports an error (is_error: true).",
+ "tokensUnit": "tokens",
+ "matchPatternPlaceholder": "e.g., error|failed|exception"
+ },
+ "custom": {
+ "description": "Create your own triggers to get notified for specific patterns or tool outputs.",
+ "empty": "No custom triggers configured yet.",
+ "title": "Custom Triggers"
+ },
+ "errors": {
+ "invalidRegexPattern": "Invalid regex pattern"
+ },
+ "fields": {
+ "contentType": "Content Type",
+ "matchField": "Match Field",
+ "matchPattern": "Match Pattern (Regex)",
+ "scopeToolName": "Scope / Tool Name",
+ "scopeToolNameOptional": "Scope / Tool Name (optional)",
+ "threshold": "Threshold",
+ "tokenType": "Token Type",
+ "triggerNamePlaceholder": "e.g., Build Failure Alert",
+ "triggerNameRequired": "Trigger Name *"
+ },
+ "ignorePatterns": {
+ "hint": "Press Enter to add. Notification is skipped if any pattern matches.",
+ "placeholder": "Add ignore regex...",
+ "removeAriaLabel": "Remove ignore pattern",
+ "summary": "Advanced: Exclusion Rules",
+ "title": "Ignore Patterns (skip if matches)"
+ },
+ "options": {
+ "contentTypes": {
+ "text": "Text Output",
+ "thinking": "Thinking",
+ "tool_result": "Tool Result",
+ "tool_use": "Tool Use"
+ },
+ "matchFields": {
+ "args": "Arguments",
+ "command": "Command",
+ "content": "Content",
+ "description": "Description",
+ "file_path": "File Path",
+ "fullInput": "Full Input (JSON)",
+ "glob": "Glob Filter",
+ "new_string": "New String",
+ "old_string": "Old String",
+ "path": "Path",
+ "pattern": "Pattern",
+ "prompt": "Prompt",
+ "query": "Query",
+ "skill": "Skill Name",
+ "subagent_type": "Subagent Type",
+ "text": "Text Content",
+ "thinking": "Thinking Content",
+ "url": "URL"
+ },
+ "modes": {
+ "content_match": "Content Pattern",
+ "error_status": "Execution Error",
+ "token_threshold": "High Token Usage"
+ },
+ "tokenTypes": {
+ "input": "Input Tokens",
+ "output": "Output Tokens",
+ "total": "Total Tokens"
+ },
+ "toolNames": {
+ "anyTool": "Any Tool"
+ }
+ },
+ "preview": {
+ "defaultTestTriggerName": "Test Trigger",
+ "detectedSuffix": "errors would have been detected",
+ "more": "...and {{count}} more",
+ "more_few": "...and {{count}} more",
+ "more_many": "...and {{count}} more",
+ "more_one": "...and {{count}} more",
+ "more_other": "...and {{count}} more",
+ "testTrigger": "Test Trigger",
+ "testing": "Testing...",
+ "title": "Preview",
+ "truncatedWarning": "Search stopped early (timeout or count limit). Actual matches may be higher.",
+ "viewSession": "View Session"
+ },
+ "repositoryScope": {
+ "empty": "No repositories selected - trigger applies to all repositories",
+ "hint": "When repositories are selected, this trigger only fires for errors in those repositories.",
+ "placeholder": "Select repository to add...",
+ "summary": "Advanced: Repository Scope",
+ "title": "Limit to Repositories (applies only to selected repositories)"
+ },
+ "sections": {
+ "configuration": "Configuration",
+ "dotColor": "Dot Color",
+ "generalInfo": "General Info",
+ "triggerCondition": "Trigger Condition"
+ }
+ },
+ "workspaceProfiles": {
+ "actions": {
+ "addProfile": "Add Profile",
+ "cancel": "Cancel",
+ "deleteProfile": "Delete profile",
+ "editProfile": "Edit profile",
+ "save": "Save"
+ },
+ "authMethods": {
+ "agent": "SSH Agent",
+ "auto": "Auto (from SSH Config)",
+ "password": "Password",
+ "privateKey": "Private Key"
+ },
+ "deleteConfirm": {
+ "confirmLabel": "Delete",
+ "message": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
+ "title": "Delete Profile"
+ },
+ "description": "Save SSH connection profiles for quick reconnection",
+ "empty": {
+ "description": "Add an SSH profile to connect quickly",
+ "title": "No saved profiles"
+ },
+ "form": {
+ "authentication": "Authentication",
+ "host": "Host",
+ "name": "Name",
+ "passwordPrompt": "You will be prompted for the password when connecting.",
+ "port": "Port",
+ "privateKeyPath": "Private Key Path",
+ "username": "Username",
+ "namePlaceholder": "My Server",
+ "hostPlaceholder": "hostname or IP",
+ "usernamePlaceholder": "user"
+ },
+ "loading": "Loading profiles...",
+ "title": "Workspace Profiles"
+ },
+ "connection": {
+ "actions": {
+ "connect": "Connect",
+ "connecting": "Connecting...",
+ "disconnect": "Disconnect",
+ "testConnection": "Test Connection",
+ "testing": "Testing..."
+ },
+ "currentMode": {
+ "description": "Data source for session files",
+ "label": "Current Mode",
+ "local": "Local ({{path}})"
+ },
+ "description": "Connect to a remote machine to view Claude Code sessions running there",
+ "form": {
+ "authentication": "Authentication",
+ "host": "Host",
+ "password": "Password",
+ "port": "Port",
+ "privateKeyPath": "Private Key Path",
+ "username": "Username",
+ "hostPlaceholder": "hostname or SSH config alias",
+ "usernamePlaceholder": "user"
+ },
+ "savedProfiles": {
+ "title": "Saved Profiles"
+ },
+ "ssh": {
+ "title": "SSH Connection"
+ },
+ "status": {
+ "connectedTo": "Connected to {{host}}",
+ "remoteSessions": "Viewing remote sessions via SSH"
+ },
+ "test": {
+ "failed": "Connection failed: {{error}}",
+ "success": "Connection successful",
+ "unknownError": "Unknown error"
+ },
+ "title": "Remote Connection"
+ },
+ "providerRuntime": {
+ "actions": {
+ "cancel": "Cancel",
+ "cancelLogin": "Cancel login",
+ "connectChatGpt": "Connect ChatGPT",
+ "delete": "Delete",
+ "disable": "Disable",
+ "disconnectAccount": "Disconnect account",
+ "generateLink": "Generate link",
+ "openLogin": "Open login",
+ "reconnectAnthropic": "Reconnect Anthropic",
+ "refresh": "Refresh",
+ "replaceKey": "Replace key",
+ "saveEndpoint": "Save endpoint",
+ "saveKey": "Save key",
+ "saving": "Saving...",
+ "setApiKey": "Set API key",
+ "updateKey": "Update key",
+ "useCode": "Use code"
+ },
+ "apiKey": {
+ "loadingStoredCredentials": "Loading stored credentials...",
+ "projectScope": "Project",
+ "scope": "Scope",
+ "storedIn": "Stored in {{backend}}",
+ "userScope": "User",
+ "storedInApp": "Stored in app",
+ "providers": {
+ "anthropic": {
+ "name": "Anthropic API Key",
+ "title": "API key",
+ "description": "Use a direct Anthropic API key for API-billed access. Your Anthropic subscription session stays available when you switch back.",
+ "placeholder": "sk-ant-..."
+ },
+ "codex": {
+ "name": "Codex API Key",
+ "title": "API key",
+ "description": "Use an OpenAI API key as a secondary Codex auth path. If you switch Codex to API key mode, the app will mirror OPENAI_API_KEY into CODEX_API_KEY for native launches.",
+ "placeholder": "sk-proj-..."
+ },
+ "gemini": {
+ "name": "Gemini API Key",
+ "title": "API access",
+ "description": "Use `GEMINI_API_KEY` for the Gemini API backend. CLI SDK and ADC do not require it.",
+ "placeholder": "AIza..."
+ }
+ }
+ },
+ "codex": {
+ "account": {
+ "appServer": "App-server: {{state}}",
+ "connected": "Connected",
+ "description": "Manage the local Codex app-server account session that powers subscription-backed native launches.",
+ "loginInProgress": "Login in progress",
+ "plan": "Plan: {{plan}}",
+ "reconnectRequired": "Reconnect required",
+ "title": "ChatGPT account",
+ "hints": {
+ "autoUsesApiKeyUntilChatgpt": "{{message}} Auto will keep using the detected API key until ChatGPT is connected.",
+ "detectedApiKeyNeedsApiMode": "{{message}} The detected API key is only used after you switch Codex to API key mode.",
+ "localArtifactsNoSession": "Codex CLI currently reports no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Usage limits appear here only after Codex CLI sees one.",
+ "noActiveAccount": "Codex CLI currently reports no active ChatGPT account. Usage limits appear here only after Codex CLI sees one.",
+ "reconnectBeforeUsage": "Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here.",
+ "usageLimitsAfterReport": "Usage limits appear here after Codex reports them for the connected ChatGPT account."
+ }
+ },
+ "install": {
+ "checking": "Checking",
+ "downloading": "Downloading",
+ "installCli": "Install Codex CLI",
+ "installing": "Installing",
+ "retryInstall": "Retry install",
+ "title": "Install Codex CLI into app data"
+ },
+ "rateLimits": {
+ "credits": "Credits",
+ "creditsDescription": "Credits are shown separately from window-based subscription usage and may be unavailable for plan-backed ChatGPT sessions.",
+ "noSecondaryWindow": "Codex did not return a secondary window for this account snapshot.",
+ "notReported": "Not reported",
+ "primaryReset": "Primary reset",
+ "primaryUsed": "Primary used",
+ "primaryWindow": "Primary window",
+ "remainingLeft": "{{value}} left",
+ "remainingUnknown": "Remaining unknown",
+ "secondaryReset": "Secondary reset",
+ "secondaryUsed": "Secondary used",
+ "secondaryWindow": "Secondary window",
+ "usedQuotaNote": "These percentages show used quota, not remaining quota.",
+ "weeklyReset": "Weekly reset",
+ "weeklyUsed": "Weekly used",
+ "weeklyUsedOneWeek": "Weekly used (1w)",
+ "weeklyWindow": "Weekly window",
+ "secondaryFallback": "secondary",
+ "secondaryWindowNote": " Weekly limits are shown separately in the {{window}} window.",
+ "usageExplanationGeneric": "Shows used quota, not remaining quota.",
+ "usageExplanationWindowOnly": "Shows used quota in the current {{window}} window, not remaining quota.",
+ "usageExplanationWithRemaining": "{{used}} used - about {{remaining}} left in the current {{window}} window."
+ }
+ },
+ "compatibleEndpoint": {
+ "authToken": "Auth token",
+ "authTokenMissing": "Auth token is not configured.",
+ "baseUrl": "Base URL",
+ "description": "Use an Anthropic-compatible local runtime endpoint.",
+ "keepSavedToken": "Leave blank to keep saved token",
+ "title": "Local / compatible endpoint",
+ "tokenStatus": "Token {{status}}",
+ "validation": {
+ "baseUrlRequired": "Base URL is required",
+ "firstPartyAnthropic": "Use Auto, Subscription, or API key for first-party Anthropic",
+ "httpRequired": "Base URL must use http:// or https://",
+ "invalidUrl": "Invalid URL",
+ "noCredentials": "Base URL must not include credentials"
+ },
+ "status": {
+ "endpointDisabledTokenKept": "Endpoint disabled. Saved token was kept.",
+ "endpointSaved": "Endpoint saved",
+ "endpointSavedTokenMissing": "Endpoint saved. Auth token is not configured."
+ }
+ },
+ "connection": {
+ "authenticationMethod": "Authentication method",
+ "descriptions": {
+ "anthropic": "Choose how app-launched Anthropic sessions authenticate.",
+ "codex": "Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.",
+ "gemini": "Configure optional API access. CLI SDK and ADC are still discovered automatically.",
+ "opencode": "OpenCode authentication and provider inventory are managed by the OpenCode runtime."
+ },
+ "method": "Connection method",
+ "mode": "Mode: {{mode}}",
+ "selected": "Selected",
+ "switching": "Switching...",
+ "title": "Connection"
+ },
+ "connectionCards": {
+ "apiKey": {
+ "title": "API key"
+ },
+ "anthropic": {
+ "apiKeyDescription": "Use ANTHROPIC_API_KEY and Anthropic API billing.",
+ "autoDescription": "Use Anthropic runtime defaults and the best local credential available.",
+ "hint": "Auto keeps Anthropic on its default local credential resolution.",
+ "subscriptionDescription": "Use your local Anthropic sign-in session and subscription access.",
+ "subscriptionTitle": "Anthropic subscription"
+ },
+ "auto": {
+ "title": "Auto"
+ },
+ "codex": {
+ "apiKeyDescription": "Use OPENAI_API_KEY and CODEX_API_KEY billing for native Codex launches.",
+ "autoDescription": "Prefer your ChatGPT account and subscription. Use API key mode only if needed.",
+ "chatgptDescription": "Use your connected ChatGPT account and Codex subscription.",
+ "chatgptTitle": "ChatGPT account",
+ "hint": "Codex always runs through the native runtime. Auto prefers your ChatGPT account before falling back to API-key credentials."
+ }
+ },
+ "description": "Manage how each provider connects and, when supported, which backend the multimodel runtime should use.",
+ "fastMode": {
+ "defaultOff": "Default Off",
+ "description": "Apply Claude Code Fast mode by default for new Anthropic team launches when the resolved model and runtime allow it.",
+ "disabledHint": "New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.",
+ "enabledHint": "New Anthropic launches will request Fast mode by default when the resolved model supports it.",
+ "notExposed": "This Anthropic runtime does not expose Fast mode.",
+ "preferFast": "Prefer Fast",
+ "title": "Fast mode default",
+ "unavailableForRuntime": "Fast mode is currently unavailable for this Anthropic runtime."
+ },
+ "alerts": {
+ "anthropicApiKeyMissing": "API key mode is selected, but no Anthropic API credential is available yet.",
+ "anthropicStoredKeyAvailable": "A saved API key is available, but app-launched Anthropic sessions use it only after you switch to API key mode.",
+ "anthropicSubscriptionMissing": "Anthropic subscription mode is selected. Sign in with Anthropic to use this provider.",
+ "authTokenMissing": "Auth token is not configured. Many local Anthropic-compatible endpoints require a non-empty token.",
+ "chatgptLoginPending": "Waiting for ChatGPT account login to finish...",
+ "chatgptLoginStarting": "Starting ChatGPT login...",
+ "codexApiKeyMissing": "API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.",
+ "codexLocalArtifactsNoSession": "Codex CLI currently has no active ChatGPT account. Local Codex account data exists, but no active managed session is selected.",
+ "codexNeedsReconnect": "Codex has a locally selected ChatGPT account, but the current session needs reconnect.",
+ "codexNoChatgptAccount": "Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription.",
+ "codexNoCredential": "No ChatGPT account or API key is available yet.",
+ "geminiApiUnavailable": "Gemini API is currently unavailable. Configure `GEMINI_API_KEY` here or use valid Google ADC credentials.",
+ "withApiKeyFallback": "{{message}} Switch to API key mode to use the detected API key."
+ },
+ "authModeDescriptions": {
+ "anthropic": {
+ "apiKey": "Force app-launched Anthropic sessions to use an API key credential.",
+ "auto": "Use the runtime default behavior. Saved API keys in this app are only used after you switch to API key mode.",
+ "oauth": "Force app-launched Anthropic sessions to use the local Anthropic subscription session."
+ },
+ "codex": {
+ "apiKey": "Force native Codex launches to use OPENAI_API_KEY / CODEX_API_KEY billing.",
+ "auto": "Prefer your ChatGPT account when it is available. Fall back to API key mode only when needed.",
+ "chatgpt": "Force native Codex launches to use your connected ChatGPT account and subscription."
+ }
+ },
+ "progress": {
+ "applyingConnectionChanges": "Applying connection changes...",
+ "refreshingProviderStatus": "Refreshing provider status...",
+ "savingCompatibleEndpoint": "Saving compatible endpoint...",
+ "switchingAnthropicSubscription": "Switching to Anthropic subscription...",
+ "switchingApiKey": "Switching to API key...",
+ "switchingApiKeyMode": "Switching to API key mode...",
+ "switchingAuto": "Switching to Auto...",
+ "switchingChatgpt": "Switching to ChatGPT account mode..."
+ },
+ "provider": "Provider",
+ "runtime": {
+ "descriptions": {
+ "anthropic": "Anthropic currently has no separate runtime backend selector.",
+ "codex": "Codex now runs only through the native runtime path.",
+ "gemini": "Choose which Gemini runtime backend multimodel should use.",
+ "opencode": "OpenCode uses its own managed runtime host. Desktop currently exposes status only."
+ },
+ "title": "Runtime",
+ "updating": "Updating runtime..."
+ },
+ "runtimeSummary": "Runtime: {{runtime}}",
+ "status": {
+ "configured": "configured",
+ "enabled": "Enabled",
+ "notConfigured": "Not configured",
+ "notSet": "not set",
+ "off": "Off",
+ "unknown": "Unknown"
+ },
+ "title": "Provider Settings",
+ "usage": {
+ "apiKey": "Using API key",
+ "apiKeyRequired": "API key required",
+ "compatibleEndpoint": "Using compatible endpoint",
+ "notConnected": "Not connected",
+ "usingMethod": "Using {{method}}"
+ },
+ "errors": {
+ "apiKeyDeletedRefreshFailed": "API key deleted, but failed to refresh provider status.",
+ "apiKeySavedRefreshFailed": "API key saved, but failed to refresh provider status.",
+ "connectionUpdatedRefreshFailed": "Connection updated, but failed to refresh provider status.",
+ "deleteApiKey": "Failed to delete API key",
+ "disableEndpoint": "Failed to disable endpoint",
+ "endpointDisabledRefreshFailed": "Endpoint disabled, but failed to refresh provider status.",
+ "endpointSavedRefreshFailed": "Endpoint saved, but failed to refresh provider status.",
+ "refreshCodexAccount": "Failed to refresh Codex account",
+ "saveApiKey": "Failed to save API key",
+ "saveEndpoint": "Failed to save endpoint",
+ "updateAnthropicFastMode": "Failed to update Anthropic Fast mode",
+ "updateConnection": "Failed to update connection",
+ "updateRuntimeBackend": "Failed to update runtime backend",
+ "apiKeyRequired": "API key is required"
+ },
+ "connectionUi": {
+ "authMode": {
+ "auto": "Auto",
+ "oauth": "Subscription / OAuth",
+ "chatgpt": "ChatGPT account",
+ "apiKey": "API key",
+ "anthropicSubscription": "Anthropic subscription"
+ },
+ "authMethod": {
+ "apiKey": "API key",
+ "apiKeyHelper": "API key helper",
+ "oauth": "OAuth",
+ "claudeSubscription": "Claude subscription",
+ "geminiCli": "Gemini CLI",
+ "googleAccount": "Google account",
+ "serviceAccount": "service account"
+ },
+ "runtime": {
+ "codexNative": "Codex native",
+ "currentRuntime": "Current runtime",
+ "selectedRuntime": "Selected runtime",
+ "summary": "{{prefix}}: {{runtime}}"
+ },
+ "status": {
+ "checking": "Checking...",
+ "checked": "Checked",
+ "providerActivity": "Provider Activity",
+ "notConnected": "Not connected",
+ "startingChatGptLogin": "Starting ChatGPT login...",
+ "waitingForChatGptLogin": "Waiting for ChatGPT account login...",
+ "chatGptVerificationDegraded": "ChatGPT account detected - account verification is currently degraded.",
+ "chatGptAccountReady": "ChatGPT account ready",
+ "apiKeyReady": "API key ready",
+ "codexLocalAccountNeedsReconnect": "Codex has a locally selected ChatGPT account, but the current session needs reconnect.",
+ "codexNoActiveManagedSession": "Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.",
+ "codexNoActiveChatGptLogin": "Codex CLI reports no active ChatGPT login",
+ "connectChatGptForSubscription": "Connect a ChatGPT account to use your Codex subscription.",
+ "codexNativeReady": "Codex native ready",
+ "codexNativeUnavailable": "Codex native unavailable",
+ "unavailableInCurrentRuntime": "Unavailable in current runtime",
+ "connectedViaApiKey": "Connected via API key",
+ "apiKeyConfiguredNotVerified": "API key configured, but not verified yet",
+ "apiKeyModeMissingCredential": "API key mode selected, but no API key is configured",
+ "connectedVia": "Connected via {{method}}",
+ "unableToVerify": "Unable to verify"
+ },
+ "mode": {
+ "selectedAuth": "Selected auth: {{authMode}}",
+ "preferredAuth": "Preferred auth: {{authMode}}"
+ },
+ "credential": {
+ "apiKeyConfigured": "API key is configured",
+ "savedApiKeyAvailable": "Saved API key available in Manage",
+ "apiKeyAlsoConfigured": "API key also configured in Manage",
+ "apiKeyConfiguredInManage": "API key is configured in Manage",
+ "apiKeyFallbackInManage": "API key also available in Manage as fallback",
+ "availableAsFallback": "{{summary}} - available as fallback",
+ "savedApiKeyAvailableIfSwitch": "Saved API key available in Manage if you switch to API key mode",
+ "availableIfSwitch": "{{summary}} - available if you switch to API key mode",
+ "autoWillUseUntilChatGpt": "{{summary}} - Auto will use this until ChatGPT is connected"
+ },
+ "actions": {
+ "connect": "Connect",
+ "connectAnthropic": "Connect Anthropic",
+ "connectChatGpt": "Connect ChatGPT",
+ "disconnect": "Disconnect",
+ "openLogin": "Open Login"
+ },
+ "disconnect": {
+ "anthropicTitle": "Disconnect Anthropic subscription?",
+ "anthropic": "This removes the local Anthropic subscription session from the Claude CLI runtime.",
+ "anthropicWithApiKey": "This removes the local Anthropic subscription session from the Claude CLI runtime. Saved API keys in Manage stay available.",
+ "geminiTitle": "Disconnect Gemini CLI?",
+ "gemini": "This clears the local Gemini CLI session metadata. External ADC credentials and saved API keys are not removed."
+ }
+ }
+ },
+ "cliRuntime": {
+ "actions": {
+ "checkForUpdates": "Check for Updates",
+ "checking": "Checking...",
+ "extensions": "Extensions",
+ "installRuntime": "Install {{runtime}}",
+ "manage": "Manage",
+ "recheck": "Re-check",
+ "reinstallRuntime": "Reinstall {{runtime}}",
+ "retry": "Retry",
+ "update": "Update"
+ },
+ "installer": {
+ "checkingLatest": "Checking latest version...",
+ "downloading": "Downloading...",
+ "failed": "Installation failed",
+ "installed": "Installed v{{version}}",
+ "installing": "Installing...",
+ "latest": "latest",
+ "verifying": "Verifying checksum..."
+ },
+ "labels": {
+ "multimodel": "Multimodel"
+ },
+ "loading": {
+ "aiProviders": "Checking AI Providers...",
+ "claudeCli": "Checking Claude CLI..."
+ },
+ "provider": {
+ "backend": "Backend: {{backend}}",
+ "loadingModels": "Loading models...",
+ "modelsUnavailable": "Models unavailable for this runtime build",
+ "runtime": "Runtime: {{runtime}}"
+ },
+ "providerTerminal": {
+ "authFailed": "Authentication failed",
+ "authUpdated": "Authentication updated",
+ "loggedOut": "Provider logged out",
+ "login": "Login",
+ "logout": "Logout",
+ "logoutFailed": "Logout failed"
+ },
+ "status": {
+ "configuredNotFound": "The configured {{runtime}} was not found.",
+ "foundButFailed": "{{runtime}} was found but failed to start",
+ "healthCheckFailed": "The configured {{runtime}} failed its startup health check.",
+ "notInstalled": "{{runtime}} not installed"
+ },
+ "title": "CLI Runtime"
+ },
+ "cliStatus": {
+ "versionUpgrade": "v{{current}} -> v{{latest}}"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/team.json b/src/features/localization/renderer/locales/en/team.json
new file mode 100644
index 00000000..4e31e250
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/team.json
@@ -0,0 +1,2415 @@
+{
+ "activity": {
+ "actions": {
+ "createTaskFromMessage": "Create task from message",
+ "expandMessage": "Expand message",
+ "replyToMessage": "Reply to message",
+ "restartTeam": "Restart team"
+ },
+ "authError": {
+ "description": "Authentication failed. Restarting the team will refresh the session and may resolve this issue. If the problem persists, check your API credentials or try again later."
+ },
+ "automation": {
+ "reviewPickup": "Asked teammate to pick up review",
+ "stallNudge": "Asked teammate to continue stalled task",
+ "workSyncBody": "Asked teammate to sync current work"
+ },
+ "badges": {
+ "automation": "automation",
+ "bootstrap": "bootstrap",
+ "command": "command",
+ "comment": "Comment",
+ "live": "live",
+ "note": "note",
+ "rateLimited": "Rate Limited",
+ "restart": "restart",
+ "result": "result",
+ "session": "session",
+ "stallNudge": "stall nudge",
+ "start": "start",
+ "workSync": "work sync"
+ },
+ "bootstrap": {
+ "acknowledged": "Bootstrap acknowledged",
+ "restarting": "Restarting teammate",
+ "starting": "Starting teammate"
+ },
+ "rawJson": "Raw JSON",
+ "unread": "Unread",
+ "thoughts": {
+ "count": "{{count}} thoughts",
+ "count_one": "{{count}} thought",
+ "expand": "Expand thoughts",
+ "showMore": "Show more",
+ "showLess": "Show less",
+ "count_few": "{{count}} thoughts",
+ "count_many": "{{count}} thoughts",
+ "count_other": "{{count}} thoughts",
+ "toolSummary": "🔧 {{summary}}",
+ "titleForMember": "{{name}} - thoughts"
+ },
+ "timeline": {
+ "loadingMessages": "Loading messages...",
+ "noMessages": "No messages",
+ "emptyHint": "Send a message to a member to see activity.",
+ "newSession": "New session",
+ "olderCount": "+{{count}} older",
+ "showMore": "Show {{count}} more",
+ "showAll": "Show all",
+ "olderCount_one": "+{{count}} older",
+ "olderCount_few": "+{{count}} older",
+ "olderCount_many": "+{{count}} older",
+ "olderCount_other": "+{{count}} older"
+ },
+ "pendingReplies": {
+ "title": "Awaiting replies",
+ "openMember": "Open member",
+ "messageSentAwaitingReply": "Message sent, awaiting reply",
+ "awaitingReply": "awaiting reply",
+ "externalTeam": "external team",
+ "crossTeamAwaitingReply": "Cross-team message sent, awaiting reply",
+ "user": "user",
+ "awaitingApproval": "awaiting approval"
+ },
+ "reply": {
+ "replyingTo": "Replying to",
+ "action": "Reply"
+ },
+ "activeTasks": {
+ "inProgress": "In progress"
+ },
+ "expandDialog": {
+ "description": "Expanded message view"
+ }
+ },
+ "create": {
+ "actions": {
+ "create": "Create",
+ "creating": "Creating...",
+ "openExisting": "Open Existing Team",
+ "skipPreflightAndCreate": "Skip preflight and create"
+ },
+ "conflict": {
+ "description": "Running two teams in the same directory is risky - they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.",
+ "title": "Another team \"{{team}}\" is already running for this working directory",
+ "workingDirectory": "Working directory:"
+ },
+ "description": {
+ "copy": "Create a new team based on an existing one.",
+ "create": "Set up your team and choose how it starts."
+ },
+ "errors": {
+ "nameExists": "Team name already exists",
+ "nameLaunching": "A team with this name is currently launching",
+ "createConfigFailed": "Failed to create team config",
+ "loadProjectsFailed": "Failed to load projects"
+ },
+ "fields": {
+ "color": "Color (optional)",
+ "description": "Description (optional)",
+ "prompt": "Prompt for team lead (optional)",
+ "teamName": "Team name"
+ },
+ "launchAfterCreate": {
+ "description": "Start the team immediately via local Claude CLI.",
+ "label": "Run command after create"
+ },
+ "localOnly": "Available only in local Electron mode.",
+ "onDisk": "On disk:",
+ "placeholders": {
+ "description": "Brief description of the team purpose",
+ "prompt": "Instructions for the team lead during provisioning..."
+ },
+ "saved": "Saved",
+ "solo": {
+ "description": "Only the team lead (main process) will be started - no teammates will be spawned. Works like a regular agent session in your chosen runtime (Claude Code, Codex, OpenCode, Gemini) but with access to the task board for planning. Saves tokens by avoiding teammate coordination overhead. You can add members later from the team settings.",
+ "label": "Solo team"
+ },
+ "title": {
+ "copy": "Copy Team",
+ "create": "Create Team"
+ },
+ "optional": {
+ "launchSettingsTitle": "Optional launch settings",
+ "launchSettingsDescription": "Prompt, safety, and CLI overrides live here when you need them.",
+ "teamDetailsTitle": "Optional team details",
+ "teamDetailsDescription": "Keep the default flow compact and only open this when you want extra context or a custom color."
+ },
+ "prepare": {
+ "unsupportedPreload": "Current preload version does not support team:prepareProvisioning. Restart the dev app.",
+ "selectWorkingDirectory": "Select a working directory to validate the launch environment.",
+ "someProvidersNeedAttention": "Some selected providers need attention.",
+ "readyWithNotes": "All selected providers are ready, with notes.",
+ "ready": "All selected providers are ready.",
+ "failed": "Failed to prepare selected providers",
+ "checkingProviders": "Checking selected providers...",
+ "preparingEnvironment": "Preparing environment...",
+ "selectedProvidersReadyWithNotes": "Selected providers ready (with notes)",
+ "selectedProvidersReady": "Selected providers ready"
+ },
+ "validation": {
+ "nameMustContainLetterOrDigit": "Name must contain at least one letter or digit",
+ "nameTooLong": "Name is too long (max 128 chars)",
+ "selectWorkingDirectory": "Select working directory (cwd)",
+ "memberNameRequired": "Member name cannot be empty",
+ "memberNameInvalid": "Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars",
+ "memberNamesUnique": "Member names must be unique",
+ "openCodeLeadModelRequired": "OpenCode lead requires a selected model.",
+ "openCodeTeammateRequired": "OpenCode lead requires at least one OpenCode teammate.",
+ "teamLaunching": "Team is currently launching",
+ "teamNameExists": "Team name already exists",
+ "checkFormFields": "Check form fields"
+ }
+ },
+ "editTeam": {
+ "actions": {
+ "cancel": "Cancel",
+ "save": "Save"
+ },
+ "addMemberLockReason": "Use the dedicated Add member dialog to add new teammates while the team is live.",
+ "description": "Change team name, description and color",
+ "errors": {
+ "changesSavedRefreshFailed": "Team changes were saved, but failed to refresh the latest view: {{message}}",
+ "liveRenameBlocked": "Existing teammates cannot be renamed while the team is live. renamed: {{names}}",
+ "memberNameEmpty": "Member name cannot be empty",
+ "memberNameInvalid": "Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars",
+ "memberNameNumericSuffix": "Member name \"{{name}}\" is not allowed (reserved for Claude CLI auto-suffix). Use \"{{base}}\" instead.",
+ "memberNameReserved": "Member name \"{{name}}\" is reserved",
+ "memberNamesUnique": "Member names must be unique before saving",
+ "newLiveTeammates": "Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.",
+ "provisioning": "Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.",
+ "restartFailedMany": "Team saved, but failed to restart these teammates: {{failures}}",
+ "restartFailedOne": "Team saved, but failed to restart this teammate: {{failures}}",
+ "saveFailed": "Failed to save",
+ "settingsChanged": "Team settings changed while this dialog was open. Reopen it and review the latest state before saving.",
+ "settingsSavedMembersAndRefreshFailed": "Team settings were saved, but member changes failed: {{message}}. Refresh also failed: {{refreshError}}",
+ "settingsSavedMembersFailed": "Team settings were saved, but member changes failed: {{message}}",
+ "settingsSavedRefreshFailed": "Team settings were saved, but failed to refresh the latest view: {{message}}",
+ "teamNameEmpty": "Team name cannot be empty",
+ "unsupportedMixedPrimaryMutation": "Live edits to primary-owned teammates in mixed OpenCode teams are not supported yet. Stop the team, edit the roster, then relaunch. Affected: {{names}}"
+ },
+ "fields": {
+ "colorOptional": "Color (optional)",
+ "description": "Description",
+ "name": "Name"
+ },
+ "memberRestartWarning": "Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes.",
+ "notices": {
+ "liveRenameBlocked": "Live save is blocked because existing teammates were renamed. Revert those identity changes or stop the team first.",
+ "newLiveTeammates": "New teammates cannot be added from Edit Team while the team is live. Use the Add member dialog instead.",
+ "provisioning": "Team provisioning is still in progress. Editing is temporarily locked until launch finishes.",
+ "restartMany": "Saving will restart or relaunch these teammates to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes: {{names}}.",
+ "restartOne": "Saving will restart or relaunch this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes: {{names}}.",
+ "unsupportedMixedPrimaryMutation": "Live edits/removals for primary-owned teammates in mixed OpenCode teams require stopping and relaunching the team: {{names}}."
+ },
+ "placeholders": {
+ "description": "Team description (optional)",
+ "teamName": "Team name"
+ },
+ "teamLead": {
+ "changeRuntime": "Change lead runtime",
+ "changeRuntimeDescription": "Open Relaunch Team to change the lead provider, model, or effort.",
+ "modelLockReason": "Team lead runtime is managed from Relaunch Team.",
+ "readOnlyHint": "Team lead name and role stay read-only here. Open the runtime panel on the lead row to change provider, model, or effort.",
+ "role": "Team Lead"
+ },
+ "title": "Edit Team"
+ },
+ "memberDraft": {
+ "actions": {
+ "remove": "Remove member",
+ "removeAria": "Remove {{name}}",
+ "restore": "Restore member",
+ "restoreAria": "Restore {{name}}"
+ },
+ "anthropicContext": {
+ "defaultSetting": "default context setting",
+ "description": "Anthropic context is team-wide for this launch: {{mode}}. Use the lead runtime panel's Limit context checkbox to change it.",
+ "limitEnabled": "200K limit enabled"
+ },
+ "mcp": {
+ "buttonInherit": "MCP inherit",
+ "buttonScopes": "MCP scopes",
+ "chooseScopes": "Choose scopes",
+ "inheritLead": "Inherit lead",
+ "lockedInfo": "Agent Teams MCP only is enabled for all teammates. This teammate will launch with only the Agent Teams server.",
+ "mode": "MCP mode",
+ "scopes": {
+ "local": "local",
+ "project": "project",
+ "user": "user"
+ },
+ "serverNames": "Server names",
+ "settingInfo": "Agent Teams MCP launches this teammate with only the Agent Teams server. Scope and allowlist modes apply only to this teammate launch.",
+ "strictAllowlist": "Strict allowlist",
+ "tooltip": "{{label}}: Control this member's MCP inheritance policy",
+ "agentTeamsMcp": "Agent Teams MCP"
+ },
+ "model": {
+ "ariaLabel": "{{provider}} provider, {{model}}",
+ "currentLeadRuntime": "Current lead runtime",
+ "default": "Default",
+ "inheritedTooltip": "Provider, model, and effort are inherited from the lead while sync is enabled.",
+ "leadSuffix": "{{label}} (lead)",
+ "liveDisabled": "Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.",
+ "lockedActionFallback": "Lead runtime changes open Relaunch Team, where provider, model, and effort can be updated.",
+ "restartWholeTeam": "Saving those runtime changes restarts the whole team."
+ },
+ "nameAria": "Member {{index}} name",
+ "nameFallback": "member {{index}}",
+ "noRole": "No role",
+ "removed": "Removed",
+ "workflow": {
+ "addTooltip": "Add teammate workflow",
+ "editTooltip": "Edit teammate workflow",
+ "label": "Workflow (optional)",
+ "placeholder": "How this agent should behave, interact with others...",
+ "saved": "Saved"
+ },
+ "worktree": {
+ "description": "Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.",
+ "label": "Worktree"
+ },
+ "addMembers": {
+ "title": "Add Members",
+ "description": "Add new members to {{teamName}}"
+ },
+ "placeholders": {
+ "name": "member-name",
+ "mcpServers": "github, sentry"
+ }
+ },
+ "detail": {
+ "actions": {
+ "add": "Add",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "editCode": "Edit code",
+ "launch": "Launch",
+ "remove": "Remove",
+ "stop": "Stop",
+ "task": "Task",
+ "visualize": "Visualize"
+ },
+ "deleteTeam": {
+ "description": "Delete team \"{{team}}\"? This action is irreversible. All team data and tasks will be deleted.",
+ "title": "Delete team"
+ },
+ "draft": {
+ "descriptionPrefix": "This is a draft team -",
+ "descriptionSuffix": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "descriptionSuffix_few": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "descriptionSuffix_many": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "descriptionSuffix_one": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "descriptionSuffix_other": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "member": "members",
+ "member_few": "members",
+ "member_many": "members",
+ "member_one": "member",
+ "member_other": "members",
+ "title": "Team not launched yet"
+ },
+ "invalidTab": "Invalid team tab",
+ "kanbanSafeData": "Failed to fully load kanban. Displaying safe data.",
+ "loadFailed": "Failed to load team",
+ "loading": "Loading team",
+ "loadingSidebar": "Loading team sidebar",
+ "offline": {
+ "offline": "Team is offline",
+ "partialFailed": "Last launch failed partway",
+ "partialMissing": "Last launch failed partway - {{missing}}/{{expected}} teammates did not join",
+ "reconciling": "Last launch is still reconciling"
+ },
+ "previous": "Previous: {{paths}}",
+ "removeMember": {
+ "description": "Remove \"{{member}}\" from the team? Tasks and messages will be preserved, but this name cannot be reused.",
+ "title": "Remove member"
+ },
+ "sections": {
+ "team": "Team"
+ },
+ "solo": "Solo",
+ "status": {
+ "active": "Active",
+ "launching": "Launching...",
+ "running": "Running"
+ },
+ "telemetry": {
+ "cpu": "CPU",
+ "memory": "Memory"
+ },
+ "tooltips": {
+ "deleteTeam": "Delete team",
+ "editTeam": "Edit team",
+ "editUnavailableProvisioning": "Edit team is unavailable while provisioning is still in progress",
+ "openBuiltInEditor": "Open project in built-in editor",
+ "openTeamGraph": "Open team graph",
+ "stopTeam": "Stop team"
+ },
+ "waitingForProvisioning": "Team data will appear once provisioning completes",
+ "context": {
+ "title": "Context"
+ }
+ },
+ "review": {
+ "fileHeader": {
+ "actions": {
+ "accept": "Accept",
+ "discard": "Discard",
+ "discardTooltip": "Discard all edits for this file",
+ "keepMyDraft": "Keep my draft",
+ "reject": "Reject",
+ "reloadFromDisk": "Reload from disk",
+ "restore": "Restore",
+ "restoreTooltip": "Create/restore this file on disk from the preview",
+ "saveFile": "Save File",
+ "saveFileTooltip": "Save file to disk"
+ },
+ "badges": {
+ "deleted": "DELETED",
+ "manualReview": "MANUAL REVIEW",
+ "new": "NEW",
+ "worktree": "WORKTREE"
+ },
+ "contentSource": {
+ "disk-current": "Current Disk",
+ "file-history": "File History",
+ "git-fallback": "Git Fallback",
+ "ledger-exact": "Task Ledger",
+ "ledger-snapshot": "Ledger Snapshot",
+ "snippet-reconstruction": "Reconstructed",
+ "unavailable": "Content unavailable"
+ },
+ "contentUnavailable": {
+ "badge": "Content unavailable",
+ "description": "The ledger recorded metadata for this change, but full text content is not available. This usually means binary, large, or hash-only content.",
+ "safety": "Automatic accept/reject is disabled for this file to avoid unsafe disk writes.",
+ "title": "Text content is unavailable"
+ },
+ "disabled": {
+ "acceptRejectContentUnavailable": "Accept/Reject is disabled because full text content is unavailable.",
+ "acceptRejectMissingOnDisk": "Accept/Reject is disabled while the file is missing on disk.",
+ "rejectBaselineUnavailable": "Reject is disabled because the original baseline is unavailable.",
+ "rejectContentUnavailable": "Reject is disabled because full text content is unavailable.",
+ "rejectManualLedgerReview": "Reject is disabled because this ledger change has binary, large, or unavailable content."
+ },
+ "externalChange": {
+ "changedOnDisk": "Changed on disk",
+ "deletedOnDisk": "Deleted on disk",
+ "recreatedOnDisk": "Recreated on disk"
+ },
+ "missingOnDisk": {
+ "badge": "Missing on disk",
+ "description": "We can still show a preview from agent logs, but your filesystem is out of sync.",
+ "restorePrefix": "Use",
+ "restoreSuffix": "to write the preview content back to disk.",
+ "restoreUnavailable": "Full file content is not available to restore automatically.",
+ "title": "File is missing on disk"
+ },
+ "pathChange": {
+ "from": "From {{path}}",
+ "to": "To {{path}}"
+ },
+ "worktree": {
+ "isolated": "Isolated worktree"
+ }
+ },
+ "toolbar": {
+ "stats": {
+ "pending": "{{count}} pending",
+ "pending_one": "{{count}} pending",
+ "pending_other": "{{count}} pending",
+ "accepted": "{{count}} accepted",
+ "accepted_one": "{{count}} accepted",
+ "accepted_other": "{{count}} accepted",
+ "rejected": "{{count}} rejected",
+ "rejected_one": "{{count}} rejected",
+ "rejected_other": "{{count}} rejected",
+ "acrossFiles": "across {{count}} files",
+ "acrossFiles_one": "across {{count}} file",
+ "acrossFiles_other": "across {{count}} files",
+ "edited": "{{count}} edited",
+ "edited_one": "{{count}} edited",
+ "edited_other": "{{count}} edited",
+ "pending_few": "{{count}} pending",
+ "pending_many": "{{count}} pending",
+ "accepted_few": "{{count}} accepted",
+ "accepted_many": "{{count}} accepted",
+ "rejected_few": "{{count}} rejected",
+ "rejected_many": "{{count}} rejected",
+ "acrossFiles_few": "across {{count}} files",
+ "acrossFiles_many": "across {{count}} files",
+ "edited_few": "{{count}} edited",
+ "edited_many": "{{count}} edited"
+ },
+ "actions": {
+ "auto": "Auto",
+ "undo": "Undo",
+ "acceptAll": "Accept All",
+ "rejectAll": "Reject All",
+ "applying": "Applying...",
+ "applyRejections": "Apply Rejections"
+ },
+ "tooltips": {
+ "autoOn": "Auto-mark files as viewed when scrolled to end (ON)",
+ "autoOff": "Auto-mark files as viewed when scrolled to end (OFF)",
+ "undo": "Undo last review operation (Ctrl+Z)",
+ "acceptAll": "Accept all changes across all files",
+ "rejectAll": "Reject all safely rejectable changes across all files",
+ "rejectAllDisabled": "No pending files have a safe original baseline to reject.",
+ "applyRejections": "Apply rejected hunks to disk; accepted changes are kept as-is"
+ }
+ },
+ "diffError": {
+ "title": "Failed to render diff view",
+ "unexpected": "An unexpected error occurred while rendering the diff.",
+ "actions": {
+ "retry": "Retry"
+ },
+ "raw": {
+ "show": "Show raw diff data",
+ "file": "File: {{file}}",
+ "original": "--- Original",
+ "modified": "+++ Modified",
+ "charsTotal": "... ({{count}} chars total)",
+ "charsTotal_one": "... ({{count}} char total)",
+ "charsTotal_other": "... ({{count}} chars total)",
+ "charsTotal_few": "... ({{count}} chars total)",
+ "charsTotal_many": "... ({{count}} chars total)"
+ }
+ },
+ "fileTree": {
+ "viewed": "Viewed",
+ "badges": {
+ "new": "new",
+ "deleted": "deleted"
+ },
+ "collapseFolder": "Collapse {{name}}",
+ "expandFolder": "Expand {{name}}",
+ "empty": {
+ "noChangedFiles": "No changed files",
+ "noMatchingFiles": "No matching files"
+ },
+ "searchPlaceholder": "Search files…",
+ "filters": {
+ "unresolved": "Unresolved",
+ "rejected": "Rejected",
+ "new": "New",
+ "clear": "Clear"
+ }
+ },
+ "diffControls": {
+ "previousChunk": "Previous chunk",
+ "nextChunk": "Next chunk",
+ "rejectChange": "Reject change (⌘N)",
+ "acceptChange": "Accept change (⌘Y)",
+ "undo": "Undo",
+ "keep": "Keep",
+ "rejectShortcut": "⌘N",
+ "acceptShortcut": "⌘Y"
+ },
+ "conflict": {
+ "title": "Conflict Detected",
+ "description": "This file has been modified since the agent's changes",
+ "cancel": "Cancel",
+ "saveResolution": "Save Resolution",
+ "editManually": "Edit Manually",
+ "useOriginal": "Use Original",
+ "keepCurrent": "Keep Current"
+ },
+ "fullDiffLoading": {
+ "titleOne": "Preparing Full Diff",
+ "titleMany": "Preparing {{count}} Full Diffs",
+ "subtitleForFile": "Finalizing the exact editor diff for {{file}}.",
+ "subtitleCurrentFile": "Finalizing the exact editor diff for the current file.",
+ "subtitleMany": "Resolving exact before/after baselines for the files currently loading.",
+ "previewsReady": "{{count}} previews ready",
+ "previewsReady_one": "{{count}} preview ready",
+ "editorViewLoading": "Editor view loading",
+ "filesInProgress": "{{count}} files in progress",
+ "filesInProgress_one": "{{count}} file in progress",
+ "filesReady": "{{ready}}/{{total}} files ready",
+ "progressDescription": "{{ready}} ready, {{loading}} still loading. Preview diffs stay visible below while the remaining baselines are resolved.",
+ "singleDescription": "Preview diffs stay visible below while the exact baseline is resolved.",
+ "previewsReady_few": "{{count}} previews ready",
+ "previewsReady_many": "{{count}} previews ready",
+ "previewsReady_other": "{{count}} previews ready",
+ "filesInProgress_few": "{{count}} files in progress",
+ "filesInProgress_many": "{{count}} files in progress",
+ "filesInProgress_other": "{{count}} files in progress"
+ },
+ "fileMissingPrefix": "File is missing on disk. This diff may be only a preview from agent logs. Use",
+ "restore": "Restore",
+ "fileMissingSuffix": "to create the file on disk.",
+ "filePlaceholder": {
+ "loading": "Loading",
+ "description": "Preparing a full editor diff for this file."
+ },
+ "loading": {
+ "diff": "DIFF",
+ "ledgerObjectsProcessed": "{{count}} ledger objects processed",
+ "ledgerObjectsProcessed_one": "{{count}} ledger object processed",
+ "ledgerObjectsProcessed_other": "{{count}} ledger objects processed",
+ "ledgerObjectsProcessed_few": "{{count}} ledger objects processed",
+ "ledgerObjectsProcessed_many": "{{count}} ledger objects processed",
+ "phases": {
+ "readingLedger": "Reading task ledger...",
+ "resolvingFiles": "Resolving file states...",
+ "checkingWorktree": "Checking worktree context...",
+ "preparingDiffs": "Preparing review diffs..."
+ }
+ },
+ "progress": {
+ "viewed": "{{viewed}}/{{total}} viewed"
+ },
+ "scope": {
+ "readMore": "Read more",
+ "tiers": {
+ "exact": {
+ "title": "Task scope determined precisely",
+ "detail": "Both start and completion markers found in the session log. The diff includes only changes made during this specific task - other tasks that modified the same files are excluded."
+ },
+ "endEstimated": {
+ "title": "End boundary estimated",
+ "detail": "Only the start marker was found - the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included."
+ },
+ "startEstimated": {
+ "title": "Start boundary estimated",
+ "detail": "Only the completion marker was found - the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included."
+ },
+ "allSession": {
+ "title": "Showing all session changes",
+ "detail": "No task markers found in the session log. Cannot isolate this task - all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows."
+ }
+ },
+ "ledger": {
+ "exact": {
+ "title": "Changes captured by task ledger",
+ "detail": "The orchestrator captured these file changes while the agent was working on this task.",
+ "badge": "Ledger exact"
+ },
+ "limited": {
+ "title": "Changes captured with limited reviewability",
+ "detail": "The orchestrator captured these file changes for this task, but at least one change was captured from a snapshot or metadata-only source. Review exact text diffs where available; binary or unavailable content may require manual review.",
+ "mixedBadge": "Mixed reviewability",
+ "needsReviewBadge": "Needs review"
+ }
+ },
+ "workInterval": {
+ "title": "Scoped by persisted work interval",
+ "detail": "The task start marker was not available in the session log, so the diff is scoped by the task work interval stored on the board.",
+ "badge": "Interval scoped"
+ },
+ "confidence": {
+ "high": "High confidence",
+ "medium": "Medium confidence",
+ "low": "Low confidence",
+ "bestEffort": "Best effort"
+ }
+ },
+ "shortcuts": {
+ "title": "Keyboard Shortcuts",
+ "actions": {
+ "nextChange": "Next change",
+ "previousChange": "Previous change",
+ "nextFile": "Next file",
+ "previousFile": "Previous file",
+ "acceptChange": "Accept change",
+ "rejectChange": "Reject change",
+ "saveFile": "Save file",
+ "undo": "Undo",
+ "redo": "Redo",
+ "toggleShortcuts": "Toggle shortcuts",
+ "closeDialog": "Close dialog"
+ }
+ },
+ "timeline": {
+ "empty": "No edit events",
+ "titleWithCount": "Edit Timeline ({{count}})"
+ },
+ "continuousScroll": {
+ "empty": "No reviewable file changes"
+ },
+ "empty": {
+ "noSafeDiff": "No safe diff available",
+ "noFileChangesRecorded": "No file changes recorded",
+ "noSafeDiffDescription": "The task ledger did not expose a safe file diff for this task.",
+ "noSafeDiffDiagnosticsDescription": "The task ledger did not expose a safe file diff for this task. The diagnostics below explain why.",
+ "noFileEventsYet": "The task ledger has no file events for this task yet.",
+ "noFileEvents": "The task ledger has no file events for this task."
+ }
+ },
+ "messages": {
+ "actions": {
+ "bottomSheetActions": "Message bottom sheet actions",
+ "collapseAll": "Collapse all messages",
+ "collapseSheet": "Collapse sheet",
+ "expandAll": "Expand all messages",
+ "expandSheet": "Expand sheet",
+ "floatComposer": "Float composer",
+ "floatMessagesComposer": "Float messages composer",
+ "hideSearch": "Hide search",
+ "loadOlder": "Load older messages",
+ "markAllRead": "Mark all as read",
+ "messageActions": "Message actions",
+ "moveMessagesToBottomSheet": "Move messages to bottom sheet",
+ "moveMessagesToSidebar": "Move messages to sidebar",
+ "moveToBottomSheet": "Move to bottom sheet",
+ "moveToInline": "Move to inline",
+ "moveToSidebar": "Move to sidebar",
+ "panelActions": "Message panel actions",
+ "searchMessages": "Search messages"
+ },
+ "delivery": {
+ "copied": "Copied",
+ "copyDebugDetails": "Copy debug details",
+ "details": "Details",
+ "fields": {
+ "acceptanceUnknown": "acceptanceUnknown",
+ "delivered": "delivered",
+ "diagnostics": "diagnostics",
+ "ledgerStatus": "ledgerStatus",
+ "messageId": "messageId",
+ "providerId": "providerId",
+ "queuedBehindMessageId": "queuedBehindMessageId",
+ "reason": "reason",
+ "responsePending": "responsePending",
+ "responseState": "responseState",
+ "statusMessageId": "statusMessageId",
+ "userVisibleMessage": "userVisibleMessage",
+ "userVisibleNextReviewAt": "userVisibleNextReviewAt",
+ "userVisibleReasonCode": "userVisibleReasonCode",
+ "userVisibleState": "userVisibleState",
+ "visibleReplyCorrelation": "visibleReplyCorrelation",
+ "visibleReplyMessageId": "visibleReplyMessageId"
+ }
+ },
+ "panelMode": "Message panel mode",
+ "title": "Messages",
+ "unread": {
+ "new": "{{count}} new",
+ "unread": "{{count}} unread",
+ "new_few": "{{count}} new",
+ "new_many": "{{count}} new",
+ "new_one": "{{count}} new",
+ "new_other": "{{count}} new",
+ "unread_few": "{{count}} unread",
+ "unread_many": "{{count}} unread",
+ "unread_one": "{{count}} unread",
+ "unread_other": "{{count}} unread"
+ },
+ "filter": {
+ "ariaLabel": "Filter messages",
+ "tooltip": "Filter messages",
+ "from": "From",
+ "to": "To",
+ "noData": "No data",
+ "showStatusUpdates": "Show status updates (idle/shutdown)",
+ "actions": {
+ "reset": "Reset",
+ "save": "Save"
+ }
+ },
+ "status": {
+ "title": "Status"
+ },
+ "actionMode": {
+ "label": "Action mode"
+ },
+ "search": {
+ "placeholder": "Search..."
+ }
+ },
+ "modelSelector": {
+ "badges": {
+ "configured": "Configured",
+ "connected": "Connected",
+ "failed": "Failed",
+ "free": "Free",
+ "local": "Local",
+ "needsTest": "Needs test",
+ "verified": "Verified",
+ "unavailable": "Unavailable",
+ "issue": "Issue"
+ },
+ "customModelId": "Custom model id",
+ "label": "Model (optional)",
+ "multimodelRequired": "Codex and Gemini require Multimodel mode.",
+ "openCode": {
+ "allSources": "All OpenCode sources",
+ "filterSource": "Filter {{source}}",
+ "filterSources": "Filter OpenCode sources",
+ "freeOnly": "Free only",
+ "freeTooltip": "OpenCode marks this model as free.",
+ "loadingModels": "Loading OpenCode models...",
+ "noSourcesFound": "No sources found.",
+ "recommendedOnly": "Recommended only",
+ "searchSources": "Search sources",
+ "sourcesCount": "{{count}} OpenCode sources",
+ "sourcesCount_few": "{{count}} OpenCode sources",
+ "sourcesCount_many": "{{count}} OpenCode sources",
+ "sourcesCount_one": "{{count}} OpenCode sources",
+ "sourcesCount_other": "{{count}} OpenCode sources"
+ },
+ "reason": "Reason: {{reason}}",
+ "runtimeModelsSyncing": "Explicit models load from the current runtime. Default remains available while the list is syncing.",
+ "fastMode": {
+ "codexLabel": "Fast mode (2x credits)",
+ "optionalLabel": "Fast mode (optional)",
+ "defaultOff": "Default (Off)",
+ "fast": "Fast",
+ "off": "Off",
+ "defaultFast": "Default (Fast)",
+ "defaultResolvesTo": "Default currently resolves to {{mode}}.",
+ "runtimeBackedHint": "Fast mode is runtime-backed and only unlocks when the resolved Anthropic launch model supports it."
+ },
+ "anthropicExtraUsage": {
+ "pricingDocs": "Read Anthropic pricing docs"
+ },
+ "searchModels": "Search models",
+ "defaultModel": "Default",
+ "empty": {
+ "noSearchMatches": "No models match this search.",
+ "recommendedFreeOpenCode": "No recommended free OpenCode models are available in the current runtime list.",
+ "freeOpenCode": "No free OpenCode models are available in the current runtime list.",
+ "recommendedOpenCode": "No recommended OpenCode models are available in the current runtime list.",
+ "noModels": "No models are available in the current runtime list."
+ },
+ "openCodeStatus": {
+ "notReadyTitle": "OpenCode is not ready for team launch",
+ "freeModelsAvailableTitle": "OpenCode free models are available",
+ "providerNotConnectedTitle": "OpenCode provider is not connected",
+ "readyTitle": "OpenCode is ready",
+ "readyMessage": "OpenCode passed provider readiness. Select it to use OpenCode models for this team.",
+ "useOpenCode": "Use OpenCode",
+ "badges": {
+ "check": "Check",
+ "install": "Install",
+ "free": "Free",
+ "setup": "Setup"
+ },
+ "summary": {
+ "checking": "OpenCode status: checking runtime",
+ "status": "OpenCode status: {{parts}}"
+ },
+ "summaryParts": {
+ "teamLaunchBlocked": "team launch blocked",
+ "providerOptional": "provider connection optional",
+ "providerModelsNeedSetup": "provider-backed models need setup",
+ "teamLaunchReady": "team launch ready",
+ "runtimeDetected": "runtime detected",
+ "runtimeMissing": "runtime missing",
+ "freeWithoutAuth": "free models available without auth",
+ "providerConnected": "provider connected",
+ "providerNotConnected": "provider not connected"
+ },
+ "messages": {
+ "checking": "The app is still checking the OpenCode runtime. Wait for provider status to finish, then try again.",
+ "unsupported": "OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.",
+ "freeAvailable": "OpenCode is detected. You can use free OpenCode models such as Big Pickle without connecting a provider. Connect a provider only when you want provider-backed models.",
+ "noFreeListed": "OpenCode is detected, but no free OpenCode model is listed yet. Refresh provider status, or connect a provider in OpenCode for provider-backed models.",
+ "launchBlocked": "OpenCode is installed and authenticated, but Agent Teams launch readiness is blocked.",
+ "ready": "OpenCode is ready for team launch."
+ },
+ "loadingRuntime": "OpenCode runtime status is still loading."
+ },
+ "advisory": {
+ "pingNotConfirmed": "Ping not confirmed",
+ "note": "Note"
+ },
+ "placeholders": {
+ "customModelId": "openai/gpt-oss-20b"
+ },
+ "routeGroups": {
+ "openCodeConfig": "OpenCode config",
+ "builtinFree": "Free built-in",
+ "connectedProviders": "Connected providers",
+ "otherCatalog": "Other OpenCode catalog"
+ },
+ "pricing": {
+ "free": "Free",
+ "inputShort": "in {{rate}}",
+ "outputShort": "out {{rate}}",
+ "perMillionSummary": "{{summary}} / 1M",
+ "inputTitle": "Input: {{rate}} per 1M tokens",
+ "outputTitle": "Output: {{rate}} per 1M tokens",
+ "cacheReadTitle": "Cache read: {{rate}} per 1M tokens",
+ "cacheWriteTitle": "Cache write: {{rate}} per 1M tokens"
+ },
+ "defaultTooltip": {
+ "anthropicCompatibleWithResolved": "Uses the Anthropic-compatible endpoint default model.\nCurrently resolves to {{model}}.",
+ "anthropicCompatible": "Uses the Anthropic-compatible endpoint default model.",
+ "anthropic": "Uses the Claude team default model.\nResolves to {{longContextModel}} with 1M context, or {{limitedContextModel}} with 200K context when Limit context is enabled.",
+ "openCodeWithResolved": "Uses the OpenCode default model.\nCurrently resolves to {{model}}.",
+ "openCode": "Uses the OpenCode runtime default model.",
+ "runtime": "Uses the runtime default for the selected provider."
+ },
+ "multimodelOff": "Multimodel off",
+ "unavailableInRuntime": "Unavailable in current runtime"
+ },
+ "taskDetail": {
+ "actions": {
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "markResolved": "Mark resolved",
+ "save": "Save"
+ },
+ "attachments": {
+ "commentAttachment": "Comment attachment",
+ "fromComments": "From comments",
+ "preview": "Preview {{filename}}"
+ },
+ "changes": {
+ "badges": {
+ "attention": "attention",
+ "noSafeDiff": "no safe diff"
+ },
+ "empty": {
+ "noFileChangesRecorded": "No file changes recorded",
+ "noFileChangesRecordedYet": "No file changes recorded yet",
+ "noReviewableChangesRecovered": "No reviewable file changes recovered",
+ "noSafeDiffAvailable": "No safe diff available"
+ },
+ "loadFailed": "Failed to load task changes summary",
+ "loading": "Loading changes...",
+ "fileCount": "{{count}} files",
+ "fileRowsHidden": "{{count}} file rows hidden",
+ "moreDiagnostics": "{{count}} more diagnostics",
+ "moreFiles": "{{count}} more files",
+ "openInEditor": "Open in editor",
+ "openTask": "Open task {{subject}}",
+ "refresh": "Refresh changes",
+ "refreshFailed": "Refresh failed: {{error}}",
+ "refreshing": "Refreshing",
+ "refreshingChanges": "Refreshing changes...",
+ "refreshTeamChanges": "Refresh team changes",
+ "refreshShort": "Refresh",
+ "reviewDiff": "Review diff",
+ "reviewTaskDiff": "Review task diff",
+ "scannedCandidateTasks": "Scanned {{requested}} of {{eligible}} candidate tasks",
+ "tasksDeferred": "{{count}} tasks deferred this pass",
+ "title": "Changes",
+ "fileCount_few": "{{count}} files",
+ "fileCount_many": "{{count}} files",
+ "fileCount_one": "{{count}} files",
+ "fileCount_other": "{{count}} files",
+ "fileRowsHidden_few": "{{count}} file rows hidden",
+ "fileRowsHidden_many": "{{count}} file rows hidden",
+ "fileRowsHidden_one": "{{count}} file rows hidden",
+ "fileRowsHidden_other": "{{count}} file rows hidden",
+ "moreDiagnostics_few": "{{count}} more diagnostics",
+ "moreDiagnostics_many": "{{count}} more diagnostics",
+ "moreDiagnostics_one": "{{count}} more diagnostics",
+ "moreDiagnostics_other": "{{count}} more diagnostics",
+ "moreFiles_few": "{{count}} more files",
+ "moreFiles_many": "{{count}} more files",
+ "moreFiles_one": "{{count}} more files",
+ "moreFiles_other": "{{count}} more files",
+ "tasksDeferred_few": "{{count}} tasks deferred this pass",
+ "tasksDeferred_many": "{{count}} tasks deferred this pass",
+ "tasksDeferred_one": "{{count}} tasks deferred this pass",
+ "tasksDeferred_other": "{{count}} tasks deferred this pass"
+ },
+ "clarification": {
+ "awaitingLead": "Awaiting clarification from team lead",
+ "awaitingUser": "Awaiting clarification from you"
+ },
+ "description": {
+ "add": "Click to add description...",
+ "edit": "Edit description",
+ "placeholder": "Task description (supports markdown)"
+ },
+ "loading": {
+ "fetchingTeamData": "Fetching team data",
+ "title": "Loading task..."
+ },
+ "logs": {
+ "newArriving": "New task logs arriving"
+ },
+ "notFound": "Task not found",
+ "related": {
+ "blockedBy": "Blocked by",
+ "blocks": "Blocks",
+ "linkedFrom": "Linked from",
+ "links": "Links",
+ "title": "Related tasks"
+ },
+ "review": {
+ "reviewer": "Reviewer: {{reviewer}}"
+ },
+ "sections": {
+ "attachments": "Attachments",
+ "changes": "Changes",
+ "comments": "Comments",
+ "description": "Description",
+ "taskLogs": "Task Logs",
+ "workflowHistory": "Workflow History"
+ },
+ "unassigned": "Unassigned",
+ "workflow": {
+ "implementationTimeTitle": "Implementation time from persisted work intervals",
+ "inProgressTime": "In progress time {{duration}}"
+ },
+ "comments": {
+ "renderLimit": "Showing the most recent {{formattedCount}} comments to keep the UI responsive.",
+ "badges": {
+ "approved": "Approved",
+ "reviewRequested": "Review requested"
+ },
+ "unknownTime": "unknown time",
+ "actions": {
+ "reply": "Reply",
+ "replyToComment": "Reply to comment",
+ "showMore": "Show more comments ({{visible}}/{{total}})",
+ "cancelReply": "Cancel reply",
+ "comment": "Comment"
+ },
+ "attachments": {
+ "previewAlt": "Attachment preview",
+ "downloadFailed": "Download failed"
+ },
+ "replyingTo": "Replying to",
+ "input": {
+ "placeholder": "Add a comment... (Enter to send)",
+ "charsLeft": "{{count}} chars left",
+ "charsLeft_one": "{{count}} char left",
+ "charsLeft_other": "{{count}} chars left",
+ "charsLeft_few": "{{count}} chars left",
+ "charsLeft_many": "{{count}} chars left"
+ }
+ },
+ "workflowTimeline": {
+ "empty": "No workflow history recorded",
+ "currentImplementationInterval": "Current implementation interval",
+ "implementationIntervalEnded": "Implementation interval ended at this transition",
+ "runningPrefix": "running ",
+ "createdAs": "Created as",
+ "by": "by",
+ "reassigned": "Reassigned",
+ "assignedTo": "Assigned to",
+ "unassignedFrom": "Unassigned from",
+ "ownerChanged": "Owner changed",
+ "reviewRequested": "Review requested",
+ "reviewStarted": "Review started",
+ "changesRequested": "Changes requested",
+ "approved": "Approved",
+ "unknownEvent": "Unknown event"
+ },
+ "reviewStates": {
+ "approved": "Approved",
+ "needsFix": "Needs fix",
+ "inReview": "In review"
+ }
+ },
+ "tasks": {
+ "createTask": {
+ "assignee": "Assignee",
+ "assigneeOptional": "Assignee (optional)",
+ "blockedByOptional": "Blocked by tasks (optional)",
+ "blockedBySummary": "Task will be blocked by: {{tasks}}",
+ "cancel": "Cancel",
+ "create": "Create",
+ "creating": "Creating...",
+ "description": "The task will be created in the team's tasks/ directory and appear on the Kanban board.",
+ "descriptionOptional": "Description (optional)",
+ "detailsPlaceholder": "Task details (supports markdown)",
+ "hideOptionalFields": "Hide optional fields",
+ "offlineNotice": {
+ "after": "- launch the team to start execution.",
+ "before": "Team is offline. The task will be added to"
+ },
+ "promptOptional": "Prompt for assignee (optional)",
+ "promptPlaceholder": "Custom instructions for the team member...",
+ "relatedOptional": "Related tasks (optional)",
+ "relatedSummary": "Related: {{tasks}}",
+ "saved": "Saved",
+ "searchTasks": "Search tasks...",
+ "selectMember": "Select a member",
+ "selectMemberOptional": "Select member...",
+ "showOptionalFields": "Show optional fields",
+ "startImmediately": "Start immediately",
+ "startOfflineHint": "Team is offline. Launch the team first to start tasks immediately.",
+ "subject": "Subject",
+ "subjectPlaceholder": "What needs to be done?",
+ "title": "Create Task",
+ "todo": "TODO"
+ },
+ "list": {
+ "columns": {
+ "blockedBy": "Blocked By",
+ "blocks": "Blocks",
+ "id": "ID",
+ "owner": "Owner",
+ "status": "Status",
+ "subject": "Subject"
+ },
+ "empty": "No tasks in this team",
+ "filters": {
+ "allOwners": "All owners",
+ "allStatuses": "All statuses",
+ "ownerAria": "Filter tasks by owner",
+ "statusAria": "Filter tasks by status"
+ },
+ "showing": "Showing {{shown}} of {{total}}"
+ },
+ "status": {
+ "completed": "completed",
+ "deleted": "deleted",
+ "inProgress": "in_progress",
+ "pending": "pending"
+ },
+ "statusSummary": {
+ "progressAria": "Tasks {{completed}}/{{total}} completed",
+ "inProgress": "{{count}} in_progress",
+ "inProgress_one": "{{count}} in_progress",
+ "inProgress_other": "{{count}} in_progress",
+ "inProgress_few": "{{count}} in_progress",
+ "inProgress_many": "{{count}} in_progress",
+ "pending": "{{count}} pending",
+ "pending_one": "{{count}} pending",
+ "pending_other": "{{count}} pending",
+ "pending_few": "{{count}} pending",
+ "pending_many": "{{count}} pending",
+ "completed": "{{count}} completed",
+ "completed_one": "{{count}} completed",
+ "completed_other": "{{count}} completed",
+ "completed_few": "{{count}} completed",
+ "completed_many": "{{count}} completed"
+ },
+ "unassigned": "Unassigned",
+ "teamPrefix": "Team:",
+ "openTask": "Open task",
+ "deleteConfirm": {
+ "title": "Delete task",
+ "message": "Move task #{{taskId}} to trash?",
+ "confirmLabel": "Delete",
+ "cancelLabel": "Cancel"
+ }
+ },
+ "editor": {
+ "actions": {
+ "cancel": "Cancel",
+ "closeEditor": "Close editor",
+ "closeTab": "Close tab",
+ "closeTooltip": "Close editor (Esc)",
+ "discard": "Discard",
+ "discardAndClose": "Discard & Close",
+ "keep": "Keep",
+ "keepMine": "Keep mine",
+ "keyboardShortcuts": "Keyboard shortcuts",
+ "overwrite": "Overwrite",
+ "refreshAria": "Refresh (F5)",
+ "refreshTooltip": "Refresh git status (F5)",
+ "reload": "Reload",
+ "retry": "Retry",
+ "save": "Save",
+ "saveAllAndClose": "Save All & Close"
+ },
+ "ariaLabel": "Project Editor",
+ "dialogs": {
+ "conflictDescription": "The file has been modified externally since you opened it. Overwrite with your changes?",
+ "conflictTitle": "Save Conflict",
+ "unsavedDescription": "You have unsaved changes. What would you like to do?",
+ "unsavedFileDescription": "This file has unsaved changes. What would you like to do?",
+ "unsavedTitle": "Unsaved Changes"
+ },
+ "newFile": {
+ "validation": {
+ "nameRequired": "Name cannot be empty",
+ "invalidName": "Invalid name",
+ "invalidCharacters": "Name contains invalid characters",
+ "nameTooLong": "Name is too long"
+ },
+ "placeholders": {
+ "fileName": "File name...",
+ "folderName": "Folder name..."
+ },
+ "aria": {
+ "newFileName": "New file name",
+ "newFolderName": "New folder name"
+ }
+ },
+ "draftRecovered": "Recovered unsaved changes from a previous session.",
+ "externalChange": {
+ "changed": "File changed on disk.",
+ "deleted": "File no longer exists on disk."
+ },
+ "saveFailed": "Save failed: {{error}}",
+ "sidebar": {
+ "explorer": "Explorer",
+ "hide": "Hide sidebar",
+ "hideWithShortcut": "Hide sidebar ({{shortcut}})",
+ "show": "Show sidebar",
+ "showWithShortcut": "Show sidebar ({{shortcut}})"
+ },
+ "searchInFiles": {
+ "title": "Search in Files",
+ "closeSearch": "Close search",
+ "closeSearchShortcut": "Close search (Esc)",
+ "searchPlaceholder": "Search...",
+ "matchCase": "Match Case",
+ "matchCaseToggle": "Aa",
+ "noResults": "No results found",
+ "resultsSummary": "{{count}} matches in {{fileCount}} files",
+ "resultsSummary_one": "{{count}} match in {{fileCount}} files",
+ "truncated": "(truncated)",
+ "resultsSummary_few": "{{count}} matches in {{fileCount}} files",
+ "resultsSummary_many": "{{count}} matches in {{fileCount}} files",
+ "resultsSummary_other": "{{count}} matches in {{fileCount}} files"
+ },
+ "fileTree": {
+ "failedToLoadFiles": "Failed to load files: {{error}}",
+ "loading": "Loading files...",
+ "empty": "No files found",
+ "dropForProjectRoot": "Drop here for project root",
+ "moveToTrash": "Move to Trash",
+ "moveToTrashConfirm": "Move \"{{name}}\" to Trash?",
+ "cancel": "Cancel"
+ },
+ "goToLine": {
+ "title": "Go to Line",
+ "position": "(current: {{current}}, total: {{total}})",
+ "placeholder": "Line number, +offset, -offset, or %",
+ "go": "Go"
+ },
+ "searchPanel": {
+ "previousMatch": "Previous Match",
+ "nextMatch": "Next Match",
+ "close": "Close",
+ "replacePlaceholder": "Replace",
+ "replace": "Replace",
+ "replaceNext": "Replace Next",
+ "all": "All",
+ "replaceAll": "Replace All"
+ },
+ "statusBar": {
+ "position": "Ln {{line}}, Col {{col}}",
+ "enableWatcher": "Enable file watcher",
+ "disableWatcher": "Disable file watcher",
+ "watch": "watch",
+ "watching": "watching",
+ "watchExternalChanges": "Watch for external changes",
+ "disableExternalWatcher": "Disable external change watcher",
+ "encodingUtf8": "UTF-8",
+ "spaces": "Spaces: {{count}}"
+ },
+ "imagePreview": {
+ "loading": "Loading preview...",
+ "openFullSize": "Open full-size preview",
+ "openSystemViewer": "Open in System Viewer"
+ },
+ "quickOpen": {
+ "title": "Quick Open",
+ "searchPlaceholder": "Search files by name...",
+ "loading": "Loading files...",
+ "empty": "No files found"
+ },
+ "errorBoundary": {
+ "crashed": "Editor crashed",
+ "unknownError": "Unknown error"
+ },
+ "binaryPlaceholder": {
+ "file": "Binary file ({{size}})"
+ },
+ "unsavedChanges": "Unsaved changes",
+ "empty": {
+ "selectFile": "Select a file from the tree to edit"
+ },
+ "search": {
+ "toggleReplace": "Toggle Replace",
+ "placeholder": "Search"
+ },
+ "shortcuts": {
+ "title": "Keyboard Shortcuts",
+ "groups": {
+ "fileOperations": "File Operations",
+ "search": "Search",
+ "navigation": "Navigation",
+ "editing": "Editing",
+ "markdown": "Markdown",
+ "general": "General"
+ },
+ "actions": {
+ "quickOpen": "Quick Open",
+ "save": "Save",
+ "saveAll": "Save All",
+ "closeTab": "Close Tab",
+ "findInFile": "Find in File",
+ "searchInFiles": "Search in Files",
+ "goToLine": "Go to Line",
+ "nextTab": "Next Tab",
+ "previousTab": "Previous Tab",
+ "cycleTabs": "Cycle Tabs",
+ "toggleSidebar": "Toggle Sidebar",
+ "undo": "Undo",
+ "redo": "Redo",
+ "selectNextMatch": "Select Next Match",
+ "toggleComment": "Toggle Comment",
+ "splitPreview": "Split Preview",
+ "fullPreview": "Full Preview",
+ "closeEditor": "Close Editor"
+ }
+ },
+ "toolbar": {
+ "enableWordWrap": "Enable word wrap",
+ "disableWordWrap": "Disable word wrap",
+ "closeSplitPreview": "Close split preview",
+ "closePreview": "Close preview"
+ }
+ },
+ "launch": {
+ "actions": {
+ "createSchedule": "Create Schedule",
+ "creating": "Creating...",
+ "goToDashboard": "Go to Dashboard",
+ "launchTeam": "Launch team",
+ "launching": "Launching...",
+ "relaunchTeam": "Relaunch team",
+ "relaunching": "Relaunching...",
+ "saveChanges": "Save Changes",
+ "saving": "Saving..."
+ },
+ "billing": {
+ "prefix": "Starting June 15, 2026, Anthropic bills",
+ "readArticle": "Read Anthropic article",
+ "suffix": "and Agent SDK usage from the monthly Agent SDK credit, separate from interactive Claude Code limits. The credit resets each billing cycle and unused credit does not roll over."
+ },
+ "conflict": {
+ "description": "Running two teams in the same directory is risky - they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.",
+ "title": "Another team \"{{team}}\" is already running for this working directory",
+ "workingDirectory": "Working directory:"
+ },
+ "description": {
+ "createSchedule": "Schedule automatic Claude task execution",
+ "createScheduleForTeam": "Schedule automatic runs for team \"{{team}}\"",
+ "editSchedule": "Editing schedule for team \"{{team}}\"",
+ "launchPrefix": "Start team",
+ "launchSuffix": "via local Claude CLI.",
+ "relaunchPrefix": "Stop the current run for",
+ "relaunchSuffix": "and start it again via local Claude CLI."
+ },
+ "prepare": {
+ "action": {
+ "launch": "launch",
+ "relaunch": "relaunch"
+ },
+ "blocked": "Runtime environment is not available - {{action}} is blocked",
+ "checkingProviders": "Checking selected providers...",
+ "failed": "Failed to prepare selected providers",
+ "preflight": "Pre-flight check to catch errors before {{action}}",
+ "preparingEnvironment": "Preparing environment...",
+ "ready": "All selected providers are ready.",
+ "readyWithNotes": "All selected providers are ready, with notes.",
+ "unsupportedPreload": "Current preload version does not support team:prepareProvisioning. Restart the dev app.",
+ "selectWorkingDirectory": "Select a working directory to validate the launch environment.",
+ "someProvidersNeedAttention": "Some selected providers need attention."
+ },
+ "prompt": {
+ "label": "Prompt",
+ "oneShotPrefix": "This prompt will be passed to",
+ "oneShotSuffix": "for one-shot execution",
+ "saved": "Saved",
+ "schedulePlaceholder": "Instructions for Claude to execute on schedule...",
+ "teamLeadOptional": "Prompt for team lead (optional)",
+ "teamLeadPlaceholder": "Instructions for team lead..."
+ },
+ "providerChanged": "Provider changed from {{from}} to {{to}}. The previous lead session will not be resumed, and the lead will start with fresh context so the new runtime is applied correctly.",
+ "relaunchFreshSession": "Team relaunch starts a fresh lead session. Durable team state, task board, and member configuration are rehydrated into the launch prompt.",
+ "relaunchWarning": {
+ "description": "Saving these settings will stop the current team process, persist the updated roster, and launch the team again with the new runtime.",
+ "title": "Relaunch will restart the current team run"
+ },
+ "schedule": {
+ "labelOptional": "Label (optional)",
+ "labelPlaceholder": "e.g., Daily code review, Nightly tests...",
+ "maxBudgetUsd": "Max budget (USD)",
+ "maxTurns": "Max turns",
+ "noLimit": "No limit",
+ "noMatches": "No teams match your search.",
+ "noTeams": "No teams available. Create a team first.",
+ "searchTeams": "Search teams...",
+ "selectTeam": "Select a team...",
+ "team": "Team",
+ "title": "Schedule"
+ },
+ "title": {
+ "createSchedule": "Create Schedule",
+ "editSchedule": "Edit Schedule",
+ "launch": "Launch Team",
+ "relaunch": "Relaunch Team"
+ },
+ "errors": {
+ "loadProjectsFailed": "Failed to load projects",
+ "saveScheduleFailed": "Failed to save schedule",
+ "relaunchFailed": "Failed to relaunch team",
+ "launchFailed": "Failed to launch team"
+ },
+ "validation": {
+ "openCodeLeadModelRequired": "OpenCode lead requires a selected model.",
+ "openCodeTeammateRequired": "OpenCode lead requires at least one OpenCode teammate.",
+ "selectWorkingDirectory": "Select working directory (cwd)",
+ "fixMemberNames": "Fix member names before launch",
+ "memberNamesUnique": "Member names must be unique before launch"
+ },
+ "optionalSettings": {
+ "relaunchTitle": "Relaunch settings",
+ "title": "Optional launch settings",
+ "relaunchDescription": "Review the roster and lead runtime before restarting the team.",
+ "description": "Keep the launch flow focused on the project path and only expand this when you want extra control."
+ }
+ },
+ "list": {
+ "actions": {
+ "copyTeam": "Copy team",
+ "createTeam": "Create Team",
+ "deleteForever": "Delete forever",
+ "deletePermanently": "Delete permanently",
+ "deleteTeam": "Delete team",
+ "launching": "Launching...",
+ "launchTeam": "Launch team",
+ "relaunchTeam": "Relaunch team",
+ "restore": "Restore",
+ "restoreTeam": "Restore team",
+ "retry": "Retry",
+ "stopTeam": "Stop team",
+ "stopping": "Stopping..."
+ },
+ "electronOnly": {
+ "description": "In browser mode, access to local `~/.claude/teams` directories is not available.",
+ "title": "Teams is only available in Electron mode"
+ },
+ "empty": {
+ "description": "Create a team here to get started. It will show up in the list automatically.",
+ "localOnly": "Team creation is only available in local Electron mode.",
+ "title": "No teams found"
+ },
+ "filter": {
+ "clearAll": "Clear all",
+ "label": "Filter teams",
+ "projectPriority": "Project priority",
+ "status": "Status"
+ },
+ "loadFailed": "Failed to load teams",
+ "loading": "Loading teams...",
+ "localOnly": "Only available in local Electron mode.",
+ "membersCount": "Members: {{count}}",
+ "membersCount_few": "Members: {{count}}",
+ "membersCount_many": "Members: {{count}}",
+ "membersCount_one": "Member: {{count}}",
+ "membersCount_other": "Members: {{count}}",
+ "noDescription": "No description",
+ "noMatches": "No teams matching current filters",
+ "partial": {
+ "pending": "Last launch is still reconciling.",
+ "skipped": "Last launch has skipped teammates.",
+ "skippedWithCount": "Last launch skipped {{count}}/{{expected}} teammate.",
+ "skippedWithCount_few": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "skippedWithCount_many": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "skippedWithCount_one": "Last launch skipped {{count}}/{{expected}} teammate.",
+ "skippedWithCount_other": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "stopped": "Last launch stopped before all teammates joined.",
+ "stoppedWithCount": "Last launch stopped before {{count}}/{{expected}} teammate joined.",
+ "stoppedWithCount_few": "Last launch stopped before {{count}}/{{expected}} teammates joined.",
+ "stoppedWithCount_many": "Last launch stopped before {{count}}/{{expected}} teammates joined.",
+ "stoppedWithCount_one": "Last launch stopped before {{count}}/{{expected}} teammate joined.",
+ "stoppedWithCount_other": "Last launch stopped before {{count}}/{{expected}} teammates joined."
+ },
+ "searchPlaceholder": "Search teams...",
+ "sections": {
+ "otherTeams": "Other teams",
+ "projectTeams": "Teams for {{project}}",
+ "selectedProject": "selected project"
+ },
+ "solo": "Solo",
+ "status": {
+ "active": "Active",
+ "deleted": "Deleted",
+ "launching": "Launching...",
+ "offline": "Offline",
+ "partialFailure": "Launch failed partway",
+ "partialPending": "Bootstrap pending",
+ "partialSkipped": "Launch skipped member",
+ "running": "Running"
+ },
+ "title": "Select Team",
+ "trash": "Trash ({{count}})",
+ "trash_few": "Trash ({{count}})",
+ "trash_many": "Trash ({{count}})",
+ "trash_one": "Trash ({{count}})",
+ "trash_other": "Trash ({{count}})",
+ "deleteDraft": {
+ "title": "Delete draft",
+ "message": "Delete draft team \"{{teamName}}\"? This cannot be undone.",
+ "confirmLabel": "Delete",
+ "cancelLabel": "Cancel"
+ },
+ "moveToTrash": {
+ "title": "Move to trash",
+ "message": "Move team \"{{teamName}}\" to trash? You can restore it later.",
+ "confirmLabel": "Move to trash",
+ "cancelLabel": "Cancel"
+ },
+ "deleteForever": {
+ "title": "Delete permanently",
+ "message": "Delete team \"{{teamName}}\" permanently? All data will be lost.",
+ "confirmLabel": "Delete forever",
+ "cancelLabel": "Cancel"
+ }
+ },
+ "messageComposer": {
+ "crossTeam": {
+ "hint": "Tip: Cross-team messages go to the target team lead. If you want the reply to come back to your team lead instead of you, say that explicitly in the message."
+ },
+ "attachments": {
+ "attachFiles": "Attach files (paste or drag & drop)",
+ "unavailable": "Attachments are unavailable",
+ "disabledHint": "File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.",
+ "restrictions": {
+ "crossTeam": "File attachments are not supported for cross-team messages",
+ "teamOffline": "Team must be online to attach files",
+ "unsupportedRecipient": "Files can be sent to the team lead or OpenCode teammates",
+ "openCodeOffline": "Team must be online to attach files for OpenCode teammates",
+ "sending": "Wait for current message to finish sending before adding files",
+ "maximumReached": "Maximum attachments reached",
+ "leadOnly": "Files can only be sent to the team lead"
+ }
+ },
+ "slash": {
+ "restrictions": {
+ "attachments": "Slash commands require a live team lead and cannot be sent with attachments",
+ "crossTeam": "Slash commands can only be run on the current team lead",
+ "notLead": "Slash commands can only be sent to the team lead",
+ "leadOffline": "Slash commands require the team lead to be online"
+ }
+ },
+ "status": {
+ "reusedCrossTeamRequest": "Reused recent cross-team request",
+ "teamOffline": "Team offline"
+ },
+ "input": {
+ "charsLeft": "{{count}} chars left",
+ "charsLeft_one": "{{count}} char left",
+ "charsLeft_other": "{{count}} chars left",
+ "teamLaunchingPlaceholder": "Team is launching... message will be queued for inbox delivery.",
+ "crossTeamPlaceholder": "Cross-team message to {{team}}...",
+ "teamFallback": "team",
+ "placeholder": "Write a message... (Enter to send, Shift+Enter for new line)",
+ "slashTip": "Tip: You can use \"/\" to run any Claude commands.",
+ "charsLeft_few": "{{count}} chars left",
+ "charsLeft_many": "{{count}} chars left"
+ },
+ "teamSelector": {
+ "thisTeam": "This team",
+ "current": "current",
+ "online": "online",
+ "offline": "offline",
+ "onlineTitle": "Online",
+ "offlineTitle": "Offline"
+ },
+ "recipient": {
+ "select": "Select...",
+ "searchPlaceholder": "Search...",
+ "noResults": "No results"
+ },
+ "actions": {
+ "voiceToText": "Voice to text",
+ "send": "Send",
+ "sendingUnavailableLaunching": "Sending unavailable while team is launching"
+ }
+ },
+ "claudeLogs": {
+ "filter": {
+ "ariaLabel": "Filter Claude logs",
+ "tooltip": "Filter logs",
+ "sections": {
+ "stream": "Stream",
+ "content": "Content"
+ },
+ "kinds": {
+ "output": "Output",
+ "thinking": "Thinking",
+ "tool": "Tool calls"
+ },
+ "actions": {
+ "reset": "Reset",
+ "save": "Save"
+ },
+ "streams": {
+ "stdout": "stdout",
+ "stderr": "stderr"
+ }
+ },
+ "rawLineCount": "{{formattedCount}} raw lines",
+ "rawLineCount_one": "{{formattedCount}} raw line",
+ "rawLinesCaptured": "{{count}} captured",
+ "emptyRawLogs": "{{count}}; none are assistant/tool output yet.",
+ "noLogsYet": "No logs yet.",
+ "teamNotRunning": "Team is not running.",
+ "searchPlaceholder": "Search logs...",
+ "clearSearch": "Clear search",
+ "newCount": "+{{count}} new",
+ "loading": "Loading...",
+ "showMore": "Show more",
+ "noLogsCaptured": "No logs captured.",
+ "noMatchingLogs": "No matching logs.",
+ "rawLineCount_few": "{{formattedCount}} raw lines",
+ "rawLineCount_many": "{{formattedCount}} raw lines",
+ "rawLineCount_other": "{{formattedCount}} raw lines",
+ "openFullscreen": "Open fullscreen logs",
+ "fullscreen": "Fullscreen",
+ "viewingFullscreen": "Viewing in fullscreen mode",
+ "logsTitle": "Logs"
+ },
+ "agentGraph": {
+ "popover": {
+ "externalTeam": "External team",
+ "process": {
+ "startedBy": "Started by:",
+ "at": "At:",
+ "openUrl": "Open URL"
+ },
+ "overflow": {
+ "hiddenTasks": "Hidden tasks",
+ "empty": "No hidden tasks available."
+ },
+ "member": {
+ "lead": "Lead",
+ "workingOn": "working on",
+ "recentTools": "Recent tools",
+ "spawn": {
+ "waitingToStart": "waiting to start",
+ "starting": "starting",
+ "failed": "failed"
+ },
+ "state": {
+ "active": "active",
+ "idle": "idle",
+ "offline": "offline",
+ "runningTool": "running tool"
+ },
+ "activeTool": {
+ "running": "Running tool",
+ "failed": "Tool failed",
+ "finished": "Tool finished"
+ },
+ "actions": {
+ "message": "Message",
+ "profile": "Profile",
+ "task": "Task"
+ }
+ }
+ },
+ "logPreview": {
+ "logs": "Logs",
+ "loading": "Loading logs",
+ "more": "+{{count}} more",
+ "more_one": "+{{count}} more",
+ "more_other": "+{{count}} more",
+ "more_few": "+{{count}} more",
+ "more_many": "+{{count}} more"
+ },
+ "blockingEdge": {
+ "title": "Blocking Dependency",
+ "blocks": "blocks",
+ "close": "Close",
+ "blockingHiddenTasks": "Blocking hidden tasks",
+ "blockedHiddenTasks": "Blocked hidden tasks"
+ },
+ "activityHud": {
+ "activity": "Activity",
+ "noRecentActivity": "No recent activity",
+ "more": "+{{count}} more",
+ "more_one": "+{{count}} more",
+ "more_other": "+{{count}} more",
+ "more_few": "+{{count}} more",
+ "more_many": "+{{count}} more"
+ },
+ "provisioning": {
+ "launchDetails": "Launch details",
+ "launchDetailsDescription": "Detailed team launch progress, live output and CLI logs."
+ }
+ },
+ "projectPath": {
+ "label": "Project",
+ "source": {
+ "claude": "Found by Claude",
+ "codex": "Found by Codex",
+ "mixed": "Found by Claude and Codex"
+ },
+ "deleted": {
+ "title": "Project folder no longer exists",
+ "label": "Deleted"
+ },
+ "mode": {
+ "projectList": "From project list",
+ "customPath": "Custom path"
+ },
+ "loadingProjects": "Loading projects...",
+ "selectProject": "Select a project...",
+ "searchPlaceholder": "Search project by name or path",
+ "empty": "Nothing found",
+ "selectFromList": "Select a project from the list",
+ "noProjects": "No projects found, switch to custom path.",
+ "customWorkingDirectory": "Custom working directory",
+ "browse": "Browse",
+ "createAutomatically": "If the directory does not exist, it will be created automatically."
+ },
+ "members": {
+ "badges": {
+ "worktree": "worktree"
+ },
+ "runtimeTelemetry": {
+ "title": "Local runtime load",
+ "description": "Parent and child processes only. Remote LLM inference is not included.",
+ "cpu": "CPU",
+ "memory": "Memory",
+ "summedRss": "summed RSS",
+ "sharedHost": "Shared OpenCode host metric. It is not exclusive to this member.",
+ "processTreeCapped": "Process tree was capped for this sample.",
+ "rssHint": "RSS can include shared pages, so it is best read as a load signal, not exclusive memory."
+ },
+ "editor": {
+ "title": "Members",
+ "addMember": "Add member",
+ "editAsJson": "Edit as JSON",
+ "runInSeparateWorktrees": "Run teammates in separate worktrees",
+ "agentTeamsMcpOnly": "Agent Teams MCP only",
+ "removedCount": "Removed ({{count}})",
+ "removedModelLockReason": "Removed members are kept for soft delete history. Restore them to edit settings.",
+ "memberNamesUnique": "Member names must be unique"
+ },
+ "stats": {
+ "computing": "Computing stats...",
+ "empty": "No stats available",
+ "lines": "Lines",
+ "linesInfo": "Approximate. Accurate for Edit and Write tools. Bash file writes are estimated from command patterns (heredoc, echo, sed) and may be underreported.",
+ "files": "Files",
+ "toolCalls": "Tool Calls",
+ "tokens": "Tokens",
+ "toolUsage": "Tool Usage",
+ "filesTouched": "Files Touched ({{count}})",
+ "viewAllChanges": "View All Changes",
+ "showLess": "Show less",
+ "moreFiles": "+{{count}} more",
+ "footer": "{{count}} sessions · computed {{computedAgo}}",
+ "footer_one": "{{count}} session · computed {{computedAgo}}",
+ "footer_few": "{{count}} sessions · computed {{computedAgo}}",
+ "footer_many": "{{count}} sessions · computed {{computedAgo}}",
+ "footer_other": "{{count}} sessions · computed {{computedAgo}}"
+ },
+ "logs": {
+ "searching": "Searching logs...",
+ "empty": "No logs found",
+ "waitingForTaskActivity": "Task is in progress - waiting for session activity (auto-refreshing)...",
+ "noTaskActivity": "No session activity for this task yet",
+ "noMemberActivity": "This member has no recorded session activity yet",
+ "leadSessionTooltip": "Full team lead session logs - useful for global orchestration context, not specific to this agent",
+ "memberSessionTooltip": "Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file",
+ "startedAt": "started {{time}}",
+ "active": "active",
+ "showDetails": "Show details",
+ "hideDetails": "Hide details",
+ "loadingDetails": "Loading details...",
+ "failedToLoadDetails": "Failed to load details"
+ },
+ "detail": {
+ "relaunchOpenCode": "Relaunch OpenCode",
+ "restart": "Restart",
+ "legacyLogsFallback": "Legacy Logs Fallback",
+ "copyDiagnostics": "Copy diagnostics",
+ "pid": "PID {{pid}}",
+ "removedAt": "Removed {{date}}",
+ "failedToRestartMember": "Failed to restart member",
+ "sendMessage": "Send Message",
+ "assignTask": "Assign Task",
+ "remove": "Remove"
+ },
+ "list": {
+ "loading": "Loading team members",
+ "unavailable": "Member roster unavailable",
+ "unavailableDescription": "{{count}} teammates are known from team metadata, but roster details are missing.",
+ "unavailableDescription_one": "{{count}} teammate is known from team metadata, but roster details are missing.",
+ "soloLeadOnly": "Solo team - lead only",
+ "removedCount": "Removed ({{count}})",
+ "unavailableDescription_few": "{{count}} teammates are known from team metadata, but roster details are missing.",
+ "unavailableDescription_many": "{{count}} teammates are known from team metadata, but roster details are missing.",
+ "unavailableDescription_other": "{{count}} teammates are known from team metadata, but roster details are missing."
+ },
+ "executionLog": {
+ "empty": "Nothing to display",
+ "emptyUserMessage": "{{time}} - (empty)",
+ "agentInstructions": "Agent instructions",
+ "memberTurn": "{{member}} turn",
+ "agentTurn": "Agent turn",
+ "turn": "turn"
+ },
+ "recentMessages": {
+ "latest": "Latest messages",
+ "latestForMember": "Latest messages - {{member}}",
+ "loadMore": "Load more",
+ "expand": "Expand",
+ "collapse": "Collapse"
+ },
+ "leadModel": {
+ "defaultModel": "Default",
+ "providerModelAria": "{{provider}} provider, {{model}}",
+ "leadShort": "lead",
+ "teamLead": "Team Lead",
+ "syncWithTeammates": "Sync model with teammates",
+ "anthropicTeamWide": "Anthropic team-wide",
+ "runtimeInheritance": "Lead runtime applies to teammates unless they set their own provider or model.",
+ "anthropicContextLimit": "The 200K context limit is team-wide for Anthropic runtimes in this launch, including custom Anthropic teammates."
+ },
+ "runtimeLogs": {
+ "autoRefresh": "Auto-refresh",
+ "wrapLines": "Wrap lines",
+ "loadingTail": "Loading process log tail...",
+ "empty": "No process log file captured for this member yet."
+ },
+ "tasks": {
+ "empty": "No tasks assigned to this member"
+ },
+ "messages": {
+ "loadOlder": "Load older messages",
+ "filters": {
+ "all": "All",
+ "messages": "Messages",
+ "comments": "Comments"
+ },
+ "empty": {
+ "loading": "Loading activity...",
+ "noComments": "No comments for this member",
+ "noLoadedMessages": "No loaded messages for this member yet",
+ "noMessages": "No messages with this member",
+ "noLoadedActivity": "No loaded activity for this member yet",
+ "noActivity": "No activity with this member"
+ }
+ },
+ "actions": {
+ "openProfile": "Open profile",
+ "editRole": "Edit role",
+ "sendMessage": "Send message",
+ "assignTask": "Assign task"
+ },
+ "roleSelect": {
+ "customRolePlaceholder": "Enter custom role..."
+ }
+ },
+ "schedule": {
+ "count": "{{count}} schedules",
+ "count_one": "{{count}} schedule",
+ "count_other": "{{count}} schedules",
+ "nextRun": "Next: {{next}}",
+ "actions": {
+ "runNow": "Run now",
+ "edit": "Edit",
+ "pause": "Pause",
+ "resume": "Resume",
+ "delete": "Delete",
+ "addSchedule": "Add Schedule"
+ },
+ "runHistory": {
+ "loading": "Loading run history...",
+ "empty": "No runs yet"
+ },
+ "count_few": "{{count}} schedules",
+ "count_many": "{{count}} schedules",
+ "runLog": {
+ "title": "Run Log",
+ "exitCode": "exit {{code}}",
+ "retryCount": "retry {{count}}/{{max}}",
+ "stillRunning": "Task is still running...",
+ "loadingLogs": "Loading logs...",
+ "errors": "Errors",
+ "close": "Close"
+ },
+ "cron": {
+ "expression": "Cron expression",
+ "highFrequencyWarning": "High frequency schedule (less than 5 min interval)",
+ "nextRuns": "Next runs:",
+ "timezone": "Timezone",
+ "selectTimezone": "Select timezone",
+ "warmUpTime": "Warm-up time",
+ "warmUpDescription": "Prepares selected providers before scheduled execution",
+ "errors": {
+ "enterExpression": "Enter a cron expression",
+ "invalidExpression": "Invalid cron expression"
+ },
+ "presets": {
+ "everyHour": "Every hour",
+ "everySixHours": "Every 6 hours",
+ "dailyAtNine": "Daily at 9am",
+ "weekdaysAtNine": "Weekdays at 9am",
+ "mondayAtNine": "Monday at 9am",
+ "everyThirtyMinutes": "Every 30 min"
+ },
+ "warmUpOptions": {
+ "none": "No warm-up",
+ "fiveMinutes": "5 min",
+ "tenMinutes": "10 min",
+ "fifteenMinutes": "15 min",
+ "thirtyMinutes": "30 min"
+ }
+ },
+ "empty": {
+ "title": "No schedules yet",
+ "description": "Create a schedule to run Claude tasks automatically on a cron schedule."
+ },
+ "title": "Schedules",
+ "status": {
+ "active": "Active",
+ "paused": "Paused",
+ "disabled": "Disabled"
+ },
+ "runStatus": {
+ "pending": "Pending",
+ "warmingUp": "Warming up",
+ "warm": "Warm",
+ "running": "Running",
+ "completed": "Completed",
+ "failed": "Failed",
+ "interrupted": "Interrupted",
+ "cancelled": "Cancelled"
+ }
+ },
+ "openCodeContextConfigHint": {
+ "summary": "OpenCode local models can use an OpenCode context budget instead of prompt-only limits.",
+ "description": "Add matching limits to the OpenCode config for the provider and model used by this teammate. This helps OpenCode compact and prune before local models overflow their context window.",
+ "replacePrefix": "Replace",
+ "and": "and",
+ "replaceSuffix": "with the provider and model IDs from your OpenCode setup. Prompt instructions like",
+ "promptInstructionsSuffix": "are weaker because the request is assembled before the model reads them.",
+ "providerLimits": "Provider limits",
+ "compactionConfig": "Compaction config"
+ },
+ "sessions": {
+ "noProjectPath": "No project path linked",
+ "provisioningHint": "Sessions will appear after team provisioning",
+ "projectNotFound": "Project not found",
+ "loading": "Loading sessions...",
+ "empty": "No sessions found",
+ "showAllSessions": "Show for all sessions",
+ "lead": "lead",
+ "removeFilter": "Remove filter",
+ "filterBySession": "Filter by this session",
+ "openSession": "Open session",
+ "title": "Sessions"
+ },
+ "provisioning": {
+ "pid": "PID {{pid}}",
+ "cancel": "Cancel",
+ "moreWarningsHidden": "{{count}} more warnings hidden",
+ "diagnostics": "Diagnostics",
+ "liveOutput": "Live output",
+ "diagnosticsCopied": "Diagnostics copied",
+ "copyDiagnostics": "Copy diagnostics",
+ "copied": "Copied",
+ "noOutput": "No output captured yet.",
+ "cliLogs": "CLI logs",
+ "steps": {
+ "starting": "Starting",
+ "configuring": "Team setup",
+ "assembling": "Members joining",
+ "finalizing": "Finalizing"
+ },
+ "providerStatus": {
+ "status": {
+ "checking": "checking...",
+ "ready": "OK",
+ "notes": "OK (notes)",
+ "failed": "ERR",
+ "pending": "waiting"
+ },
+ "detailSummary": {
+ "cliBinaryMissing": "CLI binary missing",
+ "openCodeRuntimeMissing": "OpenCode runtime missing",
+ "openCodeWindowsAccessBlocked": "OpenCode Windows access blocked",
+ "openCodeNoOutput": "OpenCode runtime check returned no output",
+ "openCodeMcpUnreachable": "OpenCode app MCP unreachable",
+ "workingDirectoryMissing": "Working directory missing",
+ "cliBinaryCouldNotStart": "CLI binary could not be started",
+ "cliPreflightIncomplete": "CLI preflight did not complete",
+ "authenticationRequired": "Authentication required",
+ "runtimeProviderNotConfigured": "Runtime provider is not configured",
+ "cliPreflightFailed": "CLI preflight failed",
+ "selectedModelCompatible": "Selected model compatible",
+ "selectedModelCompatibilityPending": "Selected model compatibility pending",
+ "selectedModelAvailable": "Selected model available",
+ "selectedModelVerified": "Selected model verified",
+ "selectedModelUnavailable": "Selected model unavailable",
+ "selectedModelTimedOut": "Selected model verification timed out",
+ "selectedModelCheckFailed": "Selected model check failed",
+ "selectedModelDeferred": "Selected model verification deferred",
+ "selectedModelPingNotConfirmed": "Selected model ping not confirmed",
+ "readyWithNotes": "Ready with notes",
+ "needsAttention": "Needs attention"
+ },
+ "modelChecksSummary": "Selected model checks - {{details}}",
+ "modelParts": {
+ "unavailable": "{{count}} model unavailable",
+ "unavailable_one": "{{count}} model unavailable",
+ "unavailable_other": "{{count}} models unavailable",
+ "checkFailed": "{{count}} model check failed",
+ "checkFailed_one": "{{count}} model check failed",
+ "checkFailed_other": "{{count}} models check failed",
+ "timedOut": "{{count}} model timed out",
+ "timedOut_one": "{{count}} model timed out",
+ "timedOut_other": "{{count}} models timed out",
+ "deferred": "{{count}} verification deferred",
+ "deferred_one": "{{count}} verification deferred",
+ "deferred_other": "{{count}} verification deferred",
+ "pingNotConfirmed": "{{count}} ping not confirmed",
+ "pingNotConfirmed_one": "{{count}} ping not confirmed",
+ "pingNotConfirmed_other": "{{count}} ping not confirmed",
+ "compatibilityPending": "{{count}} compatible, deep verification pending",
+ "compatibilityPending_one": "{{count}} compatible, deep verification pending",
+ "compatibilityPending_other": "{{count}} compatible, deep verification pending",
+ "compatible": "{{count}} compatible",
+ "compatible_one": "{{count}} compatible",
+ "compatible_other": "{{count}} compatible",
+ "checking": "{{count}} checking",
+ "checking_one": "{{count}} checking",
+ "checking_other": "{{count}} checking",
+ "available": "{{count}} available",
+ "available_one": "{{count}} available",
+ "available_other": "{{count}} available",
+ "verified": "{{count}} verified",
+ "verified_one": "{{count}} verified",
+ "verified_other": "{{count}} verified",
+ "unavailable_few": "{{count}} models unavailable",
+ "unavailable_many": "{{count}} models unavailable",
+ "checkFailed_few": "{{count}} models check failed",
+ "checkFailed_many": "{{count}} models check failed",
+ "timedOut_few": "{{count}} models timed out",
+ "timedOut_many": "{{count}} models timed out",
+ "deferred_few": "{{count}} verification deferred",
+ "deferred_many": "{{count}} verification deferred",
+ "pingNotConfirmed_few": "{{count}} ping not confirmed",
+ "pingNotConfirmed_many": "{{count}} ping not confirmed",
+ "compatibilityPending_few": "{{count}} compatible, deep verification pending",
+ "compatibilityPending_many": "{{count}} compatible, deep verification pending",
+ "compatible_few": "{{count}} compatible",
+ "compatible_many": "{{count}} compatible",
+ "checking_few": "{{count}} checking",
+ "checking_many": "{{count}} checking",
+ "available_few": "{{count}} available",
+ "available_many": "{{count}} available",
+ "verified_few": "{{count}} verified",
+ "verified_many": "{{count}} verified"
+ },
+ "openProviderSettings": "Open {{provider}} settings",
+ "copied": "Copied",
+ "copyDiagnostics": "Copy diagnostics",
+ "deepVerificationPending": "Deep verification is still running. OpenCode free models may take around 20 seconds.",
+ "progress": {
+ "checkingSelectedProviders": "Checking selected providers in parallel...",
+ "checkingProvider": "Checking {{provider}} provider...",
+ "checkingProviders": "Checking {{providers}} providers..."
+ },
+ "failureHints": {
+ "openCodeAccessDenied": "Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.",
+ "openCodeBridgeNoOutput": "Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.",
+ "workingDirectoryMissing": "Choose an existing working directory, then reopen this dialog.",
+ "authenticationRequired": "Authenticate the required provider in Claude CLI, then reopen this dialog.",
+ "runtimeProviderNotConfigured": "Configure the selected provider runtime, then reopen this dialog.",
+ "openCodeRuntimeMissing": "Install or retry OpenCode runtime from the provider status card, then reopen this dialog.",
+ "openCodeAppMcpUnreachable": "Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.",
+ "cliBinaryMissing": "Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.",
+ "default": "Resolve the issue above, then reopen this dialog."
+ }
+ },
+ "presentation": {
+ "awaitingPermission": "{{count}} teammate awaiting permission approval",
+ "nameListWithMore": "{{names}}, +{{count}} more",
+ "waitingForOpenCode": "Waiting for OpenCode: {{names}}",
+ "bootstrapStalled": "Bootstrap stalled: {{names}}",
+ "bootstrapStalledWithOpenCodeWait": "{{stalled}}; Waiting for OpenCode: {{names}}",
+ "namedPendingDiagnostic": "{{label}}: {{names}}",
+ "countPendingDiagnostic": "{{count}} {{label}}",
+ "pendingLabels": {
+ "bootstrapStalled": "Bootstrap stalled",
+ "shellOnly": "Shell-only",
+ "waitingForBootstrap": "Waiting for bootstrap",
+ "bootstrapUnconfirmed": "Bootstrap unconfirmed",
+ "awaitingPermission": "Awaiting permission",
+ "waitingForRuntime": "Waiting for runtime",
+ "shellOnlyLower": "shell-only",
+ "waitingForBootstrapLower": "waiting for bootstrap",
+ "bootstrapUnconfirmedLower": "bootstrap unconfirmed",
+ "awaitingPermissionLower": "awaiting permission",
+ "waitingForRuntimeLower": "waiting for runtime"
+ },
+ "failed": {
+ "memberFailedToStart": "{{name}} failed to start",
+ "teammatesFailedToStart": "{{count}} teammates failed to start",
+ "teammatesFailedRatio": "{{count}}/{{total}} teammates failed to start"
+ },
+ "skipped": {
+ "memberSkipped": "{{name}} skipped for this launch",
+ "memberSkippedWithReason": "{{name}} skipped for this launch - {{reason}}",
+ "memberSkippedCompact": "{{name}} skipped",
+ "teammatesSkipped": "{{count}} teammates skipped",
+ "teammatesSkippedList": "Skipped teammates: {{list}}",
+ "teammatesSkippedRatio": "{{count}}/{{total}} teammates skipped for this launch"
+ },
+ "joining": {
+ "teammatesStillJoining": "{{count}} teammates still joining",
+ "teammatesConfirmedRatio": "{{count}}/{{total}} teammates confirmed"
+ },
+ "ready": {
+ "leadOnline": "Lead online",
+ "allTeammatesJoined": "All {{count}} teammates joined",
+ "teamProvisionedLeadOnline": "Team provisioned - lead online",
+ "teamProvisionedAllJoined": "Team provisioned - all {{count}} teammates joined",
+ "teamProvisionedStillJoining": "Team provisioned - teammates are still joining",
+ "launchFinishedWithErrors": "Launch finished with errors - {{count}}/{{total}} teammates failed to start",
+ "launchContinuedSkipped": "Launch continued - {{count}}/{{total}} teammates skipped",
+ "teamLaunchedLeadOnline": "Team launched - lead online",
+ "teamLaunchedAllJoined": "Team launched - all {{count}} teammates joined"
+ },
+ "panel": {
+ "launchFailed": "Launch failed",
+ "launchDetails": "Launch details",
+ "launchFinishedWithErrors": "Launch finished with errors",
+ "launchContinuedSkipped": "Launch continued with skipped teammates",
+ "coreTeamReady": "Core team ready",
+ "finishingLaunch": "Finishing launch",
+ "teamLaunched": "Team launched",
+ "launchingTeam": "Launching team"
+ }
+ }
+ },
+ "liveRuntimeStatus": {
+ "title": "Live runtime status",
+ "description": "Display-only heartbeat and launch state. Process controls remain below.",
+ "source": "source: {{source}}",
+ "lane": "{{lane}} lane",
+ "diagnosticOnly": "Diagnostic only",
+ "updated": "updated {{value}}",
+ "states": {
+ "running": "Running",
+ "starting": "Starting",
+ "waiting": "Waiting",
+ "degraded": "Needs attention",
+ "stopped": "Stopped",
+ "unknown": "Unknown"
+ }
+ },
+ "taskLogs": {
+ "exact": {
+ "title": "Exact Task Logs",
+ "loading": "Loading exact task logs...",
+ "description": "Exact transcript slices rendered with the same execution-log components used in Logs.",
+ "emptyTitle": "No exact task logs yet",
+ "emptyDescription": "Exact transcript bundles will appear here when explicit task-linked transcript metadata is available.",
+ "summaryOnly": "summary only"
+ },
+ "executionSessions": {
+ "title": "Execution Sessions",
+ "online": "Online",
+ "updating": "Updating...",
+ "description": "Legacy session-centric transcript browsing and previews."
+ },
+ "stream": {
+ "title": "Task Log Stream"
+ }
+ },
+ "kanban": {
+ "taskCard": {
+ "cancelTask": "Cancel task {{taskId}}",
+ "cancel": "Cancel",
+ "moveBackToTodoConfirm": "Move this task back to TODO and notify the team?",
+ "confirm": "Confirm",
+ "keep": "Keep",
+ "changesNeedAttention": "Changes need attention",
+ "changes": "Changes",
+ "deleteTask": "Delete task",
+ "taskLogsActive": "Task logs active",
+ "newTaskLogsArriving": "New task logs arriving",
+ "awaitingUser": "Awaiting user",
+ "awaitingLead": "Awaiting lead",
+ "blockedBy": "Blocked by",
+ "blocks": "Blocks",
+ "start": "Start",
+ "complete": "Complete",
+ "approve": "Approve",
+ "requestReview": "Request review",
+ "manualReview": "Manual review",
+ "requestChanges": "Request changes"
+ },
+ "filter": {
+ "title": "Filter tasks",
+ "session": "Session",
+ "allSessions": "All sessions",
+ "teammate": "Teammate",
+ "unassigned": "(unassigned)",
+ "column": "Column",
+ "clearAll": "Clear all"
+ },
+ "board": {
+ "addTask": "Add task",
+ "noTasks": "No tasks",
+ "showMore": "Show {{count}} more",
+ "hiddenCount": "{{count}} hidden",
+ "trash": "Trash",
+ "gridView": "Grid view",
+ "columnsView": "Columns view"
+ },
+ "trash": {
+ "title": "Trash",
+ "empty": "No deleted tasks",
+ "subject": "Subject",
+ "owner": "Owner",
+ "deleted": "Deleted",
+ "unassigned": "Unassigned",
+ "restoreTask": "Restore task",
+ "restore": "Restore",
+ "close": "Close"
+ },
+ "sort": {
+ "title": "Sort tasks",
+ "sortBy": "Sort by",
+ "reset": "Reset",
+ "options": {
+ "updatedAt": {
+ "label": "Last updated",
+ "description": "Recently updated first"
+ },
+ "createdAt": {
+ "label": "Created",
+ "description": "Newest first"
+ },
+ "owner": {
+ "label": "Owner",
+ "description": "Alphabetically by assignee"
+ },
+ "manual": {
+ "label": "Manual",
+ "description": "Drag-and-drop order"
+ }
+ }
+ },
+ "search": {
+ "clearSearch": "Clear search",
+ "tasks": "Tasks",
+ "createdAgo": "created {{time}}",
+ "updatedAgo": "updated {{time}}",
+ "placeholder": "Search tasks... (#id or text)"
+ },
+ "grid": {
+ "addTask": "Add task",
+ "noTasks": "No tasks"
+ },
+ "title": "Kanban",
+ "columns": {
+ "todo": "TODO",
+ "inProgress": "IN PROGRESS",
+ "review": "REVIEW",
+ "done": "DONE",
+ "approved": "APPROVED"
+ }
+ },
+ "worktreeGitReadiness": {
+ "checking": "Checking Git repository status for teammate worktrees...",
+ "ready": "Git worktrees are ready.",
+ "readyOnBranch": "Git worktrees are ready on branch {{branch}}.",
+ "needsSetup": "Worktree isolation needs Git setup",
+ "initialCommitNotice": "The initial commit action stages and commits all current files with message",
+ "initializeRepository": "Initialize Git repository",
+ "createInitialCommit": "Create initial commit",
+ "initialCommitMessage": "chore: initial commit"
+ },
+ "toolApproval": {
+ "settings": "Settings",
+ "autoAllowAllTools": "Auto-allow all tools",
+ "autoAllowFileEdits": "Auto-allow file edits (Edit, Write, NotebookEdit)",
+ "autoAllowSafeCommands": "Auto-allow safe commands (git, pnpm, npm, ls...)",
+ "onTimeout": "On timeout:",
+ "after": "after",
+ "secondsShort": "sec",
+ "timeoutActions": {
+ "wait": "Wait forever",
+ "allow": "Allow",
+ "deny": "Deny"
+ },
+ "submit": "Submit",
+ "allow": "Allow",
+ "deny": "Deny",
+ "allowAll": "Allow all",
+ "pendingCount": "{{count}} pending",
+ "autoActionIn": "Auto-{{action}} in {{time}}",
+ "diff": {
+ "previewChanges": "Preview changes",
+ "readingFile": "Reading file...",
+ "binaryFile": "Binary file - cannot preview",
+ "truncated": "File truncated at 2MB - diff may be incomplete",
+ "newFile": "New file"
+ }
+ },
+ "memberWorkSync": {
+ "details": {
+ "title": "Member work sync",
+ "actionableItems": "Actionable items",
+ "fingerprint": "Fingerprint",
+ "report": "Report",
+ "none": "none",
+ "shadowWouldNudge": "Shadow would nudge",
+ "yes": "yes",
+ "no": "no",
+ "moreActionableItems": "{{count}} more actionable item(s)",
+ "diagnostics": "Diagnostics: {{diagnostics}}"
+ },
+ "title": "Member work sync",
+ "loadingDiagnostics": "Loading member work sync diagnostics.",
+ "diagnosticsUnavailable": "Member work sync diagnostics are unavailable."
+ },
+ "advancedCli": {
+ "title": "Advanced",
+ "useWorktree": "Use worktree",
+ "recent": "Recent",
+ "commandPreview": "Command preview",
+ "customArguments": "Custom arguments",
+ "validate": "Validate",
+ "validation": {
+ "allFlagsValid": "All flags valid",
+ "unknownFlags": "Unknown: {{flags}}",
+ "protectedFlags": "Protected: {{flags}}",
+ "failed": "Validation failed"
+ },
+ "placeholders": {
+ "worktreeName": "worktree-name"
+ }
+ },
+ "processes": {
+ "ago": "{{time}} ago",
+ "stoppedAgo": "stopped {{time}} ago",
+ "running": "Running",
+ "stopped": "Stopped",
+ "stopProcess": "Stop process (SIGTERM)",
+ "kill": "Kill",
+ "openInBrowser": "Open in browser",
+ "open": "Open",
+ "pid": "PID{{pid}}",
+ "title": "CLI Processes"
+ },
+ "taskActivity": {
+ "loadingDetails": "Loading activity details...",
+ "contextUnavailable": "Detailed transcript context is no longer available for this activity.",
+ "loading": "Loading task activity...",
+ "lowSignalOnly": "No key task activity was found yet. Low-level execution details are available below in Task Log Stream.",
+ "empty": "No explicit task activity was found in the available transcripts yet. Older or heuristic session logs may still be available below in Execution Sessions.",
+ "title": "Task Activity",
+ "description": "Key explicit runtime activity linked to this task from transcript metadata."
+ },
+ "sendMessage": {
+ "title": "Send Message",
+ "description": "Send a direct message to a team member.",
+ "recipientLabel": "Recipient",
+ "selectMemberPlaceholder": "Select member...",
+ "messageLabel": "Message",
+ "placeholder": "Write your message... (Enter to send)",
+ "send": "Send",
+ "sending": "Sending...",
+ "charsLeft": "{{count}} chars left",
+ "saved": "Saved",
+ "attachments": {
+ "teamOnlineRequired": "Team must be online to attach files",
+ "recipientUnsupported": "Files can be sent to the team lead or OpenCode teammates",
+ "openCodeOnlineRequired": "Team must be online to attach files for OpenCode teammates",
+ "disabledHint": "File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.",
+ "attachFiles": "Attach files (paste or drag & drop)",
+ "unavailable": "Attachments are unavailable"
+ },
+ "quote": {
+ "remove": "Remove quote",
+ "replyingTo": "Replying to"
+ }
+ },
+ "taskComments": {
+ "cancelReply": "Cancel reply",
+ "replyingTo": "Replying to",
+ "placeholder": "Add a comment... (Enter to send)",
+ "attachFile": "Attach file (or paste)",
+ "voiceToText": "Voice to text",
+ "comment": "Comment",
+ "charsLeft": "{{count}} chars left",
+ "saved": "Saved",
+ "awaitingReplyFrom": "Awaiting reply from",
+ "or": "or"
+ },
+ "taskAttachments": {
+ "dropImageHere": "Drop image here",
+ "attachImage": "Attach image",
+ "pasteOrDragDrop": "or paste / drag-drop",
+ "fromOriginalMessage": "From original message",
+ "dropFilesHere": "Drop files here",
+ "loading": "Loading attachments..."
+ },
+ "permissions": {
+ "autoApproveAllTools": "Auto-approve all tools",
+ "autonomousModeDescription": "Autonomous mode: team tools execute without confirmation. Be cautious with untrusted code.",
+ "manualModeDescription": "Manual mode: you'll approve or deny each tool call in real time."
+ },
+ "memberLogStream": {
+ "tabs": {
+ "execution": "Execution",
+ "process": "Process"
+ },
+ "filters": {
+ "all": "All"
+ },
+ "logs": {
+ "title": "Logs",
+ "loading": "Loading member log stream...",
+ "emptyTitle": "No log stream entries were found for this member yet.",
+ "emptyDescription": "Member-scoped transcript or runtime logs will appear here when available."
+ }
+ },
+ "reviewDialog": {
+ "placeholder": "Describe what needs to change... (Enter to submit)",
+ "submit": "Submit",
+ "charsLeft": "{{count}} chars left",
+ "saved": "Saved",
+ "title": "Request Changes"
+ },
+ "dialogs": {
+ "actions": {
+ "openDashboard": "Open Dashboard",
+ "openTeam": "Open team",
+ "cancel": "Cancel"
+ },
+ "membersJson": {
+ "hide": "Hide JSON"
+ },
+ "optional": {
+ "badge": "Optional"
+ }
+ },
+ "runningTeams": {
+ "title": "Running Teams"
+ },
+ "layout": {
+ "maxPanesReached": "Maximum of {{count}} panes reached"
+ },
+ "codexReconnect": {
+ "description": "Your Codex session appears stale. Reconnect to continue.",
+ "useCode": "Use code"
+ },
+ "effortLevel": {
+ "label": "Effort level (optional)",
+ "maxDescription": "Max gives the model the most reasoning time for difficult tasks."
+ },
+ "contextLimit": {
+ "limitTo200k": "Limit context to 200K tokens",
+ "always200k": "(always 200K for this model)",
+ "tooltipContent": "Keeps launches within a 200K-token context window when supported.",
+ "tooltipTitle": "Context limit"
+ },
+ "roleSelect": {
+ "noRole": "No role",
+ "customRole": "Custom role...",
+ "searchPlaceholder": "Search roles...",
+ "empty": "No roles found.",
+ "reservedRole": "This role is reserved"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/common.json b/src/features/localization/renderer/locales/ru/common.json
new file mode 100644
index 00000000..74068192
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/common.json
@@ -0,0 +1,900 @@
+{
+ "actions": {
+ "cancel": "Отмена",
+ "close": "Закрыть",
+ "copied": "Скопировано",
+ "copyUrl": "Копировать URL",
+ "open": "Открыть",
+ "reveal": "Показать",
+ "retry": "Повторить",
+ "save": "Сохранить",
+ "showLess": "Свернуть",
+ "showMore": "Показать больше",
+ "refresh": "Обновить",
+ "reset": "Сбросить",
+ "copyToClipboard": "Скопировать в буфер",
+ "moreActions": "Ещё действия",
+ "closeDialog": "Закрыть диалог",
+ "goToDashboard": "На дашборд",
+ "or": "или",
+ "hide": "Скрыть",
+ "resetSelection": "Сбросить выбор"
+ },
+ "code": {
+ "line": "строка {{line}}",
+ "lines": "строки {{from}}-{{to}}",
+ "moreLines": "({{count}} строк ещё...)",
+ "moreLines_few": "({{count}} строки ещё...)",
+ "moreLines_many": "({{count}} строк ещё...)",
+ "moreLines_one": "({{count}} строка ещё...)",
+ "moreLines_other": "({{count}} строки ещё...)",
+ "code": "Код",
+ "preview": "Предпросмотр",
+ "markdownPreview": "Предпросмотр Markdown",
+ "linesParenthesized": "(строки {{from}}-{{to}})",
+ "mermaidSyntaxError": "Синтаксическая ошибка Mermaid"
+ },
+ "contextBadge": {
+ "badge": "Контекст",
+ "breakdown": {
+ "text": "Текст",
+ "thinking": "Thinking"
+ },
+ "detailsAria": "Детали инъекции контекста",
+ "sectionSummary": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sectionSummary_few": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sectionSummary_many": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sectionSummary_one": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sectionSummary_other": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sections": {
+ "claudeMdFiles": "Файлы CLAUDE.md",
+ "mentionedFiles": "Упомянутые файлы",
+ "taskCoordination": "Координация задач",
+ "thinkingText": "Thinking + Text",
+ "toolOutputs": "Выводы инструментов",
+ "userMessages": "Сообщения пользователя"
+ },
+ "title": "Новый контекст, добавленный в этом ходе",
+ "tokenCount": "~{{tokens}} токенов",
+ "totalNewTokens": "Всего новых токенов",
+ "turn": "Ход {{turn}}"
+ },
+ "locales": {
+ "emptyMessage": "Язык не найден.",
+ "names": {
+ "en": "Английский",
+ "ru": "Русский",
+ "system": "Системный"
+ },
+ "searchPlaceholder": "Поиск языка...",
+ "selectPlaceholder": "Выберите язык интерфейса...",
+ "systemWithResolved": "Системный - {{locale}}"
+ },
+ "members": {
+ "emptyMessage": "Участники не найдены.",
+ "searchPlaceholder": "Поиск участников...",
+ "unassigned": "Не назначено",
+ "teammateFallback": "участник"
+ },
+ "providerRuntime": {
+ "codex": {
+ "install": {
+ "checking": "Проверка",
+ "downloading": "Загрузка",
+ "installCli": "Установить Codex CLI",
+ "installing": "Установка",
+ "retryInstall": "Повторить установку"
+ }
+ }
+ },
+ "search": {
+ "noMatchingSuggestions": "Подходящих вариантов нет",
+ "searching": "Поиск...",
+ "searchingFiles": "Поиск файлов...",
+ "findInConversation": "Найти в разговоре...",
+ "resultCount": "{{current}} из {{total}}",
+ "resultCountCapped": "{{current}} из {{total}}+",
+ "noResults": "Нет результатов",
+ "previousResultShortcut": "Предыдущий результат (Shift+Enter)",
+ "nextResultShortcut": "Следующий результат (Enter)",
+ "closeShortcut": "Закрыть (Esc)",
+ "nothingFound": "Ничего не найдено",
+ "placeholder": "Поиск..."
+ },
+ "schedules": {
+ "actions": {
+ "addSchedule": "Добавить расписание",
+ "clearFilters": "Сбросить фильтры",
+ "createSchedule": "Создать расписание",
+ "delete": "Удалить",
+ "edit": "Редактировать",
+ "pause": "Приостановить",
+ "resume": "Возобновить",
+ "runNow": "Запустить сейчас"
+ },
+ "empty": {
+ "description": "Создайте расписание в любой команде, чтобы автоматизировать выполнение Claude-задач через cron expressions. Расписания всех команд появятся здесь.",
+ "noMatches": "Нет расписаний под текущие фильтры",
+ "title": "Запланированных задач нет"
+ },
+ "filters": {
+ "allTeams": "Все команды"
+ },
+ "item": {
+ "loadingRunHistory": "Загрузка истории запусков...",
+ "nextRun": "Следующий запуск: {{value}}",
+ "noRunsYet": "Запусков пока нет"
+ },
+ "loading": "Загрузка расписаний...",
+ "searchPlaceholder": "Поиск расписаний...",
+ "status": {
+ "active": "Активные",
+ "all": "Все",
+ "disabled": "Отключённые",
+ "paused": "На паузе"
+ },
+ "title": "Расписания"
+ },
+ "sessions": {
+ "actions": {
+ "hide": "Скрыть",
+ "pin": "Закрепить",
+ "unhide": "Показать"
+ },
+ "empty": {
+ "noMatchingSessions": "Подходящих сессий нет",
+ "noMatchingSessionsDescription": "В этом проекте пока нет подходящих сессий.",
+ "noMatchingSessionsFiltered": "Попробуйте другой запрос или сбросьте фильтр provider.",
+ "noSessions": "Сессии не найдены",
+ "noSessionsDescription": "В этом проекте пока нет сессий",
+ "selectProject": "Выберите проект, чтобы посмотреть сессии"
+ },
+ "errors": {
+ "loading": "Ошибка загрузки сессий"
+ },
+ "loadedMatchingMore": "Загружено подходящих сессий: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadedMatchingMore_few": "Загружено подходящих сессий: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadedMatchingMore_many": "Загружено подходящих сессий: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadedMatchingMore_one": "Загружена подходящая сессия: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadedMatchingMore_other": "Загружено подходящих сессий: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadingMore": "Загрузка следующих сессий...",
+ "pinned": "Закреплённые",
+ "scrollToLoadMore": "Прокрутите, чтобы загрузить ещё",
+ "search": {
+ "clear": "Очистить поиск сессий",
+ "placeholder": "Поиск сессий..."
+ },
+ "selection": {
+ "cancel": "Отменить выделение",
+ "exitMode": "Выйти из режима выделения",
+ "hideSelected": "Скрыть выбранные сессии",
+ "pinSelected": "Закрепить выбранные сессии",
+ "selectSessions": "Выбрать сессии",
+ "selected": "Выбрано: {{count}}",
+ "selected_few": "Выбрано: {{count}}",
+ "selected_many": "Выбрано: {{count}}",
+ "selected_one": "Выбрана: {{count}}",
+ "selected_other": "Выбрано: {{count}}",
+ "unhideSelected": "Показать выбранные сессии"
+ },
+ "sort": {
+ "byContext": "По контексту",
+ "byContextTooltip": "Сортировать по потреблению контекста",
+ "byRecentTooltip": "Сортировать по недавним",
+ "contextLoadedOnly": "Сортировка по контексту ранжирует только загруженные сессии."
+ },
+ "title": "Сессии",
+ "visibility": {
+ "hideHidden": "Скрыть скрытые сессии",
+ "showHidden": "Показать скрытые сессии"
+ },
+ "worktree": {
+ "switch": "Переключить worktree"
+ },
+ "failedToLoad": "Не удалось загрузить сессию",
+ "loading": "Загрузка сессии...",
+ "filter": {
+ "title": "Фильтр сессий"
+ },
+ "count": "{{count}} сессий",
+ "count_one": "{{count}} сессия",
+ "count_few": "{{count}} сессии",
+ "count_many": "{{count}} сессий",
+ "count_other": "{{count}} сессий",
+ "inProgress": "Сессия выполняется..."
+ },
+ "states": {
+ "loading": "Загрузка...",
+ "offline": "Офлайн",
+ "online": "Онлайн",
+ "unknown": "Неизвестно",
+ "error": "Ошибка"
+ },
+ "markdown": {
+ "imageFallback": "[Изображение: {{label}}]",
+ "largeContentNotice": "Контент очень большой ({{count}} символов). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentNotice_few": "Контент очень большой ({{count}} символа). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentNotice_many": "Контент очень большой ({{count}} символов). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentNotice_one": "Контент очень большой ({{count}} символ). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentNotice_other": "Контент очень большой ({{count}} символа). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentTitle": "Большой контент показан как raw, чтобы интерфейс не завис",
+ "raw": "Raw",
+ "rawPreview": "Raw preview",
+ "renderMarkdown": "Отрендерить Markdown",
+ "showAll": "Показать всё",
+ "showMore": "Показать ещё",
+ "showRaw": "Показать raw",
+ "showingChars": "Показано {{shown}} / {{total}} символов"
+ },
+ "terminal": {
+ "checkOutputForDetails": "Подробности смотрите в выводе терминала выше",
+ "closingInSeconds": "Закрытие через {{count}} с...",
+ "closingInSeconds_few": "Закрытие через {{count}} с...",
+ "closingInSeconds_many": "Закрытие через {{count}} с...",
+ "closingInSeconds_one": "Закрытие через {{count}} с...",
+ "closingInSeconds_other": "Закрытие через {{count}} с...",
+ "completedSuccessfully": "Успешно завершено",
+ "exitCode": "(код выхода {{code}})",
+ "processFailed": "Процесс завершился с ошибкой",
+ "title": "Терминал"
+ },
+ "tokens": {
+ "accumulatedWithoutDuplication": "Накоплено по всей сессии без дублирования",
+ "cacheRead": "Cache Read",
+ "cacheWrite": "Cache Write",
+ "costUsd": "Стоимость (USD)",
+ "inputTokens": "Входные токены",
+ "model": "Модель",
+ "outputTokens": "Выходные токены",
+ "phase": "Фаза {{phase}}/{{total}}",
+ "promptInputShare": "{{percent}}% от prompt input",
+ "taskCoordination": "Координация задач",
+ "thinkingText": "Thinking + Text",
+ "toolOutputs": "Выводы инструментов",
+ "total": "Итого",
+ "userMessages": "Сообщения пользователя",
+ "visibleContext": "Видимый контекст",
+ "includesClaudeMd": "вкл. CLAUDE.md ×{{count}}",
+ "claudeMd": "CLAUDE.md",
+ "mentionedFiles": "@files",
+ "percentValue": "({{percent}}%)",
+ "approxTokens": "~{{tokens}} токенов",
+ "approxTokensParenthesized": "(~{{tokens}})"
+ },
+ "list": {
+ "actions": {
+ "copyTeam": "Скопировать команду",
+ "createTeam": "Создать команду",
+ "deleteForever": "Удалить навсегда",
+ "deletePermanently": "Удалить окончательно",
+ "deleteTeam": "Удалить команду",
+ "launching": "Запуск...",
+ "launchTeam": "Запустить команду",
+ "relaunchTeam": "Перезапустить команду",
+ "restore": "Восстановить",
+ "restoreTeam": "Восстановить команду",
+ "retry": "Повторить",
+ "stopTeam": "Остановить команду",
+ "stopping": "Остановка..."
+ },
+ "status": {
+ "active": "Активно",
+ "deleted": "Удалено",
+ "launching": "Запуск...",
+ "offline": "Offline",
+ "partialFailure": "Запуск частично не удался",
+ "partialPending": "Bootstrap ожидает",
+ "partialSkipped": "Запуск пропустил участника",
+ "running": "Работает"
+ },
+ "partial": {
+ "pending": "Последний запуск ещё сверяется.",
+ "skipped": "В последнем запуске были пропущены teammates.",
+ "skippedWithCount": "Последний запуск пропустил {{count}}/{{expected}} teammate.",
+ "skippedWithCount_few": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "skippedWithCount_many": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "skippedWithCount_one": "Последний запуск пропустил {{count}}/{{expected}} teammate.",
+ "skippedWithCount_other": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "stopped": "Последний запуск остановился до подключения всех teammates.",
+ "stoppedWithCount": "Последний запуск остановился до подключения {{count}}/{{expected}} teammate.",
+ "stoppedWithCount_few": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates.",
+ "stoppedWithCount_many": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates.",
+ "stoppedWithCount_one": "Последний запуск остановился до подключения {{count}}/{{expected}} teammate.",
+ "stoppedWithCount_other": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates."
+ },
+ "noDescription": "Нет описания",
+ "solo": "Solo",
+ "membersCount": "Участников: {{count}}",
+ "membersCount_few": "Участников: {{count}}",
+ "membersCount_many": "Участников: {{count}}",
+ "membersCount_one": "Участник: {{count}}",
+ "membersCount_other": "Участников: {{count}}",
+ "all": "Все",
+ "moreCount": "+{{count}} ещё",
+ "moreCount_one": "+{{count}} ещё",
+ "moreCount_few": "+{{count}} ещё",
+ "moreCount_many": "+{{count}} ещё",
+ "moreCount_other": "+{{count}} ещё"
+ },
+ "runtimeProvider": {
+ "defaults": {
+ "scopeDescriptionAllProjects": "Default для всех проектов, у которых нет собственного OpenCode override.",
+ "scopeDescriptionProject": "Override только для выбранного проекта. Уже запущенные команды не изменяются.",
+ "setAllProjectsDefault": "Задать default для всех проектов",
+ "setProjectDefault": "Задать default для проекта",
+ "validationContext": "Validation context",
+ "projectOverrideContext": "Project override context",
+ "selectProjectHint": "Выберите проект перед тестированием local models или сохранением defaults.",
+ "allProjectsHint": "Тесты используют {{project}}. Default применяется, если у проекта нет своего override.",
+ "projectHint": "Сохранение изменит override только для {{project}}."
+ }
+ },
+ "sessionContext": {
+ "header": {
+ "title": "Контекст",
+ "closePanel": "Закрыть панель",
+ "phase": "Фаза:",
+ "current": "Текущая",
+ "view": "Вид:",
+ "category": "Категории",
+ "bySize": "По размеру"
+ },
+ "metrics": {
+ "unavailable": "Недоступно",
+ "contextUsed": "Использовано контекста",
+ "promptInput": "Вход prompt",
+ "visibleContext": "Видимый контекст",
+ "ofContext": "от контекста",
+ "ofPrompt": "от prompt",
+ "codexTelemetryUnavailable": "Текущий runtime пока не передаёт prompt-side usage для Codex, поэтому Prompt Input и Context Used остаются недоступными вместо фейкового нуля.",
+ "sessionCost": "Стоимость сессии:",
+ "parentPlus": "parent +",
+ "subagents": "subagents",
+ "details": "подробности"
+ },
+ "help": {
+ "contextUsed": {
+ "title": "Использовано контекста",
+ "description": "Prompt input плюс output tokens, которые сейчас занимают context window модели."
+ },
+ "promptInput": {
+ "title": "Вход prompt",
+ "description": "Tokens, отправленные модели перед генерацией. Для Claude это включает `input_tokens + cache_creation_input_tokens + cache_read_input_tokens`."
+ },
+ "visibleContext": {
+ "title": "Видимый контекст",
+ "description": "Инспектируемая часть prompt input: файлы, CLAUDE.md, tool outputs, сообщения пользователя и похожие injections, которые можно оптимизировать напрямую."
+ },
+ "availability": {
+ "title": "Доступность",
+ "description": "Если provider runtime пока не отдаёт prompt-side usage, панель показывает metrics как unavailable, а не притворяется, что это ноль."
+ }
+ },
+ "items": {
+ "turn": "@Ход {{turn}}",
+ "tokensApprox": "~{{tokens}} токенов",
+ "toolsCount": "{{count}} инструментов",
+ "toolsCount_one": "{{count}} инструмент",
+ "toolsCount_few": "{{count}} инструмента",
+ "toolsCount_many": "{{count}} инструментов",
+ "toolsCount_other": "{{count}} инструментов",
+ "itemsCount": "{{count}} элементов",
+ "itemsCount_one": "{{count}} элемент",
+ "itemsCount_few": "{{count}} элемента",
+ "itemsCount_many": "{{count}} элементов",
+ "itemsCount_other": "{{count}} элементов",
+ "missing": "нет файла",
+ "thinking": "Размышление",
+ "text": "Текст"
+ },
+ "empty": "В этой сессии контекстные вставки не обнаружены",
+ "view": {
+ "grouped": "Группировано",
+ "flat": "Плоско"
+ },
+ "claudeMdFiles": "Файлы CLAUDE.md",
+ "mentionedFiles": "Упомянутые файлы"
+ },
+ "chat": {
+ "subagent": {
+ "fallbackName": "Subagent",
+ "shutdownConfirmed": "Shutdown подтверждён",
+ "summary": {
+ "tools": "{{count}} tools",
+ "tools_one": "{{count}} tool",
+ "tools_few": "{{count}} tools",
+ "tools_many": "{{count}} tools",
+ "tools_other": "{{count}} tools"
+ },
+ "meta": {
+ "type": "Тип",
+ "duration": "Длительность",
+ "model": "Модель",
+ "id": "ID"
+ },
+ "metrics": {
+ "contextWindow": "Context Window",
+ "contextUsage": "Использование контекста",
+ "mainContext": "Основной контекст",
+ "totalOutput": "Общий output",
+ "turns": "({{count}} turns)",
+ "turns_one": "({{count}} turn)",
+ "turns_few": "({{count}} turns)",
+ "turns_many": "({{count}} turns)",
+ "turns_other": "({{count}} turns)",
+ "subagentContext": "Контекст subagent",
+ "phase": "Фаза {{phase}}"
+ },
+ "trace": {
+ "title": "Execution trace"
+ }
+ },
+ "user": {
+ "you": "Вы",
+ "showMore": "Показать больше",
+ "showLess": "Показать меньше",
+ "backgroundTask": "Фоновая задача",
+ "exitCode": "exit {{code}}",
+ "imagesAttached": "прикреплено изображений: {{count}}",
+ "imagesAttached_one": "прикреплено {{count}} изображение",
+ "imagesAttached_few": "прикреплено {{count}} изображения",
+ "imagesAttached_many": "прикреплено изображений: {{count}}",
+ "imagesAttached_other": "прикреплено {{count}} изображения"
+ },
+ "compact": {
+ "toggle": "Переключить сжатое содержимое",
+ "contextCompacted": "Контекст сжат",
+ "freedTokens": "(освобождено {{tokens}})",
+ "phase": "Фаза {{phase}}",
+ "conversationCompacted": "Диалог сжат",
+ "summary": "Предыдущие сообщения были суммаризированы для экономии контекста. Полная история диалога сохранена в файле сессии.",
+ "compacted": "Сжато"
+ },
+ "executionTrace": {
+ "empty": "Нет элементов выполнения",
+ "nested": "Вложенный: {{name}}",
+ "input": "Ввод"
+ },
+ "items": {
+ "empty": "Нет элементов для отображения"
+ },
+ "tools": {
+ "teammateSpawned": "Участник запущен",
+ "shutdownRequested": "Запрошено завершение ->",
+ "noResultReceived": "Результат не получен",
+ "duration": "Длительность: {{duration}}",
+ "result": "Результат",
+ "write": {
+ "createdFile": "Файл создан",
+ "wroteToFile": "Записано в файл"
+ },
+ "skill": {
+ "instructions": "Инструкции навыка",
+ "unknown": "Неизвестный навык"
+ }
+ },
+ "lastOutput": {
+ "requestInterrupted": "Запрос прерван пользователем",
+ "planReadyForApproval": "План готов к подтверждению"
+ },
+ "empty": {
+ "icon": "💬",
+ "title": "История разговора пуста",
+ "description": "В этой сессии пока нет сообщений."
+ },
+ "context": {
+ "remainingPercent": "(осталось {{percent}}%)",
+ "count": "Контекст ({{count}})",
+ "count_one": "Контекст ({{count}})",
+ "count_few": "Контекст ({{count}})",
+ "count_many": "Контекст ({{count}})",
+ "count_other": "Контекст ({{count}})"
+ },
+ "scrollToBottom": "Прокрутить вниз",
+ "bottom": "Вниз",
+ "teammateMessage": {
+ "message": "Сообщение",
+ "resent": "Отправлено повторно",
+ "fallback": "Сообщение участника"
+ },
+ "system": {
+ "label": "Система"
+ }
+ },
+ "tmuxInstaller": {
+ "summaryTitle": "tmux не установлен",
+ "detectedOs": "Обнаруженная ОС: {{os}}",
+ "runtimePath": "Путь runtime: {{path}}",
+ "phase": "Фаза: {{phase}}",
+ "actions": {
+ "cancel": "Отмена",
+ "manualGuide": "Manual guide",
+ "hideSetupSteps": "Скрыть шаги настройки",
+ "showSetupSteps": "Показать шаги настройки ({{count}})",
+ "showSetupSteps_one": "Показать шаг настройки ({{count}})",
+ "showSetupSteps_few": "Показать шаги настройки ({{count}})",
+ "showSetupSteps_many": "Показать шаги настройки ({{count}})",
+ "showSetupSteps_other": "Показать шаги настройки ({{count}})",
+ "recheck": "Проверить снова"
+ },
+ "installerProgress": "Прогресс установки",
+ "input": {
+ "placeholder": "Отправить input в installer",
+ "send": "Отправить input",
+ "passwordNotice": "Password input отправляется напрямую в terminal installer и не добавляется в log output."
+ },
+ "details": {
+ "show": "Показать details",
+ "hide": "Скрыть details"
+ }
+ },
+ "commandPalette": {
+ "noRecentActivity": "Нет недавней активности",
+ "sessionsCount": "{{count}} sessions",
+ "sessionsCount_one": "{{count}} session",
+ "sessionsCount_few": "{{count}} sessions",
+ "sessionsCount_many": "{{count}} sessions",
+ "sessionsCount_other": "{{count}} sessions",
+ "mode": {
+ "searchProjects": "Поиск проектов",
+ "searchAcrossProjects": "Поиск по всем проектам",
+ "searchInProject": "Поиск в проекте"
+ },
+ "currentProject": "Текущий проект",
+ "global": "Global",
+ "placeholders": {
+ "projects": "Поиск проектов...",
+ "conversations": "Поиск conversations..."
+ },
+ "empty": {
+ "noProjectsForQuery": "Проекты по запросу \"{{query}}\" не найдены",
+ "noProjects": "Проекты не найдены",
+ "minChars": "Введите минимум 2 символа для поиска",
+ "noFastResults": "В недавних sessions нет быстрых результатов по запросу \"{{query}}\"",
+ "noResults": "Результаты по запросу \"{{query}}\" не найдены"
+ },
+ "footer": {
+ "projectsCount": "{{count}} проектов",
+ "projectsCount_one": "{{count}} проект",
+ "projectsCount_few": "{{count}} проекта",
+ "projectsCount_many": "{{count}} проектов",
+ "projectsCount_other": "{{count}} проектов",
+ "results": "{{count}} {{speed}}результатов",
+ "results_one": "{{count}} {{speed}}результат",
+ "results_few": "{{count}} {{speed}}результата",
+ "results_many": "{{count}} {{speed}}результатов",
+ "results_other": "{{count}} {{speed}}результатов",
+ "resultsAcrossProjects": "{{count}} {{speed}}результатов по всем проектам",
+ "resultsAcrossProjects_one": "{{count}} {{speed}}результат по всем проектам",
+ "resultsAcrossProjects_few": "{{count}} {{speed}}результата по всем проектам",
+ "resultsAcrossProjects_many": "{{count}} {{speed}}результатов по всем проектам",
+ "resultsAcrossProjects_other": "{{count}} {{speed}}результатов по всем проектам",
+ "fastPrefix": "fast ",
+ "typeToSearch": "Введите запрос",
+ "navigate": "навигация",
+ "select": "выбрать",
+ "open": "открыть",
+ "global": "global",
+ "close": "закрыть",
+ "upDownKey": "↑↓",
+ "escapeKey": "esc"
+ }
+ },
+ "tasksPanel": {
+ "title": "Задачи",
+ "searchPlaceholder": "Поиск задач...",
+ "pinned": "Закреплённые",
+ "groupByLabel": "Группировать:",
+ "groupByAria": "Группировка",
+ "groupModes": {
+ "none": "Нет",
+ "project": "Проект",
+ "time": "Время"
+ },
+ "showArchived": "Показать архивные",
+ "hideArchived": "Скрыть архивные",
+ "empty": {
+ "noMatchingTasks": "Нет подходящих задач",
+ "noTasks": "Задачи не найдены"
+ },
+ "teamLabel": "Команда: {{team}}",
+ "showMore": "Показать ещё",
+ "showLess": "Показать меньше",
+ "deleteConfirm": {
+ "title": "Удалить задачу",
+ "message": "Переместить задачу #{{taskId}} в корзину?",
+ "confirmLabel": "Удалить",
+ "cancelLabel": "Отмена"
+ },
+ "deleteFailed": {
+ "title": "Не удалось удалить задачу",
+ "fallbackMessage": "Произошла непредвиденная ошибка",
+ "confirmLabel": "OK"
+ },
+ "sort": {
+ "byTime": "По времени",
+ "byUnread": "По непрочитанным",
+ "byProject": "По проекту",
+ "byTeam": "По команде"
+ }
+ },
+ "toolViewer": {
+ "input": "Ввод",
+ "replaceAll": "(заменить все)",
+ "noInputRecorded": "Для этого вызова инструмента ввод не записан.",
+ "agent": {
+ "action": "действие",
+ "teammate": "участник",
+ "team": "команда",
+ "runtime": "runtime",
+ "type": "тип",
+ "startupInstructionsHidden": "Стартовые инструкции скрыты в интерфейсе."
+ }
+ },
+ "taskContextMenu": {
+ "unpin": "Открепить",
+ "pin": "Закрепить",
+ "rename": "Переименовать",
+ "markUnread": "Пометить непрочитанной",
+ "unarchive": "Разархивировать",
+ "archive": "Архивировать",
+ "deleteTask": "Удалить задачу"
+ },
+ "updateDialog": {
+ "closeDialog": "Закрыть диалог",
+ "updateAvailable": "Доступно обновление",
+ "updateReady": "Обновление готово",
+ "noReleaseNotes": "Описание релиза недоступно.",
+ "viewOnGitHub": "Открыть на GitHub",
+ "later": "Позже",
+ "restartNow": "Перезапустить сейчас",
+ "download": "Скачать"
+ },
+ "errorBoundary": {
+ "title": "Что-то пошло не так",
+ "description": "В приложении произошла непредвиденная ошибка. Можно попробовать перезагрузить страницу или сбросить состояние ошибки.",
+ "componentStack": "Стек компонентов",
+ "tryAgain": "Попробовать снова",
+ "copied": "Скопировано",
+ "copyErrorDetails": "Скопировать детали ошибки",
+ "reportBugOnGitHub": "Сообщить об ошибке на GitHub",
+ "reloadApp": "Перезагрузить приложение",
+ "diagnosticsNotice": "GitHub-отчёты и скопированная диагностика включают сообщение об ошибке, stack trace, версию приложения, активную вкладку, выбранную команду, контекст задачи и сведения окружения."
+ },
+ "runtimeBackendSelector": {
+ "label": "Runtime backend",
+ "resolved": "Определено: {{backend}}",
+ "current": "Текущий",
+ "recommended": "Рекомендуется",
+ "unavailable": "Недоступно",
+ "cannotSelectYet": "Этот backend пока нельзя выбрать.",
+ "auto": "Авто",
+ "autoCurrently": "Авто (сейчас: {{backend}})",
+ "audience": {
+ "internal": "Внутренний"
+ },
+ "states": {
+ "locked": "Заблокировано",
+ "disabled": "Отключено",
+ "authRequired": "Нужен вход",
+ "runtimeMissing": "Runtime отсутствует",
+ "degraded": "Есть проблемы",
+ "unavailable": "Недоступно"
+ }
+ },
+ "providerModelBadges": {
+ "checking": "Проверка",
+ "unavailable": "Недоступно",
+ "checkFailed": "Проверка не удалась",
+ "free": "Бесплатно",
+ "freeTooltip": "Передано метаданными OpenCode. Доступность и лимиты могут измениться."
+ },
+ "taskFilters": {
+ "status": "Статус",
+ "clearAll": "Очистить всё",
+ "selectAll": "Выбрать всё",
+ "team": "Команда",
+ "allTeams": "Все команды",
+ "searchTeams": "Искать команды...",
+ "noTeamsFound": "Команды не найдены",
+ "project": "Проект",
+ "allProjects": "Все проекты",
+ "searchProjects": "Искать проекты...",
+ "noProjects": "Проектов нет",
+ "comments": "Комментарии",
+ "apply": "Применить",
+ "read": {
+ "all": "Все",
+ "unread": "Непрочитанные",
+ "read": "Прочитанные"
+ },
+ "statusOptions": {
+ "todo": "TODO",
+ "inProgress": "В РАБОТЕ",
+ "needsFix": "НУЖНЫ ПРАВКИ",
+ "done": "ГОТОВО",
+ "review": "РЕВЬЮ",
+ "approved": "ОДОБРЕНО"
+ }
+ },
+ "sessionItem": {
+ "totalContext": "Всего контекста: {{tokens}} токенов",
+ "context": "Контекст: {{tokens}}",
+ "phase": "Фаза {{phase}}:",
+ "compactedTo": "(сжато до {{tokens}})"
+ },
+ "notifications": {
+ "row": {
+ "team": "команда",
+ "subagent": "subagent",
+ "markAsRead": "Пометить прочитанным",
+ "delete": "Удалить",
+ "viewInSession": "Открыть в сессии"
+ },
+ "title": "Уведомления",
+ "loading": "Загрузка уведомлений...",
+ "actions": {
+ "markFilteredAsRead": "Пометить отфильтрованные прочитанными",
+ "markAllAsRead": "Пометить все прочитанными",
+ "markFilteredRead": "Пометить фильтр",
+ "markAllRead": "Пометить все",
+ "clearFilteredNotifications": "Очистить отфильтрованные уведомления",
+ "clearAllNotifications": "Очистить все уведомления",
+ "clickToConfirm": "Нажмите для подтверждения",
+ "clearFiltered": "Очистить фильтр",
+ "clearAll": "Очистить все"
+ },
+ "counts": {
+ "unreadInFilter": "{{count}} непрочитанных в фильтре",
+ "unreadInFilter_one": "{{count}} непрочитанное в фильтре",
+ "unreadInFilter_few": "{{count}} непрочитанных в фильтре",
+ "unreadInFilter_many": "{{count}} непрочитанных в фильтре",
+ "unreadInFilter_other": "{{count}} непрочитанных в фильтре",
+ "inFilter": "{{count}} в фильтре",
+ "inFilter_one": "{{count}} в фильтре",
+ "inFilter_few": "{{count}} в фильтре",
+ "inFilter_many": "{{count}} в фильтре",
+ "inFilter_other": "{{count}} в фильтре",
+ "unread": "{{count}} непрочитанных",
+ "unread_one": "{{count}} непрочитанное",
+ "unread_few": "{{count}} непрочитанных",
+ "unread_many": "{{count}} непрочитанных",
+ "unread_other": "{{count}} непрочитанных",
+ "total": "{{count}} всего",
+ "total_one": "{{count}} всего",
+ "total_few": "{{count}} всего",
+ "total_many": "{{count}} всего",
+ "total_other": "{{count}} всего"
+ },
+ "filters": {
+ "other": "Другое"
+ },
+ "empty": {
+ "noMatching": "Подходящих уведомлений нет",
+ "noNotifications": "Уведомлений нет",
+ "tryDifferentFilter": "Попробуйте другой фильтр",
+ "allCaughtUp": "Все уведомления разобраны"
+ }
+ },
+ "updates": {
+ "restartToUpdate": "Перезапустить для обновления",
+ "updateApp": "Обновить приложение",
+ "downloadedRestartTooltip": "Обновление загружено, перезапустите приложение для применения",
+ "newVersionAvailable": "Доступна новая версия",
+ "updatingApp": "Обновление приложения",
+ "updateReady": "Обновление готово",
+ "restartNow": "Перезапустить сейчас"
+ },
+ "layout": {
+ "github": "GitHub",
+ "discord": "Discord",
+ "expandSidebar": "Развернуть боковую панель",
+ "collapseSidebarShortcut": "Свернуть боковую панель ({{shortcut}})",
+ "sidebarView": "Вид боковой панели",
+ "resizeSidebar": "Изменить ширину боковой панели",
+ "closeTab": "Закрыть вкладку",
+ "openedFromSearch": "Открыто из поиска",
+ "pinnedSession": "Закрепленная сессия",
+ "jumpToSection": "Перейти к разделу",
+ "newTab": "Новая вкладка",
+ "newTabDashboard": "Новая вкладка (дашборд)",
+ "refreshSession": "Обновить сессию",
+ "refreshSessionWithShortcut": "Обновить сессию ({{shortcut}})",
+ "loadingTab": "Загрузка вкладки",
+ "menu": {
+ "teams": "Команды",
+ "settings": "Настройки",
+ "extensions": "Расширения",
+ "search": "Поиск",
+ "schedules": "Расписания",
+ "docs": "Документация",
+ "exportMarkdown": "Экспорт в Markdown",
+ "exportJson": "Экспорт в JSON",
+ "exportPlainText": "Экспорт в обычный текст",
+ "analyzeSession": "Анализировать сессию"
+ },
+ "tabMenu": {
+ "closeTabs": "Закрыть {{count}} вкладок",
+ "closeTabs_one": "Закрыть {{count}} вкладку",
+ "closeTabs_few": "Закрыть {{count}} вкладки",
+ "closeTabs_many": "Закрыть {{count}} вкладок",
+ "closeTabs_other": "Закрыть {{count}} вкладки",
+ "closeTab": "Закрыть вкладку",
+ "closeOtherTabs": "Закрыть остальные вкладки",
+ "splitRight": "Разделить вправо",
+ "splitLeft": "Разделить влево",
+ "pinToSidebar": "Закрепить в боковой панели",
+ "unpinFromSidebar": "Открепить от боковой панели",
+ "hideFromSidebar": "Скрыть из боковой панели",
+ "unhideFromSidebar": "Вернуть в боковую панель",
+ "closeAllTabs": "Закрыть все вкладки"
+ },
+ "sections": {
+ "team": "Команда",
+ "sessions": "Сессии",
+ "kanban": "Канбан",
+ "claudeLogs": "Логи Claude",
+ "messages": "Сообщения"
+ }
+ },
+ "editorFormatting": {
+ "bold": "Жирный",
+ "italic": "Курсив",
+ "strike": "Зачеркнутый",
+ "code": "Код"
+ },
+ "diff": {
+ "changed": "Изменено",
+ "noChangesDetected": "Изменений нет"
+ },
+ "codexLogin": {
+ "copyLoginLinkAndCode": "Скопировать ссылку входа ChatGPT и код",
+ "copyLoginLink": "Скопировать ссылку входа ChatGPT",
+ "copyFailed": "Не удалось скопировать",
+ "copyLinkAndCode": "Скопировать ссылку + код",
+ "copyLink": "Скопировать ссылку",
+ "enterCodeOnLoginPage": "Введите этот код на странице входа ChatGPT"
+ },
+ "window": {
+ "minimize": "Свернуть",
+ "maximize": "Развернуть",
+ "restore": "Восстановить"
+ },
+ "context": {
+ "local": "Локально",
+ "switchingTo": "Переключение на {{workspace}}",
+ "loadingWorkspace": "Загрузка workspace",
+ "switchWorkspace": "Сменить рабочую область"
+ },
+ "repositories": {
+ "noneAvailable": "Репозитории недоступны",
+ "remove": "Удалить репозиторий"
+ },
+ "export": {
+ "session": "Экспортировать сессию",
+ "sessionTitle": "Экспорт сессии"
+ },
+ "brand": {
+ "claude": "Claude"
+ },
+ "sessionReport": {
+ "noSessionData": "Данные сессии недоступны",
+ "title": "Отчёт по сессии"
+ },
+ "sessionFilters": {
+ "project": {
+ "selectProject": "Выберите проект"
+ }
+ },
+ "tasks": {
+ "date": {
+ "updatedPrefix": "обн.",
+ "updatedYesterday": "обн. вчера",
+ "yesterday": "Вчера"
+ },
+ "reviewState": {
+ "needsFix": "Нужны правки"
+ },
+ "unassigned": "не назначено"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/dashboard.json b/src/features/localization/renderer/locales/ru/dashboard.json
new file mode 100644
index 00000000..2ff0e122
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/dashboard.json
@@ -0,0 +1,197 @@
+{
+ "cliStatus": {
+ "actions": {
+ "alreadyLoggedIn": "Уже вошли?",
+ "becomeSponsor": "Стать спонсором",
+ "cancel": "Отмена",
+ "checkNow": "Проверить сейчас",
+ "checkUpdates": "Проверить обновления",
+ "checking": "Проверка...",
+ "connect": "Подключить",
+ "extensions": "Расширения",
+ "login": "Войти",
+ "manage": "Управлять",
+ "manageProviders": "Управлять провайдерами",
+ "plan": "План",
+ "recheck": "Проверить снова",
+ "recheckProvider": "Проверить {{provider}} снова",
+ "retry": "Повторить",
+ "updateTo": "Обновить до v{{version}}",
+ "useCode": "Использовать код"
+ },
+ "atlas": {
+ "alt": "Atlas Cloud",
+ "description": "Atlas Cloud - full-modal AI inference platform, который даёт разработчикам единый AI API для доступа к video generation, image generation и LLM API. Вместо нескольких интеграций с вендорами вы подключаетесь один раз и получаете единый доступ к 300+ отобранным моделям во всех модальностях. Посмотрите новую coding plan promotion Atlas Cloud для более бюджетного API-доступа.",
+ "openCodeProvider": "OpenCode provider",
+ "plan": "Coding plan Atlas Cloud",
+ "sponsor": "Спонсор"
+ },
+ "errors": {
+ "checkStatusFailed": "Не удалось проверить статус CLI",
+ "installationFailed": "Установка не удалась",
+ "refreshFailed": "Не удалось проверить обновления. Проверьте сетевое подключение и повторите попытку.",
+ "runtimeUpdatedRefreshFailed": "Runtime обновлён, но не удалось обновить статус провайдера."
+ },
+ "hints": {
+ "backgroundStatus": "Статус {{runtime}} будет проверен в фоне.",
+ "codexApiKeyFallback": "{{hint}} API key fallback доступен, если переключить режим аутентификации.",
+ "codexAutoApiKey": "{{hint}} Auto продолжит использовать API key, пока ChatGPT не подключён.",
+ "codexFinishLogin": "Завершите вход ChatGPT в браузере. Если потребуется, введите показанный код.",
+ "codexNoActiveLogin": "Лимиты появятся только после того, как Codex CLI увидит активный ChatGPT account. Сейчас он сообщает, что активного входа ChatGPT нет.",
+ "codexNoActiveManagedSession": "Лимиты появятся только после того, как Codex CLI увидит активный ChatGPT account. Локальные данные account есть, но активная managed-сессия сейчас не выбрана.",
+ "codexReconnectNeeded": "Лимиты появятся только после того, как Codex обновит текущую выбранную ChatGPT-сессию. Сейчас локальную сессию нужно переподключить.",
+ "firstCheckSlow": "Первая проверка может занять до 30 секунд",
+ "loginRequiredForTeams": "Просмотр сессий и проектов работает без входа. Вход нужен только для запуска agent teams.",
+ "troubleshootTitle": "Если вы уверены, что уже вошли, попробуйте:"
+ },
+ "installer": {
+ "checkingLatest": "Проверка последней версии...",
+ "downloading": "Загрузка {{runtime}}...",
+ "installing": "Установка {{runtime}}...",
+ "success": "{{runtime}} успешно установлен, версия v{{version}}",
+ "verifying": "Проверка checksum..."
+ },
+ "labels": {
+ "apiKeyRequired": "Требуется API key",
+ "comingSoon": "Скоро",
+ "collapseProviderDetails": "Свернуть детали провайдера",
+ "expandProviderDetails": "Развернуть детали провайдера",
+ "generateLink": "Создать ссылку",
+ "loadingRateLimits": "Загрузка лимитов",
+ "loggedOut": "Провайдер отключён",
+ "loginAuthFailed": "Аутентификация не удалась",
+ "loginAuthUpdated": "Аутентификация обновлена",
+ "loginComplete": "Вход выполнен",
+ "loginFailed": "Войти не удалось",
+ "loginTitle": "Вход",
+ "logoutFailed": "Выход не удался",
+ "logoutTitle": "Выход",
+ "notLoggedIn": "Вход не выполнен",
+ "openLogin": "Открыть вход",
+ "providerActionRequired": "Требуется действие с провайдером",
+ "resets": "сброс {{time}}",
+ "runtimeLoginTitle": "Вход в {{runtime}}"
+ },
+ "loading": {
+ "aiProviders": "Проверка AI-провайдеров...",
+ "claudeCli": "Проверка Claude CLI..."
+ },
+ "provider": {
+ "authenticated": "Аутентифицировано",
+ "backend": "Backend: {{backend}}",
+ "checkingAuthentication": "Проверка аутентификации...",
+ "checkingProviders": "Проверка провайдеров...",
+ "configuredLocalCount": "{{count}} локальных настроено",
+ "configuredLocalCount_few": "{{count}} локальных настроено",
+ "configuredLocalCount_many": "{{count}} локальных настроено",
+ "configuredLocalCount_one": "{{count}} локальный настроен",
+ "configuredLocalCount_other": "{{count}} локальных настроено",
+ "configuredLocalTitle": "Локальные маршруты OpenCode, импортированные из вашей конфигурации OpenCode.",
+ "connectedCount": "Провайдеры: {{connected}}/{{denominator}} подключено",
+ "freeModels": "Бесплатные модели",
+ "freeModelsTitle": "OpenCode включает бесплатные варианты моделей, например Big Pickle, если они доступны в вашей настройке. OpenRouter через OpenCode тоже может показывать бесплатные модели, но не каждая модель OpenCode/OpenRouter бесплатна. Доступность и лимиты могут меняться.",
+ "loadingModels": "Загрузка моделей...",
+ "modelsUnavailable": "Модели недоступны для этой сборки runtime",
+ "runtime": "Runtime: {{runtime}}",
+ "verifiedCount": "{{count}} проверено",
+ "verifiedCount_few": "{{count}} проверено",
+ "verifiedCount_many": "{{count}} проверено",
+ "verifiedCount_one": "{{count}} проверен",
+ "verifiedCount_other": "{{count}} проверено",
+ "verifiedTitle": "Маршруты OpenCode с успешным proof выполнения."
+ },
+ "runtime": {
+ "configuredHealthCheckFailed": "Настроенный {{runtime}} не прошёл health check запуска.",
+ "configuredNotFound": "Настроенный {{runtime}} не найден.",
+ "foundButFailed": "{{runtime}} найден, но не запустился",
+ "healthCheckFailedDescription": "Приложение нашло настроенный {{runtime}}, но его startup health check не прошёл. Почините или переустановите его, затем повторите.",
+ "install": "Установить {{runtime}}",
+ "installRequiredDescription": "{{runtime}} нужен для provisioning команд и управления сессиями. Установите его, чтобы начать.",
+ "isRequired": "Требуется {{runtime}}",
+ "reinstall": "Переустановить {{runtime}}"
+ },
+ "runtimeInstall": {
+ "checking": "Проверка",
+ "codexTitle": "Установить Codex CLI в данные приложения",
+ "downloading": "Загрузка",
+ "downloadingPercent": "Загрузка {{percent}}%",
+ "install": "Установить",
+ "installing": "Установка",
+ "openCodeTitle": "Установить OpenCode runtime в данные приложения",
+ "retryInstall": "Повторить установку"
+ },
+ "troubleshoot": {
+ "again": "снова",
+ "authStatusCommand": "настроенную команду проверки статуса аутентификации CLI",
+ "checkLoggedIn": "- проверьте, показывает ли она \"Logged in\"",
+ "click": "Нажмите",
+ "loginCommand": "команду входа runtime",
+ "logoutCommand": "команду выхода runtime",
+ "openTerminal": "Откройте терминал и выполните:",
+ "reloginPrefix": "Если команда говорит, что вход выполнен, но приложение этого не видит, попробуйте:",
+ "sameRuntime": "Убедитесь, что CLI в терминале совпадает с runtime, который использует приложение",
+ "statusCacheHint": "- иногда статус кэшируется на несколько секунд",
+ "then": "затем"
+ },
+ "warnings": {
+ "multipleApiKeysMissing": "Один или несколько провайдеров работают в режиме API key, но API key не настроен. Откройте управление провайдерами, чтобы добавить ключи или переключить режим подключения.",
+ "multipleApiKeysNeedAttention": "Один или несколько провайдеров работают в режиме API key и требуют внимания. Откройте управление провайдерами, чтобы проверить сохранённые ключи или переключить режим подключения.",
+ "notAuthenticated": "{{runtime}} установлен, но вход не выполнен. Вход нужен для provisioning команд и AI-функций.",
+ "singleApiKeyMissing": "{{provider}} работает в режиме API key, но API key не настроен. Откройте управление провайдерами, чтобы добавить ключ или переключить режим подключения.",
+ "singleApiKeyNeedsAttention": "{{provider}} работает в режиме API key, но не подключён. Откройте управление провайдерами, чтобы проверить сохранённый ключ или переключить режим подключения."
+ }
+ },
+ "recentProjects": {
+ "selectFolderTitle": "Выбрать папку проекта",
+ "selectFolder": "Выбрать папку",
+ "failedToLoad": "Не удалось загрузить проекты",
+ "retry": "Повторить",
+ "noProjects": "Проекты не найдены",
+ "noMatches": "Нет совпадений для \"{{query}}\"",
+ "noRecentProjects": "Недавние проекты не найдены",
+ "emptyDescription": "Здесь появится недавняя активность Claude и Codex.",
+ "loadMore": "Загрузить ещё",
+ "card": {
+ "deleted": "Удалён",
+ "projectFolderMissing": "Папка проекта больше не существует",
+ "taskCounts": {
+ "active": "{{count}} активных",
+ "active_one": "{{count}} активная",
+ "active_few": "{{count}} активные",
+ "active_many": "{{count}} активных",
+ "active_other": "{{count}} активных",
+ "pending": "{{count}} ожидают",
+ "pending_one": "{{count}} ожидает",
+ "pending_few": "{{count}} ожидают",
+ "pending_many": "{{count}} ожидают",
+ "pending_other": "{{count}} ожидают",
+ "done": "{{count}} готово",
+ "done_one": "{{count}} готова",
+ "done_few": "{{count}} готово",
+ "done_many": "{{count}} готово",
+ "done_other": "{{count}} готово"
+ }
+ },
+ "title": "Недавние проекты",
+ "searchResults": "Результаты поиска",
+ "searchPlaceholder": "Поиск проектов..."
+ },
+ "actions": {
+ "selectTeam": "Выбрать команду",
+ "or": "или",
+ "clearSearch": "Очистить поиск"
+ },
+ "windowsAdmin": {
+ "title": "Рекомендуется режим администратора Windows",
+ "description": "Проверки рантайма OpenCode могут завершаться по таймауту, если Agent Teams AI не запущен с повышенными правами. Перезапустите приложение через Run as administrator перед запуском команд OpenCode."
+ },
+ "webPreview": {
+ "title": "Откройте desktop-приложение для полной функциональности",
+ "description": "Браузерная версия всё ещё в разработке. Действия с проектами, интеграции и live-статусы здесь могут быть ограничены. Используйте desktop-приложение для надёжного доступа ко всем возможностям."
+ },
+ "updateBanner": {
+ "newVersionAvailable": "Доступна новая версия",
+ "restartNow": "Перезапустить сейчас",
+ "viewDetails": "Подробнее"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/errors.json b/src/features/localization/renderer/locales/ru/errors.json
new file mode 100644
index 00000000..9db1e8e4
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/errors.json
@@ -0,0 +1,3 @@
+{
+ "fallback": "Что-то пошло не так."
+}
diff --git a/src/features/localization/renderer/locales/ru/extensions.json b/src/features/localization/renderer/locales/ru/extensions.json
new file mode 100644
index 00000000..a3f437d8
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/extensions.json
@@ -0,0 +1,684 @@
+{
+ "store": {
+ "actions": {
+ "addCustom": "Добавить custom",
+ "openDashboard": "Открыть Dashboard",
+ "refreshCatalog": "Обновить каталог"
+ },
+ "capabilities": {
+ "mcp": "MCP: {{status}}",
+ "plugins": "Plugins: {{status}}",
+ "skills": "Skills: {{status}}"
+ },
+ "desktopOnly": "Доступно только в desktop app.",
+ "provider": {
+ "checkingStatus": "Проверка статуса provider...",
+ "connected": "Подключён",
+ "loading": "Загрузка...",
+ "needsSetup": "Нужна настройка",
+ "readyToConfigure": "Готов к настройке",
+ "unsupported": "Не поддерживается"
+ },
+ "runtime": {
+ "checkingAvailabilityDescription": "Extensions требуют настроенный runtime для управления plugins, MCP servers, skills и provider connections.",
+ "checkingAvailabilityTitle": "Проверка доступности runtime для extensions",
+ "failedToStartDescription": "Extensions отключены, пока runtime не пройдёт startup health check. Откройте Dashboard, чтобы исправить или переустановить его.",
+ "failedToStartTitle": "Настроенный runtime найден, но не запустился",
+ "multimodelCapabilitiesDescription": "Поддержка providers может отличаться по секциям. Plugins показываются только там, где runtime явно сообщает поддержку.",
+ "multimodelCapabilitiesTitle": "Возможности multimodel runtime",
+ "needsSignInDescription": "{{runtime}} найден{{version}}, но установка plugins отключена, пока вы не войдёте через Dashboard.",
+ "needsSignInTitle": "{{runtime}} требует вход",
+ "notAvailableDescription": "Extensions отключены, пока runtime не установлен. Откройте Dashboard, установите его и повторите попытку.",
+ "notAvailableTitle": "Настроенный runtime недоступен",
+ "readyDescription": "Plugins можно устанавливать с этой страницы{{versionSuffix}}.",
+ "readyTitle": "{{runtime}} готов",
+ "requiredForMutations": "Настроенный runtime требуется для установки или удаления extensions. Установите или исправьте его через Dashboard."
+ },
+ "sessionsRestartWarning": "Запущенные сессии не увидят изменения extensions до перезапуска.",
+ "tabs": {
+ "apiKeys": {
+ "description": "Secret keys для online services. Добавьте их здесь, чтобы plugins, servers и integrations могли подключаться и работать.",
+ "label": "API Keys"
+ },
+ "mcpServers": {
+ "description": "Подключения к внешним tools и apps. Они позволяют runtime читать данные или выполнять действия за пределами приложения.",
+ "label": "MCP Servers"
+ },
+ "plugins": {
+ "description": "Небольшие add-ons для runtime. В multimodel mode сейчас применяются к Anthropic sessions, когда поддерживаются. Более широкая поддержка providers в разработке.",
+ "label": "Plugins"
+ },
+ "skills": {
+ "description": "Готовые инструкции для типовых задач. Они помогают runtime стабильнее выполнять повторяемые действия.",
+ "label": "Skills"
+ }
+ },
+ "title": "Extensions"
+ },
+ "pluginsPanel": {
+ "activeFilters": "Активно: {{count}}",
+ "activeFilters_few": "Активно: {{count}}",
+ "activeFilters_many": "Активно: {{count}}",
+ "activeFilters_one": "Активен: {{count}}",
+ "activeFilters_other": "Активно: {{count}}",
+ "browseByFit": "Подбор по назначению",
+ "capabilities": "Возможности",
+ "categories": "Категории",
+ "clearAllFilters": "Сбросить все фильтры",
+ "clearFilters": "Сбросить фильтры",
+ "counts": {
+ "capabilities": "Возможностей: {{count}}",
+ "capabilities_few": "Возможностей: {{count}}",
+ "capabilities_many": "Возможностей: {{count}}",
+ "capabilities_one": "Возможность: {{count}}",
+ "capabilities_other": "Возможностей: {{count}}",
+ "categories": "Категорий: {{count}}",
+ "categories_few": "Категории: {{count}}",
+ "categories_many": "Категорий: {{count}}",
+ "categories_one": "Категория: {{count}}",
+ "categories_other": "Категории: {{count}}",
+ "plugins": "Plugins: {{count}}",
+ "plugins_few": "Plugins: {{count}}",
+ "plugins_many": "Plugins: {{count}}",
+ "plugins_one": "Plugin: {{count}}",
+ "plugins_other": "Plugins: {{count}}"
+ },
+ "empty": {
+ "description": "Проверьте позже, когда появятся новые plugins",
+ "filteredDescription": "Попробуйте изменить поиск или критерии фильтра",
+ "filteredTitle": "Нет plugins под выбранные фильтры",
+ "title": "Plugins недоступны"
+ },
+ "filterDescription": "Сужайте каталог по категории, возможностям или статусу установки.",
+ "installedOnly": "Только установленные",
+ "providerSupportNotice": "Поддержка plugins сейчас гарантирована только для Anthropic (Claude) sessions. Мы работаем над поддержкой plugins во всех agents.",
+ "resultsUpdateInstantly": "Результаты обновляются сразу при изменении фильтров.",
+ "searchPlaceholder": "Поиск plugins...",
+ "selectedCount": "Выбрано: {{count}}",
+ "selectedCount_few": "Выбрано: {{count}}",
+ "selectedCount_many": "Выбрано: {{count}}",
+ "selectedCount_one": "Выбран: {{count}}",
+ "selectedCount_other": "Выбрано: {{count}}",
+ "showing": "Показано {{shown}} из {{total}} plugins",
+ "sort": {
+ "category": "Категория",
+ "nameAsc": "Имя A-Z",
+ "nameDesc": "Имя Z-A",
+ "popular": "Популярные"
+ }
+ },
+ "customMcp": {
+ "actions": {
+ "add": "Добавить",
+ "cancel": "Отмена",
+ "install": "Установить",
+ "installing": "Установка..."
+ },
+ "description": "Добавьте server вручную без каталога.",
+ "errors": {
+ "installFailed": "Не удалось установить",
+ "invalidServerName": "Некорректное имя server. Используйте латинские буквы, цифры, дефисы, подчёркивания и точки.",
+ "npmPackageRequired": "Требуется имя npm package",
+ "serverNameRequired": "Требуется имя server",
+ "serverUrlRequired": "Требуется URL server"
+ },
+ "fields": {
+ "environmentVariables": "Environment variables",
+ "headers": "Headers",
+ "npmPackage": "npm package",
+ "scope": "Scope",
+ "serverName": "Имя server",
+ "serverUrl": "URL server",
+ "transport": "Transport",
+ "transportType": "Тип transport",
+ "versionOptional": "Версия (необязательно)"
+ },
+ "title": "Добавить custom MCP server",
+ "transport": {
+ "httpSse": "HTTP / SSE",
+ "stdio": "Stdio (npm)"
+ },
+ "placeholders": {
+ "headerName": "Header-Name",
+ "envVarName": "ENV_VAR_NAME",
+ "serverName": "my-server",
+ "latest": "latest",
+ "value": "value",
+ "serverUrl": "https://api.example.com/mcp"
+ }
+ },
+ "mcpDetail": {
+ "auth": {
+ "remoteMayNeedHeaders": "Remote MCP servers могут требовать custom headers или API keys, даже если registry их не описывает. Если connection после установки не работает, проверьте provider docs.",
+ "required": "Этот server требует authentication"
+ },
+ "diagnostics": {
+ "launchTarget": "Launch Target"
+ },
+ "form": {
+ "autoFilled": "Заполнено автоматически",
+ "environmentVariables": "Environment variables",
+ "headers": "Headers",
+ "scope": "Scope",
+ "serverName": "Имя server"
+ },
+ "install": {
+ "httpTransport": "HTTP: {{transport}}",
+ "manualSetupDescription": "Этот server требует ручной настройки. Проверьте repository для инструкций по установке.",
+ "manualSetupRequired": "Требуется ручная настройка",
+ "npmPackage": "npm: {{package}}",
+ "manage": "Управление установкой",
+ "install": "Установить server"
+ },
+ "links": {
+ "glama": "Glama",
+ "repository": "Repository",
+ "website": "Website"
+ },
+ "metadata": {
+ "author": "Автор",
+ "githubStars": "GitHub Stars",
+ "hosting": "Hosting",
+ "installType": "Тип установки",
+ "license": "Лицензия",
+ "published": "Опубликовано",
+ "source": "Источник",
+ "updated": "Обновлено",
+ "version": "Версия"
+ },
+ "scope": {
+ "local": "Local",
+ "project": "Project"
+ },
+ "tools": {
+ "title": "Tools ({{count}})",
+ "title_few": "Tools ({{count}})",
+ "title_many": "Tools ({{count}})",
+ "title_one": "Tool ({{count}})",
+ "title_other": "Tools ({{count}})"
+ },
+ "placeholders": {
+ "serverName": "my-server"
+ }
+ },
+ "skillEditor": {
+ "actions": {
+ "cancel": "Отмена",
+ "createSkill": "Создать skill",
+ "preparing": "Подготовка...",
+ "reviewAndCreate": "Проверить и создать",
+ "reviewAndSave": "Проверить и сохранить",
+ "saveSkill": "Сохранить skill"
+ },
+ "advanced": {
+ "customDescription": "Этот skill использует собственный markdown-формат, поэтому редактируйте его напрямую здесь.",
+ "customTitle": "2. Редактор SKILL.md",
+ "description": "В большинстве случаев это можно пропустить. Открывайте только если нужен прямой контроль над raw markdown-файлом.",
+ "hide": "Скрыть расширенный редактор",
+ "resetFromStructuredFields": "Сбросить из структурированных полей",
+ "show": "Показать расширенный редактор",
+ "title": "4. Расширенный редактор SKILL.md"
+ },
+ "basics": {
+ "description": "Дайте skill понятное имя, выберите, кто может его использовать, и где он должен храниться.",
+ "title": "1. Основное"
+ },
+ "description": {
+ "create": "Опишите workflow простым языком, проверьте файлы, которые будут созданы, затем сохраните skill.",
+ "edit": "Обновите этот skill, проверьте итоговые изменения файлов, затем сохраните."
+ },
+ "extraFiles": {
+ "addedFiles": "Добавленные файлы:",
+ "assets": "Assets",
+ "assetsDescription": "Добавляйте screenshots или bundled media только если они помогают объяснить workflow.",
+ "description": "Добавляйте supporting docs, scripts или assets только если этот skill действительно в них нуждается.",
+ "lockedForEdits": "Root и folder заблокированы при редактировании",
+ "optionalDescription": "Добавьте starter files, которые попадут в review и будут записаны вместе с `SKILL.md`.",
+ "optionalTitle": "Дополнительные файлы",
+ "references": "References",
+ "referencesDescription": "Добавьте supporting docs, links или examples, которые runtime сможет использовать.",
+ "scripts": "Scripts",
+ "scriptsDescription": "Добавьте helper commands или setup notes. Внимательно проверьте перед публикацией skill.",
+ "title": "3. Дополнительные файлы"
+ },
+ "fields": {
+ "compatibility": "Compatibility",
+ "description": "Описание",
+ "folderName": "Имя folder",
+ "folderNameHint": "Мы предлагаем его автоматически из имени skill, чтобы review сразу работал корректно.",
+ "invocation": "Как его использовать",
+ "license": "Лицензия",
+ "name": "Имя skill",
+ "notes": "Дополнительные notes или guardrails",
+ "root": "Где хранить",
+ "scope": "Кто может использовать",
+ "steps": "Основные шаги",
+ "whenToUse": "Когда использовать"
+ },
+ "instructions": {
+ "description": "Эти секции генерируют skill-файл автоматически, поэтому markdown можно не редактировать вручную.",
+ "locked": "Структурированные поля заблокированы, потому что вы переключились на ручное редактирование `SKILL.md` ниже.",
+ "title": "2. Инструкции"
+ },
+ "invocation": {
+ "auto": "Можно использовать автоматически",
+ "manualOnly": "Только когда вы явно попросите"
+ },
+ "placeholders": {
+ "description": "С чем помогает этот skill",
+ "name": "Напишите короткое имя skill",
+ "notes": "Пример: отметьте отсутствующие тесты, регрессии и рискованные assumptions.",
+ "steps": "1. Проверьте релевантные файлы.\n2. Сначала объясните главный риск.\n3. Предложите самый безопасный fix.",
+ "whenToUse": "Пример: используйте это для code review или bug triage.",
+ "license": "MIT",
+ "compatibility": "claude-code, cursor"
+ },
+ "review": {
+ "creating": "Создание skill",
+ "hint": "Сначала проверьте изменения файлов, затем подтвердите сохранение на следующем шаге.",
+ "saving": "Сохранение skill"
+ },
+ "root": {
+ "codexOnly": " - только Codex",
+ "shared": " - общий"
+ },
+ "scope": {
+ "project": "Проект: {{project}}",
+ "projectUnavailable": "Проект недоступен",
+ "user": "Пользователь"
+ },
+ "title": {
+ "create": "Создать skill",
+ "edit": "Редактировать skill"
+ }
+ },
+ "skillDetail": {
+ "actions": {
+ "cancel": "Отмена",
+ "delete": "Удалить",
+ "deleteSkill": "Удалить skill",
+ "deleting": "Удаление...",
+ "editSkill": "Редактировать skill",
+ "openFolder": "Открыть папку",
+ "openSkillFile": "Открыть SKILL.md",
+ "retry": "Повторить"
+ },
+ "badges": {
+ "assets": "Assets",
+ "autoUse": "Auto use",
+ "hasScripts": "Есть scripts",
+ "manualUse": "Manual use",
+ "references": "References",
+ "storedIn": "Хранится в {{root}}"
+ },
+ "deleteDialog": {
+ "description": "Удалить этот skill и переместить его в Trash?",
+ "descriptionWithName": "Удалить \"{{name}}\" и переместить в Trash? При необходимости его можно восстановить из Trash.",
+ "title": "Удалить skill?"
+ },
+ "descriptionFallback": "Просмотр найденных metadata skill и raw instructions.",
+ "errors": {
+ "deleteFailed": "Не удалось удалить skill",
+ "loadFailed": "Не удалось загрузить этот skill."
+ },
+ "files": {
+ "advancedDetails": "Расширенные сведения о файле",
+ "assets": "Assets",
+ "references": "References",
+ "scripts": "Scripts",
+ "storedAt": "Хранится в"
+ },
+ "includes": {
+ "assets": "assets",
+ "instructionsOnly": "Только инструкции skill",
+ "references": "references",
+ "scripts": "scripts"
+ },
+ "invocation": {
+ "auto": "Запускается автоматически, когда подходит к задаче.",
+ "manualOnly": "Запускается только по явному запросу."
+ },
+ "issues": {
+ "bundledScripts": "Этот skill содержит bundled scripts",
+ "reviewCarefully": "Внимательно проверьте skill перед использованием"
+ },
+ "loading": "Загрузка сведений о skill...",
+ "scope": {
+ "personal": "Ваши личные skills",
+ "projectOnly": "Только этот проект"
+ },
+ "summary": {
+ "howUsed": "Как используется",
+ "included": "Что входит",
+ "whoCanUse": "Кто может использовать"
+ },
+ "titleFallback": "Сведения о skill"
+ },
+ "skillsPanel": {
+ "actions": {
+ "createSkill": "Создать skill",
+ "import": "Импорт"
+ },
+ "badges": {
+ "assets": "Assets",
+ "hasScripts": "Есть scripts",
+ "needsAttention": "Требует внимания",
+ "references": "References",
+ "storedIn": "Хранится в {{root}}"
+ },
+ "configuredRuntime": "настроенный runtime",
+ "counts": {
+ "codexOnly": "Codex only: {{count}}",
+ "codexOnly_few": "Codex only: {{count}}",
+ "codexOnly_many": "Codex only: {{count}}",
+ "codexOnly_one": "Codex only: {{count}}",
+ "codexOnly_other": "Codex only: {{count}}",
+ "personal": "Личных: {{count}}",
+ "personal_few": "Личных: {{count}}",
+ "personal_many": "Личных: {{count}}",
+ "personal_one": "Личный: {{count}}",
+ "personal_other": "Личных: {{count}}",
+ "project": "Проектных: {{count}}",
+ "project_few": "Проектных: {{count}}",
+ "project_many": "Проектных: {{count}}",
+ "project_one": "Проектный: {{count}}",
+ "project_other": "Проектных: {{count}}",
+ "shared": "Общих: {{count}}",
+ "shared_few": "Общих: {{count}}",
+ "shared_many": "Общих: {{count}}",
+ "shared_one": "Общий: {{count}}",
+ "shared_other": "Общих: {{count}}",
+ "total": "Всего: {{count}}",
+ "total_few": "Всего: {{count}}",
+ "total_many": "Всего: {{count}}",
+ "total_one": "Всего: {{count}}",
+ "total_other": "Всего: {{count}}"
+ },
+ "empty": {
+ "noMatches": "Skills под поиск не найдены",
+ "noMatchesDescription": "Попробуйте другой запрос или переключите фильтры.",
+ "noSkills": "Skills пока нет",
+ "noSkillsDescription": "Создайте первый skill для повторяемого workflow или импортируйте уже используемый."
+ },
+ "filters": {
+ "all": "Все skills",
+ "codexOnly": "Codex only",
+ "hasScripts": "Есть scripts",
+ "needsAttention": "Требует внимания",
+ "personal": "Личные",
+ "project": "Проектные",
+ "shared": "Общие"
+ },
+ "hero": {
+ "codexAvailable": "Используйте `.codex`, если skill должен оставаться только для Codex.",
+ "codexUnavailable": "Существующие `.codex` skills можно редактировать здесь, но для новых Codex-only skills нужен включённый Codex runtime.",
+ "description": "Skills - это переиспользуемые инструкции, которые помогают runtime стабильнее выполнять однотипные задачи.",
+ "guidance": "Используйте личные skills для привычек, нужных везде. Используйте проектные skills для workflows, которые имеют смысл только внутри одного codebase.",
+ "personalContext": "Сейчас показаны только ваши личные skills.",
+ "projectContext": "Показаны skills для {{project}} плюс ваши личные skills.",
+ "title": "Обучить повторяемой работе"
+ },
+ "invocation": {
+ "auto": "Запускается автоматически, когда подходит",
+ "manualOnly": "Запускается только по явному запросу"
+ },
+ "loading": {
+ "loading": "Загрузка skills...",
+ "refreshing": "Обновление skills..."
+ },
+ "runtimeAudience": "Shared skills в `.claude`, `.cursor` и `.agents` доступны для {{audience}}. Skills в `.codex` остаются Codex-only, когда поддержка Codex доступна.",
+ "scope": {
+ "project": "Этот проект",
+ "user": "Личный"
+ },
+ "searchPlaceholder": "Поиск по имени skill или назначению...",
+ "sections": {
+ "personal": {
+ "description": "Привычки и инструкции, которые должны быть доступны везде.",
+ "title": "Личные skills"
+ },
+ "project": {
+ "description": "Workflows, которые имеют смысл только для этого codebase.",
+ "title": "Проектные skills"
+ }
+ },
+ "sort": {
+ "label": "Сортировать skills",
+ "name": "Имя",
+ "recent": "Недавние"
+ },
+ "status": {
+ "hasScripts": "Содержит scripts, поэтому внимательно проверьте",
+ "needsAttention": "Требует внимания перед использованием",
+ "ready": "Готов к использованию"
+ },
+ "success": {
+ "created": "Skill успешно создан.",
+ "imported": "Skill успешно импортирован.",
+ "saved": "Skill успешно сохранён."
+ }
+ },
+ "pluginDetail": {
+ "unknown": "Неизвестно",
+ "metadata": {
+ "author": "Автор",
+ "category": "Категория",
+ "source": "Источник",
+ "version": "Версия",
+ "capabilities": "Возможности",
+ "installs": "Установки"
+ },
+ "scope": {
+ "label": "Scope:",
+ "options": {
+ "user": "User (global)",
+ "project": "Project (shared)",
+ "local": "Local (gitignored)"
+ }
+ },
+ "links": {
+ "homepage": "Homepage",
+ "contact": "Контакт"
+ },
+ "readme": {
+ "loading": "Загрузка README...",
+ "empty": "README недоступен."
+ }
+ },
+ "skillImport": {
+ "title": "Импорт skill",
+ "description": "Выберите существующую папку skill, проверьте, что будет скопировано, затем импортируйте её в одну из поддерживаемых локаций skills.",
+ "steps": {
+ "chooseFolder": {
+ "title": "1. Выберите папку skill",
+ "description": "Это должна быть папка, где уже есть файл `SKILL.md`, `Skill.md` или `skill.md`."
+ },
+ "location": {
+ "title": "2. Выберите, где хранить skill",
+ "description": "Личные skills работают везде. Проектные skills показываются только для одного codebase."
+ }
+ },
+ "fields": {
+ "sourceFolder": "Исходная папка",
+ "destinationFolderName": "Имя целевой папки",
+ "audience": "Кто может использовать",
+ "storage": "Где хранить"
+ },
+ "placeholders": {
+ "defaultFolderName": "По умолчанию имя исходной папки"
+ },
+ "actions": {
+ "browse": "Выбрать",
+ "cancel": "Отмена",
+ "preparing": "Подготовка...",
+ "reviewAndImport": "Проверить и импортировать",
+ "importSkill": "Импортировать skill",
+ "backToImport": "Назад к импорту"
+ },
+ "scope": {
+ "user": "User",
+ "project": "Project: {{project}}",
+ "projectUnavailable": "Project недоступен"
+ },
+ "rootSuffix": {
+ "codexOnly": " - Codex only",
+ "shared": " - Shared"
+ },
+ "reviewHint": "Сначала проверьте скопированные файлы, затем подтвердите импорт на следующем шаге.",
+ "reviewLabel": "Импорт этого skill",
+ "errors": {
+ "missingSkillFile": "Эта папка пока не похожа на skill. Нужен файл SKILL.md, Skill.md или skill.md.",
+ "symbolicLinks": "В этой папке есть symbolic links. Импортируйте реальные файлы вместо links.",
+ "tooManyFiles": "В этой папке skill слишком много файлов для одного импорта. Уберите лишние файлы и повторите попытку.",
+ "tooLarge": "Эта папка skill слишком большая для безопасного импорта. Уменьшите крупные assets и повторите попытку.",
+ "invalidFolderName": "Выберите более простое имя целевой папки: буквы, цифры, точки, дефисы или подчёркивания.",
+ "mustBeDirectory": "Выберите папку для импорта, а не отдельный файл.",
+ "reviewFailed": "Не удалось подготовить review изменений импорта",
+ "importFailed": "Не удалось импортировать skill"
+ }
+ },
+ "mcpPanel": {
+ "sort": {
+ "nameAsc": "Name A→Z",
+ "nameDesc": "Name Z→A",
+ "toolsDesc": "Больше всего tools"
+ },
+ "health": {
+ "title": "MCP health status",
+ "checkingViaRuntime": "Проверка установленных MCP servers через {{runtime}} ...",
+ "lastChecked": "Последняя проверка {{time}}",
+ "description": "Запустите diagnostics на этой странице, чтобы проверить подключение установленных MCP.",
+ "checking": "Проверка...",
+ "checkStatus": "Проверить status"
+ },
+ "diagnostics": {
+ "title": "Runtime MCP diagnostics",
+ "serversCount": "{{count}} servers",
+ "serversCount_one": "{{count}} server",
+ "serversCount_few": "{{count}} servers",
+ "serversCount_many": "{{count}} servers",
+ "serversCount_other": "{{count}} servers",
+ "waiting": "Ожидание результатов diagnostics...",
+ "disableReasons": {
+ "checkingRuntimeStatus": "Проверка runtime status...",
+ "checkingRuntimeAvailability": "Проверка доступности runtime...",
+ "runtimeFailedToStart": "Настроенный runtime найден, но не запустился. Откройте Dashboard, чтобы repair или reinstall его.",
+ "runtimeRequired": "Требуется настроенный runtime. Установите или repair его из Dashboard."
+ }
+ },
+ "searchPlaceholder": "Поиск MCP servers...",
+ "runtime": {
+ "notAvailable": "{{runtime}} недоступен",
+ "notInstalled": "{{runtime}} не установлен",
+ "requiredDescription": "MCP health checks требуют {{runtime}}. Перейдите в Dashboard, чтобы установить или repair его."
+ },
+ "empty": {
+ "searchTitle": "Servers не найдены",
+ "title": "MCP servers недоступны",
+ "searchDescription": "Попробуйте другой поисковый запрос",
+ "description": "Новые servers могут появиться позже"
+ },
+ "loadMore": "Загрузить ещё"
+ },
+ "apiKeys": {
+ "description": "Безопасно храните API keys для auto-fill при установке MCP servers.",
+ "storage": {
+ "osKeychain": "Keys шифруются через {{backend}} и хранятся с ограниченными file permissions (только owner).",
+ "localEncryption": "OS keychain недоступен - keys шифруются локально через AES-256. Для более сильной защиты установите keyring service (gnome-keyring, kwallet)."
+ },
+ "actions": {
+ "add": "Добавить API key",
+ "addFirst": "Добавить первый key",
+ "edit": "Редактировать"
+ },
+ "empty": {
+ "title": "API keys не сохранены",
+ "description": "Добавьте keys для auto-fill environment variables при установке MCP servers."
+ },
+ "form": {
+ "addTitle": "Добавить API-ключ",
+ "editTitle": "Редактировать API-ключ",
+ "addDescription": "Сохраните API-ключ для автозаполнения при установке MCP-серверов.",
+ "editDescription": "Обновите детали ключа. Значение нужно ввести заново.",
+ "keychainUnavailable": "OS keychain недоступен - ключи локально шифруются AES-256. Установите gnome-keyring для защиты на уровне ОС.",
+ "name": "Название",
+ "namePlaceholder": "например OpenAI Production",
+ "environmentVariableName": "Имя переменной окружения",
+ "envVarPlaceholder": "например OPENAI_API_KEY",
+ "value": "Значение",
+ "reenterValue": "Введите значение ключа заново",
+ "valuePlaceholder": "sk-...",
+ "scope": "Область",
+ "userScopeLabel": "Пользователь (глобально)",
+ "projectScopeLabel": "Проект: {{project}}",
+ "projectUnavailable": "Проект недоступен",
+ "boundTo": "Привязано к {{path}}",
+ "cancel": "Отмена",
+ "saving": "Сохранение...",
+ "update": "Обновить",
+ "save": "Сохранить",
+ "errors": {
+ "invalidEnvVarFormat": "Используйте буквы, цифры и подчёркивания. Должно начинаться с буквы или подчёркивания.",
+ "nameRequired": "Название обязательно",
+ "envVarRequired": "Имя переменной окружения обязательно",
+ "invalidEnvVar": "Некорректное имя переменной окружения",
+ "valueRequired": "Значение ключа обязательно",
+ "projectScopeRequiresProject": "API-ключи уровня проекта требуют активный проект",
+ "saveFailed": "Не удалось сохранить"
+ }
+ }
+ },
+ "skillReview": {
+ "title": "Проверка изменений навыка",
+ "description": "{{reviewLabel}} сначала показывает изменения файловой системы. Ничего не будет записано до подтверждения ниже.",
+ "noPreview": "Предпросмотр недоступен.",
+ "confirmPromptPrefix": "Проверьте diff ниже, затем нажмите",
+ "confirmPromptSuffix": "чтобы применить изменения.",
+ "noChanges": "Изменения файлов пока не обнаружены.",
+ "binaryBadge": "бинарный",
+ "binaryPreviewHidden": "Предпросмотр бинарного файла не показывается. Файл будет скопирован как есть.",
+ "summary": {
+ "fileChanges": "{{count}} изменений файлов",
+ "fileChanges_one": "{{count}} изменение файла",
+ "fileChanges_few": "{{count}} изменения файлов",
+ "fileChanges_many": "{{count}} изменений файлов",
+ "fileChanges_other": "{{count}} изменений файлов",
+ "new": "{{count}} новых",
+ "updated": "{{count}} обновлено",
+ "removed": "{{count}} удалено",
+ "binary": "{{count}} бинарных"
+ }
+ },
+ "mcpCard": {
+ "toolsCount": "{{count}} инструментов",
+ "toolsCount_one": "{{count}} инструмент",
+ "toolsCount_few": "{{count}} инструмента",
+ "toolsCount_many": "{{count}} инструментов",
+ "toolsCount_other": "{{count}} инструментов",
+ "envCount": "{{count}} env-переменных",
+ "envCount_one": "{{count}} env-переменная",
+ "envCount_few": "{{count}} env-переменные",
+ "envCount_many": "{{count}} env-переменных",
+ "envCount_other": "{{count}} env-переменных",
+ "auth": "Авторизация",
+ "byAuthor": "от {{author}}",
+ "hosting": {
+ "remote": "Удаленный",
+ "local": "Локальный",
+ "both": "Оба варианта"
+ },
+ "repository": "Репозиторий",
+ "website": "Сайт"
+ },
+ "installButton": {
+ "installing": "Установка...",
+ "removing": "Удаление...",
+ "done": "Готово",
+ "retry": "Повторить",
+ "uninstall": "Удалить",
+ "install": "Установить"
+ },
+ "pluginCard": {
+ "official": "Официальный"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/report.json b/src/features/localization/renderer/locales/ru/report.json
new file mode 100644
index 00000000..c5d958d1
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/report.json
@@ -0,0 +1,217 @@
+{
+ "cost": {
+ "breakdownTitle": "Разбивка стоимости (за 1M токенов)",
+ "cacheRead": "Cache Read",
+ "cacheWrite": "Cache Write",
+ "cost": "Стоимость",
+ "input": "Вход",
+ "noCommits": "коммитов нет",
+ "noLinesChanged": "изменённых строк нет",
+ "output": "Выход",
+ "parent": "Parent: {{cost}}",
+ "parentCost": "Стоимость parent-сессии",
+ "perCommit": "На коммит",
+ "perCommitFormula": "общая стоимость ÷ {{count}} коммит",
+ "perCommitFormula_few": "общая стоимость ÷ {{count}} коммита",
+ "perCommitFormula_many": "общая стоимость ÷ {{count}} коммитов",
+ "perCommitFormula_one": "общая стоимость ÷ {{count}} коммит",
+ "perCommitFormula_other": "общая стоимость ÷ {{count}} коммита",
+ "perLineChanged": "На изменённую строку",
+ "perLineFormula": "общая стоимость ÷ {{count}} строка",
+ "perLineFormula_few": "общая стоимость ÷ {{count}} строки",
+ "perLineFormula_many": "общая стоимость ÷ {{count}} строк",
+ "perLineFormula_one": "общая стоимость ÷ {{count}} строка",
+ "perLineFormula_other": "общая стоимость ÷ {{count}} строки",
+ "subagent": "Субагенты: {{cost}}",
+ "subagentCost": "Стоимость субагентов",
+ "title": "Анализ стоимости",
+ "total": "Итого"
+ },
+ "insights": {
+ "agent": "агент",
+ "agent_few": "агента",
+ "agent_many": "агентов",
+ "agent_one": "агент",
+ "agent_other": "агента",
+ "agentTree": "Дерево агентов ({{count}} {{unit}})",
+ "background": "(в фоне)",
+ "bashCommands": "Bash-команды",
+ "outOfScopeFindings": "Находки вне scope ({{count}})",
+ "questionsAsked": "Заданные вопросы ({{count}})",
+ "repeated": "Повторные",
+ "skillsInvoked": "Вызванные skills ({{count}})",
+ "taskDispatches": "Запуски Task ({{count}})",
+ "tasksCreated": "Созданные задачи ({{count}})",
+ "teamMode": "Командный режим",
+ "teams": "Команды: {{teams}}",
+ "title": "Инсайты сессии",
+ "total": "Всего",
+ "unique": "Уникальные",
+ "skillsInvoked_few": "Вызванные skills ({{count}})",
+ "skillsInvoked_many": "Вызванные skills ({{count}})",
+ "skillsInvoked_one": "Вызванные skills ({{count}})",
+ "skillsInvoked_other": "Вызванные skills ({{count}})",
+ "taskDispatches_few": "Запуски Task ({{count}})",
+ "taskDispatches_many": "Запуски Task ({{count}})",
+ "taskDispatches_one": "Запуски Task ({{count}})",
+ "taskDispatches_other": "Запуски Task ({{count}})",
+ "tasksCreated_few": "Созданные задачи ({{count}})",
+ "tasksCreated_many": "Созданные задачи ({{count}})",
+ "tasksCreated_one": "Созданные задачи ({{count}})",
+ "tasksCreated_other": "Созданные задачи ({{count}})",
+ "questionsAsked_few": "Заданные вопросы ({{count}})",
+ "questionsAsked_many": "Заданные вопросы ({{count}})",
+ "questionsAsked_one": "Заданные вопросы ({{count}})",
+ "questionsAsked_other": "Заданные вопросы ({{count}})",
+ "agentTree_few": "Дерево агентов ({{count}} {{unit}})",
+ "agentTree_many": "Дерево агентов ({{count}} {{unit}})",
+ "agentTree_one": "Дерево агентов ({{count}} {{unit}})",
+ "agentTree_other": "Дерево агентов ({{count}} {{unit}})",
+ "outOfScopeFindings_few": "Находки вне scope ({{count}})",
+ "outOfScopeFindings_many": "Находки вне scope ({{count}})",
+ "outOfScopeFindings_one": "Находки вне scope ({{count}})",
+ "outOfScopeFindings_other": "Находки вне scope ({{count}})",
+ "keyTakeaways": "Ключевые выводы"
+ },
+ "quality": {
+ "chars": "симв.",
+ "corrections": "Исправления",
+ "failed": "failed",
+ "fileReadRedundancy": "Повторное чтение файлов",
+ "firstMessage": "Первое сообщение",
+ "firstRun": "Первый запуск",
+ "frictionRate": "Уровень friction",
+ "lastRun": "Последний запуск",
+ "messagesBeforeWork": "Сообщений до работы",
+ "passed": "passed",
+ "promptQuality": "Качество промпта",
+ "readsPerUniqueFile": "Чтений на уникальный файл",
+ "snapshot": "snapshot",
+ "snapshot_few": "snapshots",
+ "snapshot_many": "snapshots",
+ "snapshot_one": "snapshot",
+ "snapshot_other": "snapshots",
+ "startupOverhead": "Startup overhead",
+ "testProgression": "Динамика тестов",
+ "title": "Сигналы качества",
+ "tokensBeforeWork": "Токенов до работы",
+ "totalReads": "Всего чтений",
+ "uniqueFiles": "Уникальные файлы",
+ "userMessages": "Сообщения пользователя",
+ "percentOfTotal": "% от общего"
+ },
+ "tokens": {
+ "apiCalls": "API-вызовы",
+ "cacheCreate": "Cache Create",
+ "cacheEfficiency": "Эффективность cache",
+ "cacheRead": "Cache Read",
+ "cacheReadPct": "Cache Read %",
+ "coldStart": "Cold Start",
+ "cost": "Стоимость",
+ "input": "Вход",
+ "model": "Модель",
+ "no": "Нет",
+ "output": "Выход",
+ "readWriteRatio": "R/W Ratio",
+ "title": "Использование токенов",
+ "total": "Итого",
+ "yes": "Да"
+ },
+ "subagents": {
+ "title": "Subagents",
+ "metrics": {
+ "count": "Количество",
+ "totalTokens": "Всего tokens",
+ "totalDuration": "Общая длительность",
+ "totalCost": "Общая стоимость"
+ },
+ "table": {
+ "description": "Описание",
+ "type": "Тип",
+ "tokens": "Tokens",
+ "duration": "Длительность",
+ "cost": "Стоимость"
+ }
+ },
+ "overview": {
+ "title": "Обзор",
+ "yes": "Да",
+ "no": "Нет",
+ "metrics": {
+ "duration": "Длительность",
+ "messages": "Сообщения",
+ "contextUsage": "Использование контекста",
+ "compactions": "Compactions",
+ "branch": "Branch",
+ "subagents": "Subagents",
+ "project": "Проект",
+ "sessionId": "Session ID"
+ }
+ },
+ "timeline": {
+ "title": "Timeline и активность",
+ "idleAnalysis": "Idle analysis",
+ "metrics": {
+ "idleGaps": "Idle gaps",
+ "totalIdle": "Всего idle",
+ "activeTime": "Активное время",
+ "idlePercent": "Idle %"
+ },
+ "modelSwitches": "Смены модели ({{count}})",
+ "modelSwitches_one": "Смена модели ({{count}})",
+ "modelSwitches_few": "Смены модели ({{count}})",
+ "modelSwitches_many": "Смены модели ({{count}})",
+ "modelSwitches_other": "Смены модели ({{count}})",
+ "messageNumber": "msg #{{number}}",
+ "keyEvents": "Ключевые события"
+ },
+ "tools": {
+ "title": "Использование инструментов",
+ "summary": "{{formattedCount}} вызовов всего по {{toolCount}} инструментам",
+ "columns": {
+ "tool": "Инструмент",
+ "calls": "Вызовы",
+ "errors": "Ошибки",
+ "successPercent": "Успех %",
+ "health": "Состояние"
+ }
+ },
+ "git": {
+ "title": "Git-активность",
+ "commits": "Коммиты",
+ "pushes": "Pushes",
+ "linesAdded": "Строк добавлено",
+ "linesRemoved": "Строк удалено",
+ "branchesCreated": "Созданные ветки"
+ },
+ "friction": {
+ "title": "Сигналы friction",
+ "rate": "Friction rate: {{rate}}%",
+ "correctionsCount": "исправлений: {{count}}",
+ "correctionsCount_one": "{{count}} исправление",
+ "correctionsCount_few": "исправлений: {{count}}",
+ "correctionsCount_many": "исправлений: {{count}}",
+ "correctionsCount_other": "исправлений: {{count}}",
+ "corrections": "Исправления",
+ "thrashingSignals": "Сигналы thrashing",
+ "repeatedBashCommands": "Повторяющиеся Bash-команды",
+ "reworkedFiles": "Переделанные файлы (3+ правки)"
+ },
+ "errors": {
+ "title": "Ошибки",
+ "permissionDenied": "Доступ запрещён",
+ "messageIndex": "сообщ. #{{index}}",
+ "input": "Ввод",
+ "error": "Ошибка",
+ "count": "ошибок: {{count}}",
+ "count_one": "{{count}} ошибка",
+ "count_few": "ошибок: {{count}}",
+ "count_many": "ошибок: {{count}}",
+ "count_other": "ошибок: {{count}}",
+ "permissionDenialCount": "permission denial: {{count}}",
+ "permissionDenialCount_one": "{{count}} permission denial",
+ "permissionDenialCount_few": "permission denial: {{count}}",
+ "permissionDenialCount_many": "permission denial: {{count}}",
+ "permissionDenialCount_other": "permission denial: {{count}}"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/settings.json b/src/features/localization/renderer/locales/ru/settings.json
new file mode 100644
index 00000000..6e346b9f
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/settings.json
@@ -0,0 +1,983 @@
+{
+ "tabs": {
+ "advanced": {
+ "description": "Расширенные параметры: экспорт и импорт конфигурации, сброс настроек и редактирование raw-конфигурации.",
+ "label": "Расширенные"
+ },
+ "general": {
+ "description": "Основные настройки приложения: тема, язык, плотность интерфейса и поведение при запуске.",
+ "label": "Общие"
+ },
+ "infoAriaLabel": "Что такое «{{label}}»?",
+ "notifications": {
+ "description": "Настройте, когда и как получать уведомления об активности агентов, завершении задач и ошибках.",
+ "label": "Уведомления"
+ }
+ },
+ "view": {
+ "description": "Управление настройками приложения",
+ "loading": "Загрузка настроек...",
+ "title": "Настройки"
+ },
+ "runtimeProvider": {
+ "actions": {
+ "cancel": "Отмена",
+ "test": "Тест"
+ },
+ "defaults": {
+ "allProjects": "Все проекты",
+ "allProjectsHint": "Тесты используют {{project}}. Default применяется, если у проекта нет своего override.",
+ "loadingContexts": "Загрузка contexts...",
+ "projectHint": "Сохранение изменит override только для {{project}}.",
+ "projectOverrideContext": "Project override context",
+ "scopeDescriptionAllProjects": "Default для всех проектов, у которых нет собственного OpenCode override.",
+ "scopeDescriptionProject": "Override только для выбранного проекта. Уже запущенные команды не изменяются.",
+ "selectProjectContext": "Выберите project context",
+ "selectProjectHint": "Выберите проект перед тестированием local models или сохранением defaults.",
+ "selectValidationContext": "Выберите validation context",
+ "setAllProjectsDefault": "Задать default для всех проектов",
+ "setProjectDefault": "Задать default для проекта",
+ "thisProject": "Этот проект",
+ "title": "OpenCode defaults",
+ "validationContext": "Validation context"
+ },
+ "diagnostics": {
+ "copied": "Diagnostics скопированы",
+ "copiedShort": "Скопировано",
+ "copy": "Скопировать diagnostics",
+ "hints": "Подсказки",
+ "likelyCause": "Вероятная причина:"
+ },
+ "models": {
+ "alreadyDefault": "Это уже выбранный OpenCode default.",
+ "empty": "Модели не найдены.",
+ "emptyFree": "Free models не найдены.",
+ "emptyRecommended": "Recommended models не найдены.",
+ "emptyRecommendedFree": "Recommended free models не найдены.",
+ "freeOnly": "Только free",
+ "launchableDescription": "Routes, которые можно тестировать или использовать в team picker: local config, free built-in models и текущий default.",
+ "launchableTitle": "Launchable OpenCode models",
+ "loadingRoutes": "Загрузка OpenCode model routes...",
+ "noRoutesMatch": "OpenCode model routes не найдены по запросу \"{{query}}\".",
+ "noneReported": "Launchable OpenCode model routes пока не получены. Настройте local route в OpenCode или используйте вкладку Providers для просмотра catalog providers.",
+ "recommendedOnly": "Только recommended",
+ "searchPlaceholder": "Поиск моделей",
+ "selectProjectBeforeTesting": "Выберите project context перед тестированием моделей.",
+ "selectProjectBeforeTestingDefaults": "Выберите project context перед тестированием или сохранением OpenCode defaults.",
+ "useInTeamPicker": "Использовать в team picker"
+ },
+ "providers": {
+ "catalog": "OpenCode provider catalog",
+ "countFallback": "OpenCode providers",
+ "description": "{{count}}. Connected и recommended providers показаны первыми.",
+ "description_few": "{{count}}. Connected и recommended providers показаны первыми.",
+ "description_many": "{{count}}. Connected и recommended providers показаны первыми.",
+ "description_one": "{{count}}. Connected и recommended providers показаны первыми.",
+ "description_other": "{{count}}. Connected и recommended providers показаны первыми.",
+ "loadMore": "Загрузить ещё providers",
+ "loading": "Загрузка OpenCode providers",
+ "noMatches": "Providers не найдены по поиску.",
+ "noneReported": "Managed runtime не сообщил OpenCode providers.",
+ "recommended": "Recommended",
+ "refreshCatalog": "Обновить catalog",
+ "searchPlaceholder": "Поиск providers"
+ },
+ "setup": {
+ "loading": "Загрузка provider setup..."
+ },
+ "summary": {
+ "defaultModel": "OpenCode default: {{model}}",
+ "loading": "Загрузка managed OpenCode runtime, connected providers и model defaults...",
+ "source": "Источник: {{source}}",
+ "title": "OpenCode runtime"
+ },
+ "tabs": {
+ "models": "Модели",
+ "providers": "Providers"
+ },
+ "modelRoutes": {
+ "searchPlaceholder": "Поиск маршрутов моделей"
+ },
+ "badges": {
+ "usedInTeamPicker": "Используется в выборе команды",
+ "free": "free",
+ "local": "local",
+ "configured": "настроено",
+ "connected": "подключено",
+ "verified": "проверено",
+ "needsTest": "нужен тест",
+ "failed": "ошибка",
+ "unknown": "неизвестно",
+ "default": "по умолчанию"
+ },
+ "compatibleEndpoint": {
+ "baseUrlPlaceholder": "http://localhost:1234"
+ }
+ },
+ "general": {
+ "agentLanguage": {
+ "description": "Язык общения с агентами",
+ "descriptionWithDetected": "Язык общения с агентами (определён: {{detected}})",
+ "emptyMessage": "Язык не найден.",
+ "label": "Язык",
+ "searchPlaceholder": "Поиск языка...",
+ "selectPlaceholder": "Выберите язык...",
+ "title": "Язык агентов"
+ },
+ "appLanguage": {
+ "description": "Язык интерфейса приложения.",
+ "label": "Язык",
+ "title": "Язык приложения"
+ },
+ "appearance": {
+ "autoExpandAIGroups": {
+ "description": "Автоматически раскрывать каждый ответ при открытии транскрипта или получении нового сообщения",
+ "label": "Раскрывать ответы AI по умолчанию"
+ },
+ "nativeTitleBar": {
+ "description": "Использовать стандартную системную рамку окна вместо кастомной панели заголовка",
+ "label": "Использовать системную панель заголовка",
+ "restartConfirm": {
+ "confirmLabel": "Перезапустить",
+ "message": "Чтобы применить изменение панели заголовка, приложение нужно перезапустить. Перезапустить сейчас?",
+ "title": "Требуется перезапуск"
+ }
+ },
+ "theme": {
+ "description": "Выберите предпочитаемую цветовую тему",
+ "label": "Тема",
+ "options": {
+ "dark": "Тёмная",
+ "light": "Светлая",
+ "system": "Системная"
+ }
+ },
+ "title": "Внешний вид"
+ },
+ "browserAccess": {
+ "serverMode": {
+ "description": "Запустить HTTP-сервер для доступа к интерфейсу из браузера или встраивания в iframe",
+ "label": "Включить режим сервера"
+ },
+ "title": "Доступ из браузера"
+ },
+ "localClaudeRoot": {
+ "actions": {
+ "selectFolder": "Выбрать папку",
+ "selectFolderManually": "Выбрать папку вручную",
+ "useAutoDetect": "Использовать автоопределение",
+ "useFolder": "Использовать папку",
+ "usePath": "Использовать путь",
+ "useThisPath": "Использовать этот путь",
+ "useWsl": "Используете Linux/WSL?"
+ },
+ "confirm": {
+ "noProjectsDir": {
+ "message": "В этой папке нет директории \"projects\". Всё равно продолжить?",
+ "title": "Директория projects не найдена"
+ },
+ "notClaudeDir": {
+ "message": "Эта папка называется \"{{folderName}}\", а не \".claude\". Всё равно продолжить?",
+ "title": "Выбранная папка не является .claude"
+ },
+ "noWslPaths": {
+ "message": "Не удалось автоматически найти WSL-дистрибутивы с данными Claude. Выбрать папку вручную?",
+ "title": "Пути Claude в WSL не найдены"
+ },
+ "wslNoProjectsDir": {
+ "message": "В \"{{path}}\" нет директории \"projects\". Всё равно продолжить?",
+ "title": "В пути WSL нет директории projects"
+ }
+ },
+ "current": {
+ "autoDetected": "Автоопределено: {{path}}",
+ "autoDetectedPath": "Используется автоопределённый путь",
+ "customPath": "Используется пользовательский путь",
+ "label": "Текущий локальный корень"
+ },
+ "description": "Выберите локальную папку, которая будет считаться корнем данных Claude",
+ "errors": {
+ "detectWslFailed": "Не удалось определить пути корня Claude в WSL",
+ "loadFailed": "Не удалось загрузить настройки локального корня Claude",
+ "updateFailed": "Не удалось обновить корень Claude"
+ },
+ "title": "Локальный корень Claude",
+ "wslModal": {
+ "closeAriaLabel": "Закрыть окно выбора пути WSL",
+ "description": "Найденные WSL-дистрибутивы и кандидаты корня Claude",
+ "noProjectsDir": "Директория projects не найдена",
+ "title": "Выберите корень Claude в WSL"
+ }
+ },
+ "privacy": {
+ "telemetry": {
+ "description": "Помогите улучшить приложение, отправляя анонимные отчёты о сбоях и производительности",
+ "label": "Отправлять отчёты о сбоях"
+ },
+ "title": "Приватность"
+ },
+ "server": {
+ "runningOn": "Запущено на",
+ "standaloneModeDescription": "Приложение работает в автономном режиме. HTTP-сервер всегда активен. Системные уведомления недоступны - триггеры уведомлений записываются только внутри приложения.",
+ "title": "Сервер"
+ },
+ "startup": {
+ "launchAtLogin": {
+ "description": "Автоматически запускать приложение при входе в систему",
+ "label": "Запускать при входе"
+ },
+ "showDockIcon": {
+ "description": "Показывать значок приложения в Dock (macOS)",
+ "label": "Показывать значок в Dock"
+ },
+ "title": "Запуск"
+ }
+ },
+ "notifications": {
+ "dev": {
+ "descriptionPrefix": "В режиме разработки уведомления могут работать некорректно. macOS определяет приложение как \"Electron\" (bundle ID",
+ "descriptionSuffix": "), а не как production-приложение. Проверьте разрешения в System Settings > Notifications > Electron.",
+ "title": "Режим разработки"
+ },
+ "ignoredRepositories": {
+ "description": "Уведомления из этих репозиториев будут игнорироваться",
+ "empty": "Игнорируемых репозиториев нет",
+ "selectPlaceholder": "Выберите репозиторий для игнорирования...",
+ "title": "Игнорируемые репозитории"
+ },
+ "settings": {
+ "enabled": {
+ "description": "Показывать системные уведомления об ошибках и событиях",
+ "label": "Включить системные уведомления"
+ },
+ "sound": {
+ "description": "Воспроизводить звук при появлении уведомлений",
+ "label": "Воспроизводить звук"
+ },
+ "subagentErrors": {
+ "description": "Обнаруживать ошибки в сессиях субагентов и уведомлять о них",
+ "label": "Включать ошибки субагентов"
+ },
+ "title": "Настройки уведомлений"
+ },
+ "snooze": {
+ "clear": "Снять паузу",
+ "description": "Временно приостановить уведомления",
+ "descriptionWithTime": "Приостановлено до {{time}}",
+ "label": "Приостановить уведомления",
+ "options": {
+ "15": "15 минут",
+ "30": "30 минут",
+ "60": "1 час",
+ "120": "2 часа",
+ "240": "4 часа",
+ "-1": "До завтра"
+ },
+ "selectDuration": "Выберите длительность..."
+ },
+ "taskCompletion": {
+ "description": "Получайте системные уведомления, когда Claude завершает задачи: звуки, баннеры и бейджи Dock/панели задач. Работает на macOS, Linux и Windows.",
+ "installPlugin": "Установить плагин claude-notifications-go",
+ "title": "Уведомления о завершении задач"
+ },
+ "team": {
+ "allTasksCompleted": {
+ "description": "Уведомлять, когда все задачи в команде переходят в статус completed",
+ "label": "Все задачи завершены"
+ },
+ "autoResumeOnRateLimit": {
+ "description": "Когда Claude сообщает время сброса лимита, запланировать follow-up для лида команды после восстановления лимита",
+ "label": "Автовозобновление после rate limit"
+ },
+ "clarifications": {
+ "description": "Показывать системные уведомления, когда задаче нужен ваш ввод",
+ "label": "Уведомления об уточнениях по задачам"
+ },
+ "crossTeamMessage": {
+ "description": "Уведомлять, когда приходит сообщение от другой команды",
+ "label": "Уведомления о сообщениях между командами"
+ },
+ "leadInbox": {
+ "description": "Уведомлять, когда участники команды отправляют сообщения лиду команды",
+ "label": "Уведомления inbox лида"
+ },
+ "statusChange": {
+ "description": "Показывать системные уведомления при изменении статуса задачи",
+ "label": "Уведомления об изменении статуса задач",
+ "onlySolo": {
+ "description": "Уведомлять только когда в команде нет участников",
+ "label": "Только в Solo-режиме"
+ },
+ "statuses": {
+ "description": "Какие целевые статусы вызывают уведомление",
+ "label": "Уведомлять по этим статусам",
+ "options": {
+ "approved": "Одобрено",
+ "completed": "Завершено",
+ "deleted": "Удалено",
+ "in_progress": "Запущено",
+ "needsFix": "Нужны исправления",
+ "pending": "Ожидание",
+ "review": "Ревью"
+ }
+ }
+ },
+ "taskComments": {
+ "description": "Показывать системные уведомления, когда агенты комментируют задачи",
+ "label": "Уведомления о комментариях к задачам"
+ },
+ "taskCreated": {
+ "description": "Показывать системные уведомления при создании новой задачи",
+ "label": "Уведомления о новых задачах"
+ },
+ "teamLaunched": {
+ "description": "Уведомлять, когда команда завершила запуск и готова к работе",
+ "label": "Уведомления о запуске команды"
+ },
+ "title": "Уведомления команды",
+ "toolApproval": {
+ "description": "Уведомлять, когда инструменту нужно ваше подтверждение (Allow/Deny), пока приложение не в фокусе",
+ "label": "Уведомления о подтверждении инструментов"
+ },
+ "userInbox": {
+ "description": "Уведомлять, когда участники команды отправляют сообщения вам",
+ "label": "Уведомления вашего inbox"
+ }
+ },
+ "test": {
+ "action": "Отправить тест",
+ "description": "Отправить тестовое уведомление, чтобы проверить доставку",
+ "failedToSend": "Не удалось отправить тестовое уведомление",
+ "label": "Тестовое уведомление",
+ "sending": "Отправка...",
+ "sent": "Отправлено!",
+ "unknownError": "Неизвестная ошибка"
+ }
+ },
+ "advanced": {
+ "about": {
+ "appIconAlt": "Значок приложения",
+ "description": "Собирайте команды AI-агентов, которые автономно работают параллельно, общаются между командами и управляют задачами на kanban-доске - со встроенным code review, живым мониторингом процессов и полной видимостью инструментов.",
+ "standalone": "Автономно",
+ "title": "О приложении",
+ "version": "Версия {{version}}"
+ },
+ "configuration": {
+ "editConfig": "Редактировать конфиг",
+ "exportConfig": "Экспортировать конфиг",
+ "importConfig": "Импортировать конфиг",
+ "openInEditor": "Открыть в редакторе",
+ "resetToDefaults": "Сбросить по умолчанию",
+ "title": "Конфигурация"
+ },
+ "updates": {
+ "available": "Доступна v{{version}}",
+ "check": "Проверить обновления",
+ "checking": "Проверка...",
+ "ready": "Обновление готово",
+ "unknownVersion": "неизвестная",
+ "upToDate": "Актуальная версия"
+ },
+ "appName": "Agent Teams AI"
+ },
+ "configEditor": {
+ "errors": {
+ "loadFailed": "Не удалось загрузить конфиг",
+ "saveFailed": "Не удалось сохранить конфиг"
+ },
+ "footer": {
+ "autoSave": "Изменения сохраняются автоматически после редактирования",
+ "toClose": "чтобы закрыть",
+ "escapeKey": "Esc"
+ },
+ "loading": "Загрузка конфига...",
+ "status": {
+ "invalidJson": "Некорректный JSON",
+ "saveFailed": "Сохранение не удалось",
+ "saved": "Сохранено",
+ "saving": "Сохранение..."
+ },
+ "title": "Редактирование конфигурации"
+ },
+ "notificationTriggers": {
+ "add": {
+ "cancel": "Отмена",
+ "submit": "Добавить триггер",
+ "title": "Добавить свой триггер"
+ },
+ "builtin": {
+ "description": "Стандартные триггеры, встроенные в приложение. Их можно включать или отключать и настраивать их паттерны.",
+ "title": "Встроенные триггеры"
+ },
+ "card": {
+ "builtinBadge": "Встроенный",
+ "collapseAriaLabel": "Свернуть",
+ "deleteAriaLabel": "Удалить триггер",
+ "editNameAriaLabel": "Редактировать имя",
+ "expandAriaLabel": "Развернуть"
+ },
+ "color": {
+ "customHexTitle": "Пользовательский HEX-цвет",
+ "invalidHex": "Некорректный HEX"
+ },
+ "configuration": {
+ "alertIfGreaterThan": "Уведомить если >",
+ "emptyPatternHint": "Оставьте пустым, чтобы совпадало с любым содержимым. Используется синтаксис JavaScript regex.",
+ "errorStatusDescription": "Срабатывает, когда выполнение инструмента сообщает об ошибке (is_error: true).",
+ "tokensUnit": "токенов",
+ "matchPatternPlaceholder": "например, error|failed|exception"
+ },
+ "custom": {
+ "description": "Создавайте собственные триггеры для уведомлений по конкретным паттернам или выводам инструментов.",
+ "empty": "Пользовательские триггеры пока не настроены.",
+ "title": "Пользовательские триггеры"
+ },
+ "errors": {
+ "invalidRegexPattern": "Некорректный regex-паттерн"
+ },
+ "fields": {
+ "contentType": "Тип содержимого",
+ "matchField": "Поле для поиска",
+ "matchPattern": "Паттерн совпадения (Regex)",
+ "scopeToolName": "Область / инструмент",
+ "scopeToolNameOptional": "Область / инструмент (необязательно)",
+ "threshold": "Порог",
+ "tokenType": "Тип токенов",
+ "triggerNamePlaceholder": "например, Ошибка сборки",
+ "triggerNameRequired": "Название триггера *"
+ },
+ "ignorePatterns": {
+ "hint": "Нажмите Enter, чтобы добавить. Уведомление пропускается, если совпал любой паттерн.",
+ "placeholder": "Добавить ignore regex...",
+ "removeAriaLabel": "Удалить ignore-паттерн",
+ "summary": "Дополнительно: правила исключения",
+ "title": "Ignore-паттерны (пропустить при совпадении)"
+ },
+ "options": {
+ "contentTypes": {
+ "text": "Текстовый вывод",
+ "thinking": "Thinking",
+ "tool_result": "Результат инструмента",
+ "tool_use": "Вызов инструмента"
+ },
+ "matchFields": {
+ "args": "Аргументы",
+ "command": "Команда",
+ "content": "Содержимое",
+ "description": "Описание",
+ "file_path": "Путь к файлу",
+ "fullInput": "Весь ввод (JSON)",
+ "glob": "Glob-фильтр",
+ "new_string": "Новая строка",
+ "old_string": "Старая строка",
+ "path": "Путь",
+ "pattern": "Паттерн",
+ "prompt": "Промпт",
+ "query": "Запрос",
+ "skill": "Название skill",
+ "subagent_type": "Тип субагента",
+ "text": "Текстовое содержимое",
+ "thinking": "Thinking-содержимое",
+ "url": "URL"
+ },
+ "modes": {
+ "content_match": "Паттерн в содержимом",
+ "error_status": "Ошибка выполнения",
+ "token_threshold": "Высокий расход токенов"
+ },
+ "tokenTypes": {
+ "input": "Входные токены",
+ "output": "Выходные токены",
+ "total": "Всего токенов"
+ },
+ "toolNames": {
+ "anyTool": "Любой инструмент"
+ }
+ },
+ "preview": {
+ "defaultTestTriggerName": "Тестовый триггер",
+ "detectedSuffix": "ошибок было бы обнаружено",
+ "more": "...и ещё {{count}}",
+ "more_few": "...и ещё {{count}}",
+ "more_many": "...и ещё {{count}}",
+ "more_one": "...и ещё {{count}}",
+ "more_other": "...и ещё {{count}}",
+ "testTrigger": "Проверить триггер",
+ "testing": "Проверка...",
+ "title": "Предпросмотр",
+ "truncatedWarning": "Поиск остановлен раньше времени (таймаут или лимит количества). Фактических совпадений может быть больше.",
+ "viewSession": "Открыть сессию"
+ },
+ "repositoryScope": {
+ "empty": "Репозитории не выбраны - триггер применяется ко всем репозиториям",
+ "hint": "Если репозитории выбраны, триггер срабатывает только для ошибок в этих репозиториях.",
+ "placeholder": "Выберите репозиторий для добавления...",
+ "summary": "Дополнительно: область репозиториев",
+ "title": "Ограничить репозиториями (применяется только к выбранным)"
+ },
+ "sections": {
+ "configuration": "Конфигурация",
+ "dotColor": "Цвет точки",
+ "generalInfo": "Основная информация",
+ "triggerCondition": "Условие триггера"
+ }
+ },
+ "workspaceProfiles": {
+ "actions": {
+ "addProfile": "Добавить профиль",
+ "cancel": "Отмена",
+ "deleteProfile": "Удалить профиль",
+ "editProfile": "Редактировать профиль",
+ "save": "Сохранить"
+ },
+ "authMethods": {
+ "agent": "SSH Agent",
+ "auto": "Auto (из SSH Config)",
+ "password": "Пароль",
+ "privateKey": "Приватный ключ"
+ },
+ "deleteConfirm": {
+ "confirmLabel": "Удалить",
+ "message": "Вы уверены, что хотите удалить \"{{name}}\"? Это действие нельзя отменить.",
+ "title": "Удалить профиль"
+ },
+ "description": "Сохраняйте SSH-профили для быстрого повторного подключения",
+ "empty": {
+ "description": "Добавьте SSH-профиль, чтобы быстро подключаться",
+ "title": "Сохранённых профилей нет"
+ },
+ "form": {
+ "authentication": "Аутентификация",
+ "host": "Хост",
+ "name": "Название",
+ "passwordPrompt": "Пароль будет запрошен при подключении.",
+ "port": "Порт",
+ "privateKeyPath": "Путь к приватному ключу",
+ "username": "Имя пользователя",
+ "namePlaceholder": "Мой сервер",
+ "hostPlaceholder": "hostname или IP",
+ "usernamePlaceholder": "user"
+ },
+ "loading": "Загрузка профилей...",
+ "title": "Профили рабочих окружений"
+ },
+ "connection": {
+ "actions": {
+ "connect": "Подключиться",
+ "connecting": "Подключение...",
+ "disconnect": "Отключиться",
+ "testConnection": "Проверить подключение",
+ "testing": "Проверка..."
+ },
+ "currentMode": {
+ "description": "Источник данных для файлов сессий",
+ "label": "Текущий режим",
+ "local": "Локально ({{path}})"
+ },
+ "description": "Подключитесь к удалённой машине, чтобы просматривать сессии Claude Code, запущенные там",
+ "form": {
+ "authentication": "Аутентификация",
+ "host": "Хост",
+ "password": "Пароль",
+ "port": "Порт",
+ "privateKeyPath": "Путь к приватному ключу",
+ "username": "Имя пользователя",
+ "hostPlaceholder": "hostname или alias из SSH config",
+ "usernamePlaceholder": "user"
+ },
+ "savedProfiles": {
+ "title": "Сохранённые профили"
+ },
+ "ssh": {
+ "title": "SSH-подключение"
+ },
+ "status": {
+ "connectedTo": "Подключено к {{host}}",
+ "remoteSessions": "Просмотр удалённых сессий через SSH"
+ },
+ "test": {
+ "failed": "Подключение не удалось: {{error}}",
+ "success": "Подключение успешно",
+ "unknownError": "Неизвестная ошибка"
+ },
+ "title": "Удалённое подключение"
+ },
+ "providerRuntime": {
+ "actions": {
+ "cancel": "Отмена",
+ "cancelLogin": "Отменить вход",
+ "connectChatGpt": "Подключить ChatGPT",
+ "delete": "Удалить",
+ "disable": "Отключить",
+ "disconnectAccount": "Отключить аккаунт",
+ "generateLink": "Создать ссылку",
+ "openLogin": "Открыть вход",
+ "reconnectAnthropic": "Переподключить Anthropic",
+ "refresh": "Обновить",
+ "replaceKey": "Заменить ключ",
+ "saveEndpoint": "Сохранить endpoint",
+ "saveKey": "Сохранить ключ",
+ "saving": "Сохранение...",
+ "setApiKey": "Задать API key",
+ "updateKey": "Обновить ключ",
+ "useCode": "Использовать код"
+ },
+ "apiKey": {
+ "loadingStoredCredentials": "Загрузка сохранённых credentials...",
+ "projectScope": "Проект",
+ "scope": "Scope",
+ "storedIn": "Хранится в {{backend}}",
+ "userScope": "Пользователь",
+ "storedInApp": "Сохранено в приложении",
+ "providers": {
+ "anthropic": {
+ "name": "Anthropic API Key",
+ "title": "API key",
+ "description": "Используйте прямой Anthropic API key для доступа с API-billing. Сессия подписки Anthropic останется доступной после переключения обратно.",
+ "placeholder": "sk-ant-..."
+ },
+ "codex": {
+ "name": "Codex API Key",
+ "title": "API key",
+ "description": "Используйте OpenAI API key как дополнительный способ аутентификации Codex. При переключении Codex в режим API key приложение отзеркалит OPENAI_API_KEY в CODEX_API_KEY для native launches.",
+ "placeholder": "sk-proj-..."
+ },
+ "gemini": {
+ "name": "Gemini API Key",
+ "title": "API access",
+ "description": "Используйте `GEMINI_API_KEY` для Gemini API backend. CLI SDK и ADC не требуют его.",
+ "placeholder": "AIza..."
+ }
+ }
+ },
+ "codex": {
+ "account": {
+ "appServer": "App-server: {{state}}",
+ "connected": "Подключено",
+ "description": "Управляйте локальной сессией Codex app-server account, которая используется для subscription-backed native launches.",
+ "loginInProgress": "Вход выполняется",
+ "plan": "План: {{plan}}",
+ "reconnectRequired": "Нужно переподключить",
+ "title": "ChatGPT account",
+ "hints": {
+ "autoUsesApiKeyUntilChatgpt": "{{message}} Auto продолжит использовать найденный API key, пока ChatGPT не подключён.",
+ "detectedApiKeyNeedsApiMode": "{{message}} Найденный API key используется только после переключения Codex в API key mode.",
+ "localArtifactsNoSession": "Codex CLI сейчас не видит активный ChatGPT account. Локальные данные Codex account есть, но активная managed session не выбрана. Лимиты появятся здесь только после того, как Codex CLI увидит аккаунт.",
+ "noActiveAccount": "Codex CLI сейчас не видит активный ChatGPT account. Лимиты появятся здесь только после того, как Codex CLI увидит аккаунт.",
+ "reconnectBeforeUsage": "В Codex локально выбран ChatGPT account, но текущей сессии нужно переподключение, прежде чем здесь загрузятся лимиты.",
+ "usageLimitsAfterReport": "Лимиты появятся здесь после того, как Codex сообщит их для подключённого ChatGPT account."
+ }
+ },
+ "install": {
+ "checking": "Проверка",
+ "downloading": "Загрузка",
+ "installCli": "Установить Codex CLI",
+ "installing": "Установка",
+ "retryInstall": "Повторить установку",
+ "title": "Установить Codex CLI в данные приложения"
+ },
+ "rateLimits": {
+ "credits": "Credits",
+ "creditsDescription": "Credits показываются отдельно от window-based subscription usage и могут быть недоступны для plan-backed ChatGPT-сессий.",
+ "noSecondaryWindow": "Codex не вернул secondary window для этого account snapshot.",
+ "notReported": "Не передано",
+ "primaryReset": "Сброс primary",
+ "primaryUsed": "Primary использовано",
+ "primaryWindow": "Primary window",
+ "remainingLeft": "{{value}} осталось",
+ "remainingUnknown": "Остаток неизвестен",
+ "secondaryReset": "Сброс secondary",
+ "secondaryUsed": "Secondary использовано",
+ "secondaryWindow": "Secondary window",
+ "usedQuotaNote": "Эти проценты показывают использованную квоту, а не остаток.",
+ "weeklyReset": "Сброс weekly",
+ "weeklyUsed": "Weekly использовано",
+ "weeklyUsedOneWeek": "Weekly использовано (1w)",
+ "weeklyWindow": "Weekly window",
+ "secondaryFallback": "secondary",
+ "secondaryWindowNote": " Weekly-лимиты показаны отдельно в окне {{window}}.",
+ "usageExplanationGeneric": "Показывает использованную квоту, а не остаток.",
+ "usageExplanationWindowOnly": "Показывает использованную квоту в текущем окне {{window}}, а не остаток.",
+ "usageExplanationWithRemaining": "Использовано {{used}} - примерно {{remaining}} осталось в текущем окне {{window}}."
+ }
+ },
+ "compatibleEndpoint": {
+ "authToken": "Auth token",
+ "authTokenMissing": "Auth token не настроен.",
+ "baseUrl": "Base URL",
+ "description": "Использовать локальный runtime endpoint, совместимый с Anthropic.",
+ "keepSavedToken": "Оставьте пустым, чтобы сохранить текущий token",
+ "title": "Локальный / compatible endpoint",
+ "tokenStatus": "Token {{status}}",
+ "validation": {
+ "baseUrlRequired": "Base URL обязателен",
+ "firstPartyAnthropic": "Для первого-party Anthropic используйте Auto, Subscription или API key",
+ "httpRequired": "Base URL должен использовать http:// или https://",
+ "invalidUrl": "Недопустимый URL",
+ "noCredentials": "Base URL не должен содержать credentials"
+ },
+ "status": {
+ "endpointDisabledTokenKept": "Endpoint отключён. Сохранённый token оставлен.",
+ "endpointSaved": "Endpoint сохранён",
+ "endpointSavedTokenMissing": "Endpoint сохранён. Auth token не настроен."
+ }
+ },
+ "connection": {
+ "authenticationMethod": "Метод аутентификации",
+ "descriptions": {
+ "anthropic": "Выберите, как запуски Anthropic из приложения проходят аутентификацию.",
+ "codex": "Выберите, должен ли Codex предпочитать ChatGPT subscription или API key при native runtime запуске.",
+ "gemini": "Настройте опциональный API-доступ. CLI SDK и ADC всё равно определяются автоматически.",
+ "opencode": "Аутентификация OpenCode и список провайдеров управляются runtime OpenCode."
+ },
+ "method": "Метод подключения",
+ "mode": "Режим: {{mode}}",
+ "selected": "Выбрано",
+ "switching": "Переключение...",
+ "title": "Подключение"
+ },
+ "connectionCards": {
+ "apiKey": {
+ "title": "API key"
+ },
+ "anthropic": {
+ "apiKeyDescription": "Использовать ANTHROPIC_API_KEY и биллинг Anthropic API.",
+ "autoDescription": "Использовать runtime-настройки Anthropic по умолчанию и лучший локальный credential.",
+ "hint": "Auto оставляет Anthropic на стандартном локальном выборе credentials.",
+ "subscriptionDescription": "Использовать локальную Anthropic sign-in сессию и subscription access.",
+ "subscriptionTitle": "Anthropic subscription"
+ },
+ "auto": {
+ "title": "Авто"
+ },
+ "codex": {
+ "apiKeyDescription": "Использовать OPENAI_API_KEY и CODEX_API_KEY billing для native Codex launches.",
+ "autoDescription": "Предпочитать ChatGPT account и subscription. API key mode использовать только при необходимости.",
+ "chatgptDescription": "Использовать подключённый ChatGPT account и Codex subscription.",
+ "chatgptTitle": "ChatGPT account",
+ "hint": "Codex всегда работает через native runtime. Auto предпочитает ChatGPT account перед API-key credentials."
+ }
+ },
+ "description": "Управляйте тем, как каждый провайдер подключается, и какой backend должен использовать multimodel runtime, если это поддерживается.",
+ "fastMode": {
+ "defaultOff": "По умолчанию выкл.",
+ "description": "Включать Claude Code Fast mode по умолчанию для новых запусков Anthropic-команд, когда выбранные модель и runtime это поддерживают.",
+ "disabledHint": "Новые Anthropic-запуски остаются на обычной скорости, если команда явно не включает Fast mode.",
+ "enabledHint": "Новые Anthropic-запуски будут запрашивать Fast mode по умолчанию, когда выбранная модель это поддерживает.",
+ "notExposed": "Этот Anthropic runtime не предоставляет Fast mode.",
+ "preferFast": "Предпочитать Fast",
+ "title": "Fast mode по умолчанию",
+ "unavailableForRuntime": "Fast mode сейчас недоступен для этого Anthropic runtime."
+ },
+ "alerts": {
+ "anthropicApiKeyMissing": "Выбран API key mode, но Anthropic API credential пока недоступен.",
+ "anthropicStoredKeyAvailable": "Сохранённый API key доступен, но запуски Anthropic из приложения используют его только после переключения в API key mode.",
+ "anthropicSubscriptionMissing": "Выбран Anthropic subscription mode. Войдите в Anthropic, чтобы использовать этого провайдера.",
+ "authTokenMissing": "Auth token не настроен. Многим локальным Anthropic-compatible endpoints нужен непустой token.",
+ "chatgptLoginPending": "Ожидание завершения входа в ChatGPT account...",
+ "chatgptLoginStarting": "Запуск входа в ChatGPT...",
+ "codexApiKeyMissing": "Выбран API key mode, но OPENAI_API_KEY или CODEX_API_KEY credential пока недоступен.",
+ "codexLocalArtifactsNoSession": "Codex CLI сейчас не видит активный ChatGPT account. Локальные данные Codex account есть, но активная managed session не выбрана.",
+ "codexNeedsReconnect": "В Codex локально выбран ChatGPT account, но текущей сессии нужно переподключение.",
+ "codexNoChatgptAccount": "Codex CLI сейчас не видит активный ChatGPT account. Подключите ChatGPT, чтобы использовать subscription.",
+ "codexNoCredential": "ChatGPT account или API key пока недоступны.",
+ "geminiApiUnavailable": "Gemini API сейчас недоступен. Настройте `GEMINI_API_KEY` здесь или используйте корректные Google ADC credentials.",
+ "withApiKeyFallback": "{{message}} Переключитесь в API key mode, чтобы использовать найденный API key."
+ },
+ "authModeDescriptions": {
+ "anthropic": {
+ "apiKey": "Принудительно использовать API key credential для Anthropic-запусков из приложения.",
+ "auto": "Использовать стандартное поведение runtime. Сохранённые API keys в приложении используются только после переключения в API key mode.",
+ "oauth": "Принудительно использовать локальную Anthropic subscription session для Anthropic-запусков из приложения."
+ },
+ "codex": {
+ "apiKey": "Принудительно использовать OPENAI_API_KEY / CODEX_API_KEY billing для native Codex launches.",
+ "auto": "Предпочитать ChatGPT account, когда он доступен. Переходить к API key mode только при необходимости.",
+ "chatgpt": "Принудительно использовать подключённый ChatGPT account и subscription для native Codex launches."
+ }
+ },
+ "progress": {
+ "applyingConnectionChanges": "Применение изменений подключения...",
+ "refreshingProviderStatus": "Обновление статуса провайдера...",
+ "savingCompatibleEndpoint": "Сохранение compatible endpoint...",
+ "switchingAnthropicSubscription": "Переключение на Anthropic subscription...",
+ "switchingApiKey": "Переключение на API key...",
+ "switchingApiKeyMode": "Переключение в API key mode...",
+ "switchingAuto": "Переключение на Авто...",
+ "switchingChatgpt": "Переключение в ChatGPT account mode..."
+ },
+ "provider": "Провайдер",
+ "runtime": {
+ "descriptions": {
+ "anthropic": "У Anthropic сейчас нет отдельного выбора runtime backend.",
+ "codex": "Codex теперь работает только через native runtime path.",
+ "gemini": "Выберите, какой Gemini runtime backend должен использовать multimodel.",
+ "opencode": "OpenCode использует собственный managed runtime host. Desktop сейчас показывает только статус."
+ },
+ "title": "Runtime",
+ "updating": "Обновление runtime..."
+ },
+ "runtimeSummary": "Runtime: {{runtime}}",
+ "status": {
+ "configured": "настроен",
+ "enabled": "Включено",
+ "notConfigured": "Не настроен",
+ "notSet": "не задан",
+ "off": "Выкл.",
+ "unknown": "Неизвестно"
+ },
+ "title": "Настройки провайдера",
+ "usage": {
+ "apiKey": "Используется API key",
+ "apiKeyRequired": "Нужен API key",
+ "compatibleEndpoint": "Используется compatible endpoint",
+ "notConnected": "Не подключено",
+ "usingMethod": "Используется {{method}}"
+ },
+ "errors": {
+ "apiKeyDeletedRefreshFailed": "API key удалён, но не удалось обновить статус провайдера.",
+ "apiKeySavedRefreshFailed": "API key сохранён, но не удалось обновить статус провайдера.",
+ "connectionUpdatedRefreshFailed": "Подключение обновлено, но не удалось обновить статус провайдера.",
+ "deleteApiKey": "Не удалось удалить API key",
+ "disableEndpoint": "Не удалось отключить endpoint",
+ "endpointDisabledRefreshFailed": "Endpoint отключён, но не удалось обновить статус провайдера.",
+ "endpointSavedRefreshFailed": "Endpoint сохранён, но не удалось обновить статус провайдера.",
+ "refreshCodexAccount": "Не удалось обновить Codex account",
+ "saveApiKey": "Не удалось сохранить API key",
+ "saveEndpoint": "Не удалось сохранить endpoint",
+ "updateAnthropicFastMode": "Не удалось обновить Anthropic Fast mode",
+ "updateConnection": "Не удалось обновить подключение",
+ "updateRuntimeBackend": "Не удалось обновить runtime backend",
+ "apiKeyRequired": "API key обязателен"
+ },
+ "connectionUi": {
+ "authMode": {
+ "auto": "Авто",
+ "oauth": "Подписка / OAuth",
+ "chatgpt": "Аккаунт ChatGPT",
+ "apiKey": "API key",
+ "anthropicSubscription": "Подписка Anthropic"
+ },
+ "authMethod": {
+ "apiKey": "API key",
+ "apiKeyHelper": "API key helper",
+ "oauth": "OAuth",
+ "claudeSubscription": "Подписка Claude",
+ "geminiCli": "Gemini CLI",
+ "googleAccount": "Аккаунт Google",
+ "serviceAccount": "service account"
+ },
+ "runtime": {
+ "codexNative": "Codex native",
+ "currentRuntime": "Текущий runtime",
+ "selectedRuntime": "Выбранный runtime",
+ "summary": "{{prefix}}: {{runtime}}"
+ },
+ "status": {
+ "checking": "Проверка...",
+ "checked": "Проверено",
+ "providerActivity": "Активность провайдеров",
+ "notConnected": "Не подключено",
+ "startingChatGptLogin": "Запускается вход в ChatGPT...",
+ "waitingForChatGptLogin": "Ожидание входа в аккаунт ChatGPT...",
+ "chatGptVerificationDegraded": "Аккаунт ChatGPT найден - проверка аккаунта сейчас работает в ограниченном режиме.",
+ "chatGptAccountReady": "Аккаунт ChatGPT готов",
+ "apiKeyReady": "API key готов",
+ "codexLocalAccountNeedsReconnect": "В Codex локально выбран аккаунт ChatGPT, но текущей сессии нужно переподключение.",
+ "codexNoActiveManagedSession": "Codex CLI сообщает, что активного входа ChatGPT нет. Локальные данные аккаунта Codex есть, но активная управляемая сессия не выбрана.",
+ "codexNoActiveChatGptLogin": "Codex CLI сообщает, что активного входа ChatGPT нет",
+ "connectChatGptForSubscription": "Подключите аккаунт ChatGPT, чтобы использовать подписку Codex.",
+ "codexNativeReady": "Codex native готов",
+ "codexNativeUnavailable": "Codex native недоступен",
+ "unavailableInCurrentRuntime": "Недоступно в текущем runtime",
+ "connectedViaApiKey": "Подключено через API key",
+ "apiKeyConfiguredNotVerified": "API key настроен, но ещё не проверен",
+ "apiKeyModeMissingCredential": "Выбран режим API key, но API key не настроен",
+ "connectedVia": "Подключено через {{method}}",
+ "unableToVerify": "Не удалось проверить"
+ },
+ "mode": {
+ "selectedAuth": "Выбранная аутентификация: {{authMode}}",
+ "preferredAuth": "Предпочитаемая аутентификация: {{authMode}}"
+ },
+ "credential": {
+ "apiKeyConfigured": "API key настроен",
+ "savedApiKeyAvailable": "Сохранённый API key доступен в Manage",
+ "apiKeyAlsoConfigured": "API key также настроен в Manage",
+ "apiKeyConfiguredInManage": "API key настроен в Manage",
+ "apiKeyFallbackInManage": "API key также доступен в Manage как fallback",
+ "availableAsFallback": "{{summary}} - доступен как fallback",
+ "savedApiKeyAvailableIfSwitch": "Сохранённый API key доступен в Manage, если переключиться в режим API key",
+ "availableIfSwitch": "{{summary}} - доступен при переключении в режим API key",
+ "autoWillUseUntilChatGpt": "{{summary}} - Auto будет использовать его, пока ChatGPT не подключён"
+ },
+ "actions": {
+ "connect": "Подключить",
+ "connectAnthropic": "Подключить Anthropic",
+ "connectChatGpt": "Подключить ChatGPT",
+ "disconnect": "Отключить",
+ "openLogin": "Открыть вход"
+ },
+ "disconnect": {
+ "anthropicTitle": "Отключить подписку Anthropic?",
+ "anthropic": "Это удалит локальную сессию подписки Anthropic из runtime Claude CLI.",
+ "anthropicWithApiKey": "Это удалит локальную сессию подписки Anthropic из runtime Claude CLI. Сохранённые API keys в Manage останутся доступными.",
+ "geminiTitle": "Отключить Gemini CLI?",
+ "gemini": "Это очистит локальные метаданные сессии Gemini CLI. Внешние ADC credentials и сохранённые API keys не удаляются."
+ }
+ }
+ },
+ "cliRuntime": {
+ "actions": {
+ "checkForUpdates": "Проверить обновления",
+ "checking": "Проверка...",
+ "extensions": "Расширения",
+ "installRuntime": "Установить {{runtime}}",
+ "manage": "Управлять",
+ "recheck": "Проверить снова",
+ "reinstallRuntime": "Переустановить {{runtime}}",
+ "retry": "Повторить",
+ "update": "Обновить"
+ },
+ "installer": {
+ "checkingLatest": "Проверка последней версии...",
+ "downloading": "Загрузка...",
+ "failed": "Установка не удалась",
+ "installed": "Установлено v{{version}}",
+ "installing": "Установка...",
+ "latest": "latest",
+ "verifying": "Проверка checksum..."
+ },
+ "labels": {
+ "multimodel": "Multimodel"
+ },
+ "loading": {
+ "aiProviders": "Проверка AI-провайдеров...",
+ "claudeCli": "Проверка Claude CLI..."
+ },
+ "provider": {
+ "backend": "Backend: {{backend}}",
+ "loadingModels": "Загрузка моделей...",
+ "modelsUnavailable": "Модели недоступны для этой сборки runtime",
+ "runtime": "Runtime: {{runtime}}"
+ },
+ "providerTerminal": {
+ "authFailed": "Аутентификация не удалась",
+ "authUpdated": "Аутентификация обновлена",
+ "loggedOut": "Провайдер отключён",
+ "login": "Вход",
+ "logout": "Выход",
+ "logoutFailed": "Выход не удался"
+ },
+ "status": {
+ "configuredNotFound": "Настроенный {{runtime}} не найден.",
+ "foundButFailed": "{{runtime}} найден, но не запустился",
+ "healthCheckFailed": "Настроенный {{runtime}} не прошёл health check запуска.",
+ "notInstalled": "{{runtime}} не установлен"
+ },
+ "title": "CLI Runtime"
+ },
+ "cliStatus": {
+ "versionUpgrade": "v{{current}} -> v{{latest}}"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/team.json b/src/features/localization/renderer/locales/ru/team.json
new file mode 100644
index 00000000..357a7b52
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/team.json
@@ -0,0 +1,2415 @@
+{
+ "activity": {
+ "actions": {
+ "createTaskFromMessage": "Создать задачу из сообщения",
+ "expandMessage": "Развернуть сообщение",
+ "replyToMessage": "Ответить на сообщение",
+ "restartTeam": "Перезапустить команду"
+ },
+ "authError": {
+ "description": "Не удалось пройти authentication. Перезапуск команды обновит session и может исправить проблему. Если ошибка повторится, проверьте API credentials или попробуйте позже."
+ },
+ "automation": {
+ "reviewPickup": "Teammate получил просьбу забрать review",
+ "stallNudge": "Teammate получил просьбу продолжить stalled task",
+ "workSyncBody": "Teammate получил просьбу синхронизировать текущую работу"
+ },
+ "badges": {
+ "automation": "automation",
+ "bootstrap": "bootstrap",
+ "command": "command",
+ "comment": "Комментарий",
+ "live": "live",
+ "note": "note",
+ "rateLimited": "Rate limit",
+ "restart": "restart",
+ "result": "result",
+ "session": "session",
+ "stallNudge": "stall nudge",
+ "start": "start",
+ "workSync": "work sync"
+ },
+ "bootstrap": {
+ "acknowledged": "Bootstrap подтверждён",
+ "restarting": "Перезапуск teammate",
+ "starting": "Запуск teammate"
+ },
+ "rawJson": "Raw JSON",
+ "unread": "Непрочитано",
+ "thoughts": {
+ "count": "{{count}} мыслей",
+ "count_one": "{{count}} мысль",
+ "count_few": "{{count}} мысли",
+ "count_many": "{{count}} мыслей",
+ "count_other": "{{count}} мысли",
+ "expand": "Развернуть мысли",
+ "showMore": "Показать больше",
+ "showLess": "Показать меньше",
+ "toolSummary": "🔧 {{summary}}",
+ "titleForMember": "{{name}} - мысли"
+ },
+ "timeline": {
+ "loadingMessages": "Загрузка сообщений...",
+ "noMessages": "Нет сообщений",
+ "emptyHint": "Отправьте сообщение участнику, чтобы увидеть активность.",
+ "newSession": "Новая сессия",
+ "olderCount": "+{{count}} старых",
+ "olderCount_one": "+{{count}} старое",
+ "olderCount_few": "+{{count}} старых",
+ "olderCount_many": "+{{count}} старых",
+ "olderCount_other": "+{{count}} старых",
+ "showMore": "Показать ещё {{count}}",
+ "showAll": "Показать все"
+ },
+ "pendingReplies": {
+ "title": "Ожидают ответа",
+ "openMember": "Открыть участника",
+ "messageSentAwaitingReply": "Сообщение отправлено, ожидается ответ",
+ "awaitingReply": "ожидает ответа",
+ "externalTeam": "внешняя команда",
+ "crossTeamAwaitingReply": "Межкомандное сообщение отправлено, ожидается ответ",
+ "user": "пользователь",
+ "awaitingApproval": "ожидает подтверждения"
+ },
+ "reply": {
+ "replyingTo": "Ответ на",
+ "action": "Ответить"
+ },
+ "activeTasks": {
+ "inProgress": "В работе"
+ },
+ "expandDialog": {
+ "description": "Развёрнутый просмотр сообщения"
+ }
+ },
+ "create": {
+ "actions": {
+ "create": "Создать",
+ "creating": "Создание...",
+ "openExisting": "Открыть существующую команду",
+ "skipPreflightAndCreate": "Пропустить preflight и создать"
+ },
+ "conflict": {
+ "description": "Запуск двух команд в одной директории рискован - они могут конфликтовать при изменении одних и тех же файлов. Лучше выбрать другую директорию или git worktree для изоляции.",
+ "title": "Другая команда \"{{team}}\" уже работает в этой working directory",
+ "workingDirectory": "Working directory:"
+ },
+ "description": {
+ "copy": "Создать новую команду на основе существующей.",
+ "create": "Настройте команду и выберите, как она будет запускаться."
+ },
+ "errors": {
+ "nameExists": "Команда с таким именем уже существует",
+ "nameLaunching": "Команда с таким именем сейчас запускается",
+ "createConfigFailed": "Не удалось создать конфиг команды",
+ "loadProjectsFailed": "Не удалось загрузить проекты"
+ },
+ "fields": {
+ "color": "Цвет (optional)",
+ "description": "Описание (optional)",
+ "prompt": "Prompt для team lead (optional)",
+ "teamName": "Имя команды"
+ },
+ "launchAfterCreate": {
+ "description": "Сразу запустить команду через локальный Claude CLI.",
+ "label": "Выполнить команду после создания"
+ },
+ "localOnly": "Доступно только в локальном Electron mode.",
+ "onDisk": "На диске:",
+ "placeholders": {
+ "description": "Краткое описание назначения команды",
+ "prompt": "Инструкции для team lead во время provisioning..."
+ },
+ "saved": "Сохранено",
+ "solo": {
+ "description": "Будет запущен только team lead (main process) - teammates не будут созданы. Работает как обычная agent session в выбранном runtime (Claude Code, Codex, OpenCode, Gemini), но с доступом к task board для планирования. Экономит tokens за счёт отсутствия coordination overhead между teammates. Участников можно добавить позже в настройках команды.",
+ "label": "Solo team"
+ },
+ "title": {
+ "copy": "Копировать команду",
+ "create": "Создать команду"
+ },
+ "optional": {
+ "launchSettingsTitle": "Опциональные настройки запуска",
+ "launchSettingsDescription": "Промпт, безопасность и переопределения CLI находятся здесь, когда они нужны.",
+ "teamDetailsTitle": "Опциональные детали команды",
+ "teamDetailsDescription": "Основной поток остаётся компактным. Открывайте этот блок, когда нужен дополнительный контекст или свой цвет."
+ },
+ "prepare": {
+ "unsupportedPreload": "Текущая версия preload не поддерживает team:prepareProvisioning. Перезапустите dev app.",
+ "selectWorkingDirectory": "Выберите рабочую директорию, чтобы проверить окружение запуска.",
+ "someProvidersNeedAttention": "Некоторым выбранным providers нужно внимание.",
+ "readyWithNotes": "Все выбранные providers готовы, есть заметки.",
+ "ready": "Все выбранные providers готовы.",
+ "failed": "Не удалось подготовить выбранных providers",
+ "checkingProviders": "Проверка выбранных providers...",
+ "preparingEnvironment": "Подготовка окружения...",
+ "selectedProvidersReadyWithNotes": "Выбранные providers готовы (есть заметки)",
+ "selectedProvidersReady": "Выбранные providers готовы"
+ },
+ "validation": {
+ "nameMustContainLetterOrDigit": "Имя должно содержать хотя бы одну букву или цифру",
+ "nameTooLong": "Имя слишком длинное (максимум 128 символов)",
+ "selectWorkingDirectory": "Выберите рабочую директорию (cwd)",
+ "memberNameRequired": "Имя участника не может быть пустым",
+ "memberNameInvalid": "Имя участника должно начинаться с буквы или цифры и содержать только [a-zA-Z0-9._-], максимум 128 символов",
+ "memberNamesUnique": "Имена участников должны быть уникальными",
+ "openCodeLeadModelRequired": "Для lead на OpenCode нужно выбрать модель.",
+ "openCodeTeammateRequired": "Для lead на OpenCode нужен хотя бы один teammate OpenCode.",
+ "teamLaunching": "Команда сейчас запускается",
+ "teamNameExists": "Команда с таким именем уже существует",
+ "checkFormFields": "Проверьте поля формы"
+ }
+ },
+ "editTeam": {
+ "actions": {
+ "cancel": "Отмена",
+ "save": "Сохранить"
+ },
+ "addMemberLockReason": "Используйте отдельный диалог добавления участника, чтобы добавлять teammates во время работы команды.",
+ "description": "Измените имя, описание и цвет команды",
+ "errors": {
+ "changesSavedRefreshFailed": "Изменения команды сохранены, но не удалось обновить последний вид: {{message}}",
+ "liveRenameBlocked": "Нельзя переименовывать существующих teammates, пока команда работает. Переименованы: {{names}}",
+ "memberNameEmpty": "Имя участника не может быть пустым",
+ "memberNameInvalid": "Имя участника должно начинаться с буквы или цифры, использовать только [a-zA-Z0-9._-] и быть не длиннее 128 символов",
+ "memberNameNumericSuffix": "Имя участника \"{{name}}\" недоступно, потому что зарезервировано для auto-suffix Claude CLI. Используйте \"{{base}}\".",
+ "memberNameReserved": "Имя участника \"{{name}}\" зарезервировано",
+ "memberNamesUnique": "Перед сохранением имена участников должны быть уникальными",
+ "newLiveTeammates": "Добавляйте новых teammates через отдельный диалог добавления участника, пока команда работает. Edit Team поддерживает только изменение существующих teammates.",
+ "provisioning": "Настройки команды нельзя редактировать, пока provisioning ещё идёт. Дождитесь завершения запуска и попробуйте снова.",
+ "restartFailedMany": "Команда сохранена, но не удалось перезапустить этих teammates: {{failures}}",
+ "restartFailedOne": "Команда сохранена, но не удалось перезапустить этого teammate: {{failures}}",
+ "saveFailed": "Не удалось сохранить",
+ "settingsChanged": "Настройки команды изменились, пока диалог был открыт. Откройте его заново и проверьте актуальное состояние перед сохранением.",
+ "settingsSavedMembersAndRefreshFailed": "Настройки команды сохранены, но изменения участников не применились: {{message}}. Обновление тоже завершилось ошибкой: {{refreshError}}",
+ "settingsSavedMembersFailed": "Настройки команды сохранены, но изменения участников не применились: {{message}}",
+ "settingsSavedRefreshFailed": "Настройки команды сохранены, но не удалось обновить последний вид: {{message}}",
+ "teamNameEmpty": "Имя команды не может быть пустым",
+ "unsupportedMixedPrimaryMutation": "Live-изменения primary-owned teammates в mixed OpenCode teams пока не поддерживаются. Остановите команду, измените roster и запустите заново. Затронуты: {{names}}"
+ },
+ "fields": {
+ "colorOptional": "Цвет (необязательно)",
+ "description": "Описание",
+ "name": "Имя"
+ },
+ "memberRestartWarning": "Сохранение перезапустит этого teammate, чтобы применить изменения роли, workflow, worktree isolation, provider, model, effort или MCP access.",
+ "notices": {
+ "liveRenameBlocked": "Live-сохранение заблокировано, потому что существующие teammates были переименованы. Отмените эти identity changes или сначала остановите команду.",
+ "newLiveTeammates": "Новых teammates нельзя добавлять из Edit Team, пока команда работает. Используйте диалог добавления участника.",
+ "provisioning": "Provisioning команды ещё идёт. Редактирование временно заблокировано до завершения запуска.",
+ "restartMany": "Сохранение перезапустит или перезапустит заново этих teammates, чтобы применить изменения роли, workflow, worktree isolation, provider, model, effort или MCP access: {{names}}.",
+ "restartOne": "Сохранение перезапустит или перезапустит заново этого teammate, чтобы применить изменения роли, workflow, worktree isolation, provider, model, effort или MCP access: {{names}}.",
+ "unsupportedMixedPrimaryMutation": "Live-изменения или удаления primary-owned teammates в mixed OpenCode teams требуют остановки и повторного запуска команды: {{names}}."
+ },
+ "placeholders": {
+ "description": "Описание команды (необязательно)",
+ "teamName": "Имя команды"
+ },
+ "teamLead": {
+ "changeRuntime": "Изменить runtime lead",
+ "changeRuntimeDescription": "Откройте Relaunch Team, чтобы изменить provider, model или effort для lead.",
+ "modelLockReason": "Runtime team lead управляется через Relaunch Team.",
+ "readOnlyHint": "Имя и роль team lead здесь доступны только для чтения. Откройте runtime panel в строке lead, чтобы изменить provider, model или effort.",
+ "role": "Team Lead"
+ },
+ "title": "Редактировать команду"
+ },
+ "memberDraft": {
+ "actions": {
+ "remove": "Удалить участника",
+ "removeAria": "Удалить {{name}}",
+ "restore": "Восстановить участника",
+ "restoreAria": "Восстановить {{name}}"
+ },
+ "anthropicContext": {
+ "defaultSetting": "настройка контекста по умолчанию",
+ "description": "Контекст Anthropic действует на всю команду для этого запуска: {{mode}}. Используйте checkbox Limit context в runtime panel lead, чтобы изменить это.",
+ "limitEnabled": "лимит 200K включён"
+ },
+ "mcp": {
+ "buttonInherit": "MCP inherit",
+ "buttonScopes": "MCP scopes",
+ "chooseScopes": "Выбрать scopes",
+ "inheritLead": "Наследовать lead",
+ "lockedInfo": "Для всех teammates включён режим только Agent Teams MCP. Этот teammate запустится только с Agent Teams server.",
+ "mode": "MCP mode",
+ "scopes": {
+ "local": "local",
+ "project": "project",
+ "user": "user"
+ },
+ "serverNames": "Имена servers",
+ "settingInfo": "Agent Teams MCP запускает этого teammate только с Agent Teams server. Scope и allowlist modes применяются только к запуску этого teammate.",
+ "strictAllowlist": "Строгий allowlist",
+ "tooltip": "{{label}}: управление MCP inheritance policy этого участника",
+ "agentTeamsMcp": "Agent Teams MCP"
+ },
+ "model": {
+ "ariaLabel": "{{provider}} provider, {{model}}",
+ "currentLeadRuntime": "Текущий runtime lead",
+ "default": "По умолчанию",
+ "inheritedTooltip": "Provider, model и effort наследуются от lead, пока включена синхронизация.",
+ "leadSuffix": "{{label}} (lead)",
+ "liveDisabled": "Изменения provider, model и effort отключены, пока команда работает. Переподключите команду, чтобы применить их безопасно.",
+ "lockedActionFallback": "Изменения runtime lead открывают Relaunch Team, где можно обновить provider, model и effort.",
+ "restartWholeTeam": "Сохранение этих runtime changes перезапустит всю команду."
+ },
+ "nameAria": "Имя участника {{index}}",
+ "nameFallback": "участник {{index}}",
+ "noRole": "Без роли",
+ "removed": "Удалён",
+ "workflow": {
+ "addTooltip": "Добавить workflow teammate",
+ "editTooltip": "Редактировать workflow teammate",
+ "label": "Workflow (необязательно)",
+ "placeholder": "Как этот agent должен работать и взаимодействовать с другими...",
+ "saved": "Сохранено"
+ },
+ "worktree": {
+ "description": "Запускать этого teammate в отдельном git worktree. Apply/reject changes будут работать с этим worktree, а не с workspace lead.",
+ "label": "Worktree"
+ },
+ "addMembers": {
+ "title": "Добавить участников",
+ "description": "Добавить новых участников в {{teamName}}"
+ },
+ "placeholders": {
+ "name": "member-name",
+ "mcpServers": "github, sentry"
+ }
+ },
+ "detail": {
+ "actions": {
+ "add": "Добавить",
+ "cancel": "Отмена",
+ "delete": "Удалить",
+ "editCode": "Редактировать код",
+ "launch": "Запустить",
+ "remove": "Удалить",
+ "stop": "Остановить",
+ "task": "Задача",
+ "visualize": "Визуализировать"
+ },
+ "deleteTeam": {
+ "description": "Удалить команду \"{{team}}\"? Это действие необратимо. Все данные команды и задачи будут удалены.",
+ "title": "Удалить команду"
+ },
+ "draft": {
+ "descriptionPrefix": "Это draft-команда -",
+ "descriptionSuffix": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "descriptionSuffix_few": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "descriptionSuffix_many": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "descriptionSuffix_one": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "descriptionSuffix_other": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "member": "участниками",
+ "member_few": "участниками",
+ "member_many": "участниками",
+ "member_one": "участником",
+ "member_other": "участниками",
+ "title": "Команда ещё не запущена"
+ },
+ "invalidTab": "Некорректная вкладка команды",
+ "kanbanSafeData": "Не удалось полностью загрузить kanban. Показаны безопасные данные.",
+ "loadFailed": "Не удалось загрузить команду",
+ "loading": "Загрузка команды",
+ "loadingSidebar": "Загрузка sidebar команды",
+ "offline": {
+ "offline": "Команда offline",
+ "partialFailed": "Последний запуск частично не удался",
+ "partialMissing": "Последний запуск частично не удался - {{missing}}/{{expected}} teammates не подключились",
+ "reconciling": "Последний запуск ещё сверяется"
+ },
+ "previous": "Предыдущие: {{paths}}",
+ "removeMember": {
+ "description": "Удалить \"{{member}}\" из команды? Задачи и сообщения сохранятся, но это имя нельзя будет использовать повторно.",
+ "title": "Удалить участника"
+ },
+ "sections": {
+ "team": "Команда"
+ },
+ "solo": "Solo",
+ "status": {
+ "active": "Активно",
+ "launching": "Запуск...",
+ "running": "Работает"
+ },
+ "telemetry": {
+ "cpu": "CPU",
+ "memory": "Memory"
+ },
+ "tooltips": {
+ "deleteTeam": "Удалить команду",
+ "editTeam": "Редактировать команду",
+ "editUnavailableProvisioning": "Редактирование недоступно, пока provisioning ещё выполняется",
+ "openBuiltInEditor": "Открыть проект во встроенном редакторе",
+ "openTeamGraph": "Открыть граф команды",
+ "stopTeam": "Остановить команду"
+ },
+ "waitingForProvisioning": "Данные команды появятся после завершения provisioning",
+ "context": {
+ "title": "Контекст"
+ }
+ },
+ "review": {
+ "fileHeader": {
+ "actions": {
+ "accept": "Принять",
+ "discard": "Отменить",
+ "discardTooltip": "Отменить все правки в этом файле",
+ "keepMyDraft": "Оставить мой черновик",
+ "reject": "Отклонить",
+ "reloadFromDisk": "Перезагрузить с диска",
+ "restore": "Восстановить",
+ "restoreTooltip": "Создать или восстановить этот файл на диске из предпросмотра",
+ "saveFile": "Сохранить файл",
+ "saveFileTooltip": "Сохранить файл на диск"
+ },
+ "badges": {
+ "deleted": "УДАЛЁН",
+ "manualReview": "РУЧНОЙ REVIEW",
+ "new": "НОВЫЙ",
+ "worktree": "WORKTREE"
+ },
+ "contentSource": {
+ "disk-current": "Текущее состояние диска",
+ "file-history": "История файла",
+ "git-fallback": "Git fallback",
+ "ledger-exact": "Task ledger",
+ "ledger-snapshot": "Снимок ledger",
+ "snippet-reconstruction": "Реконструировано",
+ "unavailable": "Содержимое недоступно"
+ },
+ "contentUnavailable": {
+ "badge": "Содержимое недоступно",
+ "description": "В ledger записаны metadata для этого изменения, но полный текст недоступен. Обычно это binary, большой файл или hash-only content.",
+ "safety": "Автоматические accept/reject отключены для этого файла, чтобы избежать небезопасной записи на диск.",
+ "title": "Текстовое содержимое недоступно"
+ },
+ "disabled": {
+ "acceptRejectContentUnavailable": "Accept/Reject отключён, потому что полный текст недоступен.",
+ "acceptRejectMissingOnDisk": "Accept/Reject отключён, пока файл отсутствует на диске.",
+ "rejectBaselineUnavailable": "Reject отключён, потому что исходный baseline недоступен.",
+ "rejectContentUnavailable": "Reject отключён, потому что полный текст недоступен.",
+ "rejectManualLedgerReview": "Reject отключён, потому что это ledger-изменение содержит binary, большой или недоступный content."
+ },
+ "externalChange": {
+ "changedOnDisk": "Изменён на диске",
+ "deletedOnDisk": "Удалён на диске",
+ "recreatedOnDisk": "Создан заново на диске"
+ },
+ "missingOnDisk": {
+ "badge": "Отсутствует на диске",
+ "description": "Предпросмотр из agent logs всё ещё доступен, но filesystem не синхронизирован.",
+ "restorePrefix": "Нажмите",
+ "restoreSuffix": "чтобы записать preview content обратно на диск.",
+ "restoreUnavailable": "Полное содержимое файла недоступно для автоматического восстановления.",
+ "title": "Файл отсутствует на диске"
+ },
+ "pathChange": {
+ "from": "Из {{path}}",
+ "to": "В {{path}}"
+ },
+ "worktree": {
+ "isolated": "Изолированный worktree"
+ }
+ },
+ "toolbar": {
+ "stats": {
+ "pending": "{{count}} pending",
+ "pending_one": "{{count}} pending",
+ "pending_few": "{{count}} pending",
+ "pending_many": "{{count}} pending",
+ "pending_other": "{{count}} pending",
+ "accepted": "{{count}} accepted",
+ "accepted_one": "{{count}} accepted",
+ "accepted_few": "{{count}} accepted",
+ "accepted_many": "{{count}} accepted",
+ "accepted_other": "{{count}} accepted",
+ "rejected": "{{count}} rejected",
+ "rejected_one": "{{count}} rejected",
+ "rejected_few": "{{count}} rejected",
+ "rejected_many": "{{count}} rejected",
+ "rejected_other": "{{count}} rejected",
+ "acrossFiles": "в {{count}} файлах",
+ "acrossFiles_one": "в {{count}} файле",
+ "acrossFiles_few": "в {{count}} файлах",
+ "acrossFiles_many": "в {{count}} файлах",
+ "acrossFiles_other": "в {{count}} файлах",
+ "edited": "{{count}} edited",
+ "edited_one": "{{count}} edited",
+ "edited_few": "{{count}} edited",
+ "edited_many": "{{count}} edited",
+ "edited_other": "{{count}} edited"
+ },
+ "actions": {
+ "auto": "Auto",
+ "undo": "Отменить",
+ "acceptAll": "Принять всё",
+ "rejectAll": "Отклонить всё",
+ "applying": "Применение...",
+ "applyRejections": "Применить отклонения"
+ },
+ "tooltips": {
+ "autoOn": "Auto-mark файлов как viewed при прокрутке до конца (ON)",
+ "autoOff": "Auto-mark файлов как viewed при прокрутке до конца (OFF)",
+ "undo": "Отменить последнюю review operation (Ctrl+Z)",
+ "acceptAll": "Принять все изменения во всех файлах",
+ "rejectAll": "Отклонить все безопасно отклоняемые изменения во всех файлах",
+ "rejectAllDisabled": "У pending файлов нет безопасного original baseline для отклонения.",
+ "applyRejections": "Применить отклонённые hunks на диск; принятые изменения останутся как есть"
+ }
+ },
+ "diffError": {
+ "title": "Не удалось отрендерить diff view",
+ "unexpected": "При рендеринге diff произошла неожиданная ошибка.",
+ "actions": {
+ "retry": "Повторить"
+ },
+ "raw": {
+ "show": "Показать raw diff data",
+ "file": "Файл: {{file}}",
+ "original": "--- Original",
+ "modified": "+++ Modified",
+ "charsTotal": "... (всего символов: {{count}})",
+ "charsTotal_one": "... (всего {{count}} символ)",
+ "charsTotal_few": "... (всего {{count}} символа)",
+ "charsTotal_many": "... (всего {{count}} символов)",
+ "charsTotal_other": "... (всего {{count}} символов)"
+ }
+ },
+ "fileTree": {
+ "viewed": "Просмотрено",
+ "badges": {
+ "new": "new",
+ "deleted": "deleted"
+ },
+ "collapseFolder": "Свернуть {{name}}",
+ "expandFolder": "Развернуть {{name}}",
+ "empty": {
+ "noChangedFiles": "Нет изменённых файлов",
+ "noMatchingFiles": "Нет подходящих файлов"
+ },
+ "searchPlaceholder": "Поиск файлов…",
+ "filters": {
+ "unresolved": "Нерешённые",
+ "rejected": "Отклонённые",
+ "new": "Новые",
+ "clear": "Очистить"
+ }
+ },
+ "diffControls": {
+ "previousChunk": "Предыдущий chunk",
+ "nextChunk": "Следующий chunk",
+ "rejectChange": "Отклонить изменение (⌘N)",
+ "acceptChange": "Принять изменение (⌘Y)",
+ "undo": "Отменить",
+ "keep": "Оставить",
+ "rejectShortcut": "⌘N",
+ "acceptShortcut": "⌘Y"
+ },
+ "conflict": {
+ "title": "Обнаружен конфликт",
+ "description": "Файл был изменён после правок агента",
+ "cancel": "Отмена",
+ "saveResolution": "Сохранить решение",
+ "editManually": "Редактировать вручную",
+ "useOriginal": "Использовать исходный вариант",
+ "keepCurrent": "Оставить текущий вариант"
+ },
+ "fullDiffLoading": {
+ "titleOne": "Подготовка полного diff",
+ "titleMany": "Подготовка полных diff: {{count}}",
+ "subtitleForFile": "Финализируется точный editor diff для {{file}}.",
+ "subtitleCurrentFile": "Финализируется точный editor diff для текущего файла.",
+ "subtitleMany": "Определяются точные before/after baseline для загружаемых файлов.",
+ "previewsReady": "готово preview: {{count}}",
+ "previewsReady_one": "готово {{count}} preview",
+ "previewsReady_few": "готово preview: {{count}}",
+ "previewsReady_many": "готово preview: {{count}}",
+ "previewsReady_other": "готово preview: {{count}}",
+ "editorViewLoading": "Загружается editor view",
+ "filesInProgress": "файлов в процессе: {{count}}",
+ "filesInProgress_one": "{{count}} файл в процессе",
+ "filesInProgress_few": "файлов в процессе: {{count}}",
+ "filesInProgress_many": "файлов в процессе: {{count}}",
+ "filesInProgress_other": "файлов в процессе: {{count}}",
+ "filesReady": "готово файлов: {{ready}}/{{total}}",
+ "progressDescription": "Готово: {{ready}}, ещё загружается: {{loading}}. Preview diff остаются видимыми ниже, пока остальные baseline определяются.",
+ "singleDescription": "Preview diff остаются видимыми ниже, пока определяется точный baseline."
+ },
+ "fileMissingPrefix": "Файл отсутствует на диске. Этот diff может быть только предпросмотром из логов агента. Используйте",
+ "restore": "Восстановить",
+ "fileMissingSuffix": "чтобы создать файл на диске.",
+ "filePlaceholder": {
+ "loading": "Загрузка",
+ "description": "Подготовка полного editor diff для этого файла."
+ },
+ "loading": {
+ "diff": "DIFF",
+ "ledgerObjectsProcessed": "обработано ledger-объектов: {{count}}",
+ "ledgerObjectsProcessed_one": "обработан {{count}} ledger-объект",
+ "ledgerObjectsProcessed_few": "обработано {{count}} ledger-объекта",
+ "ledgerObjectsProcessed_many": "обработано {{count}} ledger-объектов",
+ "ledgerObjectsProcessed_other": "обработано {{count}} ledger-объектов",
+ "phases": {
+ "readingLedger": "Чтение task ledger...",
+ "resolvingFiles": "Определение состояния файлов...",
+ "checkingWorktree": "Проверка worktree...",
+ "preparingDiffs": "Подготовка review diff..."
+ }
+ },
+ "progress": {
+ "viewed": "{{viewed}}/{{total}} просмотрено"
+ },
+ "scope": {
+ "readMore": "Подробнее",
+ "tiers": {
+ "exact": {
+ "title": "Область задачи определена точно",
+ "detail": "В логе сессии найдены маркеры начала и завершения. Diff включает только изменения этой задачи - изменения других задач в тех же файлах исключены."
+ },
+ "endEstimated": {
+ "title": "Граница завершения оценена приблизительно",
+ "detail": "Найден только маркер начала - у задачи ещё нет маркера завершения. Показаны изменения от старта задачи до конца сессии. Если после неё в той же сессии выполнялись другие задачи, их изменения тоже могут попасть в diff."
+ },
+ "startEstimated": {
+ "title": "Граница начала оценена приблизительно",
+ "detail": "Найден только маркер завершения - начало работы не было зафиксировано. Если до этой задачи в той же сессии выполнялись другие задачи, их изменения в тех же файлах тоже могут попасть в diff."
+ },
+ "allSession": {
+ "title": "Показаны все изменения сессии",
+ "detail": "В логе сессии нет маркеров задачи. Нельзя изолировать конкретную задачу - показаны все изменения файлов за всю сессию, включая изменения других задач. Такое возможно со старыми версиями CLI или нестандартными workflows."
+ }
+ },
+ "ledger": {
+ "exact": {
+ "title": "Изменения зафиксированы task ledger",
+ "detail": "Orchestrator зафиксировал эти изменения файлов во время работы агента над задачей.",
+ "badge": "Точно по ledger"
+ },
+ "limited": {
+ "title": "Изменения зафиксированы с ограниченной проверяемостью",
+ "detail": "Orchestrator зафиксировал эти изменения для задачи, но хотя бы одно изменение пришло из snapshot или metadata-only источника. Проверяйте точные текстовые diff там, где они доступны; binary или недоступный контент может требовать ручного review.",
+ "mixedBadge": "Смешанная проверяемость",
+ "needsReviewBadge": "Нужен review"
+ }
+ },
+ "workInterval": {
+ "title": "Область задана сохранённым рабочим интервалом",
+ "detail": "Маркер начала задачи недоступен в логе сессии, поэтому diff ограничен рабочим интервалом задачи, сохранённым на доске.",
+ "badge": "По интервалу"
+ },
+ "confidence": {
+ "high": "Высокая уверенность",
+ "medium": "Средняя уверенность",
+ "low": "Низкая уверенность",
+ "bestEffort": "Best effort"
+ }
+ },
+ "shortcuts": {
+ "title": "Горячие клавиши",
+ "actions": {
+ "nextChange": "Следующее изменение",
+ "previousChange": "Предыдущее изменение",
+ "nextFile": "Следующий файл",
+ "previousFile": "Предыдущий файл",
+ "acceptChange": "Принять изменение",
+ "rejectChange": "Отклонить изменение",
+ "saveFile": "Сохранить файл",
+ "undo": "Отменить",
+ "redo": "Повторить",
+ "toggleShortcuts": "Показать/скрыть подсказки",
+ "closeDialog": "Закрыть диалог"
+ }
+ },
+ "timeline": {
+ "empty": "Нет событий редактирования",
+ "titleWithCount": "История правок ({{count}})"
+ },
+ "continuousScroll": {
+ "empty": "Нет изменений файлов для ревью"
+ },
+ "empty": {
+ "noSafeDiff": "Нет безопасного diff",
+ "noFileChangesRecorded": "Изменения файлов не записаны",
+ "noSafeDiffDescription": "Журнал задачи не предоставил безопасный diff файлов для этой задачи.",
+ "noSafeDiffDiagnosticsDescription": "Журнал задачи не предоставил безопасный diff файлов для этой задачи. Диагностика ниже объясняет причину.",
+ "noFileEventsYet": "В журнале задачи пока нет событий файлов.",
+ "noFileEvents": "В журнале задачи нет событий файлов."
+ }
+ },
+ "messages": {
+ "actions": {
+ "bottomSheetActions": "Действия нижней панели сообщений",
+ "collapseAll": "Свернуть все сообщения",
+ "collapseSheet": "Свернуть панель",
+ "expandAll": "Развернуть все сообщения",
+ "expandSheet": "Развернуть панель",
+ "floatComposer": "Открепить composer",
+ "floatMessagesComposer": "Открепить composer сообщений",
+ "hideSearch": "Скрыть поиск",
+ "loadOlder": "Загрузить старые сообщения",
+ "markAllRead": "Отметить всё прочитанным",
+ "messageActions": "Действия сообщений",
+ "moveMessagesToBottomSheet": "Переместить сообщения в нижнюю панель",
+ "moveMessagesToSidebar": "Переместить сообщения в sidebar",
+ "moveToBottomSheet": "Переместить в нижнюю панель",
+ "moveToInline": "Переместить inline",
+ "moveToSidebar": "Переместить в sidebar",
+ "panelActions": "Действия панели сообщений",
+ "searchMessages": "Искать сообщения"
+ },
+ "delivery": {
+ "copied": "Скопировано",
+ "copyDebugDetails": "Копировать debug details",
+ "details": "Детали",
+ "fields": {
+ "acceptanceUnknown": "acceptanceUnknown",
+ "delivered": "delivered",
+ "diagnostics": "diagnostics",
+ "ledgerStatus": "ledgerStatus",
+ "messageId": "messageId",
+ "providerId": "providerId",
+ "queuedBehindMessageId": "queuedBehindMessageId",
+ "reason": "reason",
+ "responsePending": "responsePending",
+ "responseState": "responseState",
+ "statusMessageId": "statusMessageId",
+ "userVisibleMessage": "userVisibleMessage",
+ "userVisibleNextReviewAt": "userVisibleNextReviewAt",
+ "userVisibleReasonCode": "userVisibleReasonCode",
+ "userVisibleState": "userVisibleState",
+ "visibleReplyCorrelation": "visibleReplyCorrelation",
+ "visibleReplyMessageId": "visibleReplyMessageId"
+ }
+ },
+ "panelMode": "Режим панели сообщений",
+ "title": "Сообщения",
+ "unread": {
+ "new": "Новых: {{count}}",
+ "new_few": "Новых: {{count}}",
+ "new_many": "Новых: {{count}}",
+ "new_one": "Новое: {{count}}",
+ "new_other": "Новых: {{count}}",
+ "unread": "Непрочитано: {{count}}",
+ "unread_few": "Непрочитано: {{count}}",
+ "unread_many": "Непрочитано: {{count}}",
+ "unread_one": "Непрочитанное: {{count}}",
+ "unread_other": "Непрочитано: {{count}}"
+ },
+ "filter": {
+ "ariaLabel": "Фильтровать сообщения",
+ "tooltip": "Фильтровать сообщения",
+ "from": "От",
+ "to": "Кому",
+ "noData": "Нет данных",
+ "showStatusUpdates": "Показывать status updates (idle/shutdown)",
+ "actions": {
+ "reset": "Сбросить",
+ "save": "Сохранить"
+ }
+ },
+ "status": {
+ "title": "Статус"
+ },
+ "actionMode": {
+ "label": "Режим действия"
+ },
+ "search": {
+ "placeholder": "Поиск..."
+ }
+ },
+ "modelSelector": {
+ "badges": {
+ "configured": "Настроено",
+ "connected": "Подключено",
+ "failed": "Ошибка",
+ "free": "Бесплатно",
+ "local": "Локально",
+ "needsTest": "Нужен тест",
+ "verified": "Проверено",
+ "unavailable": "Недоступно",
+ "issue": "Проблема"
+ },
+ "customModelId": "Custom model id",
+ "label": "Модель (optional)",
+ "multimodelRequired": "Codex и Gemini требуют Multimodel mode.",
+ "openCode": {
+ "allSources": "Все OpenCode sources",
+ "filterSource": "Фильтровать {{source}}",
+ "filterSources": "Фильтровать OpenCode sources",
+ "freeOnly": "Только бесплатные",
+ "freeTooltip": "OpenCode помечает эту модель как бесплатную.",
+ "loadingModels": "Загрузка моделей OpenCode...",
+ "noSourcesFound": "Sources не найдены.",
+ "recommendedOnly": "Только рекомендованные",
+ "searchSources": "Поиск sources",
+ "sourcesCount": "OpenCode sources: {{count}}",
+ "sourcesCount_few": "OpenCode sources: {{count}}",
+ "sourcesCount_many": "OpenCode sources: {{count}}",
+ "sourcesCount_one": "OpenCode source: {{count}}",
+ "sourcesCount_other": "OpenCode sources: {{count}}"
+ },
+ "reason": "Причина: {{reason}}",
+ "runtimeModelsSyncing": "Явные модели загружаются из текущего runtime. Default остаётся доступным, пока список синхронизируется.",
+ "fastMode": {
+ "codexLabel": "Быстрый режим (2x credits)",
+ "optionalLabel": "Быстрый режим (опционально)",
+ "defaultOff": "По умолчанию (выкл.)",
+ "fast": "Fast",
+ "off": "Выкл.",
+ "defaultFast": "По умолчанию (Fast)",
+ "defaultResolvesTo": "Сейчас default resolves to {{mode}}.",
+ "runtimeBackedHint": "Fast mode зависит от runtime и доступен только когда resolved Anthropic launch model его поддерживает."
+ },
+ "anthropicExtraUsage": {
+ "pricingDocs": "Открыть документацию Anthropic по ценам"
+ },
+ "searchModels": "Поиск моделей",
+ "defaultModel": "По умолчанию",
+ "empty": {
+ "noSearchMatches": "По этому поиску модели не найдены.",
+ "recommendedFreeOpenCode": "В текущем списке runtime нет рекомендованных бесплатных моделей OpenCode.",
+ "freeOpenCode": "В текущем списке runtime нет бесплатных моделей OpenCode.",
+ "recommendedOpenCode": "В текущем списке runtime нет рекомендованных моделей OpenCode.",
+ "noModels": "В текущем списке runtime нет моделей."
+ },
+ "openCodeStatus": {
+ "notReadyTitle": "OpenCode не готов к запуску команды",
+ "freeModelsAvailableTitle": "Доступны бесплатные модели OpenCode",
+ "providerNotConnectedTitle": "Provider OpenCode не подключён",
+ "readyTitle": "OpenCode готов",
+ "readyMessage": "OpenCode прошёл проверку готовности provider. Выберите его, чтобы использовать модели OpenCode для этой команды.",
+ "useOpenCode": "Использовать OpenCode",
+ "badges": {
+ "check": "Проверка",
+ "install": "Установить",
+ "free": "Free",
+ "setup": "Настроить"
+ },
+ "summary": {
+ "checking": "Статус OpenCode: проверка runtime",
+ "status": "Статус OpenCode: {{parts}}"
+ },
+ "summaryParts": {
+ "teamLaunchBlocked": "запуск команды заблокирован",
+ "providerOptional": "подключение provider необязательно",
+ "providerModelsNeedSetup": "provider-backed модели требуют настройки",
+ "teamLaunchReady": "запуск команды готов",
+ "runtimeDetected": "runtime найден",
+ "runtimeMissing": "runtime не найден",
+ "freeWithoutAuth": "free модели доступны без auth",
+ "providerConnected": "provider подключён",
+ "providerNotConnected": "provider не подключён"
+ },
+ "messages": {
+ "checking": "Приложение ещё проверяет OpenCode runtime. Дождитесь завершения проверки provider status и попробуйте снова.",
+ "unsupported": "OpenCode не установлен, не найден или найденный runtime не поддерживается. Установите или обновите OpenCode, затем обновите статус provider. Также можно использовать кнопку Install на домашней странице.",
+ "freeAvailable": "OpenCode найден. Можно использовать free модели OpenCode, например Big Pickle, без подключения provider. Подключайте provider только если нужны provider-backed модели.",
+ "noFreeListed": "OpenCode найден, но free модель OpenCode пока не указана. Обновите provider status или подключите provider в OpenCode для provider-backed моделей.",
+ "launchBlocked": "OpenCode установлен и authenticated, но готовность Agent Teams launch заблокирована.",
+ "ready": "OpenCode готов к запуску команды."
+ },
+ "loadingRuntime": "Статус OpenCode runtime ещё загружается."
+ },
+ "advisory": {
+ "pingNotConfirmed": "Ping не подтверждён",
+ "note": "Заметка"
+ },
+ "placeholders": {
+ "customModelId": "openai/gpt-oss-20b"
+ },
+ "routeGroups": {
+ "openCodeConfig": "Конфиг OpenCode",
+ "builtinFree": "Бесплатные встроенные",
+ "connectedProviders": "Подключённые providers",
+ "otherCatalog": "Другой каталог OpenCode"
+ },
+ "pricing": {
+ "free": "Бесплатно",
+ "inputShort": "in {{rate}}",
+ "outputShort": "out {{rate}}",
+ "perMillionSummary": "{{summary}} / 1M",
+ "inputTitle": "Input: {{rate}} за 1M tokens",
+ "outputTitle": "Output: {{rate}} за 1M tokens",
+ "cacheReadTitle": "Cache read: {{rate}} за 1M tokens",
+ "cacheWriteTitle": "Cache write: {{rate}} за 1M tokens"
+ },
+ "defaultTooltip": {
+ "anthropicCompatibleWithResolved": "Использует default model Anthropic-compatible endpoint.\nСейчас resolves to {{model}}.",
+ "anthropicCompatible": "Использует default model Anthropic-compatible endpoint.",
+ "anthropic": "Использует default model команды Claude.\nResolves to {{longContextModel}} с 1M context или {{limitedContextModel}} с 200K context, когда включён Limit context.",
+ "openCodeWithResolved": "Использует default model OpenCode.\nСейчас resolves to {{model}}.",
+ "openCode": "Использует runtime default model OpenCode.",
+ "runtime": "Использует runtime default для выбранного provider."
+ },
+ "multimodelOff": "Multimodel выключен",
+ "unavailableInRuntime": "Недоступно в текущем runtime"
+ },
+ "taskDetail": {
+ "actions": {
+ "cancel": "Отмена",
+ "delete": "Удалить",
+ "markResolved": "Отметить решённым",
+ "save": "Сохранить"
+ },
+ "attachments": {
+ "commentAttachment": "Вложение комментария",
+ "fromComments": "Из комментариев",
+ "preview": "Предпросмотр {{filename}}"
+ },
+ "changes": {
+ "badges": {
+ "attention": "требует внимания",
+ "noSafeDiff": "нет безопасного diff"
+ },
+ "empty": {
+ "noFileChangesRecorded": "Изменения файлов не записаны",
+ "noFileChangesRecordedYet": "Изменения файлов пока не записаны",
+ "noReviewableChangesRecovered": "Не удалось восстановить изменения файлов для review",
+ "noSafeDiffAvailable": "Безопасный diff недоступен"
+ },
+ "loadFailed": "Не удалось загрузить сводку изменений задачи",
+ "loading": "Загрузка изменений...",
+ "fileCount": "Файлов: {{count}}",
+ "fileCount_few": "Файла: {{count}}",
+ "fileCount_many": "Файлов: {{count}}",
+ "fileCount_one": "Файл: {{count}}",
+ "fileCount_other": "Файла: {{count}}",
+ "fileRowsHidden": "Скрыто строк файлов: {{count}}",
+ "fileRowsHidden_few": "Скрыто строки файлов: {{count}}",
+ "fileRowsHidden_many": "Скрыто строк файлов: {{count}}",
+ "fileRowsHidden_one": "Скрыта строка файла: {{count}}",
+ "fileRowsHidden_other": "Скрыто строки файлов: {{count}}",
+ "moreDiagnostics": "Ещё диагностик: {{count}}",
+ "moreDiagnostics_few": "Ещё диагностики: {{count}}",
+ "moreDiagnostics_many": "Ещё диагностик: {{count}}",
+ "moreDiagnostics_one": "Ещё диагностика: {{count}}",
+ "moreDiagnostics_other": "Ещё диагностики: {{count}}",
+ "moreFiles": "Ещё файлов: {{count}}",
+ "moreFiles_few": "Ещё файла: {{count}}",
+ "moreFiles_many": "Ещё файлов: {{count}}",
+ "moreFiles_one": "Ещё файл: {{count}}",
+ "moreFiles_other": "Ещё файла: {{count}}",
+ "openInEditor": "Открыть в редакторе",
+ "openTask": "Открыть задачу {{subject}}",
+ "refresh": "Обновить изменения",
+ "refreshFailed": "Не удалось обновить: {{error}}",
+ "refreshing": "Обновление",
+ "refreshingChanges": "Обновление изменений...",
+ "refreshTeamChanges": "Обновить изменения команды",
+ "refreshShort": "Обновить",
+ "reviewDiff": "Открыть diff для review",
+ "reviewTaskDiff": "Открыть diff задачи для review",
+ "scannedCandidateTasks": "Просканировано {{requested}} из {{eligible}} candidate tasks",
+ "tasksDeferred": "Отложено задач за этот проход: {{count}}",
+ "tasksDeferred_few": "Отложено задачи за этот проход: {{count}}",
+ "tasksDeferred_many": "Отложено задач за этот проход: {{count}}",
+ "tasksDeferred_one": "Отложена задача за этот проход: {{count}}",
+ "tasksDeferred_other": "Отложено задачи за этот проход: {{count}}",
+ "title": "Изменения"
+ },
+ "clarification": {
+ "awaitingLead": "Ожидается уточнение от team lead",
+ "awaitingUser": "Ожидается уточнение от вас"
+ },
+ "description": {
+ "add": "Добавить описание...",
+ "edit": "Редактировать описание",
+ "placeholder": "Описание задачи (поддерживает markdown)"
+ },
+ "loading": {
+ "fetchingTeamData": "Загрузка данных команды",
+ "title": "Загрузка задачи..."
+ },
+ "logs": {
+ "newArriving": "Поступают новые task logs"
+ },
+ "notFound": "Задача не найдена",
+ "related": {
+ "blockedBy": "Заблокировано",
+ "blocks": "Блокирует",
+ "linkedFrom": "Ссылаются из",
+ "links": "Связано",
+ "title": "Связанные задачи"
+ },
+ "review": {
+ "reviewer": "Reviewer: {{reviewer}}"
+ },
+ "sections": {
+ "attachments": "Вложения",
+ "changes": "Изменения",
+ "comments": "Комментарии",
+ "description": "Описание",
+ "taskLogs": "Логи задачи",
+ "workflowHistory": "История workflow"
+ },
+ "unassigned": "Не назначено",
+ "workflow": {
+ "implementationTimeTitle": "Время реализации по сохранённым рабочим интервалам",
+ "inProgressTime": "Время в работе {{duration}}"
+ },
+ "comments": {
+ "renderLimit": "Показаны последние {{formattedCount}} комментариев, чтобы интерфейс оставался отзывчивым.",
+ "badges": {
+ "approved": "Одобрено",
+ "reviewRequested": "Запрошен review"
+ },
+ "unknownTime": "неизвестное время",
+ "actions": {
+ "reply": "Ответить",
+ "replyToComment": "Ответить на комментарий",
+ "showMore": "Показать ещё комментарии ({{visible}}/{{total}})",
+ "cancelReply": "Отменить ответ",
+ "comment": "Комментировать"
+ },
+ "attachments": {
+ "previewAlt": "Предпросмотр вложения",
+ "downloadFailed": "Не удалось скачать"
+ },
+ "replyingTo": "Ответ на",
+ "input": {
+ "placeholder": "Добавьте комментарий... (Enter для отправки)",
+ "charsLeft": "Осталось символов: {{count}}",
+ "charsLeft_one": "Остался {{count}} символ",
+ "charsLeft_few": "Осталось {{count}} символа",
+ "charsLeft_many": "Осталось {{count}} символов",
+ "charsLeft_other": "Осталось {{count}} символов"
+ }
+ },
+ "workflowTimeline": {
+ "empty": "История workflow не записана",
+ "currentImplementationInterval": "Текущий интервал реализации",
+ "implementationIntervalEnded": "Интервал реализации завершился на этом переходе",
+ "runningPrefix": "идёт ",
+ "createdAs": "Создано как",
+ "by": "от",
+ "reassigned": "Переназначено",
+ "assignedTo": "Назначено",
+ "unassignedFrom": "Снято с",
+ "ownerChanged": "Владелец изменён",
+ "reviewRequested": "Запрошен review",
+ "reviewStarted": "Review начат",
+ "changesRequested": "Запрошены изменения",
+ "approved": "Одобрено",
+ "unknownEvent": "Неизвестное событие"
+ },
+ "reviewStates": {
+ "approved": "Одобрено",
+ "needsFix": "Нужны правки",
+ "inReview": "На review"
+ }
+ },
+ "tasks": {
+ "createTask": {
+ "assignee": "Исполнитель",
+ "assigneeOptional": "Исполнитель (необязательно)",
+ "blockedByOptional": "Блокируется задачами (необязательно)",
+ "blockedBySummary": "Задачу будут блокировать: {{tasks}}",
+ "cancel": "Отмена",
+ "create": "Создать",
+ "creating": "Создание...",
+ "description": "Задача будет создана в директории tasks/ команды и появится на Kanban-доске.",
+ "descriptionOptional": "Описание (необязательно)",
+ "detailsPlaceholder": "Детали задачи (поддерживается Markdown)",
+ "hideOptionalFields": "Скрыть дополнительные поля",
+ "offlineNotice": {
+ "after": "- запустите команду, чтобы начать выполнение.",
+ "before": "Команда офлайн. Задача будет добавлена в"
+ },
+ "promptOptional": "Prompt для исполнителя (необязательно)",
+ "promptPlaceholder": "Дополнительные инструкции для участника команды...",
+ "relatedOptional": "Связанные задачи (необязательно)",
+ "relatedSummary": "Связанные: {{tasks}}",
+ "saved": "Сохранено",
+ "searchTasks": "Поиск задач...",
+ "selectMember": "Выберите участника",
+ "selectMemberOptional": "Выберите участника...",
+ "showOptionalFields": "Показать дополнительные поля",
+ "startImmediately": "Запустить сразу",
+ "startOfflineHint": "Команда офлайн. Сначала запустите команду, чтобы сразу стартовать задачи.",
+ "subject": "Тема",
+ "subjectPlaceholder": "Что нужно сделать?",
+ "title": "Создать задачу",
+ "todo": "TODO"
+ },
+ "list": {
+ "columns": {
+ "blockedBy": "Заблокировано",
+ "blocks": "Блокирует",
+ "id": "ID",
+ "owner": "Владелец",
+ "status": "Статус",
+ "subject": "Тема"
+ },
+ "empty": "В этой команде нет задач",
+ "filters": {
+ "allOwners": "Все владельцы",
+ "allStatuses": "Все статусы",
+ "ownerAria": "Фильтр задач по владельцу",
+ "statusAria": "Фильтр задач по статусу"
+ },
+ "showing": "Показано {{shown}} из {{total}}"
+ },
+ "status": {
+ "completed": "completed",
+ "deleted": "deleted",
+ "inProgress": "in_progress",
+ "pending": "pending"
+ },
+ "statusSummary": {
+ "progressAria": "Задачи: выполнено {{completed}}/{{total}}",
+ "inProgress": "{{count}} в работе",
+ "inProgress_one": "{{count}} в работе",
+ "inProgress_few": "{{count}} в работе",
+ "inProgress_many": "{{count}} в работе",
+ "inProgress_other": "{{count}} в работе",
+ "pending": "{{count}} ожидают",
+ "pending_one": "{{count}} ожидает",
+ "pending_few": "{{count}} ожидают",
+ "pending_many": "{{count}} ожидают",
+ "pending_other": "{{count}} ожидают",
+ "completed": "{{count}} выполнено",
+ "completed_one": "{{count}} выполнена",
+ "completed_few": "{{count}} выполнено",
+ "completed_many": "{{count}} выполнено",
+ "completed_other": "{{count}} выполнено"
+ },
+ "unassigned": "Не назначено",
+ "teamPrefix": "Команда:",
+ "openTask": "Открыть задачу",
+ "deleteConfirm": {
+ "title": "Удалить задачу",
+ "message": "Переместить задачу #{{taskId}} в корзину?",
+ "confirmLabel": "Удалить",
+ "cancelLabel": "Отмена"
+ }
+ },
+ "editor": {
+ "actions": {
+ "cancel": "Отмена",
+ "closeEditor": "Закрыть редактор",
+ "closeTab": "Закрыть вкладку",
+ "closeTooltip": "Закрыть редактор (Esc)",
+ "discard": "Отменить изменения",
+ "discardAndClose": "Отменить и закрыть",
+ "keep": "Оставить",
+ "keepMine": "Оставить мои изменения",
+ "keyboardShortcuts": "Горячие клавиши",
+ "overwrite": "Перезаписать",
+ "refreshAria": "Обновить (F5)",
+ "refreshTooltip": "Обновить git status (F5)",
+ "reload": "Перезагрузить",
+ "retry": "Повторить",
+ "save": "Сохранить",
+ "saveAllAndClose": "Сохранить всё и закрыть"
+ },
+ "ariaLabel": "Редактор проекта",
+ "dialogs": {
+ "conflictDescription": "Файл был изменён извне после открытия. Перезаписать его вашими изменениями?",
+ "conflictTitle": "Конфликт сохранения",
+ "unsavedDescription": "Есть несохранённые изменения. Что сделать?",
+ "unsavedFileDescription": "В этом файле есть несохранённые изменения. Что сделать?",
+ "unsavedTitle": "Несохранённые изменения"
+ },
+ "newFile": {
+ "validation": {
+ "nameRequired": "Имя не может быть пустым",
+ "invalidName": "Недопустимое имя",
+ "invalidCharacters": "Имя содержит недопустимые символы",
+ "nameTooLong": "Имя слишком длинное"
+ },
+ "placeholders": {
+ "fileName": "Имя файла...",
+ "folderName": "Имя папки..."
+ },
+ "aria": {
+ "newFileName": "Имя нового файла",
+ "newFolderName": "Имя новой папки"
+ }
+ },
+ "draftRecovered": "Восстановлены несохранённые изменения из предыдущей сессии.",
+ "externalChange": {
+ "changed": "Файл изменён на диске.",
+ "deleted": "Файла больше нет на диске."
+ },
+ "saveFailed": "Не удалось сохранить: {{error}}",
+ "sidebar": {
+ "explorer": "Проводник",
+ "hide": "Скрыть sidebar",
+ "hideWithShortcut": "Скрыть sidebar ({{shortcut}})",
+ "show": "Показать sidebar",
+ "showWithShortcut": "Показать sidebar ({{shortcut}})"
+ },
+ "searchInFiles": {
+ "title": "Поиск по файлам",
+ "closeSearch": "Закрыть поиск",
+ "closeSearchShortcut": "Закрыть поиск (Esc)",
+ "searchPlaceholder": "Искать...",
+ "matchCase": "Учитывать регистр",
+ "matchCaseToggle": "Aa",
+ "noResults": "Ничего не найдено",
+ "resultsSummary": "{{count}} совпадений в {{fileCount}} файлах",
+ "resultsSummary_one": "{{count}} совпадение в {{fileCount}} файлах",
+ "resultsSummary_few": "{{count}} совпадения в {{fileCount}} файлах",
+ "resultsSummary_many": "{{count}} совпадений в {{fileCount}} файлах",
+ "resultsSummary_other": "{{count}} совпадений в {{fileCount}} файлах",
+ "truncated": "(обрезано)"
+ },
+ "fileTree": {
+ "failedToLoadFiles": "Не удалось загрузить файлы: {{error}}",
+ "loading": "Загрузка файлов...",
+ "empty": "Файлы не найдены",
+ "dropForProjectRoot": "Перетащите сюда для корня проекта",
+ "moveToTrash": "Переместить в корзину",
+ "moveToTrashConfirm": "Переместить \"{{name}}\" в корзину?",
+ "cancel": "Отмена"
+ },
+ "goToLine": {
+ "title": "Перейти к строке",
+ "position": "(текущая: {{current}}, всего: {{total}})",
+ "placeholder": "Номер строки, +смещение, -смещение или %",
+ "go": "Перейти"
+ },
+ "searchPanel": {
+ "previousMatch": "Предыдущее совпадение",
+ "nextMatch": "Следующее совпадение",
+ "close": "Закрыть",
+ "replacePlaceholder": "Замена",
+ "replace": "Заменить",
+ "replaceNext": "Заменить следующее",
+ "all": "Все",
+ "replaceAll": "Заменить все"
+ },
+ "statusBar": {
+ "position": "Стр {{line}}, Кол {{col}}",
+ "enableWatcher": "Включить отслеживание файлов",
+ "disableWatcher": "Отключить отслеживание файлов",
+ "watch": "следить",
+ "watching": "слежение",
+ "watchExternalChanges": "Следить за внешними изменениями",
+ "disableExternalWatcher": "Отключить отслеживание внешних изменений",
+ "encodingUtf8": "UTF-8",
+ "spaces": "Пробелы: {{count}}"
+ },
+ "imagePreview": {
+ "loading": "Загрузка предпросмотра...",
+ "openFullSize": "Открыть полноразмерный предпросмотр",
+ "openSystemViewer": "Открыть в системном просмотрщике"
+ },
+ "quickOpen": {
+ "title": "Быстрое открытие",
+ "searchPlaceholder": "Поиск файлов по имени...",
+ "loading": "Загрузка файлов...",
+ "empty": "Файлы не найдены"
+ },
+ "errorBoundary": {
+ "crashed": "Редактор упал",
+ "unknownError": "Неизвестная ошибка"
+ },
+ "binaryPlaceholder": {
+ "file": "Бинарный файл ({{size}})"
+ },
+ "unsavedChanges": "Несохранённые изменения",
+ "empty": {
+ "selectFile": "Выберите файл в дереве, чтобы редактировать"
+ },
+ "search": {
+ "toggleReplace": "Переключить замену",
+ "placeholder": "Поиск"
+ },
+ "shortcuts": {
+ "title": "Горячие клавиши",
+ "groups": {
+ "fileOperations": "Операции с файлами",
+ "search": "Поиск",
+ "navigation": "Навигация",
+ "editing": "Редактирование",
+ "markdown": "Markdown",
+ "general": "Общее"
+ },
+ "actions": {
+ "quickOpen": "Быстрое открытие",
+ "save": "Сохранить",
+ "saveAll": "Сохранить всё",
+ "closeTab": "Закрыть вкладку",
+ "findInFile": "Найти в файле",
+ "searchInFiles": "Поиск по файлам",
+ "goToLine": "Перейти к строке",
+ "nextTab": "Следующая вкладка",
+ "previousTab": "Предыдущая вкладка",
+ "cycleTabs": "Переключать вкладки",
+ "toggleSidebar": "Переключить боковую панель",
+ "undo": "Отменить",
+ "redo": "Повторить",
+ "selectNextMatch": "Выбрать следующее совпадение",
+ "toggleComment": "Переключить комментарий",
+ "splitPreview": "Разделённый предпросмотр",
+ "fullPreview": "Полный предпросмотр",
+ "closeEditor": "Закрыть редактор"
+ }
+ },
+ "toolbar": {
+ "enableWordWrap": "Включить перенос строк",
+ "disableWordWrap": "Отключить перенос строк",
+ "closeSplitPreview": "Закрыть разделённый предпросмотр",
+ "closePreview": "Закрыть предпросмотр"
+ }
+ },
+ "launch": {
+ "actions": {
+ "createSchedule": "Создать расписание",
+ "creating": "Создание...",
+ "goToDashboard": "Перейти в Dashboard",
+ "launchTeam": "Запустить команду",
+ "launching": "Запуск...",
+ "relaunchTeam": "Перезапустить команду",
+ "relaunching": "Перезапуск...",
+ "saveChanges": "Сохранить изменения",
+ "saving": "Сохранение..."
+ },
+ "billing": {
+ "prefix": "С 15 июня 2026 года Anthropic списывает использование",
+ "readArticle": "Открыть статью Anthropic",
+ "suffix": "и Agent SDK из ежемесячного Agent SDK credit отдельно от интерактивных лимитов Claude Code. Credit обновляется каждый billing cycle, неиспользованный остаток не переносится."
+ },
+ "conflict": {
+ "description": "Запуск двух команд в одной директории рискован - они могут конфликтовать при изменении одних и тех же файлов. Лучше выбрать другую директорию или git worktree для изоляции.",
+ "title": "Другая команда \"{{team}}\" уже работает в этой working directory",
+ "workingDirectory": "Working directory:"
+ },
+ "description": {
+ "createSchedule": "Настроить автоматическое выполнение Claude task",
+ "createScheduleForTeam": "Настроить автоматические запуски для команды \"{{team}}\"",
+ "editSchedule": "Редактирование расписания для команды \"{{team}}\"",
+ "launchPrefix": "Запустить команду",
+ "launchSuffix": "через локальный Claude CLI.",
+ "relaunchPrefix": "Остановить текущий запуск для",
+ "relaunchSuffix": "и запустить его заново через локальный Claude CLI."
+ },
+ "prepare": {
+ "action": {
+ "launch": "запуск",
+ "relaunch": "перезапуск"
+ },
+ "blocked": "Runtime environment недоступен - {{action}} заблокирован",
+ "checkingProviders": "Проверка выбранных providers...",
+ "failed": "Не удалось подготовить выбранных providers",
+ "preflight": "Pre-flight проверка, чтобы поймать ошибки до действия: {{action}}",
+ "preparingEnvironment": "Подготовка environment...",
+ "ready": "Все выбранные providers готовы.",
+ "readyWithNotes": "Все выбранные providers готовы, есть заметки.",
+ "unsupportedPreload": "Текущая версия preload не поддерживает team:prepareProvisioning. Перезапустите dev app.",
+ "selectWorkingDirectory": "Выберите рабочую директорию, чтобы проверить окружение запуска.",
+ "someProvidersNeedAttention": "Некоторым выбранным providers нужно внимание."
+ },
+ "prompt": {
+ "label": "Prompt",
+ "oneShotPrefix": "Этот prompt будет передан в",
+ "oneShotSuffix": "для one-shot выполнения",
+ "saved": "Сохранено",
+ "schedulePlaceholder": "Инструкции для Claude, которые нужно выполнить по расписанию...",
+ "teamLeadOptional": "Prompt для team lead (optional)",
+ "teamLeadPlaceholder": "Инструкции для team lead..."
+ },
+ "providerChanged": "Provider изменён с {{from}} на {{to}}. Предыдущая lead session не будет возобновлена, lead начнёт с fresh context, чтобы новый runtime применился корректно.",
+ "relaunchFreshSession": "Team relaunch запускает fresh lead session. Durable team state, task board и настройки участников будут rehydrated в launch prompt.",
+ "relaunchWarning": {
+ "description": "Сохранение этих настроек остановит текущий team process, сохранит обновлённый roster и снова запустит команду с новым runtime.",
+ "title": "Relaunch перезапустит текущий team run"
+ },
+ "schedule": {
+ "labelOptional": "Label (optional)",
+ "labelPlaceholder": "Например: Daily code review, Nightly tests...",
+ "maxBudgetUsd": "Max budget (USD)",
+ "maxTurns": "Max turns",
+ "noLimit": "Без лимита",
+ "noMatches": "Команды не найдены по поиску.",
+ "noTeams": "Нет доступных команд. Сначала создайте команду.",
+ "searchTeams": "Поиск команд...",
+ "selectTeam": "Выберите команду...",
+ "team": "Команда",
+ "title": "Расписание"
+ },
+ "title": {
+ "createSchedule": "Создать расписание",
+ "editSchedule": "Редактировать расписание",
+ "launch": "Запуск команды",
+ "relaunch": "Перезапуск команды"
+ },
+ "errors": {
+ "loadProjectsFailed": "Не удалось загрузить проекты",
+ "saveScheduleFailed": "Не удалось сохранить расписание",
+ "relaunchFailed": "Не удалось перезапустить команду",
+ "launchFailed": "Не удалось запустить команду"
+ },
+ "validation": {
+ "openCodeLeadModelRequired": "Для lead на OpenCode нужно выбрать модель.",
+ "openCodeTeammateRequired": "Для lead на OpenCode нужен хотя бы один teammate OpenCode.",
+ "selectWorkingDirectory": "Выберите рабочую директорию (cwd)",
+ "fixMemberNames": "Исправьте имена участников перед запуском",
+ "memberNamesUnique": "Имена участников должны быть уникальными перед запуском"
+ },
+ "optionalSettings": {
+ "relaunchTitle": "Настройки перезапуска",
+ "title": "Дополнительные настройки запуска",
+ "relaunchDescription": "Проверьте состав и runtime lead перед перезапуском команды.",
+ "description": "Оставьте запуск сфокусированным на пути проекта и раскрывайте этот блок только когда нужен дополнительный контроль."
+ }
+ },
+ "list": {
+ "actions": {
+ "copyTeam": "Скопировать команду",
+ "createTeam": "Создать команду",
+ "deleteForever": "Удалить навсегда",
+ "deletePermanently": "Удалить окончательно",
+ "deleteTeam": "Удалить команду",
+ "launching": "Запуск...",
+ "launchTeam": "Запустить команду",
+ "relaunchTeam": "Перезапустить команду",
+ "restore": "Восстановить",
+ "restoreTeam": "Восстановить команду",
+ "retry": "Повторить",
+ "stopTeam": "Остановить команду",
+ "stopping": "Остановка..."
+ },
+ "electronOnly": {
+ "description": "В browser mode доступ к локальным директориям `~/.claude/teams` недоступен.",
+ "title": "Команды доступны только в Electron mode"
+ },
+ "empty": {
+ "description": "Создайте команду здесь, чтобы начать. Она автоматически появится в списке.",
+ "localOnly": "Создание команд доступно только в локальном Electron mode.",
+ "title": "Команды не найдены"
+ },
+ "filter": {
+ "clearAll": "Очистить всё",
+ "label": "Фильтровать команды",
+ "projectPriority": "Приоритет проекта",
+ "status": "Статус"
+ },
+ "loadFailed": "Не удалось загрузить команды",
+ "loading": "Загрузка команд...",
+ "localOnly": "Доступно только в локальном Electron mode.",
+ "membersCount": "Участников: {{count}}",
+ "membersCount_few": "Участников: {{count}}",
+ "membersCount_many": "Участников: {{count}}",
+ "membersCount_one": "Участник: {{count}}",
+ "membersCount_other": "Участников: {{count}}",
+ "noDescription": "Нет описания",
+ "noMatches": "Нет команд по текущим фильтрам",
+ "partial": {
+ "pending": "Последний запуск ещё сверяется.",
+ "skipped": "В последнем запуске были пропущены teammates.",
+ "skippedWithCount": "Последний запуск пропустил {{count}}/{{expected}} teammate.",
+ "skippedWithCount_few": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "skippedWithCount_many": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "skippedWithCount_one": "Последний запуск пропустил {{count}}/{{expected}} teammate.",
+ "skippedWithCount_other": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "stopped": "Последний запуск остановился до подключения всех teammates.",
+ "stoppedWithCount": "Последний запуск остановился до подключения {{count}}/{{expected}} teammate.",
+ "stoppedWithCount_few": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates.",
+ "stoppedWithCount_many": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates.",
+ "stoppedWithCount_one": "Последний запуск остановился до подключения {{count}}/{{expected}} teammate.",
+ "stoppedWithCount_other": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates."
+ },
+ "searchPlaceholder": "Поиск команд...",
+ "sections": {
+ "otherTeams": "Другие команды",
+ "projectTeams": "Команды для {{project}}",
+ "selectedProject": "выбранного проекта"
+ },
+ "solo": "Solo",
+ "status": {
+ "active": "Активно",
+ "deleted": "Удалено",
+ "launching": "Запуск...",
+ "offline": "Offline",
+ "partialFailure": "Запуск частично не удался",
+ "partialPending": "Bootstrap ожидает",
+ "partialSkipped": "Запуск пропустил участника",
+ "running": "Работает"
+ },
+ "title": "Выбор команды",
+ "trash": "Корзина ({{count}})",
+ "trash_few": "Корзина ({{count}})",
+ "trash_many": "Корзина ({{count}})",
+ "trash_one": "Корзина ({{count}})",
+ "trash_other": "Корзина ({{count}})",
+ "deleteDraft": {
+ "title": "Удалить черновик",
+ "message": "Удалить черновик команды «{{teamName}}»? Это действие нельзя отменить.",
+ "confirmLabel": "Удалить",
+ "cancelLabel": "Отмена"
+ },
+ "moveToTrash": {
+ "title": "Переместить в корзину",
+ "message": "Переместить команду «{{teamName}}» в корзину? Её можно будет восстановить позже.",
+ "confirmLabel": "В корзину",
+ "cancelLabel": "Отмена"
+ },
+ "deleteForever": {
+ "title": "Удалить навсегда",
+ "message": "Удалить команду «{{teamName}}» навсегда? Все данные будут потеряны.",
+ "confirmLabel": "Удалить навсегда",
+ "cancelLabel": "Отмена"
+ }
+ },
+ "messageComposer": {
+ "crossTeam": {
+ "hint": "Совет: cross-team сообщения идут team lead целевой команды. Если ответ должен вернуться вашему team lead, а не вам, явно напишите это в сообщении."
+ },
+ "attachments": {
+ "attachFiles": "Прикрепить файлы (paste или drag & drop)",
+ "unavailable": "Вложения недоступны",
+ "disabledHint": "Файлы можно отправлять online team lead и online OpenCode teammates. Удалите вложения или смените получателя.",
+ "restrictions": {
+ "crossTeam": "Файловые вложения не поддерживаются для cross-team сообщений",
+ "teamOffline": "Команда должна быть online, чтобы прикреплять файлы",
+ "unsupportedRecipient": "Файлы можно отправлять team lead или OpenCode teammates",
+ "openCodeOffline": "Команда должна быть online, чтобы прикреплять файлы для OpenCode teammates",
+ "sending": "Дождитесь завершения текущей отправки перед добавлением файлов",
+ "maximumReached": "Достигнут лимит вложений",
+ "leadOnly": "Файлы можно отправлять только team lead"
+ }
+ },
+ "slash": {
+ "restrictions": {
+ "attachments": "Slash commands требуют live team lead и не отправляются с вложениями",
+ "crossTeam": "Slash commands можно запускать только на team lead текущей команды",
+ "notLead": "Slash commands можно отправлять только team lead",
+ "leadOffline": "Slash commands требуют, чтобы team lead был online"
+ }
+ },
+ "status": {
+ "reusedCrossTeamRequest": "Повторно использован недавний cross-team request",
+ "teamOffline": "Команда offline"
+ },
+ "input": {
+ "charsLeft": "Осталось символов: {{count}}",
+ "charsLeft_one": "Остался {{count}} символ",
+ "charsLeft_few": "Осталось {{count}} символа",
+ "charsLeft_many": "Осталось {{count}} символов",
+ "charsLeft_other": "Осталось {{count}} символов",
+ "teamLaunchingPlaceholder": "Команда запускается... сообщение будет поставлено в очередь inbox delivery.",
+ "crossTeamPlaceholder": "Cross-team сообщение для {{team}}...",
+ "teamFallback": "команда",
+ "placeholder": "Напишите сообщение... (Enter для отправки, Shift+Enter для новой строки)",
+ "slashTip": "Совет: используйте \"/\", чтобы запускать любые Claude commands."
+ },
+ "teamSelector": {
+ "thisTeam": "Эта команда",
+ "current": "текущая",
+ "online": "online",
+ "offline": "offline",
+ "onlineTitle": "Online",
+ "offlineTitle": "Offline"
+ },
+ "recipient": {
+ "select": "Выбрать...",
+ "searchPlaceholder": "Поиск...",
+ "noResults": "Ничего не найдено"
+ },
+ "actions": {
+ "voiceToText": "Voice to text",
+ "send": "Отправить",
+ "sendingUnavailableLaunching": "Отправка недоступна, пока команда запускается"
+ }
+ },
+ "claudeLogs": {
+ "filter": {
+ "ariaLabel": "Фильтровать Claude logs",
+ "tooltip": "Фильтровать logs",
+ "sections": {
+ "stream": "Stream",
+ "content": "Content"
+ },
+ "kinds": {
+ "output": "Output",
+ "thinking": "Thinking",
+ "tool": "Tool calls"
+ },
+ "actions": {
+ "reset": "Сбросить",
+ "save": "Сохранить"
+ },
+ "streams": {
+ "stdout": "stdout",
+ "stderr": "stderr"
+ }
+ },
+ "rawLineCount": "сырых строк: {{formattedCount}}",
+ "rawLineCount_one": "{{formattedCount}} сырая строка",
+ "rawLineCount_few": "сырых строки: {{formattedCount}}",
+ "rawLineCount_many": "сырых строк: {{formattedCount}}",
+ "rawLineCount_other": "сырых строки: {{formattedCount}}",
+ "rawLinesCaptured": "{{count}} записано",
+ "emptyRawLogs": "{{count}}; среди них пока нет assistant/tool output.",
+ "noLogsYet": "Логов пока нет.",
+ "teamNotRunning": "Команда не запущена.",
+ "searchPlaceholder": "Искать в логах...",
+ "clearSearch": "Очистить поиск",
+ "newCount": "+{{count}} новых",
+ "loading": "Загрузка...",
+ "showMore": "Показать больше",
+ "noLogsCaptured": "Логи не записаны.",
+ "noMatchingLogs": "Подходящих логов нет.",
+ "openFullscreen": "Открыть логи на весь экран",
+ "fullscreen": "На весь экран",
+ "viewingFullscreen": "Просмотр в полноэкранном режиме",
+ "logsTitle": "Логи"
+ },
+ "agentGraph": {
+ "popover": {
+ "externalTeam": "Внешняя команда",
+ "process": {
+ "startedBy": "Запущено:",
+ "at": "Время:",
+ "openUrl": "Открыть URL"
+ },
+ "overflow": {
+ "hiddenTasks": "Скрытые задачи",
+ "empty": "Нет доступных скрытых задач."
+ },
+ "member": {
+ "lead": "Lead",
+ "workingOn": "работает над",
+ "recentTools": "Недавние tools",
+ "spawn": {
+ "waitingToStart": "ожидает запуска",
+ "starting": "запускается",
+ "failed": "ошибка"
+ },
+ "state": {
+ "active": "активен",
+ "idle": "ожидает",
+ "offline": "offline",
+ "runningTool": "запускает tool"
+ },
+ "activeTool": {
+ "running": "Tool выполняется",
+ "failed": "Tool завершился ошибкой",
+ "finished": "Tool завершён"
+ },
+ "actions": {
+ "message": "Сообщение",
+ "profile": "Профиль",
+ "task": "Задача"
+ }
+ }
+ },
+ "logPreview": {
+ "logs": "Логи",
+ "loading": "Загрузка логов",
+ "more": "+{{count}} ещё",
+ "more_one": "+{{count}} ещё",
+ "more_few": "+{{count}} ещё",
+ "more_many": "+{{count}} ещё",
+ "more_other": "+{{count}} ещё"
+ },
+ "blockingEdge": {
+ "title": "Блокирующая зависимость",
+ "blocks": "блокирует",
+ "close": "Закрыть",
+ "blockingHiddenTasks": "Скрытые блокирующие задачи",
+ "blockedHiddenTasks": "Скрытые заблокированные задачи"
+ },
+ "activityHud": {
+ "activity": "Активность",
+ "noRecentActivity": "Недавней активности нет",
+ "more": "+{{count}} ещё",
+ "more_one": "+{{count}} ещё",
+ "more_few": "+{{count}} ещё",
+ "more_many": "+{{count}} ещё",
+ "more_other": "+{{count}} ещё"
+ },
+ "provisioning": {
+ "launchDetails": "Детали запуска",
+ "launchDetailsDescription": "Подробный прогресс запуска команды, live-вывод и логи CLI."
+ }
+ },
+ "projectPath": {
+ "label": "Проект",
+ "source": {
+ "claude": "Найдено Claude",
+ "codex": "Найдено Codex",
+ "mixed": "Найдено Claude и Codex"
+ },
+ "deleted": {
+ "title": "Папка проекта больше не существует",
+ "label": "Удалён"
+ },
+ "mode": {
+ "projectList": "Из списка проектов",
+ "customPath": "Свой путь"
+ },
+ "loadingProjects": "Загрузка проектов...",
+ "selectProject": "Выберите проект...",
+ "searchPlaceholder": "Поиск проекта по имени или пути",
+ "empty": "Ничего не найдено",
+ "selectFromList": "Выберите проект из списка",
+ "noProjects": "Проекты не найдены, переключитесь на свой путь.",
+ "customWorkingDirectory": "Custom working directory",
+ "browse": "Выбрать",
+ "createAutomatically": "Если директории нет, она будет создана автоматически."
+ },
+ "members": {
+ "badges": {
+ "worktree": "worktree"
+ },
+ "runtimeTelemetry": {
+ "title": "Локальная нагрузка runtime",
+ "description": "Только parent и child processes. Remote LLM inference не учитывается.",
+ "cpu": "CPU",
+ "memory": "Память",
+ "summedRss": "суммарный RSS",
+ "sharedHost": "Метрика shared OpenCode host. Она не эксклюзивна для этого участника.",
+ "processTreeCapped": "Process tree был ограничен для этого sample.",
+ "rssHint": "RSS может включать shared pages, поэтому это лучше читать как сигнал нагрузки, а не эксклюзивную память."
+ },
+ "editor": {
+ "title": "Участники",
+ "addMember": "Добавить участника",
+ "editAsJson": "Редактировать как JSON",
+ "runInSeparateWorktrees": "Запускать участников в отдельных worktree",
+ "agentTeamsMcpOnly": "Только Agent Teams MCP",
+ "removedCount": "Удалённые ({{count}})",
+ "removedModelLockReason": "Удалённые участники сохранены для истории soft delete. Восстановите их, чтобы редактировать настройки.",
+ "memberNamesUnique": "Имена участников должны быть уникальными"
+ },
+ "stats": {
+ "computing": "Расчёт статистики...",
+ "empty": "Статистика недоступна",
+ "lines": "Строки",
+ "linesInfo": "Приблизительно. Точно для инструментов Edit и Write. Записи файлов через Bash оцениваются по паттернам команд (heredoc, echo, sed) и могут быть занижены.",
+ "files": "Файлы",
+ "toolCalls": "Вызовы инструментов",
+ "tokens": "Токены",
+ "toolUsage": "Использование инструментов",
+ "filesTouched": "Затронутые файлы ({{count}})",
+ "viewAllChanges": "Показать все изменения",
+ "showLess": "Показать меньше",
+ "moreFiles": "+{{count}} ещё",
+ "footer": "{{count}} сессий · рассчитано {{computedAgo}}",
+ "footer_one": "{{count}} сессия · рассчитано {{computedAgo}}",
+ "footer_few": "{{count}} сессии · рассчитано {{computedAgo}}",
+ "footer_many": "{{count}} сессий · рассчитано {{computedAgo}}",
+ "footer_other": "{{count}} сессии · рассчитано {{computedAgo}}"
+ },
+ "logs": {
+ "searching": "Поиск логов...",
+ "empty": "Логи не найдены",
+ "waitingForTaskActivity": "Задача выполняется - ожидаем активность сессии (автообновление)...",
+ "noTaskActivity": "Для этой задачи пока нет активности сессии",
+ "noMemberActivity": "У этого участника пока нет записанной активности сессии",
+ "leadSessionTooltip": "Полные логи сессии team lead - полезны для общего orchestration-контекста, не специфичного для этого агента",
+ "memberSessionTooltip": "Полные логи постоянной сессии участника - полезны, когда работа идёт в корневой member session, а не в subagent-файле",
+ "startedAt": "начато {{time}}",
+ "active": "активно",
+ "showDetails": "Показать детали",
+ "hideDetails": "Скрыть детали",
+ "loadingDetails": "Загрузка деталей...",
+ "failedToLoadDetails": "Не удалось загрузить детали"
+ },
+ "detail": {
+ "relaunchOpenCode": "Перезапустить OpenCode",
+ "restart": "Перезапустить",
+ "legacyLogsFallback": "Fallback legacy-логов",
+ "copyDiagnostics": "Скопировать диагностику",
+ "pid": "PID {{pid}}",
+ "removedAt": "Удалён {{date}}",
+ "failedToRestartMember": "Не удалось перезапустить участника",
+ "sendMessage": "Отправить сообщение",
+ "assignTask": "Назначить задачу",
+ "remove": "Удалить"
+ },
+ "list": {
+ "loading": "Загрузка участников команды",
+ "unavailable": "Состав участников недоступен",
+ "unavailableDescription": "{{count}} участников известны из метаданных команды, но детали состава отсутствуют.",
+ "unavailableDescription_one": "{{count}} участник известен из метаданных команды, но детали состава отсутствуют.",
+ "unavailableDescription_few": "{{count}} участника известны из метаданных команды, но детали состава отсутствуют.",
+ "unavailableDescription_many": "{{count}} участников известны из метаданных команды, но детали состава отсутствуют.",
+ "unavailableDescription_other": "{{count}} участника известны из метаданных команды, но детали состава отсутствуют.",
+ "soloLeadOnly": "Solo team - только lead",
+ "removedCount": "Удалённые ({{count}})"
+ },
+ "executionLog": {
+ "empty": "Нечего показать",
+ "emptyUserMessage": "{{time}} - (пусто)",
+ "agentInstructions": "Инструкции агента",
+ "memberTurn": "{{member}} turn",
+ "agentTurn": "Ход агента",
+ "turn": "ход"
+ },
+ "recentMessages": {
+ "latest": "Последние сообщения",
+ "latestForMember": "Последние сообщения - {{member}}",
+ "loadMore": "Загрузить ещё",
+ "expand": "Развернуть",
+ "collapse": "Свернуть"
+ },
+ "leadModel": {
+ "defaultModel": "По умолчанию",
+ "providerModelAria": "Провайдер {{provider}}, модель {{model}}",
+ "leadShort": "лид",
+ "teamLead": "Лид команды",
+ "syncWithTeammates": "Синхронизировать модель с участниками",
+ "anthropicTeamWide": "Anthropic для всей команды",
+ "runtimeInheritance": "Рантайм лида применяется к участникам, если они не задали собственного провайдера или модель.",
+ "anthropicContextLimit": "Лимит контекста 200K действует на всю команду для рантаймов Anthropic в этом запуске, включая кастомных участников Anthropic."
+ },
+ "runtimeLogs": {
+ "autoRefresh": "Автообновление",
+ "wrapLines": "Перенос строк",
+ "loadingTail": "Загрузка хвоста лога процесса...",
+ "empty": "Лог процесса для этого участника пока не сохранен."
+ },
+ "tasks": {
+ "empty": "У этого участника нет назначенных задач"
+ },
+ "messages": {
+ "loadOlder": "Загрузить более старые сообщения",
+ "filters": {
+ "all": "Все",
+ "messages": "Сообщения",
+ "comments": "Комментарии"
+ },
+ "empty": {
+ "loading": "Загрузка активности...",
+ "noComments": "У этого участника нет комментариев",
+ "noLoadedMessages": "Загруженных сообщений этого участника пока нет",
+ "noMessages": "Сообщений с этим участником нет",
+ "noLoadedActivity": "Загруженной активности этого участника пока нет",
+ "noActivity": "Активности с этим участником нет"
+ }
+ },
+ "actions": {
+ "openProfile": "Открыть профиль",
+ "editRole": "Редактировать роль",
+ "sendMessage": "Отправить сообщение",
+ "assignTask": "Назначить задачу"
+ },
+ "roleSelect": {
+ "customRolePlaceholder": "Введите свою роль..."
+ }
+ },
+ "schedule": {
+ "count": "{{count}} schedules",
+ "count_one": "{{count}} schedule",
+ "count_few": "{{count}} schedules",
+ "count_many": "{{count}} schedules",
+ "count_other": "{{count}} schedules",
+ "nextRun": "Next: {{next}}",
+ "actions": {
+ "runNow": "Запустить сейчас",
+ "edit": "Редактировать",
+ "pause": "Пауза",
+ "resume": "Возобновить",
+ "delete": "Удалить",
+ "addSchedule": "Добавить schedule"
+ },
+ "runHistory": {
+ "loading": "Загрузка run history...",
+ "empty": "Запусков ещё нет"
+ },
+ "runLog": {
+ "title": "Лог запуска",
+ "exitCode": "exit {{code}}",
+ "retryCount": "retry {{count}}/{{max}}",
+ "stillRunning": "Задача ещё выполняется...",
+ "loadingLogs": "Загрузка логов...",
+ "errors": "Ошибки",
+ "close": "Закрыть"
+ },
+ "cron": {
+ "expression": "Cron-выражение",
+ "highFrequencyWarning": "Высокая частота расписания (интервал меньше 5 минут)",
+ "nextRuns": "Следующие запуски:",
+ "timezone": "Часовой пояс",
+ "selectTimezone": "Выберите часовой пояс",
+ "warmUpTime": "Время прогрева",
+ "warmUpDescription": "Подготавливает выбранных провайдеров перед запуском по расписанию",
+ "errors": {
+ "enterExpression": "Введите cron-выражение",
+ "invalidExpression": "Некорректное cron-выражение"
+ },
+ "presets": {
+ "everyHour": "Каждый час",
+ "everySixHours": "Каждые 6 часов",
+ "dailyAtNine": "Ежедневно в 9:00",
+ "weekdaysAtNine": "По будням в 9:00",
+ "mondayAtNine": "В понедельник в 9:00",
+ "everyThirtyMinutes": "Каждые 30 минут"
+ },
+ "warmUpOptions": {
+ "none": "Без прогрева",
+ "fiveMinutes": "5 мин",
+ "tenMinutes": "10 мин",
+ "fifteenMinutes": "15 мин",
+ "thirtyMinutes": "30 мин"
+ }
+ },
+ "empty": {
+ "title": "Расписаний пока нет",
+ "description": "Создайте расписание, чтобы автоматически запускать задачи Claude по cron."
+ },
+ "title": "Расписания",
+ "status": {
+ "active": "Активно",
+ "paused": "Пауза",
+ "disabled": "Отключено"
+ },
+ "runStatus": {
+ "pending": "Ожидает",
+ "warmingUp": "Прогрев",
+ "warm": "Готово к запуску",
+ "running": "Выполняется",
+ "completed": "Завершено",
+ "failed": "Ошибка",
+ "interrupted": "Прервано",
+ "cancelled": "Отменено"
+ }
+ },
+ "openCodeContextConfigHint": {
+ "summary": "Локальные модели OpenCode могут использовать бюджет контекста OpenCode вместо ограничений только в промпте.",
+ "description": "Добавьте соответствующие лимиты в конфиг OpenCode для провайдера и модели этого участника. Это поможет OpenCode выполнять compact и prune до того, как локальная модель переполнит контекстное окно.",
+ "replacePrefix": "Замените",
+ "and": "и",
+ "replaceSuffix": "на ID провайдера и модели из вашей настройки OpenCode. Инструкции в промпте вроде",
+ "promptInstructionsSuffix": "слабее, потому что запрос собирается до того, как модель их прочитает.",
+ "providerLimits": "Лимиты провайдера",
+ "compactionConfig": "Конфиг compaction"
+ },
+ "sessions": {
+ "noProjectPath": "Путь проекта не привязан",
+ "provisioningHint": "Сессии появятся после provisioning команды",
+ "projectNotFound": "Проект не найден",
+ "loading": "Загрузка сессий...",
+ "empty": "Сессии не найдены",
+ "showAllSessions": "Показать для всех сессий",
+ "lead": "lead",
+ "removeFilter": "Убрать фильтр",
+ "filterBySession": "Фильтровать по этой сессии",
+ "openSession": "Открыть сессию",
+ "title": "Сессии"
+ },
+ "provisioning": {
+ "pid": "PID {{pid}}",
+ "cancel": "Отменить",
+ "moreWarningsHidden": "Скрыто ещё {{count}} предупреждений",
+ "diagnostics": "Диагностика",
+ "liveOutput": "Живой вывод",
+ "diagnosticsCopied": "Диагностика скопирована",
+ "copyDiagnostics": "Скопировать диагностику",
+ "copied": "Скопировано",
+ "noOutput": "Вывод пока не записан.",
+ "cliLogs": "Логи CLI",
+ "steps": {
+ "starting": "Запуск",
+ "configuring": "Настройка команды",
+ "assembling": "Подключение участников",
+ "finalizing": "Завершение"
+ },
+ "providerStatus": {
+ "status": {
+ "checking": "проверка...",
+ "ready": "OK",
+ "notes": "OK (есть заметки)",
+ "failed": "ERR",
+ "pending": "ожидание"
+ },
+ "detailSummary": {
+ "cliBinaryMissing": "CLI binary не найден",
+ "openCodeRuntimeMissing": "OpenCode runtime отсутствует",
+ "openCodeWindowsAccessBlocked": "Доступ OpenCode в Windows заблокирован",
+ "openCodeNoOutput": "Проверка OpenCode runtime не вернула вывод",
+ "openCodeMcpUnreachable": "OpenCode app MCP недоступен",
+ "workingDirectoryMissing": "Working directory отсутствует",
+ "cliBinaryCouldNotStart": "CLI binary не удалось запустить",
+ "cliPreflightIncomplete": "CLI preflight не завершился",
+ "authenticationRequired": "Требуется аутентификация",
+ "runtimeProviderNotConfigured": "Runtime provider не настроен",
+ "cliPreflightFailed": "CLI preflight завершился с ошибкой",
+ "selectedModelCompatible": "Выбранная модель совместима",
+ "selectedModelCompatibilityPending": "Совместимость выбранной модели ещё проверяется",
+ "selectedModelAvailable": "Выбранная модель доступна",
+ "selectedModelVerified": "Выбранная модель проверена",
+ "selectedModelUnavailable": "Выбранная модель недоступна",
+ "selectedModelTimedOut": "Проверка выбранной модели истекла по таймауту",
+ "selectedModelCheckFailed": "Проверка выбранной модели не удалась",
+ "selectedModelDeferred": "Проверка выбранной модели отложена",
+ "selectedModelPingNotConfirmed": "Ping выбранной модели не подтверждён",
+ "readyWithNotes": "Готово с заметками",
+ "needsAttention": "Требует внимания"
+ },
+ "modelChecksSummary": "Проверки выбранных моделей - {{details}}",
+ "modelParts": {
+ "unavailable": "{{count}} модель недоступна",
+ "unavailable_one": "{{count}} модель недоступна",
+ "unavailable_few": "{{count}} модели недоступны",
+ "unavailable_many": "{{count}} моделей недоступно",
+ "unavailable_other": "{{count}} модели недоступны",
+ "checkFailed": "{{count}} проверка модели не удалась",
+ "checkFailed_one": "{{count}} проверка модели не удалась",
+ "checkFailed_few": "{{count}} проверки моделей не удались",
+ "checkFailed_many": "{{count}} проверок моделей не удались",
+ "checkFailed_other": "{{count}} проверки моделей не удались",
+ "timedOut": "{{count}} модель по таймауту",
+ "timedOut_one": "{{count}} модель по таймауту",
+ "timedOut_few": "{{count}} модели по таймауту",
+ "timedOut_many": "{{count}} моделей по таймауту",
+ "timedOut_other": "{{count}} модели по таймауту",
+ "deferred": "{{count}} проверка отложена",
+ "deferred_one": "{{count}} проверка отложена",
+ "deferred_few": "{{count}} проверки отложены",
+ "deferred_many": "{{count}} проверок отложено",
+ "deferred_other": "{{count}} проверки отложены",
+ "pingNotConfirmed": "{{count}} ping не подтверждён",
+ "pingNotConfirmed_one": "{{count}} ping не подтверждён",
+ "pingNotConfirmed_few": "{{count}} ping не подтверждены",
+ "pingNotConfirmed_many": "{{count}} ping не подтверждены",
+ "pingNotConfirmed_other": "{{count}} ping не подтверждены",
+ "compatibilityPending": "{{count}} совместима, глубокая проверка продолжается",
+ "compatibilityPending_one": "{{count}} совместима, глубокая проверка продолжается",
+ "compatibilityPending_few": "{{count}} совместимы, глубокая проверка продолжается",
+ "compatibilityPending_many": "{{count}} совместимы, глубокая проверка продолжается",
+ "compatibilityPending_other": "{{count}} совместимы, глубокая проверка продолжается",
+ "compatible": "{{count}} совместима",
+ "compatible_one": "{{count}} совместима",
+ "compatible_few": "{{count}} совместимы",
+ "compatible_many": "{{count}} совместимы",
+ "compatible_other": "{{count}} совместимы",
+ "checking": "{{count}} проверяется",
+ "checking_one": "{{count}} проверяется",
+ "checking_few": "{{count}} проверяются",
+ "checking_many": "{{count}} проверяются",
+ "checking_other": "{{count}} проверяются",
+ "available": "{{count}} доступна",
+ "available_one": "{{count}} доступна",
+ "available_few": "{{count}} доступны",
+ "available_many": "{{count}} доступны",
+ "available_other": "{{count}} доступны",
+ "verified": "{{count}} проверена",
+ "verified_one": "{{count}} проверена",
+ "verified_few": "{{count}} проверены",
+ "verified_many": "{{count}} проверены",
+ "verified_other": "{{count}} проверены"
+ },
+ "openProviderSettings": "Открыть настройки {{provider}}",
+ "copied": "Скопировано",
+ "copyDiagnostics": "Скопировать диагностику",
+ "deepVerificationPending": "Глубокая проверка ещё выполняется. Бесплатные модели OpenCode могут проверяться около 20 секунд.",
+ "progress": {
+ "checkingSelectedProviders": "Проверка выбранных провайдеров параллельно...",
+ "checkingProvider": "Проверка провайдера {{provider}}...",
+ "checkingProviders": "Проверка провайдеров {{providers}}..."
+ },
+ "failureHints": {
+ "openCodeAccessDenied": "Исправьте права на папку или перенесите проект в папку, доступную пользователю для записи. Запуск от администратора - только временный обходной путь.",
+ "openCodeBridgeNoOutput": "Перезапустите приложение и runtime OpenCode, затем повторите. Если повторится, скопируйте diagnostics.",
+ "workingDirectoryMissing": "Выберите существующую рабочую папку, затем откройте этот диалог заново.",
+ "authenticationRequired": "Авторизуйте нужного провайдера в Claude CLI, затем откройте этот диалог заново.",
+ "runtimeProviderNotConfigured": "Настройте выбранный provider runtime, затем откройте этот диалог заново.",
+ "openCodeRuntimeMissing": "Установите или повторите запуск runtime OpenCode из карточки статуса провайдера, затем откройте этот диалог заново.",
+ "openCodeAppMcpUnreachable": "Повторите launch, чтобы обновить OpenCode app MCP bridge. Если повторится, перезапустите приложение и runtime OpenCode.",
+ "cliBinaryMissing": "Убедитесь, что локальный бинарь Claude CLI существует и может запускаться, затем откройте этот диалог заново.",
+ "default": "Исправьте проблему выше, затем откройте этот диалог заново."
+ }
+ },
+ "presentation": {
+ "awaitingPermission": "{{count}} участник(ов) ожидает подтверждения permission",
+ "nameListWithMore": "{{names}}, +{{count}} ещё",
+ "waitingForOpenCode": "Ожидание OpenCode: {{names}}",
+ "bootstrapStalled": "Bootstrap завис: {{names}}",
+ "bootstrapStalledWithOpenCodeWait": "{{stalled}}; ожидание OpenCode: {{names}}",
+ "namedPendingDiagnostic": "{{label}}: {{names}}",
+ "countPendingDiagnostic": "{{count}} - {{label}}",
+ "pendingLabels": {
+ "bootstrapStalled": "Bootstrap завис",
+ "shellOnly": "Только shell",
+ "waitingForBootstrap": "Ожидание bootstrap",
+ "bootstrapUnconfirmed": "Bootstrap не подтверждён",
+ "awaitingPermission": "Ожидание permission",
+ "waitingForRuntime": "Ожидание runtime",
+ "shellOnlyLower": "только shell",
+ "waitingForBootstrapLower": "ожидает bootstrap",
+ "bootstrapUnconfirmedLower": "bootstrap не подтверждён",
+ "awaitingPermissionLower": "ожидает permission",
+ "waitingForRuntimeLower": "ожидает runtime"
+ },
+ "failed": {
+ "memberFailedToStart": "{{name}} не запустился",
+ "teammatesFailedToStart": "{{count}} участник(ов) не запустилось",
+ "teammatesFailedRatio": "{{count}}/{{total}} участник(ов) не запустилось"
+ },
+ "skipped": {
+ "memberSkipped": "{{name}} пропущен для этого запуска",
+ "memberSkippedWithReason": "{{name}} пропущен для этого запуска - {{reason}}",
+ "memberSkippedCompact": "{{name}} пропущен",
+ "teammatesSkipped": "{{count}} участник(ов) пропущено",
+ "teammatesSkippedList": "Пропущенные участники: {{list}}",
+ "teammatesSkippedRatio": "{{count}}/{{total}} участник(ов) пропущено для этого запуска"
+ },
+ "joining": {
+ "teammatesStillJoining": "{{count}} участник(ов) ещё подключается",
+ "teammatesConfirmedRatio": "{{count}}/{{total}} участников подтверждено"
+ },
+ "ready": {
+ "leadOnline": "Lead online",
+ "allTeammatesJoined": "Все участники подключились: {{count}}",
+ "teamProvisionedLeadOnline": "Команда подготовлена - lead online",
+ "teamProvisionedAllJoined": "Команда подготовлена - все участники подключились: {{count}}",
+ "teamProvisionedStillJoining": "Команда подготовлена - участники ещё подключаются",
+ "launchFinishedWithErrors": "Запуск завершён с ошибками - {{count}}/{{total}} участник(ов) не запустилось",
+ "launchContinuedSkipped": "Запуск продолжен - {{count}}/{{total}} участник(ов) пропущено",
+ "teamLaunchedLeadOnline": "Команда запущена - lead online",
+ "teamLaunchedAllJoined": "Команда запущена - все участники подключились: {{count}}"
+ },
+ "panel": {
+ "launchFailed": "Запуск не удался",
+ "launchDetails": "Детали запуска",
+ "launchFinishedWithErrors": "Запуск завершён с ошибками",
+ "launchContinuedSkipped": "Запуск продолжен с пропущенными участниками",
+ "coreTeamReady": "Основная команда готова",
+ "finishingLaunch": "Завершение запуска",
+ "teamLaunched": "Команда запущена",
+ "launchingTeam": "Запуск команды"
+ }
+ }
+ },
+ "liveRuntimeStatus": {
+ "title": "Статус live runtime",
+ "description": "Информационный heartbeat и состояние запуска. Управление процессами находится ниже.",
+ "source": "источник: {{source}}",
+ "lane": "lane {{lane}}",
+ "diagnosticOnly": "Только для диагностики",
+ "updated": "обновлено {{value}}",
+ "states": {
+ "running": "Работает",
+ "starting": "Запускается",
+ "waiting": "Ожидает",
+ "degraded": "Требует внимания",
+ "stopped": "Остановлен",
+ "unknown": "Неизвестно"
+ }
+ },
+ "taskLogs": {
+ "exact": {
+ "title": "Точные логи задачи",
+ "loading": "Загрузка точных логов задачи...",
+ "description": "Точные фрагменты transcript отображаются теми же компонентами execution-log, что и в логах.",
+ "emptyTitle": "Точных логов задачи пока нет",
+ "emptyDescription": "Точные transcript bundles появятся здесь, когда будут доступны явные метаданные transcript, связанные с задачей.",
+ "summaryOnly": "только summary"
+ },
+ "executionSessions": {
+ "title": "Сессии выполнения",
+ "online": "Онлайн",
+ "updating": "Обновление...",
+ "description": "Просмотр и предпросмотр старых транскриптов, сгруппированных по сессиям."
+ },
+ "stream": {
+ "title": "Поток логов задачи"
+ }
+ },
+ "kanban": {
+ "taskCard": {
+ "cancelTask": "Отменить задачу {{taskId}}",
+ "cancel": "Отменить",
+ "moveBackToTodoConfirm": "Переместить задачу обратно в TODO и уведомить команду?",
+ "confirm": "Подтвердить",
+ "keep": "Оставить",
+ "changesNeedAttention": "Изменения требуют внимания",
+ "changes": "Изменения",
+ "deleteTask": "Удалить задачу",
+ "taskLogsActive": "Логи задачи активны",
+ "newTaskLogsArriving": "Поступают новые логи задачи",
+ "awaitingUser": "Ожидает пользователя",
+ "awaitingLead": "Ожидает lead",
+ "blockedBy": "Заблокировано",
+ "blocks": "Блокирует",
+ "start": "Начать",
+ "complete": "Завершить",
+ "approve": "Одобрить",
+ "requestReview": "Запросить ревью",
+ "manualReview": "Ручное ревью",
+ "requestChanges": "Запросить правки"
+ },
+ "filter": {
+ "title": "Фильтр задач",
+ "session": "Сессия",
+ "allSessions": "Все сессии",
+ "teammate": "Участник",
+ "unassigned": "(не назначено)",
+ "column": "Колонка",
+ "clearAll": "Очистить всё"
+ },
+ "board": {
+ "addTask": "Добавить задачу",
+ "noTasks": "Нет задач",
+ "showMore": "Показать ещё {{count}}",
+ "hiddenCount": "Скрыто: {{count}}",
+ "trash": "Корзина",
+ "gridView": "Вид сеткой",
+ "columnsView": "Вид колонками"
+ },
+ "trash": {
+ "title": "Корзина",
+ "empty": "Удалённых задач нет",
+ "subject": "Тема",
+ "owner": "Исполнитель",
+ "deleted": "Удалено",
+ "unassigned": "Не назначено",
+ "restoreTask": "Восстановить задачу",
+ "restore": "Восстановить",
+ "close": "Закрыть"
+ },
+ "sort": {
+ "title": "Сортировка задач",
+ "sortBy": "Сортировать по",
+ "reset": "Сбросить",
+ "options": {
+ "updatedAt": {
+ "label": "Последнее обновление",
+ "description": "Сначала недавно обновлённые"
+ },
+ "createdAt": {
+ "label": "Создано",
+ "description": "Сначала новые"
+ },
+ "owner": {
+ "label": "Исполнитель",
+ "description": "По имени исполнителя"
+ },
+ "manual": {
+ "label": "Вручную",
+ "description": "Порядок drag-and-drop"
+ }
+ }
+ },
+ "search": {
+ "clearSearch": "Очистить поиск",
+ "tasks": "Задачи",
+ "createdAgo": "создано {{time}}",
+ "updatedAgo": "обновлено {{time}}",
+ "placeholder": "Поиск задач... (#id или текст)"
+ },
+ "grid": {
+ "addTask": "Добавить задачу",
+ "noTasks": "Нет задач"
+ },
+ "title": "Канбан",
+ "columns": {
+ "todo": "TODO",
+ "inProgress": "В РАБОТЕ",
+ "review": "РЕВЬЮ",
+ "done": "ГОТОВО",
+ "approved": "ОДОБРЕНО"
+ }
+ },
+ "worktreeGitReadiness": {
+ "checking": "Проверка Git-репозитория для worktree участников...",
+ "ready": "Git worktree готовы.",
+ "readyOnBranch": "Git worktree готовы на ветке {{branch}}.",
+ "needsSetup": "Для worktree isolation нужна настройка Git",
+ "initialCommitNotice": "Действие initial commit добавит в индекс и закоммитит все текущие файлы с сообщением",
+ "initializeRepository": "Инициализировать Git-репозиторий",
+ "createInitialCommit": "Создать initial commit",
+ "initialCommitMessage": "chore: initial commit"
+ },
+ "toolApproval": {
+ "settings": "Настройки",
+ "autoAllowAllTools": "Автоматически разрешать все инструменты",
+ "autoAllowFileEdits": "Автоматически разрешать правки файлов (Edit, Write, NotebookEdit)",
+ "autoAllowSafeCommands": "Автоматически разрешать безопасные команды (git, pnpm, npm, ls...)",
+ "onTimeout": "При таймауте:",
+ "after": "через",
+ "secondsShort": "сек",
+ "timeoutActions": {
+ "wait": "Ждать всегда",
+ "allow": "Разрешить",
+ "deny": "Отклонить"
+ },
+ "submit": "Отправить",
+ "allow": "Разрешить",
+ "deny": "Отклонить",
+ "allowAll": "Разрешить всё",
+ "pendingCount": "ожидает: {{count}}",
+ "autoActionIn": "Авто-{{action}} через {{time}}",
+ "diff": {
+ "previewChanges": "Предпросмотр изменений",
+ "readingFile": "Чтение файла...",
+ "binaryFile": "Бинарный файл - предпросмотр невозможен",
+ "truncated": "Файл обрезан на 2MB - diff может быть неполным",
+ "newFile": "Новый файл"
+ }
+ },
+ "memberWorkSync": {
+ "details": {
+ "title": "Синхронизация работы участника",
+ "actionableItems": "Действия",
+ "fingerprint": "Fingerprint",
+ "report": "Отчёт",
+ "none": "нет",
+ "shadowWouldNudge": "Shadow отправил бы nudge",
+ "yes": "да",
+ "no": "нет",
+ "moreActionableItems": "Ещё действий: {{count}}",
+ "diagnostics": "Диагностика: {{diagnostics}}"
+ },
+ "title": "Синхронизация работы участника",
+ "loadingDiagnostics": "Загрузка диагностики синхронизации работы участника.",
+ "diagnosticsUnavailable": "Диагностика синхронизации работы участника недоступна."
+ },
+ "advancedCli": {
+ "title": "Дополнительно",
+ "useWorktree": "Использовать worktree",
+ "recent": "Недавние",
+ "commandPreview": "Предпросмотр команды",
+ "customArguments": "Пользовательские аргументы",
+ "validate": "Проверить",
+ "validation": {
+ "allFlagsValid": "Все флаги корректны",
+ "unknownFlags": "Неизвестные: {{flags}}",
+ "protectedFlags": "Защищённые: {{flags}}",
+ "failed": "Проверка не удалась"
+ },
+ "placeholders": {
+ "worktreeName": "worktree-name"
+ }
+ },
+ "processes": {
+ "ago": "{{time}} назад",
+ "stoppedAgo": "остановлен {{time}} назад",
+ "running": "Работает",
+ "stopped": "Остановлен",
+ "stopProcess": "Остановить процесс (SIGTERM)",
+ "kill": "Убить",
+ "openInBrowser": "Открыть в браузере",
+ "open": "Открыть",
+ "pid": "PID{{pid}}",
+ "title": "Процессы CLI"
+ },
+ "taskActivity": {
+ "loadingDetails": "Загрузка деталей активности...",
+ "contextUnavailable": "Детальный transcript-контекст для этой активности больше недоступен.",
+ "loading": "Загрузка активности задачи...",
+ "lowSignalOnly": "Ключевая активность задачи пока не найдена. Низкоуровневые детали выполнения доступны ниже в Task Log Stream.",
+ "empty": "Явная активность задачи пока не найдена в доступных transcript. Более старые или эвристические логи сессий могут быть доступны ниже в Execution Sessions.",
+ "title": "Активность задачи",
+ "description": "Ключевая runtime-активность, связанная с задачей через transcript metadata."
+ },
+ "sendMessage": {
+ "title": "Отправить сообщение",
+ "description": "Отправить прямое сообщение участнику команды.",
+ "recipientLabel": "Получатель",
+ "selectMemberPlaceholder": "Выберите участника...",
+ "messageLabel": "Сообщение",
+ "placeholder": "Напишите сообщение... (Enter для отправки)",
+ "send": "Отправить",
+ "sending": "Отправка...",
+ "charsLeft": "осталось символов: {{count}}",
+ "saved": "Сохранено",
+ "attachments": {
+ "teamOnlineRequired": "Команда должна быть онлайн, чтобы прикреплять файлы",
+ "recipientUnsupported": "Файлы можно отправлять лиду команды или участникам OpenCode",
+ "openCodeOnlineRequired": "Команда должна быть онлайн, чтобы прикреплять файлы для участников OpenCode",
+ "disabledHint": "Файлы поддерживаются для онлайн-лида команды и онлайн-участников OpenCode. Удалите вложения или смените получателя.",
+ "attachFiles": "Прикрепить файлы (вставка или drag & drop)",
+ "unavailable": "Вложения недоступны"
+ },
+ "quote": {
+ "remove": "Удалить цитату",
+ "replyingTo": "Ответ для"
+ }
+ },
+ "taskComments": {
+ "cancelReply": "Отменить ответ",
+ "replyingTo": "Ответ для",
+ "placeholder": "Добавьте комментарий... (Enter для отправки)",
+ "attachFile": "Прикрепить файл (или вставить)",
+ "voiceToText": "Голос в текст",
+ "comment": "Комментарий",
+ "charsLeft": "осталось символов: {{count}}",
+ "saved": "Сохранено",
+ "awaitingReplyFrom": "Ожидается ответ от",
+ "or": "или"
+ },
+ "taskAttachments": {
+ "dropImageHere": "Перетащите изображение сюда",
+ "attachImage": "Прикрепить изображение",
+ "pasteOrDragDrop": "или вставьте / перетащите",
+ "fromOriginalMessage": "Из исходного сообщения",
+ "dropFilesHere": "Перетащите файлы сюда",
+ "loading": "Загрузка вложений..."
+ },
+ "permissions": {
+ "autoApproveAllTools": "Автоодобрение всех инструментов",
+ "autonomousModeDescription": "Автономный режим: инструменты команды выполняются без подтверждения. Будьте осторожны с недоверенным кодом.",
+ "manualModeDescription": "Ручной режим: вы будете одобрять или отклонять каждый вызов инструмента в реальном времени."
+ },
+ "memberLogStream": {
+ "tabs": {
+ "execution": "Выполнение",
+ "process": "Процесс"
+ },
+ "filters": {
+ "all": "Все"
+ },
+ "logs": {
+ "title": "Логи",
+ "loading": "Загрузка потока логов участника...",
+ "emptyTitle": "Для этого участника пока не найдено записей потока логов.",
+ "emptyDescription": "Транскрипт участника и runtime-логи появятся здесь, когда будут доступны."
+ }
+ },
+ "reviewDialog": {
+ "placeholder": "Опишите, что нужно изменить... (Enter для отправки)",
+ "submit": "Отправить",
+ "charsLeft": "осталось символов: {{count}}",
+ "saved": "Сохранено",
+ "title": "Запросить изменения"
+ },
+ "dialogs": {
+ "actions": {
+ "openDashboard": "Открыть дашборд",
+ "openTeam": "Открыть команду",
+ "cancel": "Отмена"
+ },
+ "membersJson": {
+ "hide": "Скрыть JSON"
+ },
+ "optional": {
+ "badge": "Опционально"
+ }
+ },
+ "runningTeams": {
+ "title": "Активные команды"
+ },
+ "layout": {
+ "maxPanesReached": "Достигнут максимум панелей: {{count}}"
+ },
+ "codexReconnect": {
+ "description": "Сессия Codex выглядит устаревшей. Переподключитесь, чтобы продолжить.",
+ "useCode": "Использовать код"
+ },
+ "effortLevel": {
+ "label": "Уровень усилий (опционально)",
+ "maxDescription": "Max даёт модели больше всего времени на рассуждение для сложных задач."
+ },
+ "contextLimit": {
+ "limitTo200k": "Ограничить контекст до 200K токенов",
+ "always200k": "(для этой модели всегда 200K)",
+ "tooltipContent": "Ограничивает запуск окном контекста 200K токенов, когда это поддерживается.",
+ "tooltipTitle": "Лимит контекста"
+ },
+ "roleSelect": {
+ "noRole": "Без роли",
+ "customRole": "Своя роль...",
+ "searchPlaceholder": "Поиск ролей...",
+ "empty": "Роли не найдены.",
+ "reservedRole": "Эта роль зарезервирована"
+ }
+}
diff --git a/src/features/localization/renderer/resources.d.ts b/src/features/localization/renderer/resources.d.ts
new file mode 100644
index 00000000..f77efa56
--- /dev/null
+++ b/src/features/localization/renderer/resources.d.ts
@@ -0,0 +1,5402 @@
+// This file is automatically generated by i18next-cli. Do not edit manually.
+export default interface Resources {
+ common: {
+ actions: {
+ cancel: 'Cancel';
+ close: 'Close';
+ closeDialog: 'Close dialog';
+ copied: 'Copied';
+ copyToClipboard: 'Copy to clipboard';
+ copyUrl: 'Copy URL';
+ goToDashboard: 'Go to Dashboard';
+ hide: 'Hide';
+ moreActions: 'More actions';
+ open: 'Open';
+ or: 'or';
+ refresh: 'Refresh';
+ reset: 'Reset';
+ resetSelection: 'Reset selection';
+ retry: 'Retry';
+ reveal: 'Reveal';
+ save: 'Save';
+ showLess: 'Show less';
+ showMore: 'Show more';
+ };
+ brand: {
+ claude: 'Claude';
+ };
+ chat: {
+ bottom: 'Bottom';
+ compact: {
+ compacted: 'Compacted';
+ contextCompacted: 'Context compacted';
+ conversationCompacted: 'Conversation Compacted';
+ freedTokens: '({{tokens}} freed)';
+ phase: 'Phase {{phase}}';
+ summary: 'Previous messages were summarized to save context. The full conversation history is preserved in the session file.';
+ toggle: 'Toggle compacted content';
+ };
+ context: {
+ count: 'Context ({{count}})';
+ count_few: 'Context ({{count}})';
+ count_many: 'Context ({{count}})';
+ count_one: 'Context ({{count}})';
+ count_other: 'Context ({{count}})';
+ remainingPercent: '({{percent}}% left)';
+ };
+ empty: {
+ description: 'This session does not contain any messages yet.';
+ icon: '💬';
+ title: 'No conversation history';
+ };
+ executionTrace: {
+ empty: 'No execution items';
+ input: 'Input';
+ nested: 'Nested: {{name}}';
+ };
+ items: {
+ empty: 'No items to display';
+ };
+ lastOutput: {
+ planReadyForApproval: 'Plan Ready for Approval';
+ requestInterrupted: 'Request interrupted by user';
+ };
+ scrollToBottom: 'Scroll to bottom';
+ subagent: {
+ fallbackName: 'Subagent';
+ meta: {
+ duration: 'Duration';
+ id: 'ID';
+ model: 'Model';
+ type: 'Type';
+ };
+ metrics: {
+ contextUsage: 'Context Usage';
+ contextWindow: 'Context Window';
+ mainContext: 'Main Context';
+ phase: 'Phase {{phase}}';
+ subagentContext: 'Subagent Context';
+ totalOutput: 'Total Output';
+ turns: '({{count}} turns)';
+ turns_few: '({{count}} turns)';
+ turns_many: '({{count}} turns)';
+ turns_one: '({{count}} turn)';
+ turns_other: '({{count}} turns)';
+ };
+ shutdownConfirmed: 'Shutdown confirmed';
+ summary: {
+ tools: '{{count}} tools';
+ tools_few: '{{count}} tools';
+ tools_many: '{{count}} tools';
+ tools_one: '{{count}} tool';
+ tools_other: '{{count}} tools';
+ };
+ trace: {
+ title: 'Execution Trace';
+ };
+ };
+ system: {
+ label: 'System';
+ };
+ teammateMessage: {
+ fallback: 'Teammate message';
+ message: 'Message';
+ resent: 'Resent';
+ };
+ tools: {
+ duration: 'Duration: {{duration}}';
+ noResultReceived: 'No result received';
+ result: 'Result';
+ shutdownRequested: 'Shutdown requested ->';
+ skill: {
+ instructions: 'Skill Instructions';
+ unknown: 'Unknown Skill';
+ };
+ teammateSpawned: 'Teammate spawned';
+ write: {
+ createdFile: 'Created file';
+ wroteToFile: 'Wrote to file';
+ };
+ };
+ user: {
+ backgroundTask: 'Background task';
+ exitCode: 'exit {{code}}';
+ imagesAttached: '{{count}} images attached';
+ imagesAttached_few: '{{count}} images attached';
+ imagesAttached_many: '{{count}} images attached';
+ imagesAttached_one: '{{count}} image attached';
+ imagesAttached_other: '{{count}} images attached';
+ showLess: 'Show less';
+ showMore: 'Show more';
+ you: 'You';
+ };
+ };
+ code: {
+ code: 'Code';
+ line: 'line {{line}}';
+ lines: 'lines {{from}}-{{to}}';
+ linesParenthesized: '(lines {{from}}-{{to}})';
+ markdownPreview: 'Markdown Preview';
+ mermaidSyntaxError: 'Mermaid syntax error';
+ moreLines: '({{count}} more lines...)';
+ moreLines_few: '({{count}} more lines...)';
+ moreLines_many: '({{count}} more lines...)';
+ moreLines_one: '({{count}} more line...)';
+ moreLines_other: '({{count}} more lines...)';
+ preview: 'Preview';
+ };
+ codexLogin: {
+ copyFailed: 'Copy failed';
+ copyLink: 'Copy link';
+ copyLinkAndCode: 'Copy link + code';
+ copyLoginLink: 'Copy ChatGPT login link';
+ copyLoginLinkAndCode: 'Copy ChatGPT login link and code';
+ enterCodeOnLoginPage: 'Enter this code on the ChatGPT login page';
+ };
+ commandPalette: {
+ currentProject: 'Current project';
+ empty: {
+ minChars: 'Type at least 2 characters to search';
+ noFastResults: 'No fast results in recent sessions for "{{query}}"';
+ noProjects: 'No projects found';
+ noProjectsForQuery: 'No projects found for "{{query}}"';
+ noResults: 'No results found for "{{query}}"';
+ };
+ footer: {
+ close: 'close';
+ escapeKey: 'esc';
+ fastPrefix: 'fast ';
+ global: 'global';
+ navigate: 'navigate';
+ open: 'open';
+ projectsCount: '{{count}} projects';
+ projectsCount_few: '{{count}} projects';
+ projectsCount_many: '{{count}} projects';
+ projectsCount_one: '{{count}} project';
+ projectsCount_other: '{{count}} projects';
+ results: '{{count}} {{speed}}results';
+ resultsAcrossProjects: '{{count}} {{speed}}results across all projects';
+ resultsAcrossProjects_few: '{{count}} {{speed}}results across all projects';
+ resultsAcrossProjects_many: '{{count}} {{speed}}results across all projects';
+ resultsAcrossProjects_one: '{{count}} {{speed}}result across all projects';
+ resultsAcrossProjects_other: '{{count}} {{speed}}results across all projects';
+ results_few: '{{count}} {{speed}}results';
+ results_many: '{{count}} {{speed}}results';
+ results_one: '{{count}} {{speed}}result';
+ results_other: '{{count}} {{speed}}results';
+ select: 'select';
+ typeToSearch: 'Type to search';
+ upDownKey: '↑↓';
+ };
+ global: 'Global';
+ mode: {
+ searchAcrossProjects: 'Search across all projects';
+ searchInProject: 'Search in project';
+ searchProjects: 'Search projects';
+ };
+ noRecentActivity: 'No recent activity';
+ placeholders: {
+ conversations: 'Search conversations...';
+ projects: 'Search projects...';
+ };
+ sessionsCount: '{{count}} sessions';
+ sessionsCount_few: '{{count}} sessions';
+ sessionsCount_many: '{{count}} sessions';
+ sessionsCount_one: '{{count}} session';
+ sessionsCount_other: '{{count}} sessions';
+ };
+ context: {
+ loadingWorkspace: 'Loading workspace';
+ local: 'Local';
+ switchWorkspace: 'Switch Workspace';
+ switchingTo: 'Switching to {{workspace}}';
+ };
+ contextBadge: {
+ badge: 'Context';
+ breakdown: {
+ text: 'Text';
+ thinking: 'Thinking';
+ };
+ detailsAria: 'Context injection details';
+ sectionSummary: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sectionSummary_few: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sectionSummary_many: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sectionSummary_one: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sectionSummary_other: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sections: {
+ claudeMdFiles: 'CLAUDE.md Files';
+ mentionedFiles: 'Mentioned Files';
+ taskCoordination: 'Task Coordination';
+ thinkingText: 'Thinking + Text';
+ toolOutputs: 'Tool Outputs';
+ userMessages: 'User Messages';
+ };
+ title: 'New Context Injected In This Turn';
+ tokenCount: '~{{tokens}} tokens';
+ totalNewTokens: 'Total new tokens';
+ turn: 'Turn {{turn}}';
+ };
+ diff: {
+ changed: 'Changed';
+ noChangesDetected: 'No changes detected';
+ };
+ editorFormatting: {
+ bold: 'Bold';
+ code: 'Code';
+ italic: 'Italic';
+ strike: 'Strike';
+ };
+ errorBoundary: {
+ componentStack: 'Component Stack';
+ copied: 'Copied';
+ copyErrorDetails: 'Copy Error Details';
+ description: 'An unexpected error occurred in the application. You can try reloading the page or resetting the error state.';
+ diagnosticsNotice: 'GitHub bug reports and copied diagnostics include the error message, stack traces, app version, active tab, selected team, task context, and environment details.';
+ reloadApp: 'Reload App';
+ reportBugOnGitHub: 'Report Bug on GitHub';
+ title: 'Something went wrong';
+ tryAgain: 'Try Again';
+ };
+ export: {
+ session: 'Export session';
+ sessionTitle: 'Export Session';
+ };
+ layout: {
+ closeTab: 'Close tab';
+ collapseSidebarShortcut: 'Collapse sidebar ({{shortcut}})';
+ discord: 'Discord';
+ expandSidebar: 'Expand sidebar';
+ github: 'GitHub';
+ jumpToSection: 'Jump to section';
+ loadingTab: 'Loading tab';
+ menu: {
+ analyzeSession: 'Analyze Session';
+ docs: 'Docs';
+ exportJson: 'Export as JSON';
+ exportMarkdown: 'Export as Markdown';
+ exportPlainText: 'Export as Plain Text';
+ extensions: 'Extensions';
+ schedules: 'Schedules';
+ search: 'Search';
+ settings: 'Settings';
+ teams: 'Teams';
+ };
+ newTab: 'New tab';
+ newTabDashboard: 'New tab (Dashboard)';
+ openedFromSearch: 'Opened from search';
+ pinnedSession: 'Pinned session';
+ refreshSession: 'Refresh session';
+ refreshSessionWithShortcut: 'Refresh Session ({{shortcut}})';
+ resizeSidebar: 'Resize sidebar';
+ sections: {
+ claudeLogs: 'Claude Logs';
+ kanban: 'Kanban';
+ messages: 'Messages';
+ sessions: 'Sessions';
+ team: 'Team';
+ };
+ sidebarView: 'Sidebar view';
+ tabMenu: {
+ closeAllTabs: 'Close All Tabs';
+ closeOtherTabs: 'Close Other Tabs';
+ closeTab: 'Close Tab';
+ closeTabs: 'Close {{count}} Tabs';
+ closeTabs_few: 'Close {{count}} Tabs';
+ closeTabs_many: 'Close {{count}} Tabs';
+ closeTabs_one: 'Close {{count}} Tab';
+ closeTabs_other: 'Close {{count}} Tabs';
+ hideFromSidebar: 'Hide from Sidebar';
+ pinToSidebar: 'Pin to Sidebar';
+ splitLeft: 'Split Left';
+ splitRight: 'Split Right';
+ unhideFromSidebar: 'Unhide from Sidebar';
+ unpinFromSidebar: 'Unpin from Sidebar';
+ };
+ };
+ list: {
+ actions: {
+ copyTeam: 'Copy team';
+ createTeam: 'Create Team';
+ deleteForever: 'Delete forever';
+ deletePermanently: 'Delete permanently';
+ deleteTeam: 'Delete team';
+ launchTeam: 'Launch team';
+ launching: 'Launching...';
+ relaunchTeam: 'Relaunch team';
+ restore: 'Restore';
+ restoreTeam: 'Restore team';
+ retry: 'Retry';
+ stopTeam: 'Stop team';
+ stopping: 'Stopping...';
+ };
+ all: 'All';
+ membersCount: 'Members: {{count}}';
+ membersCount_few: 'Members: {{count}}';
+ membersCount_many: 'Members: {{count}}';
+ membersCount_one: 'Member: {{count}}';
+ membersCount_other: 'Members: {{count}}';
+ moreCount: '+{{count}} more';
+ moreCount_few: '+{{count}} more';
+ moreCount_many: '+{{count}} more';
+ moreCount_one: '+{{count}} more';
+ moreCount_other: '+{{count}} more';
+ noDescription: 'No description';
+ partial: {
+ pending: 'Last launch is still reconciling.';
+ skipped: 'Last launch has skipped teammates.';
+ skippedWithCount: 'Last launch skipped {{count}}/{{expected}} teammate.';
+ skippedWithCount_few: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ skippedWithCount_many: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ skippedWithCount_one: 'Last launch skipped {{count}}/{{expected}} teammate.';
+ skippedWithCount_other: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ stopped: 'Last launch stopped before all teammates joined.';
+ stoppedWithCount: 'Last launch stopped before {{count}}/{{expected}} teammate joined.';
+ stoppedWithCount_few: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ stoppedWithCount_many: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ stoppedWithCount_one: 'Last launch stopped before {{count}}/{{expected}} teammate joined.';
+ stoppedWithCount_other: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ };
+ solo: 'Solo';
+ status: {
+ active: 'Active';
+ deleted: 'Deleted';
+ launching: 'Launching...';
+ offline: 'Offline';
+ partialFailure: 'Launch failed partway';
+ partialPending: 'Bootstrap pending';
+ partialSkipped: 'Launch skipped member';
+ running: 'Running';
+ };
+ };
+ locales: {
+ emptyMessage: 'No language found.';
+ names: {
+ en: 'English';
+ ru: 'Russian';
+ system: 'System';
+ };
+ searchPlaceholder: 'Search language...';
+ selectPlaceholder: 'Select app language...';
+ systemWithResolved: 'System - {{locale}}';
+ };
+ markdown: {
+ imageFallback: '[Image: {{label}}]';
+ largeContentNotice: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentNotice_few: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentNotice_many: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentNotice_one: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentNotice_other: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentTitle: 'Large content is shown as raw to prevent UI freeze';
+ raw: 'Raw';
+ rawPreview: 'Raw preview';
+ renderMarkdown: 'Render markdown';
+ showAll: 'Show all';
+ showMore: 'Show more';
+ showRaw: 'Show raw';
+ showingChars: 'Showing {{shown}} / {{total}} chars';
+ };
+ members: {
+ emptyMessage: 'No members found.';
+ searchPlaceholder: 'Search members...';
+ teammateFallback: 'teammate';
+ unassigned: 'Unassigned';
+ };
+ notifications: {
+ actions: {
+ clearAll: 'Clear all';
+ clearAllNotifications: 'Clear all notifications';
+ clearFiltered: 'Clear filtered';
+ clearFilteredNotifications: 'Clear filtered notifications';
+ clickToConfirm: 'Click to confirm';
+ markAllAsRead: 'Mark all as read';
+ markAllRead: 'Mark all read';
+ markFilteredAsRead: 'Mark filtered as read';
+ markFilteredRead: 'Mark filtered read';
+ };
+ counts: {
+ inFilter: '{{count}} in filter';
+ inFilter_few: '{{count}} in filter';
+ inFilter_many: '{{count}} in filter';
+ inFilter_one: '{{count}} in filter';
+ inFilter_other: '{{count}} in filter';
+ total: '{{count}} total';
+ total_few: '{{count}} total';
+ total_many: '{{count}} total';
+ total_one: '{{count}} total';
+ total_other: '{{count}} total';
+ unread: '{{count}} unread';
+ unreadInFilter: '{{count}} unread in filter';
+ unreadInFilter_few: '{{count}} unread in filter';
+ unreadInFilter_many: '{{count}} unread in filter';
+ unreadInFilter_one: '{{count}} unread in filter';
+ unreadInFilter_other: '{{count}} unread in filter';
+ unread_few: '{{count}} unread';
+ unread_many: '{{count}} unread';
+ unread_one: '{{count}} unread';
+ unread_other: '{{count}} unread';
+ };
+ empty: {
+ allCaughtUp: "You're all caught up!";
+ noMatching: 'No matching notifications';
+ noNotifications: 'No notifications';
+ tryDifferentFilter: 'Try a different filter';
+ };
+ filters: {
+ other: 'Other';
+ };
+ loading: 'Loading notifications...';
+ row: {
+ delete: 'Delete';
+ markAsRead: 'Mark as read';
+ subagent: 'subagent';
+ team: 'team';
+ viewInSession: 'View in session';
+ };
+ title: 'Notifications';
+ };
+ providerModelBadges: {
+ checkFailed: 'Check failed';
+ checking: 'Checking';
+ free: 'Free';
+ freeTooltip: 'Reported by OpenCode metadata. Availability and limits may change.';
+ unavailable: 'Unavailable';
+ };
+ providerRuntime: {
+ codex: {
+ install: {
+ checking: 'Checking';
+ downloading: 'Downloading';
+ installCli: 'Install Codex CLI';
+ installing: 'Installing';
+ retryInstall: 'Retry install';
+ };
+ };
+ };
+ repositories: {
+ noneAvailable: 'No repositories available';
+ remove: 'Remove repository';
+ };
+ runtimeBackendSelector: {
+ audience: {
+ internal: 'Internal';
+ };
+ auto: 'Auto';
+ autoCurrently: 'Auto (currently: {{backend}})';
+ cannotSelectYet: 'This backend cannot be selected yet.';
+ current: 'Current';
+ label: 'Runtime backend';
+ recommended: 'Recommended';
+ resolved: 'Resolved: {{backend}}';
+ states: {
+ authRequired: 'Auth required';
+ degraded: 'Degraded';
+ disabled: 'Disabled';
+ locked: 'Locked';
+ runtimeMissing: 'Runtime missing';
+ unavailable: 'Unavailable';
+ };
+ unavailable: 'Unavailable';
+ };
+ runtimeProvider: {
+ defaults: {
+ allProjectsHint: 'Tests use {{project}}. Default applies unless a project has an override.';
+ projectHint: 'Saving overrides only {{project}}.';
+ projectOverrideContext: 'Project override context';
+ scopeDescriptionAllProjects: 'Default for every project that does not have its own OpenCode override.';
+ scopeDescriptionProject: 'Override only the selected project. Running teams are not changed.';
+ selectProjectHint: 'Select a project before testing local models or saving defaults.';
+ setAllProjectsDefault: 'Set all-projects default';
+ setProjectDefault: 'Set project default';
+ validationContext: 'Validation context';
+ };
+ };
+ schedules: {
+ actions: {
+ addSchedule: 'Add Schedule';
+ clearFilters: 'Clear filters';
+ createSchedule: 'Create Schedule';
+ delete: 'Delete';
+ edit: 'Edit';
+ pause: 'Pause';
+ resume: 'Resume';
+ runNow: 'Run now';
+ };
+ empty: {
+ description: 'Create a schedule on any team to automate Claude task execution with cron expressions. Schedules from all teams will appear here.';
+ noMatches: 'No schedules match the current filters';
+ title: 'No scheduled tasks';
+ };
+ filters: {
+ allTeams: 'All teams';
+ };
+ item: {
+ loadingRunHistory: 'Loading run history...';
+ nextRun: 'Next: {{value}}';
+ noRunsYet: 'No runs yet';
+ };
+ loading: 'Loading schedules...';
+ searchPlaceholder: 'Search schedules...';
+ status: {
+ active: 'Active';
+ all: 'All';
+ disabled: 'Disabled';
+ paused: 'Paused';
+ };
+ title: 'Schedules';
+ };
+ search: {
+ closeShortcut: 'Close (Esc)';
+ findInConversation: 'Find in conversation...';
+ nextResultShortcut: 'Next result (Enter)';
+ noMatchingSuggestions: 'No matching suggestions';
+ noResults: 'No results';
+ nothingFound: 'Nothing found';
+ placeholder: 'Search...';
+ previousResultShortcut: 'Previous result (Shift+Enter)';
+ resultCount: '{{current}} of {{total}}';
+ resultCountCapped: '{{current}} of {{total}}+';
+ searching: 'Searching...';
+ searchingFiles: 'Searching files...';
+ };
+ sessionContext: {
+ claudeMdFiles: 'CLAUDE.md Files';
+ empty: 'No context injections detected in this session';
+ header: {
+ bySize: 'By Size';
+ category: 'Category';
+ closePanel: 'Close panel';
+ current: 'Current';
+ phase: 'Phase:';
+ title: 'Context';
+ view: 'View:';
+ };
+ help: {
+ availability: {
+ description: 'If a provider runtime does not expose prompt-side usage yet, the panel shows metrics as unavailable instead of pretending they are zero.';
+ title: 'Availability';
+ };
+ contextUsed: {
+ description: "Prompt input plus output tokens currently occupying the model's context window.";
+ title: 'Context Used';
+ };
+ promptInput: {
+ description: 'Tokens sent to the model before generation. For Claude this includes `input_tokens + cache_creation_input_tokens + cache_read_input_tokens`.';
+ title: 'Prompt Input';
+ };
+ visibleContext: {
+ description: 'The inspectable subset of prompt input: files, CLAUDE.md, tool outputs, user messages, and similar injections that you can optimize directly.';
+ title: 'Visible Context';
+ };
+ };
+ items: {
+ itemsCount: '{{count}} items';
+ itemsCount_few: '{{count}} items';
+ itemsCount_many: '{{count}} items';
+ itemsCount_one: '{{count}} item';
+ itemsCount_other: '{{count}} items';
+ missing: 'missing';
+ text: 'Text';
+ thinking: 'Thinking';
+ tokensApprox: '~{{tokens}} tokens';
+ toolsCount: '{{count}} tools';
+ toolsCount_few: '{{count}} tools';
+ toolsCount_many: '{{count}} tools';
+ toolsCount_one: '{{count}} tool';
+ toolsCount_other: '{{count}} tools';
+ turn: '@Turn {{turn}}';
+ };
+ mentionedFiles: 'Mentioned Files';
+ metrics: {
+ codexTelemetryUnavailable: 'Codex prompt-side usage is not exposed by the current runtime telemetry yet, so Prompt Input and Context Used stay unavailable instead of showing a fake zero.';
+ contextUsed: 'Context Used';
+ details: 'details';
+ ofContext: 'of context';
+ ofPrompt: 'of prompt';
+ parentPlus: 'parent +';
+ promptInput: 'Prompt Input';
+ sessionCost: 'Session Cost:';
+ subagents: 'subagents';
+ unavailable: 'Unavailable';
+ visibleContext: 'Visible Context';
+ };
+ view: {
+ flat: 'Flat';
+ grouped: 'Grouped';
+ };
+ };
+ sessionFilters: {
+ project: {
+ selectProject: 'Select Project';
+ };
+ };
+ sessionItem: {
+ compactedTo: '(compacted to {{tokens}})';
+ context: 'Context: {{tokens}}';
+ phase: 'Phase {{phase}}:';
+ totalContext: 'Total Context: {{tokens}} tokens';
+ };
+ sessionReport: {
+ noSessionData: 'No session data available';
+ title: 'Session Report';
+ };
+ sessions: {
+ actions: {
+ hide: 'Hide';
+ pin: 'Pin';
+ unhide: 'Unhide';
+ };
+ count: '{{count}} sessions';
+ count_few: '{{count}} sessions';
+ count_many: '{{count}} sessions';
+ count_one: '{{count}} session';
+ count_other: '{{count}} sessions';
+ empty: {
+ noMatchingSessions: 'No matching sessions';
+ noMatchingSessionsDescription: 'This project has no matching sessions yet.';
+ noMatchingSessionsFiltered: 'Try another query or reset the provider filter.';
+ noSessions: 'No sessions found';
+ noSessionsDescription: 'This project has no sessions yet';
+ selectProject: 'Select a project to view sessions';
+ };
+ errors: {
+ loading: 'Error loading sessions';
+ };
+ failedToLoad: 'Failed to load session';
+ filter: {
+ title: 'Filter sessions';
+ };
+ inProgress: 'Session is in progress...';
+ loadedMatchingMore: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loadedMatchingMore_few: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loadedMatchingMore_many: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loadedMatchingMore_one: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loadedMatchingMore_other: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loading: 'Loading session...';
+ loadingMore: 'Loading more sessions...';
+ pinned: 'Pinned';
+ scrollToLoadMore: 'Scroll to load more';
+ search: {
+ clear: 'Clear session search';
+ placeholder: 'Search sessions...';
+ };
+ selection: {
+ cancel: 'Cancel selection';
+ exitMode: 'Exit selection mode';
+ hideSelected: 'Hide selected sessions';
+ pinSelected: 'Pin selected sessions';
+ selectSessions: 'Select sessions';
+ selected: '{{count}} selected';
+ selected_few: '{{count}} selected';
+ selected_many: '{{count}} selected';
+ selected_one: '{{count}} selected';
+ selected_other: '{{count}} selected';
+ unhideSelected: 'Unhide selected sessions';
+ };
+ sort: {
+ byContext: 'By Context';
+ byContextTooltip: 'Sort by context consumption';
+ byRecentTooltip: 'Sort by recent';
+ contextLoadedOnly: 'Context sorting only ranks loaded sessions.';
+ };
+ title: 'Sessions';
+ visibility: {
+ hideHidden: 'Hide hidden sessions';
+ showHidden: 'Show hidden sessions';
+ };
+ worktree: {
+ switch: 'Switch Worktree';
+ };
+ };
+ states: {
+ error: 'Error';
+ loading: 'Loading...';
+ offline: 'Offline';
+ online: 'Online';
+ unknown: 'Unknown';
+ };
+ taskContextMenu: {
+ archive: 'Archive';
+ deleteTask: 'Delete task';
+ markUnread: 'Mark as unread';
+ pin: 'Pin';
+ rename: 'Rename';
+ unarchive: 'Unarchive';
+ unpin: 'Unpin';
+ };
+ taskFilters: {
+ allProjects: 'All Projects';
+ allTeams: 'All teams';
+ apply: 'Apply';
+ clearAll: 'Clear all';
+ comments: 'Comments';
+ noProjects: 'No projects';
+ noTeamsFound: 'No teams found';
+ project: 'Project';
+ read: {
+ all: 'All';
+ read: 'Read';
+ unread: 'Unread';
+ };
+ searchProjects: 'Search projects...';
+ searchTeams: 'Search teams...';
+ selectAll: 'Select all';
+ status: 'Status';
+ statusOptions: {
+ approved: 'APPROVED';
+ done: 'DONE';
+ inProgress: 'IN PROGRESS';
+ needsFix: 'NEEDS FIXES';
+ review: 'REVIEW';
+ todo: 'TODO';
+ };
+ team: 'Team';
+ };
+ tasks: {
+ date: {
+ updatedPrefix: 'upd';
+ updatedYesterday: 'upd yesterday';
+ yesterday: 'Yesterday';
+ };
+ reviewState: {
+ needsFix: 'Needs Fixes';
+ };
+ unassigned: 'unassigned';
+ };
+ tasksPanel: {
+ deleteConfirm: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Delete';
+ message: 'Move task #{{taskId}} to trash?';
+ title: 'Delete task';
+ };
+ deleteFailed: {
+ confirmLabel: 'OK';
+ fallbackMessage: 'An unexpected error occurred';
+ title: 'Failed to delete task';
+ };
+ empty: {
+ noMatchingTasks: 'No matching tasks';
+ noTasks: 'No tasks found';
+ };
+ groupByAria: 'Group by';
+ groupByLabel: 'Group by:';
+ groupModes: {
+ none: 'None';
+ project: 'Project';
+ time: 'Time';
+ };
+ hideArchived: 'Hide archived';
+ pinned: 'Pinned';
+ searchPlaceholder: 'Search tasks...';
+ showArchived: 'Show archived';
+ showLess: 'Show less';
+ showMore: 'Show more';
+ sort: {
+ byProject: 'By project';
+ byTeam: 'By team';
+ byTime: 'By time';
+ byUnread: 'By unread';
+ };
+ teamLabel: 'Team: {{team}}';
+ title: 'Tasks';
+ };
+ terminal: {
+ checkOutputForDetails: 'Check terminal output above for details';
+ closingInSeconds: 'Closing in {{count}}s...';
+ closingInSeconds_few: 'Closing in {{count}}s...';
+ closingInSeconds_many: 'Closing in {{count}}s...';
+ closingInSeconds_one: 'Closing in {{count}}s...';
+ closingInSeconds_other: 'Closing in {{count}}s...';
+ completedSuccessfully: 'Completed successfully';
+ exitCode: '(exit code {{code}})';
+ processFailed: 'Process failed';
+ title: 'Terminal';
+ };
+ tmuxInstaller: {
+ actions: {
+ cancel: 'Cancel';
+ hideSetupSteps: 'Hide setup steps';
+ manualGuide: 'Manual guide';
+ recheck: 'Re-check';
+ showSetupSteps: 'Show setup steps ({{count}})';
+ showSetupSteps_few: 'Show setup steps ({{count}})';
+ showSetupSteps_many: 'Show setup steps ({{count}})';
+ showSetupSteps_one: 'Show setup step ({{count}})';
+ showSetupSteps_other: 'Show setup steps ({{count}})';
+ };
+ details: {
+ hide: 'Hide details';
+ show: 'Show details';
+ };
+ detectedOs: 'Detected OS: {{os}}';
+ input: {
+ passwordNotice: 'Password input is sent directly to the installer terminal and is not added to the log output.';
+ placeholder: 'Send input to the installer';
+ send: 'Send input';
+ };
+ installerProgress: 'Installer progress';
+ phase: 'Phase: {{phase}}';
+ runtimePath: 'Runtime path: {{path}}';
+ summaryTitle: 'tmux is not installed';
+ };
+ tokens: {
+ accumulatedWithoutDuplication: 'Accumulated across entire session without duplication';
+ approxTokens: '~{{tokens}} tokens';
+ approxTokensParenthesized: '(~{{tokens}})';
+ cacheRead: 'Cache Read';
+ cacheWrite: 'Cache Write';
+ claudeMd: 'CLAUDE.md';
+ costUsd: 'Cost (USD)';
+ includesClaudeMd: 'incl. CLAUDE.md ×{{count}}';
+ inputTokens: 'Input Tokens';
+ mentionedFiles: '@files';
+ model: 'Model';
+ outputTokens: 'Output Tokens';
+ percentValue: '({{percent}}%)';
+ phase: 'Phase {{phase}}/{{total}}';
+ promptInputShare: '{{percent}}% of prompt input';
+ taskCoordination: 'Task Coordination';
+ thinkingText: 'Thinking + Text';
+ toolOutputs: 'Tool Outputs';
+ total: 'Total';
+ userMessages: 'User Messages';
+ visibleContext: 'Visible Context';
+ };
+ toolViewer: {
+ agent: {
+ action: 'action';
+ runtime: 'runtime';
+ startupInstructionsHidden: 'Startup instructions are hidden in the UI.';
+ team: 'team';
+ teammate: 'teammate';
+ type: 'type';
+ };
+ input: 'Input';
+ noInputRecorded: 'No input recorded for this tool call.';
+ replaceAll: '(replace all)';
+ };
+ updateDialog: {
+ closeDialog: 'Close dialog';
+ download: 'Download';
+ later: 'Later';
+ noReleaseNotes: 'No release notes available.';
+ restartNow: 'Restart now';
+ updateAvailable: 'Update available';
+ updateReady: 'Update Ready';
+ viewOnGitHub: 'View on GitHub';
+ };
+ updates: {
+ downloadedRestartTooltip: 'Update downloaded, restart to apply';
+ newVersionAvailable: 'New version available';
+ restartNow: 'Restart now';
+ restartToUpdate: 'Restart to update';
+ updateApp: 'Update app';
+ updateReady: 'Update ready';
+ updatingApp: 'Updating app';
+ };
+ window: {
+ maximize: 'Maximize';
+ minimize: 'Minimize';
+ restore: 'Restore';
+ };
+ };
+ dashboard: {
+ actions: {
+ clearSearch: 'Clear search';
+ or: 'or';
+ selectTeam: 'Select Team';
+ };
+ cliStatus: {
+ actions: {
+ alreadyLoggedIn: 'Already logged in?';
+ becomeSponsor: 'Become a sponsor';
+ cancel: 'Cancel';
+ checkNow: 'Check now';
+ checkUpdates: 'Check for Updates';
+ checking: 'Checking...';
+ connect: 'Connect';
+ extensions: 'Extensions';
+ login: 'Login';
+ manage: 'Manage';
+ manageProviders: 'Manage Providers';
+ plan: 'Plan';
+ recheck: 'Re-check';
+ recheckProvider: 'Re-check {{provider}}';
+ retry: 'Retry';
+ updateTo: 'Update to v{{version}}';
+ useCode: 'Use code';
+ };
+ atlas: {
+ alt: 'Atlas Cloud';
+ description: "Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access.";
+ openCodeProvider: 'OpenCode provider';
+ plan: 'Atlas Cloud coding plan';
+ sponsor: 'Sponsor';
+ };
+ errors: {
+ checkStatusFailed: 'Failed to check CLI status';
+ installationFailed: 'Installation failed';
+ refreshFailed: 'Failed to check for updates. Check your network connection and try again.';
+ runtimeUpdatedRefreshFailed: 'Runtime updated, but failed to refresh provider status.';
+ };
+ hints: {
+ backgroundStatus: '{{runtime}} status will be checked in the background.';
+ codexApiKeyFallback: '{{hint}} API key fallback is available if you switch auth mode.';
+ codexAutoApiKey: '{{hint}} Auto will keep using the API key until ChatGPT is connected.';
+ codexFinishLogin: 'Finish ChatGPT login in the browser. Enter the shown code if prompted.';
+ codexNoActiveLogin: 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login.';
+ codexNoActiveManagedSession: 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Local Codex account data exists, but no active managed session is selected right now.';
+ codexReconnectNeeded: 'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect.';
+ firstCheckSlow: 'First check may take up to 30 seconds';
+ loginRequiredForTeams: 'Browsing sessions and projects works without login. Login is only needed to run agent teams.';
+ troubleshootTitle: "If you're sure you're logged in, try these steps:";
+ };
+ installer: {
+ checkingLatest: 'Checking latest version...';
+ downloading: 'Downloading {{runtime}}...';
+ installing: 'Installing {{runtime}}...';
+ success: 'Successfully installed {{runtime}} v{{version}}';
+ verifying: 'Verifying checksum...';
+ };
+ labels: {
+ apiKeyRequired: 'API key required';
+ collapseProviderDetails: 'Collapse provider details';
+ comingSoon: 'Coming soon';
+ expandProviderDetails: 'Expand provider details';
+ generateLink: 'Generate link';
+ loadingRateLimits: 'Rate limits loading';
+ loggedOut: 'Provider logged out';
+ loginAuthFailed: 'Authentication failed';
+ loginAuthUpdated: 'Authentication updated';
+ loginComplete: 'Login complete';
+ loginFailed: 'Login failed';
+ loginTitle: 'Login';
+ logoutFailed: 'Logout failed';
+ logoutTitle: 'Logout';
+ notLoggedIn: 'Not logged in';
+ openLogin: 'Open login';
+ providerActionRequired: 'Provider action required';
+ resets: 'resets {{time}}';
+ runtimeLoginTitle: '{{runtime}} Login';
+ };
+ loading: {
+ aiProviders: 'Checking AI Providers...';
+ claudeCli: 'Checking Claude CLI...';
+ };
+ provider: {
+ authenticated: 'Authenticated';
+ backend: 'Backend: {{backend}}';
+ checkingAuthentication: 'Checking authentication...';
+ checkingProviders: 'Checking providers...';
+ configuredLocalCount: '{{count}} configured local';
+ configuredLocalCount_few: '{{count}} configured local';
+ configuredLocalCount_many: '{{count}} configured local';
+ configuredLocalCount_one: '{{count}} configured local';
+ configuredLocalCount_other: '{{count}} configured local';
+ configuredLocalTitle: 'Local OpenCode routes imported from your OpenCode config.';
+ connectedCount: 'Providers: {{connected}}/{{denominator}} connected';
+ freeModels: 'Free models';
+ freeModelsTitle: 'OpenCode includes free model options such as Big Pickle when available in your setup. OpenRouter through OpenCode can also expose free models, but not every OpenCode/OpenRouter model is free. Availability and limits may change.';
+ loadingModels: 'Loading models...';
+ modelsUnavailable: 'Models unavailable for this runtime build';
+ runtime: 'Runtime: {{runtime}}';
+ verifiedCount: '{{count}} verified';
+ verifiedCount_few: '{{count}} verified';
+ verifiedCount_many: '{{count}} verified';
+ verifiedCount_one: '{{count}} verified';
+ verifiedCount_other: '{{count}} verified';
+ verifiedTitle: 'OpenCode routes with a successful execution proof.';
+ };
+ runtime: {
+ configuredHealthCheckFailed: 'The configured {{runtime}} failed its startup health check.';
+ configuredNotFound: 'The configured {{runtime}} was not found.';
+ foundButFailed: '{{runtime}} was found but failed to start';
+ healthCheckFailedDescription: 'The app found the configured {{runtime}}, but its startup health check failed. Repair or reinstall it, then retry.';
+ install: 'Install {{runtime}}';
+ installRequiredDescription: '{{runtime}} is required for team provisioning and session management. Install it to get started.';
+ isRequired: '{{runtime}} is required';
+ reinstall: 'Reinstall {{runtime}}';
+ };
+ runtimeInstall: {
+ checking: 'Checking';
+ codexTitle: 'Install Codex CLI into app data';
+ downloading: 'Downloading';
+ downloadingPercent: 'Downloading {{percent}}%';
+ install: 'Install';
+ installing: 'Installing';
+ openCodeTitle: 'Install OpenCode runtime into app data';
+ retryInstall: 'Retry install';
+ };
+ troubleshoot: {
+ again: 'again';
+ authStatusCommand: 'your configured CLI auth status command';
+ checkLoggedIn: '- check if it shows "Logged in"';
+ click: 'Click';
+ loginCommand: 'the runtime login command';
+ logoutCommand: 'the runtime logout command';
+ openTerminal: 'Open your terminal and run:';
+ reloginPrefix: "If it says logged in but the app doesn't see it, try:";
+ sameRuntime: 'Make sure the CLI in your terminal is the same runtime the app uses';
+ statusCacheHint: '- sometimes the status is cached for a few seconds';
+ then: 'then';
+ };
+ warnings: {
+ multipleApiKeysMissing: 'One or more providers are set to API key mode, but no API key is configured. Open Manage Providers to add keys or switch the connection mode.';
+ multipleApiKeysNeedAttention: 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.';
+ notAuthenticated: '{{runtime}} is installed but you are not authenticated. Login is required for team provisioning and AI features.';
+ singleApiKeyMissing: '{{provider}} is set to API key mode, but no API key is configured. Open Manage Providers to add a key or switch the connection mode.';
+ singleApiKeyNeedsAttention: '{{provider}} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.';
+ };
+ };
+ recentProjects: {
+ card: {
+ deleted: 'Deleted';
+ projectFolderMissing: 'Project folder no longer exists';
+ taskCounts: {
+ active: '{{count}} active';
+ active_few: '{{count}} active';
+ active_many: '{{count}} active';
+ active_one: '{{count}} active';
+ active_other: '{{count}} active';
+ done: '{{count}} done';
+ done_few: '{{count}} done';
+ done_many: '{{count}} done';
+ done_one: '{{count}} done';
+ done_other: '{{count}} done';
+ pending: '{{count}} pending';
+ pending_few: '{{count}} pending';
+ pending_many: '{{count}} pending';
+ pending_one: '{{count}} pending';
+ pending_other: '{{count}} pending';
+ };
+ };
+ emptyDescription: 'Recent Claude and Codex activity will appear here.';
+ failedToLoad: 'Failed to load projects';
+ loadMore: 'Load more';
+ noMatches: 'No matches for "{{query}}"';
+ noProjects: 'No projects found';
+ noRecentProjects: 'No recent projects found';
+ retry: 'Retry';
+ searchPlaceholder: 'Search projects...';
+ searchResults: 'Search Results';
+ selectFolder: 'Select Folder';
+ selectFolderTitle: 'Select a project folder';
+ title: 'Recent Projects';
+ };
+ updateBanner: {
+ newVersionAvailable: 'New version available';
+ restartNow: 'Restart now';
+ viewDetails: 'View details';
+ };
+ webPreview: {
+ description: 'The browser version is still in development. Project actions, integrations, and live status updates may be limited here. Use the desktop app to access all features reliably.';
+ title: 'Open the desktop app for full functionality';
+ };
+ windowsAdmin: {
+ description: 'OpenCode runtime checks can time out when Agent Teams AI is not elevated. Restart the app with Run as administrator before launching OpenCode teams.';
+ title: 'Windows Administrator mode recommended';
+ };
+ };
+ errors: {
+ fallback: 'Something went wrong.';
+ };
+ extensions: {
+ apiKeys: {
+ actions: {
+ add: 'Add API Key';
+ addFirst: 'Add your first key';
+ edit: 'Edit';
+ };
+ description: 'Securely store API keys for auto-filling when installing MCP servers.';
+ empty: {
+ description: 'Add keys to auto-fill environment variables when installing MCP servers.';
+ title: 'No API keys saved';
+ };
+ form: {
+ addDescription: 'Store an API key for auto-filling in MCP server installations.';
+ addTitle: 'Add API Key';
+ boundTo: 'Bound to {{path}}';
+ cancel: 'Cancel';
+ editDescription: 'Update the key details. You must re-enter the value.';
+ editTitle: 'Edit API Key';
+ envVarPlaceholder: 'e.g. OPENAI_API_KEY';
+ environmentVariableName: 'Environment Variable Name';
+ errors: {
+ envVarRequired: 'Environment variable name is required';
+ invalidEnvVar: 'Invalid environment variable name';
+ invalidEnvVarFormat: 'Use letters, digits, underscores. Must start with a letter or underscore.';
+ nameRequired: 'Name is required';
+ projectScopeRequiresProject: 'Project-scoped API keys require an active project';
+ saveFailed: 'Failed to save';
+ valueRequired: 'Key value is required';
+ };
+ keychainUnavailable: 'OS keychain unavailable - keys encrypted with AES-256 locally. Install gnome-keyring for OS-level protection.';
+ name: 'Name';
+ namePlaceholder: 'e.g. OpenAI Production';
+ projectScopeLabel: 'Project: {{project}}';
+ projectUnavailable: 'Project unavailable';
+ reenterValue: 'Re-enter key value';
+ save: 'Save';
+ saving: 'Saving...';
+ scope: 'Scope';
+ update: 'Update';
+ userScopeLabel: 'User (global)';
+ value: 'Value';
+ valuePlaceholder: 'sk-...';
+ };
+ storage: {
+ localEncryption: 'OS keychain unavailable - keys are encrypted locally with AES-256. For stronger protection, install a keyring service (gnome-keyring, kwallet).';
+ osKeychain: 'Keys are encrypted via {{backend}} and stored with restricted file permissions (owner-only).';
+ };
+ };
+ customMcp: {
+ actions: {
+ add: 'Add';
+ cancel: 'Cancel';
+ install: 'Install';
+ installing: 'Installing...';
+ };
+ description: 'Add a server manually without the catalog.';
+ errors: {
+ installFailed: 'Install failed';
+ invalidServerName: 'Invalid server name. Use alphanumeric characters, dashes, underscores, dots.';
+ npmPackageRequired: 'npm package name is required';
+ serverNameRequired: 'Server name is required';
+ serverUrlRequired: 'Server URL is required';
+ };
+ fields: {
+ environmentVariables: 'Environment Variables';
+ headers: 'Headers';
+ npmPackage: 'npm Package';
+ scope: 'Scope';
+ serverName: 'Server Name';
+ serverUrl: 'Server URL';
+ transport: 'Transport';
+ transportType: 'Transport Type';
+ versionOptional: 'Version (optional)';
+ };
+ placeholders: {
+ envVarName: 'ENV_VAR_NAME';
+ headerName: 'Header-Name';
+ latest: 'latest';
+ serverName: 'my-server';
+ serverUrl: 'https://api.example.com/mcp';
+ value: 'value';
+ };
+ title: 'Add Custom MCP Server';
+ transport: {
+ httpSse: 'HTTP / SSE';
+ stdio: 'Stdio (npm)';
+ };
+ };
+ installButton: {
+ done: 'Done';
+ install: 'Install';
+ installing: 'Installing...';
+ removing: 'Removing...';
+ retry: 'Retry';
+ uninstall: 'Uninstall';
+ };
+ mcpCard: {
+ auth: 'Auth';
+ byAuthor: 'by {{author}}';
+ envCount: '{{count}} envs';
+ envCount_few: '{{count}} envs';
+ envCount_many: '{{count}} envs';
+ envCount_one: '{{count}} env';
+ envCount_other: '{{count}} envs';
+ hosting: {
+ both: 'Both';
+ local: 'Local';
+ remote: 'Remote';
+ };
+ repository: 'Repository';
+ toolsCount: '{{count}} tools';
+ toolsCount_few: '{{count}} tools';
+ toolsCount_many: '{{count}} tools';
+ toolsCount_one: '{{count}} tool';
+ toolsCount_other: '{{count}} tools';
+ website: 'Website';
+ };
+ mcpDetail: {
+ auth: {
+ remoteMayNeedHeaders: 'Remote MCP servers may still require custom headers or API keys even when the registry does not describe them. If connection fails after install, check the provider docs.';
+ required: 'This server requires authentication';
+ };
+ diagnostics: {
+ launchTarget: 'Launch Target';
+ };
+ form: {
+ autoFilled: 'Auto-filled';
+ environmentVariables: 'Environment Variables';
+ headers: 'Headers';
+ scope: 'Scope';
+ serverName: 'Server Name';
+ };
+ install: {
+ httpTransport: 'HTTP: {{transport}}';
+ install: 'Install Server';
+ manage: 'Manage Installation';
+ manualSetupDescription: 'This server requires manual setup. Check the repository for installation instructions.';
+ manualSetupRequired: 'Manual setup required';
+ npmPackage: 'npm: {{package}}';
+ };
+ links: {
+ glama: 'Glama';
+ repository: 'Repository';
+ website: 'Website';
+ };
+ metadata: {
+ author: 'Author';
+ githubStars: 'GitHub Stars';
+ hosting: 'Hosting';
+ installType: 'Install Type';
+ license: 'License';
+ published: 'Published';
+ source: 'Source';
+ updated: 'Updated';
+ version: 'Version';
+ };
+ placeholders: {
+ serverName: 'my-server';
+ };
+ scope: {
+ local: 'Local';
+ project: 'Project';
+ };
+ tools: {
+ title: 'Tools ({{count}})';
+ title_few: 'Tools ({{count}})';
+ title_many: 'Tools ({{count}})';
+ title_one: 'Tools ({{count}})';
+ title_other: 'Tools ({{count}})';
+ };
+ };
+ mcpPanel: {
+ diagnostics: {
+ disableReasons: {
+ checkingRuntimeAvailability: 'Checking runtime availability...';
+ checkingRuntimeStatus: 'Checking runtime status...';
+ runtimeFailedToStart: 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.';
+ runtimeRequired: 'The configured runtime is required. Install or repair it from the Dashboard.';
+ };
+ serversCount: '{{count}} servers';
+ serversCount_few: '{{count}} servers';
+ serversCount_many: '{{count}} servers';
+ serversCount_one: '{{count}} server';
+ serversCount_other: '{{count}} servers';
+ title: 'Runtime MCP Diagnostics';
+ waiting: 'Waiting for diagnostics results...';
+ };
+ empty: {
+ description: 'Check back later for new servers';
+ searchDescription: 'Try a different search term';
+ searchTitle: 'No servers found';
+ title: 'No MCP servers available';
+ };
+ health: {
+ checkStatus: 'Check Status';
+ checking: 'Checking...';
+ checkingViaRuntime: 'Checking installed MCP servers via {{runtime}} ...';
+ description: 'Run diagnostics from this page to verify installed MCP connectivity.';
+ lastChecked: 'Last checked {{time}}';
+ title: 'MCP Health Status';
+ };
+ loadMore: 'Load more';
+ runtime: {
+ notAvailable: '{{runtime}} not available';
+ notInstalled: '{{runtime}} not installed';
+ requiredDescription: 'MCP health checks require {{runtime}}. Go to the Dashboard to install or repair it.';
+ };
+ searchPlaceholder: 'Search MCP servers...';
+ sort: {
+ nameAsc: 'Name A→Z';
+ nameDesc: 'Name Z→A';
+ toolsDesc: 'Most tools';
+ };
+ };
+ pluginCard: {
+ official: 'Official';
+ };
+ pluginDetail: {
+ links: {
+ contact: 'Contact';
+ homepage: 'Homepage';
+ };
+ metadata: {
+ author: 'Author';
+ capabilities: 'Capabilities';
+ category: 'Category';
+ installs: 'Installs';
+ source: 'Source';
+ version: 'Version';
+ };
+ readme: {
+ empty: 'No README available.';
+ loading: 'Loading README...';
+ };
+ scope: {
+ label: 'Scope:';
+ options: {
+ local: 'Local (gitignored)';
+ project: 'Project (shared)';
+ user: 'User (global)';
+ };
+ };
+ unknown: 'Unknown';
+ };
+ pluginsPanel: {
+ activeFilters: '{{count}} active';
+ activeFilters_few: '{{count}} active';
+ activeFilters_many: '{{count}} active';
+ activeFilters_one: '{{count}} active';
+ activeFilters_other: '{{count}} active';
+ browseByFit: 'Browse by fit';
+ capabilities: 'Capabilities';
+ categories: 'Categories';
+ clearAllFilters: 'Clear all filters';
+ clearFilters: 'Clear filters';
+ counts: {
+ capabilities: '{{count}} capabilities';
+ capabilities_few: '{{count}} capabilities';
+ capabilities_many: '{{count}} capabilities';
+ capabilities_one: '{{count}} capabilities';
+ capabilities_other: '{{count}} capabilities';
+ categories: '{{count}} categories';
+ categories_few: '{{count}} categories';
+ categories_many: '{{count}} categories';
+ categories_one: '{{count}} categories';
+ categories_other: '{{count}} categories';
+ plugins: '{{count}} plugins';
+ plugins_few: '{{count}} plugins';
+ plugins_many: '{{count}} plugins';
+ plugins_one: '{{count}} plugins';
+ plugins_other: '{{count}} plugins';
+ };
+ empty: {
+ description: 'Check back later for new plugins';
+ filteredDescription: 'Try adjusting your search or filter criteria';
+ filteredTitle: 'No plugins match your filters';
+ title: 'No plugins available';
+ };
+ filterDescription: 'Narrow the catalog by category, capability, or installed state.';
+ installedOnly: 'Installed only';
+ providerSupportNotice: "Plugin support is currently guaranteed for Anthropic (Claude) sessions only. We're working to support plugins across all agents.";
+ resultsUpdateInstantly: 'Results update instantly as you refine filters.';
+ searchPlaceholder: 'Search plugins...';
+ selectedCount: '{{count}} selected';
+ selectedCount_few: '{{count}} selected';
+ selectedCount_many: '{{count}} selected';
+ selectedCount_one: '{{count}} selected';
+ selectedCount_other: '{{count}} selected';
+ showing: 'Showing {{shown}} of {{total}} plugins';
+ sort: {
+ category: 'Category';
+ nameAsc: 'Name A-Z';
+ nameDesc: 'Name Z-A';
+ popular: 'Popular';
+ };
+ };
+ skillDetail: {
+ actions: {
+ cancel: 'Cancel';
+ delete: 'Delete';
+ deleteSkill: 'Delete Skill';
+ deleting: 'Deleting...';
+ editSkill: 'Edit Skill';
+ openFolder: 'Open Folder';
+ openSkillFile: 'Open SKILL.md';
+ retry: 'Retry';
+ };
+ badges: {
+ assets: 'Assets';
+ autoUse: 'Auto use';
+ hasScripts: 'Has scripts';
+ manualUse: 'Manual use';
+ references: 'References';
+ storedIn: 'Stored in {{root}}';
+ };
+ deleteDialog: {
+ description: 'Delete this skill and move it to Trash?';
+ descriptionWithName: 'Delete "{{name}}" and move it to Trash? You can restore it later from Trash if needed.';
+ title: 'Delete skill?';
+ };
+ descriptionFallback: 'Inspect discovered skill metadata and raw instructions.';
+ errors: {
+ deleteFailed: 'Failed to delete skill';
+ loadFailed: 'Unable to load this skill.';
+ };
+ files: {
+ advancedDetails: 'Advanced file details';
+ assets: 'Assets';
+ references: 'References';
+ scripts: 'Scripts';
+ storedAt: 'Stored at';
+ };
+ includes: {
+ assets: 'assets';
+ instructionsOnly: 'Just the skill instructions';
+ references: 'references';
+ scripts: 'scripts';
+ };
+ invocation: {
+ auto: 'Runs automatically when it matches the task.';
+ manualOnly: 'Only runs when you explicitly ask for it.';
+ };
+ issues: {
+ bundledScripts: 'This skill includes bundled scripts';
+ reviewCarefully: 'Review this skill carefully before using it';
+ };
+ loading: 'Loading skill details...';
+ scope: {
+ personal: 'Your personal skills';
+ projectOnly: 'This project only';
+ };
+ summary: {
+ howUsed: 'How it is used';
+ included: 'What comes with it';
+ whoCanUse: 'Who can use it';
+ };
+ titleFallback: 'Skill details';
+ };
+ skillEditor: {
+ actions: {
+ cancel: 'Cancel';
+ createSkill: 'Create Skill';
+ preparing: 'Preparing...';
+ reviewAndCreate: 'Review And Create';
+ reviewAndSave: 'Review And Save';
+ saveSkill: 'Save Skill';
+ };
+ advanced: {
+ customDescription: 'This skill uses a custom markdown format, so edit it directly here.';
+ customTitle: '2. SKILL.md editor';
+ description: 'Most people can skip this. Open it only if you want direct control over the raw markdown file.';
+ hide: 'Hide Advanced Editor';
+ resetFromStructuredFields: 'Reset From Structured Fields';
+ show: 'Show Advanced Editor';
+ title: '4. Advanced SKILL.md editor';
+ };
+ basics: {
+ description: 'Give this skill a clear name, choose who can use it, and decide where it should live.';
+ title: '1. Basics';
+ };
+ description: {
+ create: 'Describe the workflow in plain language, review the files that will be created, then save it.';
+ edit: 'Update this skill, review the resulting file changes, then save it.';
+ };
+ extraFiles: {
+ addedFiles: 'Added files:';
+ assets: 'Assets';
+ assetsDescription: 'Add screenshots or bundled media only if they help explain the workflow.';
+ description: 'Add supporting docs, scripts, or assets only if this skill really needs them.';
+ lockedForEdits: 'Root and folder are locked for edits';
+ optionalDescription: 'Add starter files that will be included in the review and written together with `SKILL.md`.';
+ optionalTitle: 'Optional files';
+ references: 'References';
+ referencesDescription: 'Add supporting docs, links, or examples the runtime can look at.';
+ scripts: 'Scripts';
+ scriptsDescription: 'Add helper commands or setup notes. Review carefully before sharing this skill.';
+ title: '3. Extra files';
+ };
+ fields: {
+ compatibility: 'Compatibility';
+ description: 'Description';
+ folderName: 'Folder name';
+ folderNameHint: 'We suggest this automatically from the skill name so review works right away.';
+ invocation: 'How it should be used';
+ license: 'License';
+ name: 'Skill name';
+ notes: 'Extra notes or guardrails';
+ root: 'Where to store it';
+ scope: 'Who can use it';
+ steps: 'Main steps to follow';
+ whenToUse: 'When to reach for this';
+ };
+ instructions: {
+ description: 'These sections generate the skill file for you, so you do not need to edit markdown unless you want to.';
+ locked: 'Structured fields are locked because you switched to manual `SKILL.md` editing below.';
+ title: '2. Instructions';
+ };
+ invocation: {
+ auto: 'Can be used automatically';
+ manualOnly: 'Only when you ask for it';
+ };
+ placeholders: {
+ compatibility: 'claude-code, cursor';
+ description: 'What this skill helps with';
+ license: 'MIT';
+ name: 'Write concise skill name';
+ notes: 'Example: Call out missing tests, regressions, and risky assumptions.';
+ steps: '1. Inspect the relevant files.\n2. Explain the main risk first.\n3. Suggest the safest fix.';
+ whenToUse: 'Example: Use this when the task is a code review or bug triage request.';
+ };
+ review: {
+ creating: 'Creating a skill';
+ hint: 'Review the file changes first, then confirm save in the next step.';
+ saving: 'Saving this skill';
+ };
+ root: {
+ codexOnly: ' - Codex only';
+ shared: ' - Shared';
+ };
+ scope: {
+ project: 'Project: {{project}}';
+ projectUnavailable: 'Project unavailable';
+ user: 'User';
+ };
+ title: {
+ create: 'Create skill';
+ edit: 'Edit skill';
+ };
+ };
+ skillImport: {
+ actions: {
+ backToImport: 'Back To Import';
+ browse: 'Browse';
+ cancel: 'Cancel';
+ importSkill: 'Import Skill';
+ preparing: 'Preparing...';
+ reviewAndImport: 'Review And Import';
+ };
+ description: 'Pick an existing skill folder, review what will be copied, then import it into one of your supported skill locations.';
+ errors: {
+ importFailed: 'Failed to import skill';
+ invalidFolderName: 'Pick a simpler destination folder name using letters, numbers, dots, dashes, or underscores.';
+ missingSkillFile: 'This folder does not look like a skill yet. It needs a SKILL.md, Skill.md, or skill.md file.';
+ mustBeDirectory: 'Choose a folder to import, not a single file.';
+ reviewFailed: 'Failed to review import changes';
+ symbolicLinks: 'This folder contains symbolic links. Import the real files instead of links.';
+ tooLarge: 'This skill folder is too large to import safely. Trim large assets and try again.';
+ tooManyFiles: 'This skill folder is too large to import at once. Remove extra files and try again.';
+ };
+ fields: {
+ audience: 'Who can use it';
+ destinationFolderName: 'Destination folder name';
+ sourceFolder: 'Source folder';
+ storage: 'Where to store it';
+ };
+ placeholders: {
+ defaultFolderName: 'Defaults to source folder name';
+ };
+ reviewHint: 'Review the copied files first, then confirm the import in the next step.';
+ reviewLabel: 'Importing this skill';
+ rootSuffix: {
+ codexOnly: ' - Codex only';
+ shared: ' - Shared';
+ };
+ scope: {
+ project: 'Project: {{project}}';
+ projectUnavailable: 'Project unavailable';
+ user: 'User';
+ };
+ steps: {
+ chooseFolder: {
+ description: 'This should be a folder that already contains a `SKILL.md`, `Skill.md`, or `skill.md` file.';
+ title: '1. Choose a skill folder';
+ };
+ location: {
+ description: 'Personal skills work everywhere. Project skills only show up for one codebase.';
+ title: '2. Decide where it belongs';
+ };
+ };
+ title: 'Import skill';
+ };
+ skillReview: {
+ binaryBadge: 'binary';
+ binaryPreviewHidden: 'Binary file preview is not shown. The file will be copied as-is.';
+ confirmPromptPrefix: 'Review the diff below, then use';
+ confirmPromptSuffix: 'to apply these changes.';
+ description: '{{reviewLabel}} previews the filesystem changes first. Nothing is written until you confirm below.';
+ noChanges: 'No file changes detected yet.';
+ noPreview: 'No preview available.';
+ summary: {
+ binary: '{{count}} binary';
+ fileChanges: '{{count}} file changes';
+ fileChanges_few: '{{count}} file changes';
+ fileChanges_many: '{{count}} file changes';
+ fileChanges_one: '{{count}} file change';
+ fileChanges_other: '{{count}} file changes';
+ new: '{{count}} new';
+ removed: '{{count}} removed';
+ updated: '{{count}} updated';
+ };
+ title: 'Review skill changes';
+ };
+ skillsPanel: {
+ actions: {
+ createSkill: 'Create Skill';
+ import: 'Import';
+ };
+ badges: {
+ assets: 'Assets';
+ hasScripts: 'Has scripts';
+ needsAttention: 'Needs attention';
+ references: 'References';
+ storedIn: 'Stored in {{root}}';
+ };
+ configuredRuntime: 'the configured runtime';
+ counts: {
+ codexOnly: '{{count}} Codex only';
+ codexOnly_few: '{{count}} Codex only';
+ codexOnly_many: '{{count}} Codex only';
+ codexOnly_one: '{{count}} Codex only';
+ codexOnly_other: '{{count}} Codex only';
+ personal: '{{count}} personal';
+ personal_few: '{{count}} personal';
+ personal_many: '{{count}} personal';
+ personal_one: '{{count}} personal';
+ personal_other: '{{count}} personal';
+ project: '{{count}} project';
+ project_few: '{{count}} project';
+ project_many: '{{count}} project';
+ project_one: '{{count}} project';
+ project_other: '{{count}} project';
+ shared: '{{count}} shared';
+ shared_few: '{{count}} shared';
+ shared_many: '{{count}} shared';
+ shared_one: '{{count}} shared';
+ shared_other: '{{count}} shared';
+ total: '{{count}} total';
+ total_few: '{{count}} total';
+ total_many: '{{count}} total';
+ total_one: '{{count}} total';
+ total_other: '{{count}} total';
+ };
+ empty: {
+ noMatches: 'No skills match your search';
+ noMatchesDescription: 'Try a different search term or switch filters.';
+ noSkills: 'No skills yet';
+ noSkillsDescription: 'Create your first skill to teach a repeatable workflow, or import one you already use.';
+ };
+ filters: {
+ all: 'All skills';
+ codexOnly: 'Codex only';
+ hasScripts: 'Has scripts';
+ needsAttention: 'Needs attention';
+ personal: 'Personal';
+ project: 'Project';
+ shared: 'Shared';
+ };
+ hero: {
+ codexAvailable: 'Use `.codex` when a skill should stay Codex-only.';
+ codexUnavailable: 'Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.';
+ description: 'Skills are reusable instructions that help the runtime handle the same kind of task more consistently.';
+ guidance: 'Use personal skills for habits you want everywhere. Use project skills for workflows that only make sense inside one codebase.';
+ personalContext: 'You are seeing only your personal skills right now.';
+ projectContext: 'You are seeing skills for {{project}} plus your personal skills.';
+ title: 'Teach repeatable work';
+ };
+ invocation: {
+ auto: 'Runs automatically when it fits';
+ manualOnly: 'Only runs when you explicitly ask for it';
+ };
+ loading: {
+ loading: 'Loading skills...';
+ refreshing: 'Refreshing skills...';
+ };
+ runtimeAudience: 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to {{audience}}. Skills stored in `.codex` stay Codex-only when Codex support is available.';
+ scope: {
+ project: 'This project';
+ user: 'Personal';
+ };
+ searchPlaceholder: 'Search by skill name or what it helps with...';
+ sections: {
+ personal: {
+ description: 'Habits and instructions you want available everywhere.';
+ title: 'Personal skills';
+ };
+ project: {
+ description: 'Workflows that only make sense for this codebase.';
+ title: 'Project skills';
+ };
+ };
+ sort: {
+ label: 'Sort skills';
+ name: 'Name';
+ recent: 'Recent';
+ };
+ status: {
+ hasScripts: 'Includes scripts, so review it carefully';
+ needsAttention: 'Needs attention before you rely on it';
+ ready: 'Ready to use';
+ };
+ success: {
+ created: 'Skill created successfully.';
+ imported: 'Skill imported successfully.';
+ saved: 'Skill saved successfully.';
+ };
+ };
+ store: {
+ actions: {
+ addCustom: 'Add Custom';
+ openDashboard: 'Open Dashboard';
+ refreshCatalog: 'Refresh catalog';
+ };
+ capabilities: {
+ mcp: 'MCP: {{status}}';
+ plugins: 'Plugins: {{status}}';
+ skills: 'Skills: {{status}}';
+ };
+ desktopOnly: 'Available in the desktop app only.';
+ provider: {
+ checkingStatus: 'Checking provider status...';
+ connected: 'Connected';
+ loading: 'Loading...';
+ needsSetup: 'Needs setup';
+ readyToConfigure: 'Ready to configure';
+ unsupported: 'Unsupported';
+ };
+ runtime: {
+ checkingAvailabilityDescription: 'Extensions need the configured runtime to manage plugins, MCP servers, skills, and provider connections.';
+ checkingAvailabilityTitle: 'Checking extensions runtime availability';
+ failedToStartDescription: 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.';
+ failedToStartTitle: 'The configured runtime was found but failed to start';
+ multimodelCapabilitiesDescription: 'Provider support can differ by section. Plugins are shown only where the runtime explicitly declares support.';
+ multimodelCapabilitiesTitle: 'Multimodel runtime capabilities';
+ needsSignInDescription: '{{runtime}} was found{{version}}, but plugin installs are disabled until you sign in from the Dashboard.';
+ needsSignInTitle: '{{runtime}} needs sign-in';
+ notAvailableDescription: 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.';
+ notAvailableTitle: 'The configured runtime is not available';
+ readyDescription: 'Plugins can be installed from this page{{versionSuffix}}.';
+ readyTitle: '{{runtime}} is ready';
+ requiredForMutations: 'The configured runtime is required to install or uninstall extensions. Install or repair it from the Dashboard.';
+ };
+ sessionsRestartWarning: "Running sessions won't pick up extension changes until restarted.";
+ tabs: {
+ apiKeys: {
+ description: 'Secret keys for online services. Add them here so plugins, servers, and integrations can connect and work.';
+ label: 'API Keys';
+ };
+ mcpServers: {
+ description: 'Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.';
+ label: 'MCP Servers';
+ };
+ plugins: {
+ description: 'Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported. Broader provider support is in development.';
+ label: 'Plugins';
+ };
+ skills: {
+ description: 'Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.';
+ label: 'Skills';
+ };
+ };
+ title: 'Extensions';
+ };
+ };
+ report: {
+ cost: {
+ breakdownTitle: 'Cost Breakdown (per 1M tokens)';
+ cacheRead: 'Cache Read';
+ cacheWrite: 'Cache Write';
+ cost: 'Cost';
+ input: 'Input';
+ noCommits: 'no commits';
+ noLinesChanged: 'no lines changed';
+ output: 'Output';
+ parent: 'Parent: {{cost}}';
+ parentCost: 'Parent Cost';
+ perCommit: 'Per Commit';
+ perCommitFormula: 'total cost ÷ {{count}} commit';
+ perCommitFormula_few: 'total cost ÷ {{count}} commits';
+ perCommitFormula_many: 'total cost ÷ {{count}} commits';
+ perCommitFormula_one: 'total cost ÷ {{count}} commit';
+ perCommitFormula_other: 'total cost ÷ {{count}} commits';
+ perLineChanged: 'Per Line Changed';
+ perLineFormula: 'total cost ÷ {{count}} line';
+ perLineFormula_few: 'total cost ÷ {{count}} lines';
+ perLineFormula_many: 'total cost ÷ {{count}} lines';
+ perLineFormula_one: 'total cost ÷ {{count}} line';
+ perLineFormula_other: 'total cost ÷ {{count}} lines';
+ subagent: 'Subagent: {{cost}}';
+ subagentCost: 'Subagent Cost';
+ title: 'Cost Analysis';
+ total: 'Total';
+ };
+ errors: {
+ count: '{{count}} errors';
+ count_few: '{{count}} errors';
+ count_many: '{{count}} errors';
+ count_one: '{{count}} error';
+ count_other: '{{count}} errors';
+ error: 'Error';
+ input: 'Input';
+ messageIndex: 'msg #{{index}}';
+ permissionDenialCount: '{{count}} permission denials';
+ permissionDenialCount_few: '{{count}} permission denials';
+ permissionDenialCount_many: '{{count}} permission denials';
+ permissionDenialCount_one: '{{count}} permission denial';
+ permissionDenialCount_other: '{{count}} permission denials';
+ permissionDenied: 'Permission Denied';
+ title: 'Errors';
+ };
+ friction: {
+ corrections: 'Corrections';
+ correctionsCount: '{{count}} corrections';
+ correctionsCount_few: '{{count}} corrections';
+ correctionsCount_many: '{{count}} corrections';
+ correctionsCount_one: '{{count}} correction';
+ correctionsCount_other: '{{count}} corrections';
+ rate: 'Friction Rate: {{rate}}%';
+ repeatedBashCommands: 'Repeated Bash Commands';
+ reworkedFiles: 'Reworked Files (3+ edits)';
+ thrashingSignals: 'Thrashing Signals';
+ title: 'Friction Signals';
+ };
+ git: {
+ branchesCreated: 'Branches Created';
+ commits: 'Commits';
+ linesAdded: 'Lines Added';
+ linesRemoved: 'Lines Removed';
+ pushes: 'Pushes';
+ title: 'Git Activity';
+ };
+ insights: {
+ agent: 'agent';
+ agentTree: 'Agent Tree ({{count}} {{unit}})';
+ agentTree_few: 'Agent Tree ({{count}} {{unit}})';
+ agentTree_many: 'Agent Tree ({{count}} {{unit}})';
+ agentTree_one: 'Agent Tree ({{count}} {{unit}})';
+ agentTree_other: 'Agent Tree ({{count}} {{unit}})';
+ agent_few: 'agents';
+ agent_many: 'agents';
+ agent_one: 'agent';
+ agent_other: 'agents';
+ background: '(background)';
+ bashCommands: 'Bash Commands';
+ keyTakeaways: 'Key Takeaways';
+ outOfScopeFindings: 'Out-of-Scope Findings ({{count}})';
+ outOfScopeFindings_few: 'Out-of-Scope Findings ({{count}})';
+ outOfScopeFindings_many: 'Out-of-Scope Findings ({{count}})';
+ outOfScopeFindings_one: 'Out-of-Scope Findings ({{count}})';
+ outOfScopeFindings_other: 'Out-of-Scope Findings ({{count}})';
+ questionsAsked: 'Questions Asked ({{count}})';
+ questionsAsked_few: 'Questions Asked ({{count}})';
+ questionsAsked_many: 'Questions Asked ({{count}})';
+ questionsAsked_one: 'Questions Asked ({{count}})';
+ questionsAsked_other: 'Questions Asked ({{count}})';
+ repeated: 'Repeated';
+ skillsInvoked: 'Skills Invoked ({{count}})';
+ skillsInvoked_few: 'Skills Invoked ({{count}})';
+ skillsInvoked_many: 'Skills Invoked ({{count}})';
+ skillsInvoked_one: 'Skills Invoked ({{count}})';
+ skillsInvoked_other: 'Skills Invoked ({{count}})';
+ taskDispatches: 'Task Dispatches ({{count}})';
+ taskDispatches_few: 'Task Dispatches ({{count}})';
+ taskDispatches_many: 'Task Dispatches ({{count}})';
+ taskDispatches_one: 'Task Dispatches ({{count}})';
+ taskDispatches_other: 'Task Dispatches ({{count}})';
+ tasksCreated: 'Tasks Created ({{count}})';
+ tasksCreated_few: 'Tasks Created ({{count}})';
+ tasksCreated_many: 'Tasks Created ({{count}})';
+ tasksCreated_one: 'Tasks Created ({{count}})';
+ tasksCreated_other: 'Tasks Created ({{count}})';
+ teamMode: 'Team Mode';
+ teams: 'Teams: {{teams}}';
+ title: 'Session Insights';
+ total: 'Total';
+ unique: 'Unique';
+ };
+ overview: {
+ metrics: {
+ branch: 'Branch';
+ compactions: 'Compactions';
+ contextUsage: 'Context Usage';
+ duration: 'Duration';
+ messages: 'Messages';
+ project: 'Project';
+ sessionId: 'Session ID';
+ subagents: 'Subagents';
+ };
+ no: 'No';
+ title: 'Overview';
+ yes: 'Yes';
+ };
+ quality: {
+ chars: 'chars';
+ corrections: 'Corrections';
+ failed: 'failed';
+ fileReadRedundancy: 'File Read Redundancy';
+ firstMessage: 'First Message';
+ firstRun: 'First Run';
+ frictionRate: 'Friction Rate';
+ lastRun: 'Last Run';
+ messagesBeforeWork: 'Messages Before Work';
+ passed: 'passed';
+ percentOfTotal: '% of Total';
+ promptQuality: 'Prompt Quality';
+ readsPerUniqueFile: 'Reads/Unique File';
+ snapshot: 'snapshot';
+ snapshot_few: 'snapshots';
+ snapshot_many: 'snapshots';
+ snapshot_one: 'snapshot';
+ snapshot_other: 'snapshots';
+ startupOverhead: 'Startup Overhead';
+ testProgression: 'Test Progression';
+ title: 'Quality Signals';
+ tokensBeforeWork: 'Tokens Before Work';
+ totalReads: 'Total Reads';
+ uniqueFiles: 'Unique Files';
+ userMessages: 'User Messages';
+ };
+ subagents: {
+ metrics: {
+ count: 'Count';
+ totalCost: 'Total Cost';
+ totalDuration: 'Total Duration';
+ totalTokens: 'Total Tokens';
+ };
+ table: {
+ cost: 'Cost';
+ description: 'Description';
+ duration: 'Duration';
+ tokens: 'Tokens';
+ type: 'Type';
+ };
+ title: 'Subagents';
+ };
+ timeline: {
+ idleAnalysis: 'Idle Analysis';
+ keyEvents: 'Key Events';
+ messageNumber: 'msg #{{number}}';
+ metrics: {
+ activeTime: 'Active Time';
+ idleGaps: 'Idle Gaps';
+ idlePercent: 'Idle %';
+ totalIdle: 'Total Idle';
+ };
+ modelSwitches: 'Model Switches ({{count}})';
+ modelSwitches_few: 'Model Switches ({{count}})';
+ modelSwitches_many: 'Model Switches ({{count}})';
+ modelSwitches_one: 'Model Switches ({{count}})';
+ modelSwitches_other: 'Model Switches ({{count}})';
+ title: 'Timeline & Activity';
+ };
+ tokens: {
+ apiCalls: 'API Calls';
+ cacheCreate: 'Cache Create';
+ cacheEfficiency: 'Cache Efficiency';
+ cacheRead: 'Cache Read';
+ cacheReadPct: 'Cache Read %';
+ coldStart: 'Cold Start';
+ cost: 'Cost';
+ input: 'Input';
+ model: 'Model';
+ no: 'No';
+ output: 'Output';
+ readWriteRatio: 'R/W Ratio';
+ title: 'Token Usage';
+ total: 'Total';
+ yes: 'Yes';
+ };
+ tools: {
+ columns: {
+ calls: 'Calls';
+ errors: 'Errors';
+ health: 'Health';
+ successPercent: 'Success %';
+ tool: 'Tool';
+ };
+ summary: '{{formattedCount}} total calls across {{toolCount}} tools';
+ title: 'Tool Usage';
+ };
+ };
+ settings: {
+ advanced: {
+ about: {
+ appIconAlt: 'App icon';
+ description: 'Assemble AI agent teams that work autonomously in parallel, communicate across teams, and manage tasks on a kanban board - with built-in code review, live process monitoring, and full tool visibility.';
+ standalone: 'Standalone';
+ title: 'About';
+ version: 'Version {{version}}';
+ };
+ appName: 'Agent Teams AI';
+ configuration: {
+ editConfig: 'Edit Config';
+ exportConfig: 'Export Config';
+ importConfig: 'Import Config';
+ openInEditor: 'Open in Editor';
+ resetToDefaults: 'Reset to Defaults';
+ title: 'Configuration';
+ };
+ updates: {
+ available: 'v{{version}} available';
+ check: 'Check for Updates';
+ checking: 'Checking...';
+ ready: 'Update ready';
+ unknownVersion: 'unknown';
+ upToDate: 'Up to date';
+ };
+ };
+ cliRuntime: {
+ actions: {
+ checkForUpdates: 'Check for Updates';
+ checking: 'Checking...';
+ extensions: 'Extensions';
+ installRuntime: 'Install {{runtime}}';
+ manage: 'Manage';
+ recheck: 'Re-check';
+ reinstallRuntime: 'Reinstall {{runtime}}';
+ retry: 'Retry';
+ update: 'Update';
+ };
+ installer: {
+ checkingLatest: 'Checking latest version...';
+ downloading: 'Downloading...';
+ failed: 'Installation failed';
+ installed: 'Installed v{{version}}';
+ installing: 'Installing...';
+ latest: 'latest';
+ verifying: 'Verifying checksum...';
+ };
+ labels: {
+ multimodel: 'Multimodel';
+ };
+ loading: {
+ aiProviders: 'Checking AI Providers...';
+ claudeCli: 'Checking Claude CLI...';
+ };
+ provider: {
+ backend: 'Backend: {{backend}}';
+ loadingModels: 'Loading models...';
+ modelsUnavailable: 'Models unavailable for this runtime build';
+ runtime: 'Runtime: {{runtime}}';
+ };
+ providerTerminal: {
+ authFailed: 'Authentication failed';
+ authUpdated: 'Authentication updated';
+ loggedOut: 'Provider logged out';
+ login: 'Login';
+ logout: 'Logout';
+ logoutFailed: 'Logout failed';
+ };
+ status: {
+ configuredNotFound: 'The configured {{runtime}} was not found.';
+ foundButFailed: '{{runtime}} was found but failed to start';
+ healthCheckFailed: 'The configured {{runtime}} failed its startup health check.';
+ notInstalled: '{{runtime}} not installed';
+ };
+ title: 'CLI Runtime';
+ };
+ cliStatus: {
+ versionUpgrade: 'v{{current}} -> v{{latest}}';
+ };
+ configEditor: {
+ errors: {
+ loadFailed: 'Failed to load config';
+ saveFailed: 'Failed to save config';
+ };
+ footer: {
+ autoSave: 'Changes auto-save after editing';
+ escapeKey: 'Esc';
+ toClose: 'to close';
+ };
+ loading: 'Loading config...';
+ status: {
+ invalidJson: 'Invalid JSON';
+ saveFailed: 'Save failed';
+ saved: 'Saved';
+ saving: 'Saving...';
+ };
+ title: 'Edit Configuration';
+ };
+ connection: {
+ actions: {
+ connect: 'Connect';
+ connecting: 'Connecting...';
+ disconnect: 'Disconnect';
+ testConnection: 'Test Connection';
+ testing: 'Testing...';
+ };
+ currentMode: {
+ description: 'Data source for session files';
+ label: 'Current Mode';
+ local: 'Local ({{path}})';
+ };
+ description: 'Connect to a remote machine to view Claude Code sessions running there';
+ form: {
+ authentication: 'Authentication';
+ host: 'Host';
+ hostPlaceholder: 'hostname or SSH config alias';
+ password: 'Password';
+ port: 'Port';
+ privateKeyPath: 'Private Key Path';
+ username: 'Username';
+ usernamePlaceholder: 'user';
+ };
+ savedProfiles: {
+ title: 'Saved Profiles';
+ };
+ ssh: {
+ title: 'SSH Connection';
+ };
+ status: {
+ connectedTo: 'Connected to {{host}}';
+ remoteSessions: 'Viewing remote sessions via SSH';
+ };
+ test: {
+ failed: 'Connection failed: {{error}}';
+ success: 'Connection successful';
+ unknownError: 'Unknown error';
+ };
+ title: 'Remote Connection';
+ };
+ general: {
+ agentLanguage: {
+ description: 'Language for agent communication';
+ descriptionWithDetected: 'Language for agent communication (detected: {{detected}})';
+ emptyMessage: 'No language found.';
+ label: 'Language';
+ searchPlaceholder: 'Search language...';
+ selectPlaceholder: 'Select language...';
+ title: 'Agent Language';
+ };
+ appLanguage: {
+ description: 'Language for the application interface.';
+ label: 'Language';
+ title: 'App Language';
+ };
+ appearance: {
+ autoExpandAIGroups: {
+ description: 'Automatically expand each response turn when opening a transcript or receiving a new message';
+ label: 'Expand AI responses by default';
+ };
+ nativeTitleBar: {
+ description: 'Use the default system window frame instead of the custom title bar';
+ label: 'Use native title bar';
+ restartConfirm: {
+ confirmLabel: 'Restart';
+ message: 'The app needs to restart to apply the title bar change. Restart now?';
+ title: 'Restart required';
+ };
+ };
+ theme: {
+ description: 'Choose your preferred color theme';
+ label: 'Theme';
+ options: {
+ dark: 'Dark';
+ light: 'Light';
+ system: 'System';
+ };
+ };
+ title: 'Appearance';
+ };
+ browserAccess: {
+ serverMode: {
+ description: 'Start an HTTP server to access the UI from a browser or embed in iframes';
+ label: 'Enable server mode';
+ };
+ title: 'Browser Access';
+ };
+ localClaudeRoot: {
+ actions: {
+ selectFolder: 'Select Folder';
+ selectFolderManually: 'Select Folder Manually';
+ useAutoDetect: 'Use Auto-Detect';
+ useFolder: 'Use Folder';
+ usePath: 'Use Path';
+ useThisPath: 'Use This Path';
+ useWsl: 'Using Linux/WSL?';
+ };
+ confirm: {
+ noProjectsDir: {
+ message: 'This folder does not contain a "projects" directory. Continue anyway?';
+ title: 'No projects directory found';
+ };
+ noWslPaths: {
+ message: 'Could not find WSL distros with Claude data automatically. Select folder manually?';
+ title: 'No WSL Claude paths found';
+ };
+ notClaudeDir: {
+ message: 'This folder is named "{{folderName}}", not ".claude". Continue anyway?';
+ title: 'Selected folder is not .claude';
+ };
+ wslNoProjectsDir: {
+ message: '"{{path}}" does not contain a "projects" directory. Continue anyway?';
+ title: 'WSL path missing projects directory';
+ };
+ };
+ current: {
+ autoDetected: 'Auto-detected: {{path}}';
+ autoDetectedPath: 'Using auto-detected path';
+ customPath: 'Using custom path';
+ label: 'Current Local Root';
+ };
+ description: 'Choose which local folder is treated as your Claude data root';
+ errors: {
+ detectWslFailed: 'Failed to detect WSL Claude root paths';
+ loadFailed: 'Failed to load local Claude root settings';
+ updateFailed: 'Failed to update Claude root';
+ };
+ title: 'Local Claude Root';
+ wslModal: {
+ closeAriaLabel: 'Close WSL path modal';
+ description: 'Detected WSL distributions and Claude root candidates';
+ noProjectsDir: 'No projects directory detected';
+ title: 'Select WSL Claude Root';
+ };
+ };
+ privacy: {
+ telemetry: {
+ description: 'Help improve the app by sending anonymous crash and performance data';
+ label: 'Send crash reports';
+ };
+ title: 'Privacy';
+ };
+ server: {
+ runningOn: 'Running on';
+ standaloneModeDescription: 'Running in standalone mode. The HTTP server is always active. System notifications are not available - notification triggers are logged in-app only.';
+ title: 'Server';
+ };
+ startup: {
+ launchAtLogin: {
+ description: 'Automatically start the app when you log in';
+ label: 'Launch at login';
+ };
+ showDockIcon: {
+ description: 'Display the app icon in the dock (macOS)';
+ label: 'Show dock icon';
+ };
+ title: 'Startup';
+ };
+ };
+ notificationTriggers: {
+ add: {
+ cancel: 'Cancel';
+ submit: 'Add Trigger';
+ title: 'Add Custom Trigger';
+ };
+ builtin: {
+ description: 'Default triggers that come with the application. You can enable or disable them and customize their patterns.';
+ title: 'Built-in Triggers';
+ };
+ card: {
+ builtinBadge: 'Builtin';
+ collapseAriaLabel: 'Collapse';
+ deleteAriaLabel: 'Delete trigger';
+ editNameAriaLabel: 'Edit name';
+ expandAriaLabel: 'Expand';
+ };
+ color: {
+ customHexTitle: 'Custom hex color';
+ invalidHex: 'Invalid hex';
+ };
+ configuration: {
+ alertIfGreaterThan: 'Alert if >';
+ emptyPatternHint: 'Leave empty to match all content. Uses JavaScript regex syntax.';
+ errorStatusDescription: 'Triggers when a tool execution reports an error (is_error: true).';
+ matchPatternPlaceholder: 'e.g., error|failed|exception';
+ tokensUnit: 'tokens';
+ };
+ custom: {
+ description: 'Create your own triggers to get notified for specific patterns or tool outputs.';
+ empty: 'No custom triggers configured yet.';
+ title: 'Custom Triggers';
+ };
+ errors: {
+ invalidRegexPattern: 'Invalid regex pattern';
+ };
+ fields: {
+ contentType: 'Content Type';
+ matchField: 'Match Field';
+ matchPattern: 'Match Pattern (Regex)';
+ scopeToolName: 'Scope / Tool Name';
+ scopeToolNameOptional: 'Scope / Tool Name (optional)';
+ threshold: 'Threshold';
+ tokenType: 'Token Type';
+ triggerNamePlaceholder: 'e.g., Build Failure Alert';
+ triggerNameRequired: 'Trigger Name *';
+ };
+ ignorePatterns: {
+ hint: 'Press Enter to add. Notification is skipped if any pattern matches.';
+ placeholder: 'Add ignore regex...';
+ removeAriaLabel: 'Remove ignore pattern';
+ summary: 'Advanced: Exclusion Rules';
+ title: 'Ignore Patterns (skip if matches)';
+ };
+ options: {
+ contentTypes: {
+ text: 'Text Output';
+ thinking: 'Thinking';
+ tool_result: 'Tool Result';
+ tool_use: 'Tool Use';
+ };
+ matchFields: {
+ args: 'Arguments';
+ command: 'Command';
+ content: 'Content';
+ description: 'Description';
+ file_path: 'File Path';
+ fullInput: 'Full Input (JSON)';
+ glob: 'Glob Filter';
+ new_string: 'New String';
+ old_string: 'Old String';
+ path: 'Path';
+ pattern: 'Pattern';
+ prompt: 'Prompt';
+ query: 'Query';
+ skill: 'Skill Name';
+ subagent_type: 'Subagent Type';
+ text: 'Text Content';
+ thinking: 'Thinking Content';
+ url: 'URL';
+ };
+ modes: {
+ content_match: 'Content Pattern';
+ error_status: 'Execution Error';
+ token_threshold: 'High Token Usage';
+ };
+ tokenTypes: {
+ input: 'Input Tokens';
+ output: 'Output Tokens';
+ total: 'Total Tokens';
+ };
+ toolNames: {
+ anyTool: 'Any Tool';
+ };
+ };
+ preview: {
+ defaultTestTriggerName: 'Test Trigger';
+ detectedSuffix: 'errors would have been detected';
+ more: '...and {{count}} more';
+ more_few: '...and {{count}} more';
+ more_many: '...and {{count}} more';
+ more_one: '...and {{count}} more';
+ more_other: '...and {{count}} more';
+ testTrigger: 'Test Trigger';
+ testing: 'Testing...';
+ title: 'Preview';
+ truncatedWarning: 'Search stopped early (timeout or count limit). Actual matches may be higher.';
+ viewSession: 'View Session';
+ };
+ repositoryScope: {
+ empty: 'No repositories selected - trigger applies to all repositories';
+ hint: 'When repositories are selected, this trigger only fires for errors in those repositories.';
+ placeholder: 'Select repository to add...';
+ summary: 'Advanced: Repository Scope';
+ title: 'Limit to Repositories (applies only to selected repositories)';
+ };
+ sections: {
+ configuration: 'Configuration';
+ dotColor: 'Dot Color';
+ generalInfo: 'General Info';
+ triggerCondition: 'Trigger Condition';
+ };
+ };
+ notifications: {
+ dev: {
+ descriptionPrefix: 'Notifications may not work in development mode. macOS identifies the app as "Electron" (bundle ID';
+ descriptionSuffix: ') instead of the production app name. Check System Settings > Notifications > Electron to verify permissions.';
+ title: 'Dev Mode';
+ };
+ ignoredRepositories: {
+ description: 'Notifications from these repositories will be ignored';
+ empty: 'No repositories ignored';
+ selectPlaceholder: 'Select repository to ignore...';
+ title: 'Ignored Repositories';
+ };
+ settings: {
+ enabled: {
+ description: 'Show system notifications for errors and events';
+ label: 'Enable System Notifications';
+ };
+ sound: {
+ description: 'Play a sound when notifications appear';
+ label: 'Play sound';
+ };
+ subagentErrors: {
+ description: 'Detect and notify about errors in subagent sessions';
+ label: 'Include subagent errors';
+ };
+ title: 'Notification Settings';
+ };
+ snooze: {
+ clear: 'Clear Snooze';
+ description: 'Temporarily pause notifications';
+ descriptionWithTime: 'Snoozed until {{time}}';
+ label: 'Snooze notifications';
+ options: {
+ '-1': 'Until tomorrow';
+ '120': '2 hours';
+ '15': '15 minutes';
+ '240': '4 hours';
+ '30': '30 minutes';
+ '60': '1 hour';
+ };
+ selectDuration: 'Select duration...';
+ };
+ taskCompletion: {
+ description: 'Get native OS notifications when Claude finishes tasks - sounds, banners, and Dock/taskbar badges. Works on macOS, Linux, and Windows.';
+ installPlugin: 'Install claude-notifications-go plugin';
+ title: 'Task Completion Notifications';
+ };
+ team: {
+ allTasksCompleted: {
+ description: 'Notify when every task in a team reaches completed status';
+ label: 'All tasks completed';
+ };
+ autoResumeOnRateLimit: {
+ description: 'When Claude reports a reset time, schedule a follow-up nudge for the team lead after the limit resets';
+ label: 'Auto-resume after rate limit';
+ };
+ clarifications: {
+ description: 'Show native OS notifications when a task needs your input';
+ label: 'Task clarification notifications';
+ };
+ crossTeamMessage: {
+ description: 'Notify when a message arrives from another team';
+ label: 'Cross-team message notifications';
+ };
+ leadInbox: {
+ description: 'Notify when teammates send messages to the team lead';
+ label: 'Lead inbox notifications';
+ };
+ statusChange: {
+ description: "Show native OS notifications when a task's status changes";
+ label: 'Task status change notifications';
+ onlySolo: {
+ description: 'Notify only when the team has no teammates';
+ label: 'Only in Solo mode';
+ };
+ statuses: {
+ description: 'Which target statuses trigger a notification';
+ label: 'Notify on these statuses';
+ options: {
+ approved: 'Approved';
+ completed: 'Completed';
+ deleted: 'Deleted';
+ in_progress: 'Started';
+ needsFix: 'Needs Fixes';
+ pending: 'Pending';
+ review: 'Review';
+ };
+ };
+ };
+ taskComments: {
+ description: 'Show native OS notifications when agents comment on tasks';
+ label: 'Task comment notifications';
+ };
+ taskCreated: {
+ description: 'Show native OS notifications when a new task is created';
+ label: 'Task created notifications';
+ };
+ teamLaunched: {
+ description: 'Notify when a team finishes launching and is ready';
+ label: 'Team launched notifications';
+ };
+ title: 'Team Notifications';
+ toolApproval: {
+ description: 'Notify when a tool needs your approval (Allow/Deny) while the app is not focused';
+ label: 'Tool approval notifications';
+ };
+ userInbox: {
+ description: 'Notify when teammates send messages to you';
+ label: 'User inbox notifications';
+ };
+ };
+ test: {
+ action: 'Send Test';
+ description: 'Send a test notification to verify delivery';
+ failedToSend: 'Failed to send test notification';
+ label: 'Test notification';
+ sending: 'Sending...';
+ sent: 'Sent!';
+ unknownError: 'Unknown error';
+ };
+ };
+ providerRuntime: {
+ actions: {
+ cancel: 'Cancel';
+ cancelLogin: 'Cancel login';
+ connectChatGpt: 'Connect ChatGPT';
+ delete: 'Delete';
+ disable: 'Disable';
+ disconnectAccount: 'Disconnect account';
+ generateLink: 'Generate link';
+ openLogin: 'Open login';
+ reconnectAnthropic: 'Reconnect Anthropic';
+ refresh: 'Refresh';
+ replaceKey: 'Replace key';
+ saveEndpoint: 'Save endpoint';
+ saveKey: 'Save key';
+ saving: 'Saving...';
+ setApiKey: 'Set API key';
+ updateKey: 'Update key';
+ useCode: 'Use code';
+ };
+ alerts: {
+ anthropicApiKeyMissing: 'API key mode is selected, but no Anthropic API credential is available yet.';
+ anthropicStoredKeyAvailable: 'A saved API key is available, but app-launched Anthropic sessions use it only after you switch to API key mode.';
+ anthropicSubscriptionMissing: 'Anthropic subscription mode is selected. Sign in with Anthropic to use this provider.';
+ authTokenMissing: 'Auth token is not configured. Many local Anthropic-compatible endpoints require a non-empty token.';
+ chatgptLoginPending: 'Waiting for ChatGPT account login to finish...';
+ chatgptLoginStarting: 'Starting ChatGPT login...';
+ codexApiKeyMissing: 'API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.';
+ codexLocalArtifactsNoSession: 'Codex CLI currently has no active ChatGPT account. Local Codex account data exists, but no active managed session is selected.';
+ codexNeedsReconnect: 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.';
+ codexNoChatgptAccount: 'Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription.';
+ codexNoCredential: 'No ChatGPT account or API key is available yet.';
+ geminiApiUnavailable: 'Gemini API is currently unavailable. Configure `GEMINI_API_KEY` here or use valid Google ADC credentials.';
+ withApiKeyFallback: '{{message}} Switch to API key mode to use the detected API key.';
+ };
+ apiKey: {
+ loadingStoredCredentials: 'Loading stored credentials...';
+ projectScope: 'Project';
+ providers: {
+ anthropic: {
+ description: 'Use a direct Anthropic API key for API-billed access. Your Anthropic subscription session stays available when you switch back.';
+ name: 'Anthropic API Key';
+ placeholder: 'sk-ant-...';
+ title: 'API key';
+ };
+ codex: {
+ description: 'Use an OpenAI API key as a secondary Codex auth path. If you switch Codex to API key mode, the app will mirror OPENAI_API_KEY into CODEX_API_KEY for native launches.';
+ name: 'Codex API Key';
+ placeholder: 'sk-proj-...';
+ title: 'API key';
+ };
+ gemini: {
+ description: 'Use `GEMINI_API_KEY` for the Gemini API backend. CLI SDK and ADC do not require it.';
+ name: 'Gemini API Key';
+ placeholder: 'AIza...';
+ title: 'API access';
+ };
+ };
+ scope: 'Scope';
+ storedIn: 'Stored in {{backend}}';
+ storedInApp: 'Stored in app';
+ userScope: 'User';
+ };
+ authModeDescriptions: {
+ anthropic: {
+ apiKey: 'Force app-launched Anthropic sessions to use an API key credential.';
+ auto: 'Use the runtime default behavior. Saved API keys in this app are only used after you switch to API key mode.';
+ oauth: 'Force app-launched Anthropic sessions to use the local Anthropic subscription session.';
+ };
+ codex: {
+ apiKey: 'Force native Codex launches to use OPENAI_API_KEY / CODEX_API_KEY billing.';
+ auto: 'Prefer your ChatGPT account when it is available. Fall back to API key mode only when needed.';
+ chatgpt: 'Force native Codex launches to use your connected ChatGPT account and subscription.';
+ };
+ };
+ codex: {
+ account: {
+ appServer: 'App-server: {{state}}';
+ connected: 'Connected';
+ description: 'Manage the local Codex app-server account session that powers subscription-backed native launches.';
+ hints: {
+ autoUsesApiKeyUntilChatgpt: '{{message}} Auto will keep using the detected API key until ChatGPT is connected.';
+ detectedApiKeyNeedsApiMode: '{{message}} The detected API key is only used after you switch Codex to API key mode.';
+ localArtifactsNoSession: 'Codex CLI currently reports no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Usage limits appear here only after Codex CLI sees one.';
+ noActiveAccount: 'Codex CLI currently reports no active ChatGPT account. Usage limits appear here only after Codex CLI sees one.';
+ reconnectBeforeUsage: 'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here.';
+ usageLimitsAfterReport: 'Usage limits appear here after Codex reports them for the connected ChatGPT account.';
+ };
+ loginInProgress: 'Login in progress';
+ plan: 'Plan: {{plan}}';
+ reconnectRequired: 'Reconnect required';
+ title: 'ChatGPT account';
+ };
+ install: {
+ checking: 'Checking';
+ downloading: 'Downloading';
+ installCli: 'Install Codex CLI';
+ installing: 'Installing';
+ retryInstall: 'Retry install';
+ title: 'Install Codex CLI into app data';
+ };
+ rateLimits: {
+ credits: 'Credits';
+ creditsDescription: 'Credits are shown separately from window-based subscription usage and may be unavailable for plan-backed ChatGPT sessions.';
+ noSecondaryWindow: 'Codex did not return a secondary window for this account snapshot.';
+ notReported: 'Not reported';
+ primaryReset: 'Primary reset';
+ primaryUsed: 'Primary used';
+ primaryWindow: 'Primary window';
+ remainingLeft: '{{value}} left';
+ remainingUnknown: 'Remaining unknown';
+ secondaryFallback: 'secondary';
+ secondaryReset: 'Secondary reset';
+ secondaryUsed: 'Secondary used';
+ secondaryWindow: 'Secondary window';
+ secondaryWindowNote: ' Weekly limits are shown separately in the {{window}} window.';
+ usageExplanationGeneric: 'Shows used quota, not remaining quota.';
+ usageExplanationWindowOnly: 'Shows used quota in the current {{window}} window, not remaining quota.';
+ usageExplanationWithRemaining: '{{used}} used - about {{remaining}} left in the current {{window}} window.';
+ usedQuotaNote: 'These percentages show used quota, not remaining quota.';
+ weeklyReset: 'Weekly reset';
+ weeklyUsed: 'Weekly used';
+ weeklyUsedOneWeek: 'Weekly used (1w)';
+ weeklyWindow: 'Weekly window';
+ };
+ };
+ compatibleEndpoint: {
+ authToken: 'Auth token';
+ authTokenMissing: 'Auth token is not configured.';
+ baseUrl: 'Base URL';
+ description: 'Use an Anthropic-compatible local runtime endpoint.';
+ keepSavedToken: 'Leave blank to keep saved token';
+ status: {
+ endpointDisabledTokenKept: 'Endpoint disabled. Saved token was kept.';
+ endpointSaved: 'Endpoint saved';
+ endpointSavedTokenMissing: 'Endpoint saved. Auth token is not configured.';
+ };
+ title: 'Local / compatible endpoint';
+ tokenStatus: 'Token {{status}}';
+ validation: {
+ baseUrlRequired: 'Base URL is required';
+ firstPartyAnthropic: 'Use Auto, Subscription, or API key for first-party Anthropic';
+ httpRequired: 'Base URL must use http:// or https://';
+ invalidUrl: 'Invalid URL';
+ noCredentials: 'Base URL must not include credentials';
+ };
+ };
+ connection: {
+ authenticationMethod: 'Authentication method';
+ descriptions: {
+ anthropic: 'Choose how app-launched Anthropic sessions authenticate.';
+ codex: 'Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.';
+ gemini: 'Configure optional API access. CLI SDK and ADC are still discovered automatically.';
+ opencode: 'OpenCode authentication and provider inventory are managed by the OpenCode runtime.';
+ };
+ method: 'Connection method';
+ mode: 'Mode: {{mode}}';
+ selected: 'Selected';
+ switching: 'Switching...';
+ title: 'Connection';
+ };
+ connectionCards: {
+ anthropic: {
+ apiKeyDescription: 'Use ANTHROPIC_API_KEY and Anthropic API billing.';
+ autoDescription: 'Use Anthropic runtime defaults and the best local credential available.';
+ hint: 'Auto keeps Anthropic on its default local credential resolution.';
+ subscriptionDescription: 'Use your local Anthropic sign-in session and subscription access.';
+ subscriptionTitle: 'Anthropic subscription';
+ };
+ apiKey: {
+ title: 'API key';
+ };
+ auto: {
+ title: 'Auto';
+ };
+ codex: {
+ apiKeyDescription: 'Use OPENAI_API_KEY and CODEX_API_KEY billing for native Codex launches.';
+ autoDescription: 'Prefer your ChatGPT account and subscription. Use API key mode only if needed.';
+ chatgptDescription: 'Use your connected ChatGPT account and Codex subscription.';
+ chatgptTitle: 'ChatGPT account';
+ hint: 'Codex always runs through the native runtime. Auto prefers your ChatGPT account before falling back to API-key credentials.';
+ };
+ };
+ connectionUi: {
+ actions: {
+ connect: 'Connect';
+ connectAnthropic: 'Connect Anthropic';
+ connectChatGpt: 'Connect ChatGPT';
+ disconnect: 'Disconnect';
+ openLogin: 'Open Login';
+ };
+ authMethod: {
+ apiKey: 'API key';
+ apiKeyHelper: 'API key helper';
+ claudeSubscription: 'Claude subscription';
+ geminiCli: 'Gemini CLI';
+ googleAccount: 'Google account';
+ oauth: 'OAuth';
+ serviceAccount: 'service account';
+ };
+ authMode: {
+ anthropicSubscription: 'Anthropic subscription';
+ apiKey: 'API key';
+ auto: 'Auto';
+ chatgpt: 'ChatGPT account';
+ oauth: 'Subscription / OAuth';
+ };
+ credential: {
+ apiKeyAlsoConfigured: 'API key also configured in Manage';
+ apiKeyConfigured: 'API key is configured';
+ apiKeyConfiguredInManage: 'API key is configured in Manage';
+ apiKeyFallbackInManage: 'API key also available in Manage as fallback';
+ autoWillUseUntilChatGpt: '{{summary}} - Auto will use this until ChatGPT is connected';
+ availableAsFallback: '{{summary}} - available as fallback';
+ availableIfSwitch: '{{summary}} - available if you switch to API key mode';
+ savedApiKeyAvailable: 'Saved API key available in Manage';
+ savedApiKeyAvailableIfSwitch: 'Saved API key available in Manage if you switch to API key mode';
+ };
+ disconnect: {
+ anthropic: 'This removes the local Anthropic subscription session from the Claude CLI runtime.';
+ anthropicTitle: 'Disconnect Anthropic subscription?';
+ anthropicWithApiKey: 'This removes the local Anthropic subscription session from the Claude CLI runtime. Saved API keys in Manage stay available.';
+ gemini: 'This clears the local Gemini CLI session metadata. External ADC credentials and saved API keys are not removed.';
+ geminiTitle: 'Disconnect Gemini CLI?';
+ };
+ mode: {
+ preferredAuth: 'Preferred auth: {{authMode}}';
+ selectedAuth: 'Selected auth: {{authMode}}';
+ };
+ runtime: {
+ codexNative: 'Codex native';
+ currentRuntime: 'Current runtime';
+ selectedRuntime: 'Selected runtime';
+ summary: '{{prefix}}: {{runtime}}';
+ };
+ status: {
+ apiKeyConfiguredNotVerified: 'API key configured, but not verified yet';
+ apiKeyModeMissingCredential: 'API key mode selected, but no API key is configured';
+ apiKeyReady: 'API key ready';
+ chatGptAccountReady: 'ChatGPT account ready';
+ chatGptVerificationDegraded: 'ChatGPT account detected - account verification is currently degraded.';
+ checked: 'Checked';
+ checking: 'Checking...';
+ codexLocalAccountNeedsReconnect: 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.';
+ codexNativeReady: 'Codex native ready';
+ codexNativeUnavailable: 'Codex native unavailable';
+ codexNoActiveChatGptLogin: 'Codex CLI reports no active ChatGPT login';
+ codexNoActiveManagedSession: 'Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.';
+ connectChatGptForSubscription: 'Connect a ChatGPT account to use your Codex subscription.';
+ connectedVia: 'Connected via {{method}}';
+ connectedViaApiKey: 'Connected via API key';
+ notConnected: 'Not connected';
+ providerActivity: 'Provider Activity';
+ startingChatGptLogin: 'Starting ChatGPT login...';
+ unableToVerify: 'Unable to verify';
+ unavailableInCurrentRuntime: 'Unavailable in current runtime';
+ waitingForChatGptLogin: 'Waiting for ChatGPT account login...';
+ };
+ };
+ description: 'Manage how each provider connects and, when supported, which backend the multimodel runtime should use.';
+ errors: {
+ apiKeyDeletedRefreshFailed: 'API key deleted, but failed to refresh provider status.';
+ apiKeyRequired: 'API key is required';
+ apiKeySavedRefreshFailed: 'API key saved, but failed to refresh provider status.';
+ connectionUpdatedRefreshFailed: 'Connection updated, but failed to refresh provider status.';
+ deleteApiKey: 'Failed to delete API key';
+ disableEndpoint: 'Failed to disable endpoint';
+ endpointDisabledRefreshFailed: 'Endpoint disabled, but failed to refresh provider status.';
+ endpointSavedRefreshFailed: 'Endpoint saved, but failed to refresh provider status.';
+ refreshCodexAccount: 'Failed to refresh Codex account';
+ saveApiKey: 'Failed to save API key';
+ saveEndpoint: 'Failed to save endpoint';
+ updateAnthropicFastMode: 'Failed to update Anthropic Fast mode';
+ updateConnection: 'Failed to update connection';
+ updateRuntimeBackend: 'Failed to update runtime backend';
+ };
+ fastMode: {
+ defaultOff: 'Default Off';
+ description: 'Apply Claude Code Fast mode by default for new Anthropic team launches when the resolved model and runtime allow it.';
+ disabledHint: 'New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.';
+ enabledHint: 'New Anthropic launches will request Fast mode by default when the resolved model supports it.';
+ notExposed: 'This Anthropic runtime does not expose Fast mode.';
+ preferFast: 'Prefer Fast';
+ title: 'Fast mode default';
+ unavailableForRuntime: 'Fast mode is currently unavailable for this Anthropic runtime.';
+ };
+ progress: {
+ applyingConnectionChanges: 'Applying connection changes...';
+ refreshingProviderStatus: 'Refreshing provider status...';
+ savingCompatibleEndpoint: 'Saving compatible endpoint...';
+ switchingAnthropicSubscription: 'Switching to Anthropic subscription...';
+ switchingApiKey: 'Switching to API key...';
+ switchingApiKeyMode: 'Switching to API key mode...';
+ switchingAuto: 'Switching to Auto...';
+ switchingChatgpt: 'Switching to ChatGPT account mode...';
+ };
+ provider: 'Provider';
+ runtime: {
+ descriptions: {
+ anthropic: 'Anthropic currently has no separate runtime backend selector.';
+ codex: 'Codex now runs only through the native runtime path.';
+ gemini: 'Choose which Gemini runtime backend multimodel should use.';
+ opencode: 'OpenCode uses its own managed runtime host. Desktop currently exposes status only.';
+ };
+ title: 'Runtime';
+ updating: 'Updating runtime...';
+ };
+ runtimeSummary: 'Runtime: {{runtime}}';
+ status: {
+ configured: 'configured';
+ enabled: 'Enabled';
+ notConfigured: 'Not configured';
+ notSet: 'not set';
+ off: 'Off';
+ unknown: 'Unknown';
+ };
+ title: 'Provider Settings';
+ usage: {
+ apiKey: 'Using API key';
+ apiKeyRequired: 'API key required';
+ compatibleEndpoint: 'Using compatible endpoint';
+ notConnected: 'Not connected';
+ usingMethod: 'Using {{method}}';
+ };
+ };
+ runtimeProvider: {
+ actions: {
+ cancel: 'Cancel';
+ test: 'Test';
+ };
+ badges: {
+ configured: 'configured';
+ connected: 'connected';
+ default: 'default';
+ failed: 'failed';
+ free: 'free';
+ local: 'local';
+ needsTest: 'needs test';
+ unknown: 'unknown';
+ usedInTeamPicker: 'Used in team picker';
+ verified: 'verified';
+ };
+ compatibleEndpoint: {
+ baseUrlPlaceholder: 'http://localhost:1234';
+ };
+ defaults: {
+ allProjects: 'All projects';
+ allProjectsHint: 'Tests use {{project}}. Default applies unless a project has an override.';
+ loadingContexts: 'Loading contexts...';
+ projectHint: 'Saving overrides only {{project}}.';
+ projectOverrideContext: 'Project override context';
+ scopeDescriptionAllProjects: 'Default for every project that does not have its own OpenCode override.';
+ scopeDescriptionProject: 'Override only the selected project. Running teams are not changed.';
+ selectProjectContext: 'Select project context';
+ selectProjectHint: 'Select a project before testing local models or saving defaults.';
+ selectValidationContext: 'Select validation context';
+ setAllProjectsDefault: 'Set all-projects default';
+ setProjectDefault: 'Set project default';
+ thisProject: 'This project';
+ title: 'OpenCode defaults';
+ validationContext: 'Validation context';
+ };
+ diagnostics: {
+ copied: 'Diagnostics copied';
+ copiedShort: 'Copied';
+ copy: 'Copy diagnostics';
+ hints: 'Hints';
+ likelyCause: 'Likely cause:';
+ };
+ modelRoutes: {
+ searchPlaceholder: 'Search model routes';
+ };
+ models: {
+ alreadyDefault: 'This is already the selected OpenCode default.';
+ empty: 'No models found.';
+ emptyFree: 'No free models found.';
+ emptyRecommended: 'No recommended models found.';
+ emptyRecommendedFree: 'No recommended free models found.';
+ freeOnly: 'Free only';
+ launchableDescription: 'Routes you can test or use in the team picker: local config, free built-in models, and current default.';
+ launchableTitle: 'Launchable OpenCode models';
+ loadingRoutes: 'Loading OpenCode model routes...';
+ noRoutesMatch: 'No OpenCode model routes match "{{query}}".';
+ noneReported: 'No launchable OpenCode model routes were reported yet. Configure a local route in OpenCode or use the Providers tab to inspect catalog providers.';
+ recommendedOnly: 'Recommended only';
+ searchPlaceholder: 'Search models';
+ selectProjectBeforeTesting: 'Select a project context before testing models.';
+ selectProjectBeforeTestingDefaults: 'Select a project context before testing or saving OpenCode defaults.';
+ useInTeamPicker: 'Use in team picker';
+ };
+ providers: {
+ catalog: 'OpenCode provider catalog';
+ countFallback: 'OpenCode providers';
+ description: '{{count}}. Connected and recommended providers are shown first.';
+ description_few: '{{count}}. Connected and recommended providers are shown first.';
+ description_many: '{{count}}. Connected and recommended providers are shown first.';
+ description_one: '{{count}}. Connected and recommended providers are shown first.';
+ description_other: '{{count}}. Connected and recommended providers are shown first.';
+ loadMore: 'Load more providers';
+ loading: 'Loading OpenCode providers';
+ noMatches: 'No providers match that search.';
+ noneReported: 'No OpenCode providers reported by the managed runtime.';
+ recommended: 'Recommended';
+ refreshCatalog: 'Refresh catalog';
+ searchPlaceholder: 'Search providers';
+ };
+ setup: {
+ loading: 'Loading provider setup...';
+ };
+ summary: {
+ defaultModel: 'OpenCode default: {{model}}';
+ loading: 'Loading managed OpenCode runtime, connected providers, and model defaults...';
+ source: 'Source: {{source}}';
+ title: 'OpenCode runtime';
+ };
+ tabs: {
+ models: 'Models';
+ providers: 'Providers';
+ };
+ };
+ tabs: {
+ advanced: {
+ description: 'Power-user options: export/import config, reset defaults, and raw configuration editing.';
+ label: 'Advanced';
+ };
+ general: {
+ description: 'Core app preferences like theme, language, display density, and startup behavior.';
+ label: 'General';
+ };
+ infoAriaLabel: 'What is {{label}}?';
+ notifications: {
+ description: 'Control when and how you get notified about agent activity, task completions, and errors.';
+ label: 'Notifications';
+ };
+ };
+ view: {
+ description: 'Manage your app preferences';
+ loading: 'Loading settings...';
+ title: 'Settings';
+ };
+ workspaceProfiles: {
+ actions: {
+ addProfile: 'Add Profile';
+ cancel: 'Cancel';
+ deleteProfile: 'Delete profile';
+ editProfile: 'Edit profile';
+ save: 'Save';
+ };
+ authMethods: {
+ agent: 'SSH Agent';
+ auto: 'Auto (from SSH Config)';
+ password: 'Password';
+ privateKey: 'Private Key';
+ };
+ deleteConfirm: {
+ confirmLabel: 'Delete';
+ message: 'Are you sure you want to delete "{{name}}"? This cannot be undone.';
+ title: 'Delete Profile';
+ };
+ description: 'Save SSH connection profiles for quick reconnection';
+ empty: {
+ description: 'Add an SSH profile to connect quickly';
+ title: 'No saved profiles';
+ };
+ form: {
+ authentication: 'Authentication';
+ host: 'Host';
+ hostPlaceholder: 'hostname or IP';
+ name: 'Name';
+ namePlaceholder: 'My Server';
+ passwordPrompt: 'You will be prompted for the password when connecting.';
+ port: 'Port';
+ privateKeyPath: 'Private Key Path';
+ username: 'Username';
+ usernamePlaceholder: 'user';
+ };
+ loading: 'Loading profiles...';
+ title: 'Workspace Profiles';
+ };
+ };
+ team: {
+ activity: {
+ actions: {
+ createTaskFromMessage: 'Create task from message';
+ expandMessage: 'Expand message';
+ replyToMessage: 'Reply to message';
+ restartTeam: 'Restart team';
+ };
+ activeTasks: {
+ inProgress: 'In progress';
+ };
+ authError: {
+ description: 'Authentication failed. Restarting the team will refresh the session and may resolve this issue. If the problem persists, check your API credentials or try again later.';
+ };
+ automation: {
+ reviewPickup: 'Asked teammate to pick up review';
+ stallNudge: 'Asked teammate to continue stalled task';
+ workSyncBody: 'Asked teammate to sync current work';
+ };
+ badges: {
+ automation: 'automation';
+ bootstrap: 'bootstrap';
+ command: 'command';
+ comment: 'Comment';
+ live: 'live';
+ note: 'note';
+ rateLimited: 'Rate Limited';
+ restart: 'restart';
+ result: 'result';
+ session: 'session';
+ stallNudge: 'stall nudge';
+ start: 'start';
+ workSync: 'work sync';
+ };
+ bootstrap: {
+ acknowledged: 'Bootstrap acknowledged';
+ restarting: 'Restarting teammate';
+ starting: 'Starting teammate';
+ };
+ expandDialog: {
+ description: 'Expanded message view';
+ };
+ pendingReplies: {
+ awaitingApproval: 'awaiting approval';
+ awaitingReply: 'awaiting reply';
+ crossTeamAwaitingReply: 'Cross-team message sent, awaiting reply';
+ externalTeam: 'external team';
+ messageSentAwaitingReply: 'Message sent, awaiting reply';
+ openMember: 'Open member';
+ title: 'Awaiting replies';
+ user: 'user';
+ };
+ rawJson: 'Raw JSON';
+ reply: {
+ action: 'Reply';
+ replyingTo: 'Replying to';
+ };
+ thoughts: {
+ count: '{{count}} thoughts';
+ count_few: '{{count}} thoughts';
+ count_many: '{{count}} thoughts';
+ count_one: '{{count}} thought';
+ count_other: '{{count}} thoughts';
+ expand: 'Expand thoughts';
+ showLess: 'Show less';
+ showMore: 'Show more';
+ titleForMember: '{{name}} - thoughts';
+ toolSummary: '🔧 {{summary}}';
+ };
+ timeline: {
+ emptyHint: 'Send a message to a member to see activity.';
+ loadingMessages: 'Loading messages...';
+ newSession: 'New session';
+ noMessages: 'No messages';
+ olderCount: '+{{count}} older';
+ olderCount_few: '+{{count}} older';
+ olderCount_many: '+{{count}} older';
+ olderCount_one: '+{{count}} older';
+ olderCount_other: '+{{count}} older';
+ showAll: 'Show all';
+ showMore: 'Show {{count}} more';
+ };
+ unread: 'Unread';
+ };
+ advancedCli: {
+ commandPreview: 'Command preview';
+ customArguments: 'Custom arguments';
+ placeholders: {
+ worktreeName: 'worktree-name';
+ };
+ recent: 'Recent';
+ title: 'Advanced';
+ useWorktree: 'Use worktree';
+ validate: 'Validate';
+ validation: {
+ allFlagsValid: 'All flags valid';
+ failed: 'Validation failed';
+ protectedFlags: 'Protected: {{flags}}';
+ unknownFlags: 'Unknown: {{flags}}';
+ };
+ };
+ agentGraph: {
+ activityHud: {
+ activity: 'Activity';
+ more: '+{{count}} more';
+ more_few: '+{{count}} more';
+ more_many: '+{{count}} more';
+ more_one: '+{{count}} more';
+ more_other: '+{{count}} more';
+ noRecentActivity: 'No recent activity';
+ };
+ blockingEdge: {
+ blockedHiddenTasks: 'Blocked hidden tasks';
+ blockingHiddenTasks: 'Blocking hidden tasks';
+ blocks: 'blocks';
+ close: 'Close';
+ title: 'Blocking Dependency';
+ };
+ logPreview: {
+ loading: 'Loading logs';
+ logs: 'Logs';
+ more: '+{{count}} more';
+ more_few: '+{{count}} more';
+ more_many: '+{{count}} more';
+ more_one: '+{{count}} more';
+ more_other: '+{{count}} more';
+ };
+ popover: {
+ externalTeam: 'External team';
+ member: {
+ actions: {
+ message: 'Message';
+ profile: 'Profile';
+ task: 'Task';
+ };
+ activeTool: {
+ failed: 'Tool failed';
+ finished: 'Tool finished';
+ running: 'Running tool';
+ };
+ lead: 'Lead';
+ recentTools: 'Recent tools';
+ spawn: {
+ failed: 'failed';
+ starting: 'starting';
+ waitingToStart: 'waiting to start';
+ };
+ state: {
+ active: 'active';
+ idle: 'idle';
+ offline: 'offline';
+ runningTool: 'running tool';
+ };
+ workingOn: 'working on';
+ };
+ overflow: {
+ empty: 'No hidden tasks available.';
+ hiddenTasks: 'Hidden tasks';
+ };
+ process: {
+ at: 'At:';
+ openUrl: 'Open URL';
+ startedBy: 'Started by:';
+ };
+ };
+ provisioning: {
+ launchDetails: 'Launch details';
+ launchDetailsDescription: 'Detailed team launch progress, live output and CLI logs.';
+ };
+ };
+ claudeLogs: {
+ clearSearch: 'Clear search';
+ emptyRawLogs: '{{count}}; none are assistant/tool output yet.';
+ filter: {
+ actions: {
+ reset: 'Reset';
+ save: 'Save';
+ };
+ ariaLabel: 'Filter Claude logs';
+ kinds: {
+ output: 'Output';
+ thinking: 'Thinking';
+ tool: 'Tool calls';
+ };
+ sections: {
+ content: 'Content';
+ stream: 'Stream';
+ };
+ streams: {
+ stderr: 'stderr';
+ stdout: 'stdout';
+ };
+ tooltip: 'Filter logs';
+ };
+ fullscreen: 'Fullscreen';
+ loading: 'Loading...';
+ logsTitle: 'Logs';
+ newCount: '+{{count}} new';
+ noLogsCaptured: 'No logs captured.';
+ noLogsYet: 'No logs yet.';
+ noMatchingLogs: 'No matching logs.';
+ openFullscreen: 'Open fullscreen logs';
+ rawLineCount: '{{formattedCount}} raw lines';
+ rawLineCount_few: '{{formattedCount}} raw lines';
+ rawLineCount_many: '{{formattedCount}} raw lines';
+ rawLineCount_one: '{{formattedCount}} raw line';
+ rawLineCount_other: '{{formattedCount}} raw lines';
+ rawLinesCaptured: '{{count}} captured';
+ searchPlaceholder: 'Search logs...';
+ showMore: 'Show more';
+ teamNotRunning: 'Team is not running.';
+ viewingFullscreen: 'Viewing in fullscreen mode';
+ };
+ codexReconnect: {
+ description: 'Your Codex session appears stale. Reconnect to continue.';
+ useCode: 'Use code';
+ };
+ contextLimit: {
+ always200k: '(always 200K for this model)';
+ limitTo200k: 'Limit context to 200K tokens';
+ tooltipContent: 'Keeps launches within a 200K-token context window when supported.';
+ tooltipTitle: 'Context limit';
+ };
+ create: {
+ actions: {
+ create: 'Create';
+ creating: 'Creating...';
+ openExisting: 'Open Existing Team';
+ skipPreflightAndCreate: 'Skip preflight and create';
+ };
+ conflict: {
+ description: 'Running two teams in the same directory is risky - they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.';
+ title: 'Another team "{{team}}" is already running for this working directory';
+ workingDirectory: 'Working directory:';
+ };
+ description: {
+ copy: 'Create a new team based on an existing one.';
+ create: 'Set up your team and choose how it starts.';
+ };
+ errors: {
+ createConfigFailed: 'Failed to create team config';
+ loadProjectsFailed: 'Failed to load projects';
+ nameExists: 'Team name already exists';
+ nameLaunching: 'A team with this name is currently launching';
+ };
+ fields: {
+ color: 'Color (optional)';
+ description: 'Description (optional)';
+ prompt: 'Prompt for team lead (optional)';
+ teamName: 'Team name';
+ };
+ launchAfterCreate: {
+ description: 'Start the team immediately via local Claude CLI.';
+ label: 'Run command after create';
+ };
+ localOnly: 'Available only in local Electron mode.';
+ onDisk: 'On disk:';
+ optional: {
+ launchSettingsDescription: 'Prompt, safety, and CLI overrides live here when you need them.';
+ launchSettingsTitle: 'Optional launch settings';
+ teamDetailsDescription: 'Keep the default flow compact and only open this when you want extra context or a custom color.';
+ teamDetailsTitle: 'Optional team details';
+ };
+ placeholders: {
+ description: 'Brief description of the team purpose';
+ prompt: 'Instructions for the team lead during provisioning...';
+ };
+ prepare: {
+ checkingProviders: 'Checking selected providers...';
+ failed: 'Failed to prepare selected providers';
+ preparingEnvironment: 'Preparing environment...';
+ ready: 'All selected providers are ready.';
+ readyWithNotes: 'All selected providers are ready, with notes.';
+ selectWorkingDirectory: 'Select a working directory to validate the launch environment.';
+ selectedProvidersReady: 'Selected providers ready';
+ selectedProvidersReadyWithNotes: 'Selected providers ready (with notes)';
+ someProvidersNeedAttention: 'Some selected providers need attention.';
+ unsupportedPreload: 'Current preload version does not support team:prepareProvisioning. Restart the dev app.';
+ };
+ saved: 'Saved';
+ solo: {
+ description: 'Only the team lead (main process) will be started - no teammates will be spawned. Works like a regular agent session in your chosen runtime (Claude Code, Codex, OpenCode, Gemini) but with access to the task board for planning. Saves tokens by avoiding teammate coordination overhead. You can add members later from the team settings.';
+ label: 'Solo team';
+ };
+ title: {
+ copy: 'Copy Team';
+ create: 'Create Team';
+ };
+ validation: {
+ checkFormFields: 'Check form fields';
+ memberNameInvalid: 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
+ memberNameRequired: 'Member name cannot be empty';
+ memberNamesUnique: 'Member names must be unique';
+ nameMustContainLetterOrDigit: 'Name must contain at least one letter or digit';
+ nameTooLong: 'Name is too long (max 128 chars)';
+ openCodeLeadModelRequired: 'OpenCode lead requires a selected model.';
+ openCodeTeammateRequired: 'OpenCode lead requires at least one OpenCode teammate.';
+ selectWorkingDirectory: 'Select working directory (cwd)';
+ teamLaunching: 'Team is currently launching';
+ teamNameExists: 'Team name already exists';
+ };
+ };
+ detail: {
+ actions: {
+ add: 'Add';
+ cancel: 'Cancel';
+ delete: 'Delete';
+ editCode: 'Edit code';
+ launch: 'Launch';
+ remove: 'Remove';
+ stop: 'Stop';
+ task: 'Task';
+ visualize: 'Visualize';
+ };
+ context: {
+ title: 'Context';
+ };
+ deleteTeam: {
+ description: 'Delete team "{{team}}"? This action is irreversible. All team data and tasks will be deleted.';
+ title: 'Delete team';
+ };
+ draft: {
+ descriptionPrefix: 'This is a draft team -';
+ descriptionSuffix: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ descriptionSuffix_few: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ descriptionSuffix_many: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ descriptionSuffix_one: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ descriptionSuffix_other: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ member: 'members';
+ member_few: 'members';
+ member_many: 'members';
+ member_one: 'member';
+ member_other: 'members';
+ title: 'Team not launched yet';
+ };
+ invalidTab: 'Invalid team tab';
+ kanbanSafeData: 'Failed to fully load kanban. Displaying safe data.';
+ loadFailed: 'Failed to load team';
+ loading: 'Loading team';
+ loadingSidebar: 'Loading team sidebar';
+ offline: {
+ offline: 'Team is offline';
+ partialFailed: 'Last launch failed partway';
+ partialMissing: 'Last launch failed partway - {{missing}}/{{expected}} teammates did not join';
+ reconciling: 'Last launch is still reconciling';
+ };
+ previous: 'Previous: {{paths}}';
+ removeMember: {
+ description: 'Remove "{{member}}" from the team? Tasks and messages will be preserved, but this name cannot be reused.';
+ title: 'Remove member';
+ };
+ sections: {
+ team: 'Team';
+ };
+ solo: 'Solo';
+ status: {
+ active: 'Active';
+ launching: 'Launching...';
+ running: 'Running';
+ };
+ telemetry: {
+ cpu: 'CPU';
+ memory: 'Memory';
+ };
+ tooltips: {
+ deleteTeam: 'Delete team';
+ editTeam: 'Edit team';
+ editUnavailableProvisioning: 'Edit team is unavailable while provisioning is still in progress';
+ openBuiltInEditor: 'Open project in built-in editor';
+ openTeamGraph: 'Open team graph';
+ stopTeam: 'Stop team';
+ };
+ waitingForProvisioning: 'Team data will appear once provisioning completes';
+ };
+ dialogs: {
+ actions: {
+ cancel: 'Cancel';
+ openDashboard: 'Open Dashboard';
+ openTeam: 'Open team';
+ };
+ membersJson: {
+ hide: 'Hide JSON';
+ };
+ optional: {
+ badge: 'Optional';
+ };
+ };
+ editTeam: {
+ actions: {
+ cancel: 'Cancel';
+ save: 'Save';
+ };
+ addMemberLockReason: 'Use the dedicated Add member dialog to add new teammates while the team is live.';
+ description: 'Change team name, description and color';
+ errors: {
+ changesSavedRefreshFailed: 'Team changes were saved, but failed to refresh the latest view: {{message}}';
+ liveRenameBlocked: 'Existing teammates cannot be renamed while the team is live. renamed: {{names}}';
+ memberNameEmpty: 'Member name cannot be empty';
+ memberNameInvalid: 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
+ memberNameNumericSuffix: 'Member name "{{name}}" is not allowed (reserved for Claude CLI auto-suffix). Use "{{base}}" instead.';
+ memberNameReserved: 'Member name "{{name}}" is reserved';
+ memberNamesUnique: 'Member names must be unique before saving';
+ newLiveTeammates: 'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.';
+ provisioning: 'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.';
+ restartFailedMany: 'Team saved, but failed to restart these teammates: {{failures}}';
+ restartFailedOne: 'Team saved, but failed to restart this teammate: {{failures}}';
+ saveFailed: 'Failed to save';
+ settingsChanged: 'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.';
+ settingsSavedMembersAndRefreshFailed: 'Team settings were saved, but member changes failed: {{message}}. Refresh also failed: {{refreshError}}';
+ settingsSavedMembersFailed: 'Team settings were saved, but member changes failed: {{message}}';
+ settingsSavedRefreshFailed: 'Team settings were saved, but failed to refresh the latest view: {{message}}';
+ teamNameEmpty: 'Team name cannot be empty';
+ unsupportedMixedPrimaryMutation: 'Live edits to primary-owned teammates in mixed OpenCode teams are not supported yet. Stop the team, edit the roster, then relaunch. Affected: {{names}}';
+ };
+ fields: {
+ colorOptional: 'Color (optional)';
+ description: 'Description';
+ name: 'Name';
+ };
+ memberRestartWarning: 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes.';
+ notices: {
+ liveRenameBlocked: 'Live save is blocked because existing teammates were renamed. Revert those identity changes or stop the team first.';
+ newLiveTeammates: 'New teammates cannot be added from Edit Team while the team is live. Use the Add member dialog instead.';
+ provisioning: 'Team provisioning is still in progress. Editing is temporarily locked until launch finishes.';
+ restartMany: 'Saving will restart or relaunch these teammates to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes: {{names}}.';
+ restartOne: 'Saving will restart or relaunch this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes: {{names}}.';
+ unsupportedMixedPrimaryMutation: 'Live edits/removals for primary-owned teammates in mixed OpenCode teams require stopping and relaunching the team: {{names}}.';
+ };
+ placeholders: {
+ description: 'Team description (optional)';
+ teamName: 'Team name';
+ };
+ teamLead: {
+ changeRuntime: 'Change lead runtime';
+ changeRuntimeDescription: 'Open Relaunch Team to change the lead provider, model, or effort.';
+ modelLockReason: 'Team lead runtime is managed from Relaunch Team.';
+ readOnlyHint: 'Team lead name and role stay read-only here. Open the runtime panel on the lead row to change provider, model, or effort.';
+ role: 'Team Lead';
+ };
+ title: 'Edit Team';
+ };
+ editor: {
+ actions: {
+ cancel: 'Cancel';
+ closeEditor: 'Close editor';
+ closeTab: 'Close tab';
+ closeTooltip: 'Close editor (Esc)';
+ discard: 'Discard';
+ discardAndClose: 'Discard & Close';
+ keep: 'Keep';
+ keepMine: 'Keep mine';
+ keyboardShortcuts: 'Keyboard shortcuts';
+ overwrite: 'Overwrite';
+ refreshAria: 'Refresh (F5)';
+ refreshTooltip: 'Refresh git status (F5)';
+ reload: 'Reload';
+ retry: 'Retry';
+ save: 'Save';
+ saveAllAndClose: 'Save All & Close';
+ };
+ ariaLabel: 'Project Editor';
+ binaryPlaceholder: {
+ file: 'Binary file ({{size}})';
+ };
+ dialogs: {
+ conflictDescription: 'The file has been modified externally since you opened it. Overwrite with your changes?';
+ conflictTitle: 'Save Conflict';
+ unsavedDescription: 'You have unsaved changes. What would you like to do?';
+ unsavedFileDescription: 'This file has unsaved changes. What would you like to do?';
+ unsavedTitle: 'Unsaved Changes';
+ };
+ draftRecovered: 'Recovered unsaved changes from a previous session.';
+ empty: {
+ selectFile: 'Select a file from the tree to edit';
+ };
+ errorBoundary: {
+ crashed: 'Editor crashed';
+ unknownError: 'Unknown error';
+ };
+ externalChange: {
+ changed: 'File changed on disk.';
+ deleted: 'File no longer exists on disk.';
+ };
+ fileTree: {
+ cancel: 'Cancel';
+ dropForProjectRoot: 'Drop here for project root';
+ empty: 'No files found';
+ failedToLoadFiles: 'Failed to load files: {{error}}';
+ loading: 'Loading files...';
+ moveToTrash: 'Move to Trash';
+ moveToTrashConfirm: 'Move "{{name}}" to Trash?';
+ };
+ goToLine: {
+ go: 'Go';
+ placeholder: 'Line number, +offset, -offset, or %';
+ position: '(current: {{current}}, total: {{total}})';
+ title: 'Go to Line';
+ };
+ imagePreview: {
+ loading: 'Loading preview...';
+ openFullSize: 'Open full-size preview';
+ openSystemViewer: 'Open in System Viewer';
+ };
+ newFile: {
+ aria: {
+ newFileName: 'New file name';
+ newFolderName: 'New folder name';
+ };
+ placeholders: {
+ fileName: 'File name...';
+ folderName: 'Folder name...';
+ };
+ validation: {
+ invalidCharacters: 'Name contains invalid characters';
+ invalidName: 'Invalid name';
+ nameRequired: 'Name cannot be empty';
+ nameTooLong: 'Name is too long';
+ };
+ };
+ quickOpen: {
+ empty: 'No files found';
+ loading: 'Loading files...';
+ searchPlaceholder: 'Search files by name...';
+ title: 'Quick Open';
+ };
+ saveFailed: 'Save failed: {{error}}';
+ search: {
+ placeholder: 'Search';
+ toggleReplace: 'Toggle Replace';
+ };
+ searchInFiles: {
+ closeSearch: 'Close search';
+ closeSearchShortcut: 'Close search (Esc)';
+ matchCase: 'Match Case';
+ matchCaseToggle: 'Aa';
+ noResults: 'No results found';
+ resultsSummary: '{{count}} matches in {{fileCount}} files';
+ resultsSummary_few: '{{count}} matches in {{fileCount}} files';
+ resultsSummary_many: '{{count}} matches in {{fileCount}} files';
+ resultsSummary_one: '{{count}} match in {{fileCount}} files';
+ resultsSummary_other: '{{count}} matches in {{fileCount}} files';
+ searchPlaceholder: 'Search...';
+ title: 'Search in Files';
+ truncated: '(truncated)';
+ };
+ searchPanel: {
+ all: 'All';
+ close: 'Close';
+ nextMatch: 'Next Match';
+ previousMatch: 'Previous Match';
+ replace: 'Replace';
+ replaceAll: 'Replace All';
+ replaceNext: 'Replace Next';
+ replacePlaceholder: 'Replace';
+ };
+ shortcuts: {
+ actions: {
+ closeEditor: 'Close Editor';
+ closeTab: 'Close Tab';
+ cycleTabs: 'Cycle Tabs';
+ findInFile: 'Find in File';
+ fullPreview: 'Full Preview';
+ goToLine: 'Go to Line';
+ nextTab: 'Next Tab';
+ previousTab: 'Previous Tab';
+ quickOpen: 'Quick Open';
+ redo: 'Redo';
+ save: 'Save';
+ saveAll: 'Save All';
+ searchInFiles: 'Search in Files';
+ selectNextMatch: 'Select Next Match';
+ splitPreview: 'Split Preview';
+ toggleComment: 'Toggle Comment';
+ toggleSidebar: 'Toggle Sidebar';
+ undo: 'Undo';
+ };
+ groups: {
+ editing: 'Editing';
+ fileOperations: 'File Operations';
+ general: 'General';
+ markdown: 'Markdown';
+ navigation: 'Navigation';
+ search: 'Search';
+ };
+ title: 'Keyboard Shortcuts';
+ };
+ sidebar: {
+ explorer: 'Explorer';
+ hide: 'Hide sidebar';
+ hideWithShortcut: 'Hide sidebar ({{shortcut}})';
+ show: 'Show sidebar';
+ showWithShortcut: 'Show sidebar ({{shortcut}})';
+ };
+ statusBar: {
+ disableExternalWatcher: 'Disable external change watcher';
+ disableWatcher: 'Disable file watcher';
+ enableWatcher: 'Enable file watcher';
+ encodingUtf8: 'UTF-8';
+ position: 'Ln {{line}}, Col {{col}}';
+ spaces: 'Spaces: {{count}}';
+ watch: 'watch';
+ watchExternalChanges: 'Watch for external changes';
+ watching: 'watching';
+ };
+ toolbar: {
+ closePreview: 'Close preview';
+ closeSplitPreview: 'Close split preview';
+ disableWordWrap: 'Disable word wrap';
+ enableWordWrap: 'Enable word wrap';
+ };
+ unsavedChanges: 'Unsaved changes';
+ };
+ effortLevel: {
+ label: 'Effort level (optional)';
+ maxDescription: 'Max gives the model the most reasoning time for difficult tasks.';
+ };
+ kanban: {
+ board: {
+ addTask: 'Add task';
+ columnsView: 'Columns view';
+ gridView: 'Grid view';
+ hiddenCount: '{{count}} hidden';
+ noTasks: 'No tasks';
+ showMore: 'Show {{count}} more';
+ trash: 'Trash';
+ };
+ columns: {
+ approved: 'APPROVED';
+ done: 'DONE';
+ inProgress: 'IN PROGRESS';
+ review: 'REVIEW';
+ todo: 'TODO';
+ };
+ filter: {
+ allSessions: 'All sessions';
+ clearAll: 'Clear all';
+ column: 'Column';
+ session: 'Session';
+ teammate: 'Teammate';
+ title: 'Filter tasks';
+ unassigned: '(unassigned)';
+ };
+ grid: {
+ addTask: 'Add task';
+ noTasks: 'No tasks';
+ };
+ search: {
+ clearSearch: 'Clear search';
+ createdAgo: 'created {{time}}';
+ placeholder: 'Search tasks... (#id or text)';
+ tasks: 'Tasks';
+ updatedAgo: 'updated {{time}}';
+ };
+ sort: {
+ options: {
+ createdAt: {
+ description: 'Newest first';
+ label: 'Created';
+ };
+ manual: {
+ description: 'Drag-and-drop order';
+ label: 'Manual';
+ };
+ owner: {
+ description: 'Alphabetically by assignee';
+ label: 'Owner';
+ };
+ updatedAt: {
+ description: 'Recently updated first';
+ label: 'Last updated';
+ };
+ };
+ reset: 'Reset';
+ sortBy: 'Sort by';
+ title: 'Sort tasks';
+ };
+ taskCard: {
+ approve: 'Approve';
+ awaitingLead: 'Awaiting lead';
+ awaitingUser: 'Awaiting user';
+ blockedBy: 'Blocked by';
+ blocks: 'Blocks';
+ cancel: 'Cancel';
+ cancelTask: 'Cancel task {{taskId}}';
+ changes: 'Changes';
+ changesNeedAttention: 'Changes need attention';
+ complete: 'Complete';
+ confirm: 'Confirm';
+ deleteTask: 'Delete task';
+ keep: 'Keep';
+ manualReview: 'Manual review';
+ moveBackToTodoConfirm: 'Move this task back to TODO and notify the team?';
+ newTaskLogsArriving: 'New task logs arriving';
+ requestChanges: 'Request changes';
+ requestReview: 'Request review';
+ start: 'Start';
+ taskLogsActive: 'Task logs active';
+ };
+ title: 'Kanban';
+ trash: {
+ close: 'Close';
+ deleted: 'Deleted';
+ empty: 'No deleted tasks';
+ owner: 'Owner';
+ restore: 'Restore';
+ restoreTask: 'Restore task';
+ subject: 'Subject';
+ title: 'Trash';
+ unassigned: 'Unassigned';
+ };
+ };
+ launch: {
+ actions: {
+ createSchedule: 'Create Schedule';
+ creating: 'Creating...';
+ goToDashboard: 'Go to Dashboard';
+ launchTeam: 'Launch team';
+ launching: 'Launching...';
+ relaunchTeam: 'Relaunch team';
+ relaunching: 'Relaunching...';
+ saveChanges: 'Save Changes';
+ saving: 'Saving...';
+ };
+ billing: {
+ prefix: 'Starting June 15, 2026, Anthropic bills';
+ readArticle: 'Read Anthropic article';
+ suffix: 'and Agent SDK usage from the monthly Agent SDK credit, separate from interactive Claude Code limits. The credit resets each billing cycle and unused credit does not roll over.';
+ };
+ conflict: {
+ description: 'Running two teams in the same directory is risky - they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.';
+ title: 'Another team "{{team}}" is already running for this working directory';
+ workingDirectory: 'Working directory:';
+ };
+ description: {
+ createSchedule: 'Schedule automatic Claude task execution';
+ createScheduleForTeam: 'Schedule automatic runs for team "{{team}}"';
+ editSchedule: 'Editing schedule for team "{{team}}"';
+ launchPrefix: 'Start team';
+ launchSuffix: 'via local Claude CLI.';
+ relaunchPrefix: 'Stop the current run for';
+ relaunchSuffix: 'and start it again via local Claude CLI.';
+ };
+ errors: {
+ launchFailed: 'Failed to launch team';
+ loadProjectsFailed: 'Failed to load projects';
+ relaunchFailed: 'Failed to relaunch team';
+ saveScheduleFailed: 'Failed to save schedule';
+ };
+ optionalSettings: {
+ description: 'Keep the launch flow focused on the project path and only expand this when you want extra control.';
+ relaunchDescription: 'Review the roster and lead runtime before restarting the team.';
+ relaunchTitle: 'Relaunch settings';
+ title: 'Optional launch settings';
+ };
+ prepare: {
+ action: {
+ launch: 'launch';
+ relaunch: 'relaunch';
+ };
+ blocked: 'Runtime environment is not available - {{action}} is blocked';
+ checkingProviders: 'Checking selected providers...';
+ failed: 'Failed to prepare selected providers';
+ preflight: 'Pre-flight check to catch errors before {{action}}';
+ preparingEnvironment: 'Preparing environment...';
+ ready: 'All selected providers are ready.';
+ readyWithNotes: 'All selected providers are ready, with notes.';
+ selectWorkingDirectory: 'Select a working directory to validate the launch environment.';
+ someProvidersNeedAttention: 'Some selected providers need attention.';
+ unsupportedPreload: 'Current preload version does not support team:prepareProvisioning. Restart the dev app.';
+ };
+ prompt: {
+ label: 'Prompt';
+ oneShotPrefix: 'This prompt will be passed to';
+ oneShotSuffix: 'for one-shot execution';
+ saved: 'Saved';
+ schedulePlaceholder: 'Instructions for Claude to execute on schedule...';
+ teamLeadOptional: 'Prompt for team lead (optional)';
+ teamLeadPlaceholder: 'Instructions for team lead...';
+ };
+ providerChanged: 'Provider changed from {{from}} to {{to}}. The previous lead session will not be resumed, and the lead will start with fresh context so the new runtime is applied correctly.';
+ relaunchFreshSession: 'Team relaunch starts a fresh lead session. Durable team state, task board, and member configuration are rehydrated into the launch prompt.';
+ relaunchWarning: {
+ description: 'Saving these settings will stop the current team process, persist the updated roster, and launch the team again with the new runtime.';
+ title: 'Relaunch will restart the current team run';
+ };
+ schedule: {
+ labelOptional: 'Label (optional)';
+ labelPlaceholder: 'e.g., Daily code review, Nightly tests...';
+ maxBudgetUsd: 'Max budget (USD)';
+ maxTurns: 'Max turns';
+ noLimit: 'No limit';
+ noMatches: 'No teams match your search.';
+ noTeams: 'No teams available. Create a team first.';
+ searchTeams: 'Search teams...';
+ selectTeam: 'Select a team...';
+ team: 'Team';
+ title: 'Schedule';
+ };
+ title: {
+ createSchedule: 'Create Schedule';
+ editSchedule: 'Edit Schedule';
+ launch: 'Launch Team';
+ relaunch: 'Relaunch Team';
+ };
+ validation: {
+ fixMemberNames: 'Fix member names before launch';
+ memberNamesUnique: 'Member names must be unique before launch';
+ openCodeLeadModelRequired: 'OpenCode lead requires a selected model.';
+ openCodeTeammateRequired: 'OpenCode lead requires at least one OpenCode teammate.';
+ selectWorkingDirectory: 'Select working directory (cwd)';
+ };
+ };
+ layout: {
+ maxPanesReached: 'Maximum of {{count}} panes reached';
+ };
+ list: {
+ actions: {
+ copyTeam: 'Copy team';
+ createTeam: 'Create Team';
+ deleteForever: 'Delete forever';
+ deletePermanently: 'Delete permanently';
+ deleteTeam: 'Delete team';
+ launchTeam: 'Launch team';
+ launching: 'Launching...';
+ relaunchTeam: 'Relaunch team';
+ restore: 'Restore';
+ restoreTeam: 'Restore team';
+ retry: 'Retry';
+ stopTeam: 'Stop team';
+ stopping: 'Stopping...';
+ };
+ deleteDraft: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Delete';
+ message: 'Delete draft team "{{teamName}}"? This cannot be undone.';
+ title: 'Delete draft';
+ };
+ deleteForever: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Delete forever';
+ message: 'Delete team "{{teamName}}" permanently? All data will be lost.';
+ title: 'Delete permanently';
+ };
+ electronOnly: {
+ description: 'In browser mode, access to local `~/.claude/teams` directories is not available.';
+ title: 'Teams is only available in Electron mode';
+ };
+ empty: {
+ description: 'Create a team here to get started. It will show up in the list automatically.';
+ localOnly: 'Team creation is only available in local Electron mode.';
+ title: 'No teams found';
+ };
+ filter: {
+ clearAll: 'Clear all';
+ label: 'Filter teams';
+ projectPriority: 'Project priority';
+ status: 'Status';
+ };
+ loadFailed: 'Failed to load teams';
+ loading: 'Loading teams...';
+ localOnly: 'Only available in local Electron mode.';
+ membersCount: 'Members: {{count}}';
+ membersCount_few: 'Members: {{count}}';
+ membersCount_many: 'Members: {{count}}';
+ membersCount_one: 'Member: {{count}}';
+ membersCount_other: 'Members: {{count}}';
+ moveToTrash: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Move to trash';
+ message: 'Move team "{{teamName}}" to trash? You can restore it later.';
+ title: 'Move to trash';
+ };
+ noDescription: 'No description';
+ noMatches: 'No teams matching current filters';
+ partial: {
+ pending: 'Last launch is still reconciling.';
+ skipped: 'Last launch has skipped teammates.';
+ skippedWithCount: 'Last launch skipped {{count}}/{{expected}} teammate.';
+ skippedWithCount_few: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ skippedWithCount_many: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ skippedWithCount_one: 'Last launch skipped {{count}}/{{expected}} teammate.';
+ skippedWithCount_other: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ stopped: 'Last launch stopped before all teammates joined.';
+ stoppedWithCount: 'Last launch stopped before {{count}}/{{expected}} teammate joined.';
+ stoppedWithCount_few: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ stoppedWithCount_many: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ stoppedWithCount_one: 'Last launch stopped before {{count}}/{{expected}} teammate joined.';
+ stoppedWithCount_other: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ };
+ searchPlaceholder: 'Search teams...';
+ sections: {
+ otherTeams: 'Other teams';
+ projectTeams: 'Teams for {{project}}';
+ selectedProject: 'selected project';
+ };
+ solo: 'Solo';
+ status: {
+ active: 'Active';
+ deleted: 'Deleted';
+ launching: 'Launching...';
+ offline: 'Offline';
+ partialFailure: 'Launch failed partway';
+ partialPending: 'Bootstrap pending';
+ partialSkipped: 'Launch skipped member';
+ running: 'Running';
+ };
+ title: 'Select Team';
+ trash: 'Trash ({{count}})';
+ trash_few: 'Trash ({{count}})';
+ trash_many: 'Trash ({{count}})';
+ trash_one: 'Trash ({{count}})';
+ trash_other: 'Trash ({{count}})';
+ };
+ liveRuntimeStatus: {
+ description: 'Display-only heartbeat and launch state. Process controls remain below.';
+ diagnosticOnly: 'Diagnostic only';
+ lane: '{{lane}} lane';
+ source: 'source: {{source}}';
+ states: {
+ degraded: 'Needs attention';
+ running: 'Running';
+ starting: 'Starting';
+ stopped: 'Stopped';
+ unknown: 'Unknown';
+ waiting: 'Waiting';
+ };
+ title: 'Live runtime status';
+ updated: 'updated {{value}}';
+ };
+ memberDraft: {
+ actions: {
+ remove: 'Remove member';
+ removeAria: 'Remove {{name}}';
+ restore: 'Restore member';
+ restoreAria: 'Restore {{name}}';
+ };
+ addMembers: {
+ description: 'Add new members to {{teamName}}';
+ title: 'Add Members';
+ };
+ anthropicContext: {
+ defaultSetting: 'default context setting';
+ description: "Anthropic context is team-wide for this launch: {{mode}}. Use the lead runtime panel's Limit context checkbox to change it.";
+ limitEnabled: '200K limit enabled';
+ };
+ mcp: {
+ agentTeamsMcp: 'Agent Teams MCP';
+ buttonInherit: 'MCP inherit';
+ buttonScopes: 'MCP scopes';
+ chooseScopes: 'Choose scopes';
+ inheritLead: 'Inherit lead';
+ lockedInfo: 'Agent Teams MCP only is enabled for all teammates. This teammate will launch with only the Agent Teams server.';
+ mode: 'MCP mode';
+ scopes: {
+ local: 'local';
+ project: 'project';
+ user: 'user';
+ };
+ serverNames: 'Server names';
+ settingInfo: 'Agent Teams MCP launches this teammate with only the Agent Teams server. Scope and allowlist modes apply only to this teammate launch.';
+ strictAllowlist: 'Strict allowlist';
+ tooltip: "{{label}}: Control this member's MCP inheritance policy";
+ };
+ model: {
+ ariaLabel: '{{provider}} provider, {{model}}';
+ currentLeadRuntime: 'Current lead runtime';
+ default: 'Default';
+ inheritedTooltip: 'Provider, model, and effort are inherited from the lead while sync is enabled.';
+ leadSuffix: '{{label}} (lead)';
+ liveDisabled: 'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.';
+ lockedActionFallback: 'Lead runtime changes open Relaunch Team, where provider, model, and effort can be updated.';
+ restartWholeTeam: 'Saving those runtime changes restarts the whole team.';
+ };
+ nameAria: 'Member {{index}} name';
+ nameFallback: 'member {{index}}';
+ noRole: 'No role';
+ placeholders: {
+ mcpServers: 'github, sentry';
+ name: 'member-name';
+ };
+ removed: 'Removed';
+ workflow: {
+ addTooltip: 'Add teammate workflow';
+ editTooltip: 'Edit teammate workflow';
+ label: 'Workflow (optional)';
+ placeholder: 'How this agent should behave, interact with others...';
+ saved: 'Saved';
+ };
+ worktree: {
+ description: 'Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.';
+ label: 'Worktree';
+ };
+ };
+ memberLogStream: {
+ filters: {
+ all: 'All';
+ };
+ logs: {
+ emptyDescription: 'Member-scoped transcript or runtime logs will appear here when available.';
+ emptyTitle: 'No log stream entries were found for this member yet.';
+ loading: 'Loading member log stream...';
+ title: 'Logs';
+ };
+ tabs: {
+ execution: 'Execution';
+ process: 'Process';
+ };
+ };
+ memberWorkSync: {
+ details: {
+ actionableItems: 'Actionable items';
+ diagnostics: 'Diagnostics: {{diagnostics}}';
+ fingerprint: 'Fingerprint';
+ moreActionableItems: '{{count}} more actionable item(s)';
+ no: 'no';
+ none: 'none';
+ report: 'Report';
+ shadowWouldNudge: 'Shadow would nudge';
+ title: 'Member work sync';
+ yes: 'yes';
+ };
+ diagnosticsUnavailable: 'Member work sync diagnostics are unavailable.';
+ loadingDiagnostics: 'Loading member work sync diagnostics.';
+ title: 'Member work sync';
+ };
+ members: {
+ actions: {
+ assignTask: 'Assign task';
+ editRole: 'Edit role';
+ openProfile: 'Open profile';
+ sendMessage: 'Send message';
+ };
+ badges: {
+ worktree: 'worktree';
+ };
+ detail: {
+ assignTask: 'Assign Task';
+ copyDiagnostics: 'Copy diagnostics';
+ failedToRestartMember: 'Failed to restart member';
+ legacyLogsFallback: 'Legacy Logs Fallback';
+ pid: 'PID {{pid}}';
+ relaunchOpenCode: 'Relaunch OpenCode';
+ remove: 'Remove';
+ removedAt: 'Removed {{date}}';
+ restart: 'Restart';
+ sendMessage: 'Send Message';
+ };
+ editor: {
+ addMember: 'Add member';
+ agentTeamsMcpOnly: 'Agent Teams MCP only';
+ editAsJson: 'Edit as JSON';
+ memberNamesUnique: 'Member names must be unique';
+ removedCount: 'Removed ({{count}})';
+ removedModelLockReason: 'Removed members are kept for soft delete history. Restore them to edit settings.';
+ runInSeparateWorktrees: 'Run teammates in separate worktrees';
+ title: 'Members';
+ };
+ executionLog: {
+ agentInstructions: 'Agent instructions';
+ agentTurn: 'Agent turn';
+ empty: 'Nothing to display';
+ emptyUserMessage: '{{time}} - (empty)';
+ memberTurn: '{{member}} turn';
+ turn: 'turn';
+ };
+ leadModel: {
+ anthropicContextLimit: 'The 200K context limit is team-wide for Anthropic runtimes in this launch, including custom Anthropic teammates.';
+ anthropicTeamWide: 'Anthropic team-wide';
+ defaultModel: 'Default';
+ leadShort: 'lead';
+ providerModelAria: '{{provider}} provider, {{model}}';
+ runtimeInheritance: 'Lead runtime applies to teammates unless they set their own provider or model.';
+ syncWithTeammates: 'Sync model with teammates';
+ teamLead: 'Team Lead';
+ };
+ list: {
+ loading: 'Loading team members';
+ removedCount: 'Removed ({{count}})';
+ soloLeadOnly: 'Solo team - lead only';
+ unavailable: 'Member roster unavailable';
+ unavailableDescription: '{{count}} teammates are known from team metadata, but roster details are missing.';
+ unavailableDescription_few: '{{count}} teammates are known from team metadata, but roster details are missing.';
+ unavailableDescription_many: '{{count}} teammates are known from team metadata, but roster details are missing.';
+ unavailableDescription_one: '{{count}} teammate is known from team metadata, but roster details are missing.';
+ unavailableDescription_other: '{{count}} teammates are known from team metadata, but roster details are missing.';
+ };
+ logs: {
+ active: 'active';
+ empty: 'No logs found';
+ failedToLoadDetails: 'Failed to load details';
+ hideDetails: 'Hide details';
+ leadSessionTooltip: 'Full team lead session logs - useful for global orchestration context, not specific to this agent';
+ loadingDetails: 'Loading details...';
+ memberSessionTooltip: 'Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file';
+ noMemberActivity: 'This member has no recorded session activity yet';
+ noTaskActivity: 'No session activity for this task yet';
+ searching: 'Searching logs...';
+ showDetails: 'Show details';
+ startedAt: 'started {{time}}';
+ waitingForTaskActivity: 'Task is in progress - waiting for session activity (auto-refreshing)...';
+ };
+ messages: {
+ empty: {
+ loading: 'Loading activity...';
+ noActivity: 'No activity with this member';
+ noComments: 'No comments for this member';
+ noLoadedActivity: 'No loaded activity for this member yet';
+ noLoadedMessages: 'No loaded messages for this member yet';
+ noMessages: 'No messages with this member';
+ };
+ filters: {
+ all: 'All';
+ comments: 'Comments';
+ messages: 'Messages';
+ };
+ loadOlder: 'Load older messages';
+ };
+ recentMessages: {
+ collapse: 'Collapse';
+ expand: 'Expand';
+ latest: 'Latest messages';
+ latestForMember: 'Latest messages - {{member}}';
+ loadMore: 'Load more';
+ };
+ roleSelect: {
+ customRolePlaceholder: 'Enter custom role...';
+ };
+ runtimeLogs: {
+ autoRefresh: 'Auto-refresh';
+ empty: 'No process log file captured for this member yet.';
+ loadingTail: 'Loading process log tail...';
+ wrapLines: 'Wrap lines';
+ };
+ runtimeTelemetry: {
+ cpu: 'CPU';
+ description: 'Parent and child processes only. Remote LLM inference is not included.';
+ memory: 'Memory';
+ processTreeCapped: 'Process tree was capped for this sample.';
+ rssHint: 'RSS can include shared pages, so it is best read as a load signal, not exclusive memory.';
+ sharedHost: 'Shared OpenCode host metric. It is not exclusive to this member.';
+ summedRss: 'summed RSS';
+ title: 'Local runtime load';
+ };
+ stats: {
+ computing: 'Computing stats...';
+ empty: 'No stats available';
+ files: 'Files';
+ filesTouched: 'Files Touched ({{count}})';
+ footer: '{{count}} sessions · computed {{computedAgo}}';
+ footer_few: '{{count}} sessions · computed {{computedAgo}}';
+ footer_many: '{{count}} sessions · computed {{computedAgo}}';
+ footer_one: '{{count}} session · computed {{computedAgo}}';
+ footer_other: '{{count}} sessions · computed {{computedAgo}}';
+ lines: 'Lines';
+ linesInfo: 'Approximate. Accurate for Edit and Write tools. Bash file writes are estimated from command patterns (heredoc, echo, sed) and may be underreported.';
+ moreFiles: '+{{count}} more';
+ showLess: 'Show less';
+ tokens: 'Tokens';
+ toolCalls: 'Tool Calls';
+ toolUsage: 'Tool Usage';
+ viewAllChanges: 'View All Changes';
+ };
+ tasks: {
+ empty: 'No tasks assigned to this member';
+ };
+ };
+ messageComposer: {
+ actions: {
+ send: 'Send';
+ sendingUnavailableLaunching: 'Sending unavailable while team is launching';
+ voiceToText: 'Voice to text';
+ };
+ attachments: {
+ attachFiles: 'Attach files (paste or drag & drop)';
+ disabledHint: 'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.';
+ restrictions: {
+ crossTeam: 'File attachments are not supported for cross-team messages';
+ leadOnly: 'Files can only be sent to the team lead';
+ maximumReached: 'Maximum attachments reached';
+ openCodeOffline: 'Team must be online to attach files for OpenCode teammates';
+ sending: 'Wait for current message to finish sending before adding files';
+ teamOffline: 'Team must be online to attach files';
+ unsupportedRecipient: 'Files can be sent to the team lead or OpenCode teammates';
+ };
+ unavailable: 'Attachments are unavailable';
+ };
+ crossTeam: {
+ hint: 'Tip: Cross-team messages go to the target team lead. If you want the reply to come back to your team lead instead of you, say that explicitly in the message.';
+ };
+ input: {
+ charsLeft: '{{count}} chars left';
+ charsLeft_few: '{{count}} chars left';
+ charsLeft_many: '{{count}} chars left';
+ charsLeft_one: '{{count}} char left';
+ charsLeft_other: '{{count}} chars left';
+ crossTeamPlaceholder: 'Cross-team message to {{team}}...';
+ placeholder: 'Write a message... (Enter to send, Shift+Enter for new line)';
+ slashTip: 'Tip: You can use "/" to run any Claude commands.';
+ teamFallback: 'team';
+ teamLaunchingPlaceholder: 'Team is launching... message will be queued for inbox delivery.';
+ };
+ recipient: {
+ noResults: 'No results';
+ searchPlaceholder: 'Search...';
+ select: 'Select...';
+ };
+ slash: {
+ restrictions: {
+ attachments: 'Slash commands require a live team lead and cannot be sent with attachments';
+ crossTeam: 'Slash commands can only be run on the current team lead';
+ leadOffline: 'Slash commands require the team lead to be online';
+ notLead: 'Slash commands can only be sent to the team lead';
+ };
+ };
+ status: {
+ reusedCrossTeamRequest: 'Reused recent cross-team request';
+ teamOffline: 'Team offline';
+ };
+ teamSelector: {
+ current: 'current';
+ offline: 'offline';
+ offlineTitle: 'Offline';
+ online: 'online';
+ onlineTitle: 'Online';
+ thisTeam: 'This team';
+ };
+ };
+ messages: {
+ actionMode: {
+ label: 'Action mode';
+ };
+ actions: {
+ bottomSheetActions: 'Message bottom sheet actions';
+ collapseAll: 'Collapse all messages';
+ collapseSheet: 'Collapse sheet';
+ expandAll: 'Expand all messages';
+ expandSheet: 'Expand sheet';
+ floatComposer: 'Float composer';
+ floatMessagesComposer: 'Float messages composer';
+ hideSearch: 'Hide search';
+ loadOlder: 'Load older messages';
+ markAllRead: 'Mark all as read';
+ messageActions: 'Message actions';
+ moveMessagesToBottomSheet: 'Move messages to bottom sheet';
+ moveMessagesToSidebar: 'Move messages to sidebar';
+ moveToBottomSheet: 'Move to bottom sheet';
+ moveToInline: 'Move to inline';
+ moveToSidebar: 'Move to sidebar';
+ panelActions: 'Message panel actions';
+ searchMessages: 'Search messages';
+ };
+ delivery: {
+ copied: 'Copied';
+ copyDebugDetails: 'Copy debug details';
+ details: 'Details';
+ fields: {
+ acceptanceUnknown: 'acceptanceUnknown';
+ delivered: 'delivered';
+ diagnostics: 'diagnostics';
+ ledgerStatus: 'ledgerStatus';
+ messageId: 'messageId';
+ providerId: 'providerId';
+ queuedBehindMessageId: 'queuedBehindMessageId';
+ reason: 'reason';
+ responsePending: 'responsePending';
+ responseState: 'responseState';
+ statusMessageId: 'statusMessageId';
+ userVisibleMessage: 'userVisibleMessage';
+ userVisibleNextReviewAt: 'userVisibleNextReviewAt';
+ userVisibleReasonCode: 'userVisibleReasonCode';
+ userVisibleState: 'userVisibleState';
+ visibleReplyCorrelation: 'visibleReplyCorrelation';
+ visibleReplyMessageId: 'visibleReplyMessageId';
+ };
+ };
+ filter: {
+ actions: {
+ reset: 'Reset';
+ save: 'Save';
+ };
+ ariaLabel: 'Filter messages';
+ from: 'From';
+ noData: 'No data';
+ showStatusUpdates: 'Show status updates (idle/shutdown)';
+ to: 'To';
+ tooltip: 'Filter messages';
+ };
+ panelMode: 'Message panel mode';
+ search: {
+ placeholder: 'Search...';
+ };
+ status: {
+ title: 'Status';
+ };
+ title: 'Messages';
+ unread: {
+ new: '{{count}} new';
+ new_few: '{{count}} new';
+ new_many: '{{count}} new';
+ new_one: '{{count}} new';
+ new_other: '{{count}} new';
+ unread: '{{count}} unread';
+ unread_few: '{{count}} unread';
+ unread_many: '{{count}} unread';
+ unread_one: '{{count}} unread';
+ unread_other: '{{count}} unread';
+ };
+ };
+ modelSelector: {
+ advisory: {
+ note: 'Note';
+ pingNotConfirmed: 'Ping not confirmed';
+ };
+ anthropicExtraUsage: {
+ pricingDocs: 'Read Anthropic pricing docs';
+ };
+ badges: {
+ configured: 'Configured';
+ connected: 'Connected';
+ failed: 'Failed';
+ free: 'Free';
+ issue: 'Issue';
+ local: 'Local';
+ needsTest: 'Needs test';
+ unavailable: 'Unavailable';
+ verified: 'Verified';
+ };
+ customModelId: 'Custom model id';
+ defaultModel: 'Default';
+ defaultTooltip: {
+ anthropic: 'Uses the Claude team default model.\nResolves to {{longContextModel}} with 1M context, or {{limitedContextModel}} with 200K context when Limit context is enabled.';
+ anthropicCompatible: 'Uses the Anthropic-compatible endpoint default model.';
+ anthropicCompatibleWithResolved: 'Uses the Anthropic-compatible endpoint default model.\nCurrently resolves to {{model}}.';
+ openCode: 'Uses the OpenCode runtime default model.';
+ openCodeWithResolved: 'Uses the OpenCode default model.\nCurrently resolves to {{model}}.';
+ runtime: 'Uses the runtime default for the selected provider.';
+ };
+ empty: {
+ freeOpenCode: 'No free OpenCode models are available in the current runtime list.';
+ noModels: 'No models are available in the current runtime list.';
+ noSearchMatches: 'No models match this search.';
+ recommendedFreeOpenCode: 'No recommended free OpenCode models are available in the current runtime list.';
+ recommendedOpenCode: 'No recommended OpenCode models are available in the current runtime list.';
+ };
+ fastMode: {
+ codexLabel: 'Fast mode (2x credits)';
+ defaultFast: 'Default (Fast)';
+ defaultOff: 'Default (Off)';
+ defaultResolvesTo: 'Default currently resolves to {{mode}}.';
+ fast: 'Fast';
+ off: 'Off';
+ optionalLabel: 'Fast mode (optional)';
+ runtimeBackedHint: 'Fast mode is runtime-backed and only unlocks when the resolved Anthropic launch model supports it.';
+ };
+ label: 'Model (optional)';
+ multimodelOff: 'Multimodel off';
+ multimodelRequired: 'Codex and Gemini require Multimodel mode.';
+ openCode: {
+ allSources: 'All OpenCode sources';
+ filterSource: 'Filter {{source}}';
+ filterSources: 'Filter OpenCode sources';
+ freeOnly: 'Free only';
+ freeTooltip: 'OpenCode marks this model as free.';
+ loadingModels: 'Loading OpenCode models...';
+ noSourcesFound: 'No sources found.';
+ recommendedOnly: 'Recommended only';
+ searchSources: 'Search sources';
+ sourcesCount: '{{count}} OpenCode sources';
+ sourcesCount_few: '{{count}} OpenCode sources';
+ sourcesCount_many: '{{count}} OpenCode sources';
+ sourcesCount_one: '{{count}} OpenCode sources';
+ sourcesCount_other: '{{count}} OpenCode sources';
+ };
+ openCodeStatus: {
+ badges: {
+ check: 'Check';
+ free: 'Free';
+ install: 'Install';
+ setup: 'Setup';
+ };
+ freeModelsAvailableTitle: 'OpenCode free models are available';
+ loadingRuntime: 'OpenCode runtime status is still loading.';
+ messages: {
+ checking: 'The app is still checking the OpenCode runtime. Wait for provider status to finish, then try again.';
+ freeAvailable: 'OpenCode is detected. You can use free OpenCode models such as Big Pickle without connecting a provider. Connect a provider only when you want provider-backed models.';
+ launchBlocked: 'OpenCode is installed and authenticated, but Agent Teams launch readiness is blocked.';
+ noFreeListed: 'OpenCode is detected, but no free OpenCode model is listed yet. Refresh provider status, or connect a provider in OpenCode for provider-backed models.';
+ ready: 'OpenCode is ready for team launch.';
+ unsupported: 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.';
+ };
+ notReadyTitle: 'OpenCode is not ready for team launch';
+ providerNotConnectedTitle: 'OpenCode provider is not connected';
+ readyMessage: 'OpenCode passed provider readiness. Select it to use OpenCode models for this team.';
+ readyTitle: 'OpenCode is ready';
+ summary: {
+ checking: 'OpenCode status: checking runtime';
+ status: 'OpenCode status: {{parts}}';
+ };
+ summaryParts: {
+ freeWithoutAuth: 'free models available without auth';
+ providerConnected: 'provider connected';
+ providerModelsNeedSetup: 'provider-backed models need setup';
+ providerNotConnected: 'provider not connected';
+ providerOptional: 'provider connection optional';
+ runtimeDetected: 'runtime detected';
+ runtimeMissing: 'runtime missing';
+ teamLaunchBlocked: 'team launch blocked';
+ teamLaunchReady: 'team launch ready';
+ };
+ useOpenCode: 'Use OpenCode';
+ };
+ placeholders: {
+ customModelId: 'openai/gpt-oss-20b';
+ };
+ pricing: {
+ cacheReadTitle: 'Cache read: {{rate}} per 1M tokens';
+ cacheWriteTitle: 'Cache write: {{rate}} per 1M tokens';
+ free: 'Free';
+ inputShort: 'in {{rate}}';
+ inputTitle: 'Input: {{rate}} per 1M tokens';
+ outputShort: 'out {{rate}}';
+ outputTitle: 'Output: {{rate}} per 1M tokens';
+ perMillionSummary: '{{summary}} / 1M';
+ };
+ reason: 'Reason: {{reason}}';
+ routeGroups: {
+ builtinFree: 'Free built-in';
+ connectedProviders: 'Connected providers';
+ openCodeConfig: 'OpenCode config';
+ otherCatalog: 'Other OpenCode catalog';
+ };
+ runtimeModelsSyncing: 'Explicit models load from the current runtime. Default remains available while the list is syncing.';
+ searchModels: 'Search models';
+ unavailableInRuntime: 'Unavailable in current runtime';
+ };
+ openCodeContextConfigHint: {
+ and: 'and';
+ compactionConfig: 'Compaction config';
+ description: 'Add matching limits to the OpenCode config for the provider and model used by this teammate. This helps OpenCode compact and prune before local models overflow their context window.';
+ promptInstructionsSuffix: 'are weaker because the request is assembled before the model reads them.';
+ providerLimits: 'Provider limits';
+ replacePrefix: 'Replace';
+ replaceSuffix: 'with the provider and model IDs from your OpenCode setup. Prompt instructions like';
+ summary: 'OpenCode local models can use an OpenCode context budget instead of prompt-only limits.';
+ };
+ permissions: {
+ autoApproveAllTools: 'Auto-approve all tools';
+ autonomousModeDescription: 'Autonomous mode: team tools execute without confirmation. Be cautious with untrusted code.';
+ manualModeDescription: "Manual mode: you'll approve or deny each tool call in real time.";
+ };
+ processes: {
+ ago: '{{time}} ago';
+ kill: 'Kill';
+ open: 'Open';
+ openInBrowser: 'Open in browser';
+ pid: 'PID{{pid}}';
+ running: 'Running';
+ stopProcess: 'Stop process (SIGTERM)';
+ stopped: 'Stopped';
+ stoppedAgo: 'stopped {{time}} ago';
+ title: 'CLI Processes';
+ };
+ projectPath: {
+ browse: 'Browse';
+ createAutomatically: 'If the directory does not exist, it will be created automatically.';
+ customWorkingDirectory: 'Custom working directory';
+ deleted: {
+ label: 'Deleted';
+ title: 'Project folder no longer exists';
+ };
+ empty: 'Nothing found';
+ label: 'Project';
+ loadingProjects: 'Loading projects...';
+ mode: {
+ customPath: 'Custom path';
+ projectList: 'From project list';
+ };
+ noProjects: 'No projects found, switch to custom path.';
+ searchPlaceholder: 'Search project by name or path';
+ selectFromList: 'Select a project from the list';
+ selectProject: 'Select a project...';
+ source: {
+ claude: 'Found by Claude';
+ codex: 'Found by Codex';
+ mixed: 'Found by Claude and Codex';
+ };
+ };
+ provisioning: {
+ cancel: 'Cancel';
+ cliLogs: 'CLI logs';
+ copied: 'Copied';
+ copyDiagnostics: 'Copy diagnostics';
+ diagnostics: 'Diagnostics';
+ diagnosticsCopied: 'Diagnostics copied';
+ liveOutput: 'Live output';
+ moreWarningsHidden: '{{count}} more warnings hidden';
+ noOutput: 'No output captured yet.';
+ pid: 'PID {{pid}}';
+ presentation: {
+ awaitingPermission: '{{count}} teammate awaiting permission approval';
+ bootstrapStalled: 'Bootstrap stalled: {{names}}';
+ bootstrapStalledWithOpenCodeWait: '{{stalled}}; Waiting for OpenCode: {{names}}';
+ countPendingDiagnostic: '{{count}} {{label}}';
+ failed: {
+ memberFailedToStart: '{{name}} failed to start';
+ teammatesFailedRatio: '{{count}}/{{total}} teammates failed to start';
+ teammatesFailedToStart: '{{count}} teammates failed to start';
+ };
+ joining: {
+ teammatesConfirmedRatio: '{{count}}/{{total}} teammates confirmed';
+ teammatesStillJoining: '{{count}} teammates still joining';
+ };
+ nameListWithMore: '{{names}}, +{{count}} more';
+ namedPendingDiagnostic: '{{label}}: {{names}}';
+ panel: {
+ coreTeamReady: 'Core team ready';
+ finishingLaunch: 'Finishing launch';
+ launchContinuedSkipped: 'Launch continued with skipped teammates';
+ launchDetails: 'Launch details';
+ launchFailed: 'Launch failed';
+ launchFinishedWithErrors: 'Launch finished with errors';
+ launchingTeam: 'Launching team';
+ teamLaunched: 'Team launched';
+ };
+ pendingLabels: {
+ awaitingPermission: 'Awaiting permission';
+ awaitingPermissionLower: 'awaiting permission';
+ bootstrapStalled: 'Bootstrap stalled';
+ bootstrapUnconfirmed: 'Bootstrap unconfirmed';
+ bootstrapUnconfirmedLower: 'bootstrap unconfirmed';
+ shellOnly: 'Shell-only';
+ shellOnlyLower: 'shell-only';
+ waitingForBootstrap: 'Waiting for bootstrap';
+ waitingForBootstrapLower: 'waiting for bootstrap';
+ waitingForRuntime: 'Waiting for runtime';
+ waitingForRuntimeLower: 'waiting for runtime';
+ };
+ ready: {
+ allTeammatesJoined: 'All {{count}} teammates joined';
+ launchContinuedSkipped: 'Launch continued - {{count}}/{{total}} teammates skipped';
+ launchFinishedWithErrors: 'Launch finished with errors - {{count}}/{{total}} teammates failed to start';
+ leadOnline: 'Lead online';
+ teamLaunchedAllJoined: 'Team launched - all {{count}} teammates joined';
+ teamLaunchedLeadOnline: 'Team launched - lead online';
+ teamProvisionedAllJoined: 'Team provisioned - all {{count}} teammates joined';
+ teamProvisionedLeadOnline: 'Team provisioned - lead online';
+ teamProvisionedStillJoining: 'Team provisioned - teammates are still joining';
+ };
+ skipped: {
+ memberSkipped: '{{name}} skipped for this launch';
+ memberSkippedCompact: '{{name}} skipped';
+ memberSkippedWithReason: '{{name}} skipped for this launch - {{reason}}';
+ teammatesSkipped: '{{count}} teammates skipped';
+ teammatesSkippedList: 'Skipped teammates: {{list}}';
+ teammatesSkippedRatio: '{{count}}/{{total}} teammates skipped for this launch';
+ };
+ waitingForOpenCode: 'Waiting for OpenCode: {{names}}';
+ };
+ providerStatus: {
+ copied: 'Copied';
+ copyDiagnostics: 'Copy diagnostics';
+ deepVerificationPending: 'Deep verification is still running. OpenCode free models may take around 20 seconds.';
+ detailSummary: {
+ authenticationRequired: 'Authentication required';
+ cliBinaryCouldNotStart: 'CLI binary could not be started';
+ cliBinaryMissing: 'CLI binary missing';
+ cliPreflightFailed: 'CLI preflight failed';
+ cliPreflightIncomplete: 'CLI preflight did not complete';
+ needsAttention: 'Needs attention';
+ openCodeMcpUnreachable: 'OpenCode app MCP unreachable';
+ openCodeNoOutput: 'OpenCode runtime check returned no output';
+ openCodeRuntimeMissing: 'OpenCode runtime missing';
+ openCodeWindowsAccessBlocked: 'OpenCode Windows access blocked';
+ readyWithNotes: 'Ready with notes';
+ runtimeProviderNotConfigured: 'Runtime provider is not configured';
+ selectedModelAvailable: 'Selected model available';
+ selectedModelCheckFailed: 'Selected model check failed';
+ selectedModelCompatibilityPending: 'Selected model compatibility pending';
+ selectedModelCompatible: 'Selected model compatible';
+ selectedModelDeferred: 'Selected model verification deferred';
+ selectedModelPingNotConfirmed: 'Selected model ping not confirmed';
+ selectedModelTimedOut: 'Selected model verification timed out';
+ selectedModelUnavailable: 'Selected model unavailable';
+ selectedModelVerified: 'Selected model verified';
+ workingDirectoryMissing: 'Working directory missing';
+ };
+ failureHints: {
+ authenticationRequired: 'Authenticate the required provider in Claude CLI, then reopen this dialog.';
+ cliBinaryMissing: 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.';
+ default: 'Resolve the issue above, then reopen this dialog.';
+ openCodeAccessDenied: 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
+ openCodeAppMcpUnreachable: 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.';
+ openCodeBridgeNoOutput: 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.';
+ openCodeRuntimeMissing: 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.';
+ runtimeProviderNotConfigured: 'Configure the selected provider runtime, then reopen this dialog.';
+ workingDirectoryMissing: 'Choose an existing working directory, then reopen this dialog.';
+ };
+ modelChecksSummary: 'Selected model checks - {{details}}';
+ modelParts: {
+ available: '{{count}} available';
+ available_few: '{{count}} available';
+ available_many: '{{count}} available';
+ available_one: '{{count}} available';
+ available_other: '{{count}} available';
+ checkFailed: '{{count}} model check failed';
+ checkFailed_few: '{{count}} models check failed';
+ checkFailed_many: '{{count}} models check failed';
+ checkFailed_one: '{{count}} model check failed';
+ checkFailed_other: '{{count}} models check failed';
+ checking: '{{count}} checking';
+ checking_few: '{{count}} checking';
+ checking_many: '{{count}} checking';
+ checking_one: '{{count}} checking';
+ checking_other: '{{count}} checking';
+ compatibilityPending: '{{count}} compatible, deep verification pending';
+ compatibilityPending_few: '{{count}} compatible, deep verification pending';
+ compatibilityPending_many: '{{count}} compatible, deep verification pending';
+ compatibilityPending_one: '{{count}} compatible, deep verification pending';
+ compatibilityPending_other: '{{count}} compatible, deep verification pending';
+ compatible: '{{count}} compatible';
+ compatible_few: '{{count}} compatible';
+ compatible_many: '{{count}} compatible';
+ compatible_one: '{{count}} compatible';
+ compatible_other: '{{count}} compatible';
+ deferred: '{{count}} verification deferred';
+ deferred_few: '{{count}} verification deferred';
+ deferred_many: '{{count}} verification deferred';
+ deferred_one: '{{count}} verification deferred';
+ deferred_other: '{{count}} verification deferred';
+ pingNotConfirmed: '{{count}} ping not confirmed';
+ pingNotConfirmed_few: '{{count}} ping not confirmed';
+ pingNotConfirmed_many: '{{count}} ping not confirmed';
+ pingNotConfirmed_one: '{{count}} ping not confirmed';
+ pingNotConfirmed_other: '{{count}} ping not confirmed';
+ timedOut: '{{count}} model timed out';
+ timedOut_few: '{{count}} models timed out';
+ timedOut_many: '{{count}} models timed out';
+ timedOut_one: '{{count}} model timed out';
+ timedOut_other: '{{count}} models timed out';
+ unavailable: '{{count}} model unavailable';
+ unavailable_few: '{{count}} models unavailable';
+ unavailable_many: '{{count}} models unavailable';
+ unavailable_one: '{{count}} model unavailable';
+ unavailable_other: '{{count}} models unavailable';
+ verified: '{{count}} verified';
+ verified_few: '{{count}} verified';
+ verified_many: '{{count}} verified';
+ verified_one: '{{count}} verified';
+ verified_other: '{{count}} verified';
+ };
+ openProviderSettings: 'Open {{provider}} settings';
+ progress: {
+ checkingProvider: 'Checking {{provider}} provider...';
+ checkingProviders: 'Checking {{providers}} providers...';
+ checkingSelectedProviders: 'Checking selected providers in parallel...';
+ };
+ status: {
+ checking: 'checking...';
+ failed: 'ERR';
+ notes: 'OK (notes)';
+ pending: 'waiting';
+ ready: 'OK';
+ };
+ };
+ steps: {
+ assembling: 'Members joining';
+ configuring: 'Team setup';
+ finalizing: 'Finalizing';
+ starting: 'Starting';
+ };
+ };
+ review: {
+ conflict: {
+ cancel: 'Cancel';
+ description: "This file has been modified since the agent's changes";
+ editManually: 'Edit Manually';
+ keepCurrent: 'Keep Current';
+ saveResolution: 'Save Resolution';
+ title: 'Conflict Detected';
+ useOriginal: 'Use Original';
+ };
+ continuousScroll: {
+ empty: 'No reviewable file changes';
+ };
+ diffControls: {
+ acceptChange: 'Accept change (⌘Y)';
+ acceptShortcut: '⌘Y';
+ keep: 'Keep';
+ nextChunk: 'Next chunk';
+ previousChunk: 'Previous chunk';
+ rejectChange: 'Reject change (⌘N)';
+ rejectShortcut: '⌘N';
+ undo: 'Undo';
+ };
+ diffError: {
+ actions: {
+ retry: 'Retry';
+ };
+ raw: {
+ charsTotal: '... ({{count}} chars total)';
+ charsTotal_few: '... ({{count}} chars total)';
+ charsTotal_many: '... ({{count}} chars total)';
+ charsTotal_one: '... ({{count}} char total)';
+ charsTotal_other: '... ({{count}} chars total)';
+ file: 'File: {{file}}';
+ modified: '+++ Modified';
+ original: '--- Original';
+ show: 'Show raw diff data';
+ };
+ title: 'Failed to render diff view';
+ unexpected: 'An unexpected error occurred while rendering the diff.';
+ };
+ empty: {
+ noFileChangesRecorded: 'No file changes recorded';
+ noFileEvents: 'The task ledger has no file events for this task.';
+ noFileEventsYet: 'The task ledger has no file events for this task yet.';
+ noSafeDiff: 'No safe diff available';
+ noSafeDiffDescription: 'The task ledger did not expose a safe file diff for this task.';
+ noSafeDiffDiagnosticsDescription: 'The task ledger did not expose a safe file diff for this task. The diagnostics below explain why.';
+ };
+ fileHeader: {
+ actions: {
+ accept: 'Accept';
+ discard: 'Discard';
+ discardTooltip: 'Discard all edits for this file';
+ keepMyDraft: 'Keep my draft';
+ reject: 'Reject';
+ reloadFromDisk: 'Reload from disk';
+ restore: 'Restore';
+ restoreTooltip: 'Create/restore this file on disk from the preview';
+ saveFile: 'Save File';
+ saveFileTooltip: 'Save file to disk';
+ };
+ badges: {
+ deleted: 'DELETED';
+ manualReview: 'MANUAL REVIEW';
+ new: 'NEW';
+ worktree: 'WORKTREE';
+ };
+ contentSource: {
+ 'disk-current': 'Current Disk';
+ 'file-history': 'File History';
+ 'git-fallback': 'Git Fallback';
+ 'ledger-exact': 'Task Ledger';
+ 'ledger-snapshot': 'Ledger Snapshot';
+ 'snippet-reconstruction': 'Reconstructed';
+ unavailable: 'Content unavailable';
+ };
+ contentUnavailable: {
+ badge: 'Content unavailable';
+ description: 'The ledger recorded metadata for this change, but full text content is not available. This usually means binary, large, or hash-only content.';
+ safety: 'Automatic accept/reject is disabled for this file to avoid unsafe disk writes.';
+ title: 'Text content is unavailable';
+ };
+ disabled: {
+ acceptRejectContentUnavailable: 'Accept/Reject is disabled because full text content is unavailable.';
+ acceptRejectMissingOnDisk: 'Accept/Reject is disabled while the file is missing on disk.';
+ rejectBaselineUnavailable: 'Reject is disabled because the original baseline is unavailable.';
+ rejectContentUnavailable: 'Reject is disabled because full text content is unavailable.';
+ rejectManualLedgerReview: 'Reject is disabled because this ledger change has binary, large, or unavailable content.';
+ };
+ externalChange: {
+ changedOnDisk: 'Changed on disk';
+ deletedOnDisk: 'Deleted on disk';
+ recreatedOnDisk: 'Recreated on disk';
+ };
+ missingOnDisk: {
+ badge: 'Missing on disk';
+ description: 'We can still show a preview from agent logs, but your filesystem is out of sync.';
+ restorePrefix: 'Use';
+ restoreSuffix: 'to write the preview content back to disk.';
+ restoreUnavailable: 'Full file content is not available to restore automatically.';
+ title: 'File is missing on disk';
+ };
+ pathChange: {
+ from: 'From {{path}}';
+ to: 'To {{path}}';
+ };
+ worktree: {
+ isolated: 'Isolated worktree';
+ };
+ };
+ fileMissingPrefix: 'File is missing on disk. This diff may be only a preview from agent logs. Use';
+ fileMissingSuffix: 'to create the file on disk.';
+ filePlaceholder: {
+ description: 'Preparing a full editor diff for this file.';
+ loading: 'Loading';
+ };
+ fileTree: {
+ badges: {
+ deleted: 'deleted';
+ new: 'new';
+ };
+ collapseFolder: 'Collapse {{name}}';
+ empty: {
+ noChangedFiles: 'No changed files';
+ noMatchingFiles: 'No matching files';
+ };
+ expandFolder: 'Expand {{name}}';
+ filters: {
+ clear: 'Clear';
+ new: 'New';
+ rejected: 'Rejected';
+ unresolved: 'Unresolved';
+ };
+ searchPlaceholder: 'Search files…';
+ viewed: 'Viewed';
+ };
+ fullDiffLoading: {
+ editorViewLoading: 'Editor view loading';
+ filesInProgress: '{{count}} files in progress';
+ filesInProgress_few: '{{count}} files in progress';
+ filesInProgress_many: '{{count}} files in progress';
+ filesInProgress_one: '{{count}} file in progress';
+ filesInProgress_other: '{{count}} files in progress';
+ filesReady: '{{ready}}/{{total}} files ready';
+ previewsReady: '{{count}} previews ready';
+ previewsReady_few: '{{count}} previews ready';
+ previewsReady_many: '{{count}} previews ready';
+ previewsReady_one: '{{count}} preview ready';
+ previewsReady_other: '{{count}} previews ready';
+ progressDescription: '{{ready}} ready, {{loading}} still loading. Preview diffs stay visible below while the remaining baselines are resolved.';
+ singleDescription: 'Preview diffs stay visible below while the exact baseline is resolved.';
+ subtitleCurrentFile: 'Finalizing the exact editor diff for the current file.';
+ subtitleForFile: 'Finalizing the exact editor diff for {{file}}.';
+ subtitleMany: 'Resolving exact before/after baselines for the files currently loading.';
+ titleMany: 'Preparing {{count}} Full Diffs';
+ titleOne: 'Preparing Full Diff';
+ };
+ loading: {
+ diff: 'DIFF';
+ ledgerObjectsProcessed: '{{count}} ledger objects processed';
+ ledgerObjectsProcessed_few: '{{count}} ledger objects processed';
+ ledgerObjectsProcessed_many: '{{count}} ledger objects processed';
+ ledgerObjectsProcessed_one: '{{count}} ledger object processed';
+ ledgerObjectsProcessed_other: '{{count}} ledger objects processed';
+ phases: {
+ checkingWorktree: 'Checking worktree context...';
+ preparingDiffs: 'Preparing review diffs...';
+ readingLedger: 'Reading task ledger...';
+ resolvingFiles: 'Resolving file states...';
+ };
+ };
+ progress: {
+ viewed: '{{viewed}}/{{total}} viewed';
+ };
+ restore: 'Restore';
+ scope: {
+ confidence: {
+ bestEffort: 'Best effort';
+ high: 'High confidence';
+ low: 'Low confidence';
+ medium: 'Medium confidence';
+ };
+ ledger: {
+ exact: {
+ badge: 'Ledger exact';
+ detail: 'The orchestrator captured these file changes while the agent was working on this task.';
+ title: 'Changes captured by task ledger';
+ };
+ limited: {
+ detail: 'The orchestrator captured these file changes for this task, but at least one change was captured from a snapshot or metadata-only source. Review exact text diffs where available; binary or unavailable content may require manual review.';
+ mixedBadge: 'Mixed reviewability';
+ needsReviewBadge: 'Needs review';
+ title: 'Changes captured with limited reviewability';
+ };
+ };
+ readMore: 'Read more';
+ tiers: {
+ allSession: {
+ detail: 'No task markers found in the session log. Cannot isolate this task - all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows.';
+ title: 'Showing all session changes';
+ };
+ endEstimated: {
+ detail: 'Only the start marker was found - the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included.';
+ title: 'End boundary estimated';
+ };
+ exact: {
+ detail: 'Both start and completion markers found in the session log. The diff includes only changes made during this specific task - other tasks that modified the same files are excluded.';
+ title: 'Task scope determined precisely';
+ };
+ startEstimated: {
+ detail: 'Only the completion marker was found - the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included.';
+ title: 'Start boundary estimated';
+ };
+ };
+ workInterval: {
+ badge: 'Interval scoped';
+ detail: 'The task start marker was not available in the session log, so the diff is scoped by the task work interval stored on the board.';
+ title: 'Scoped by persisted work interval';
+ };
+ };
+ shortcuts: {
+ actions: {
+ acceptChange: 'Accept change';
+ closeDialog: 'Close dialog';
+ nextChange: 'Next change';
+ nextFile: 'Next file';
+ previousChange: 'Previous change';
+ previousFile: 'Previous file';
+ redo: 'Redo';
+ rejectChange: 'Reject change';
+ saveFile: 'Save file';
+ toggleShortcuts: 'Toggle shortcuts';
+ undo: 'Undo';
+ };
+ title: 'Keyboard Shortcuts';
+ };
+ timeline: {
+ empty: 'No edit events';
+ titleWithCount: 'Edit Timeline ({{count}})';
+ };
+ toolbar: {
+ actions: {
+ acceptAll: 'Accept All';
+ applyRejections: 'Apply Rejections';
+ applying: 'Applying...';
+ auto: 'Auto';
+ rejectAll: 'Reject All';
+ undo: 'Undo';
+ };
+ stats: {
+ accepted: '{{count}} accepted';
+ accepted_few: '{{count}} accepted';
+ accepted_many: '{{count}} accepted';
+ accepted_one: '{{count}} accepted';
+ accepted_other: '{{count}} accepted';
+ acrossFiles: 'across {{count}} files';
+ acrossFiles_few: 'across {{count}} files';
+ acrossFiles_many: 'across {{count}} files';
+ acrossFiles_one: 'across {{count}} file';
+ acrossFiles_other: 'across {{count}} files';
+ edited: '{{count}} edited';
+ edited_few: '{{count}} edited';
+ edited_many: '{{count}} edited';
+ edited_one: '{{count}} edited';
+ edited_other: '{{count}} edited';
+ pending: '{{count}} pending';
+ pending_few: '{{count}} pending';
+ pending_many: '{{count}} pending';
+ pending_one: '{{count}} pending';
+ pending_other: '{{count}} pending';
+ rejected: '{{count}} rejected';
+ rejected_few: '{{count}} rejected';
+ rejected_many: '{{count}} rejected';
+ rejected_one: '{{count}} rejected';
+ rejected_other: '{{count}} rejected';
+ };
+ tooltips: {
+ acceptAll: 'Accept all changes across all files';
+ applyRejections: 'Apply rejected hunks to disk; accepted changes are kept as-is';
+ autoOff: 'Auto-mark files as viewed when scrolled to end (OFF)';
+ autoOn: 'Auto-mark files as viewed when scrolled to end (ON)';
+ rejectAll: 'Reject all safely rejectable changes across all files';
+ rejectAllDisabled: 'No pending files have a safe original baseline to reject.';
+ undo: 'Undo last review operation (Ctrl+Z)';
+ };
+ };
+ };
+ reviewDialog: {
+ charsLeft: '{{count}} chars left';
+ placeholder: 'Describe what needs to change... (Enter to submit)';
+ saved: 'Saved';
+ submit: 'Submit';
+ title: 'Request Changes';
+ };
+ roleSelect: {
+ customRole: 'Custom role...';
+ empty: 'No roles found.';
+ noRole: 'No role';
+ reservedRole: 'This role is reserved';
+ searchPlaceholder: 'Search roles...';
+ };
+ runningTeams: {
+ title: 'Running Teams';
+ };
+ schedule: {
+ actions: {
+ addSchedule: 'Add Schedule';
+ delete: 'Delete';
+ edit: 'Edit';
+ pause: 'Pause';
+ resume: 'Resume';
+ runNow: 'Run now';
+ };
+ count: '{{count}} schedules';
+ count_few: '{{count}} schedules';
+ count_many: '{{count}} schedules';
+ count_one: '{{count}} schedule';
+ count_other: '{{count}} schedules';
+ cron: {
+ errors: {
+ enterExpression: 'Enter a cron expression';
+ invalidExpression: 'Invalid cron expression';
+ };
+ expression: 'Cron expression';
+ highFrequencyWarning: 'High frequency schedule (less than 5 min interval)';
+ nextRuns: 'Next runs:';
+ presets: {
+ dailyAtNine: 'Daily at 9am';
+ everyHour: 'Every hour';
+ everySixHours: 'Every 6 hours';
+ everyThirtyMinutes: 'Every 30 min';
+ mondayAtNine: 'Monday at 9am';
+ weekdaysAtNine: 'Weekdays at 9am';
+ };
+ selectTimezone: 'Select timezone';
+ timezone: 'Timezone';
+ warmUpDescription: 'Prepares selected providers before scheduled execution';
+ warmUpOptions: {
+ fifteenMinutes: '15 min';
+ fiveMinutes: '5 min';
+ none: 'No warm-up';
+ tenMinutes: '10 min';
+ thirtyMinutes: '30 min';
+ };
+ warmUpTime: 'Warm-up time';
+ };
+ empty: {
+ description: 'Create a schedule to run Claude tasks automatically on a cron schedule.';
+ title: 'No schedules yet';
+ };
+ nextRun: 'Next: {{next}}';
+ runHistory: {
+ empty: 'No runs yet';
+ loading: 'Loading run history...';
+ };
+ runLog: {
+ close: 'Close';
+ errors: 'Errors';
+ exitCode: 'exit {{code}}';
+ loadingLogs: 'Loading logs...';
+ retryCount: 'retry {{count}}/{{max}}';
+ stillRunning: 'Task is still running...';
+ title: 'Run Log';
+ };
+ runStatus: {
+ cancelled: 'Cancelled';
+ completed: 'Completed';
+ failed: 'Failed';
+ interrupted: 'Interrupted';
+ pending: 'Pending';
+ running: 'Running';
+ warm: 'Warm';
+ warmingUp: 'Warming up';
+ };
+ status: {
+ active: 'Active';
+ disabled: 'Disabled';
+ paused: 'Paused';
+ };
+ title: 'Schedules';
+ };
+ sendMessage: {
+ attachments: {
+ attachFiles: 'Attach files (paste or drag & drop)';
+ disabledHint: 'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.';
+ openCodeOnlineRequired: 'Team must be online to attach files for OpenCode teammates';
+ recipientUnsupported: 'Files can be sent to the team lead or OpenCode teammates';
+ teamOnlineRequired: 'Team must be online to attach files';
+ unavailable: 'Attachments are unavailable';
+ };
+ charsLeft: '{{count}} chars left';
+ description: 'Send a direct message to a team member.';
+ messageLabel: 'Message';
+ placeholder: 'Write your message... (Enter to send)';
+ quote: {
+ remove: 'Remove quote';
+ replyingTo: 'Replying to';
+ };
+ recipientLabel: 'Recipient';
+ saved: 'Saved';
+ selectMemberPlaceholder: 'Select member...';
+ send: 'Send';
+ sending: 'Sending...';
+ title: 'Send Message';
+ };
+ sessions: {
+ empty: 'No sessions found';
+ filterBySession: 'Filter by this session';
+ lead: 'lead';
+ loading: 'Loading sessions...';
+ noProjectPath: 'No project path linked';
+ openSession: 'Open session';
+ projectNotFound: 'Project not found';
+ provisioningHint: 'Sessions will appear after team provisioning';
+ removeFilter: 'Remove filter';
+ showAllSessions: 'Show for all sessions';
+ title: 'Sessions';
+ };
+ taskActivity: {
+ contextUnavailable: 'Detailed transcript context is no longer available for this activity.';
+ description: 'Key explicit runtime activity linked to this task from transcript metadata.';
+ empty: 'No explicit task activity was found in the available transcripts yet. Older or heuristic session logs may still be available below in Execution Sessions.';
+ loading: 'Loading task activity...';
+ loadingDetails: 'Loading activity details...';
+ lowSignalOnly: 'No key task activity was found yet. Low-level execution details are available below in Task Log Stream.';
+ title: 'Task Activity';
+ };
+ taskAttachments: {
+ attachImage: 'Attach image';
+ dropFilesHere: 'Drop files here';
+ dropImageHere: 'Drop image here';
+ fromOriginalMessage: 'From original message';
+ loading: 'Loading attachments...';
+ pasteOrDragDrop: 'or paste / drag-drop';
+ };
+ taskComments: {
+ attachFile: 'Attach file (or paste)';
+ awaitingReplyFrom: 'Awaiting reply from';
+ cancelReply: 'Cancel reply';
+ charsLeft: '{{count}} chars left';
+ comment: 'Comment';
+ or: 'or';
+ placeholder: 'Add a comment... (Enter to send)';
+ replyingTo: 'Replying to';
+ saved: 'Saved';
+ voiceToText: 'Voice to text';
+ };
+ taskDetail: {
+ actions: {
+ cancel: 'Cancel';
+ delete: 'Delete';
+ markResolved: 'Mark resolved';
+ save: 'Save';
+ };
+ attachments: {
+ commentAttachment: 'Comment attachment';
+ fromComments: 'From comments';
+ preview: 'Preview {{filename}}';
+ };
+ changes: {
+ badges: {
+ attention: 'attention';
+ noSafeDiff: 'no safe diff';
+ };
+ empty: {
+ noFileChangesRecorded: 'No file changes recorded';
+ noFileChangesRecordedYet: 'No file changes recorded yet';
+ noReviewableChangesRecovered: 'No reviewable file changes recovered';
+ noSafeDiffAvailable: 'No safe diff available';
+ };
+ fileCount: '{{count}} files';
+ fileCount_few: '{{count}} files';
+ fileCount_many: '{{count}} files';
+ fileCount_one: '{{count}} files';
+ fileCount_other: '{{count}} files';
+ fileRowsHidden: '{{count}} file rows hidden';
+ fileRowsHidden_few: '{{count}} file rows hidden';
+ fileRowsHidden_many: '{{count}} file rows hidden';
+ fileRowsHidden_one: '{{count}} file rows hidden';
+ fileRowsHidden_other: '{{count}} file rows hidden';
+ loadFailed: 'Failed to load task changes summary';
+ loading: 'Loading changes...';
+ moreDiagnostics: '{{count}} more diagnostics';
+ moreDiagnostics_few: '{{count}} more diagnostics';
+ moreDiagnostics_many: '{{count}} more diagnostics';
+ moreDiagnostics_one: '{{count}} more diagnostics';
+ moreDiagnostics_other: '{{count}} more diagnostics';
+ moreFiles: '{{count}} more files';
+ moreFiles_few: '{{count}} more files';
+ moreFiles_many: '{{count}} more files';
+ moreFiles_one: '{{count}} more files';
+ moreFiles_other: '{{count}} more files';
+ openInEditor: 'Open in editor';
+ openTask: 'Open task {{subject}}';
+ refresh: 'Refresh changes';
+ refreshFailed: 'Refresh failed: {{error}}';
+ refreshShort: 'Refresh';
+ refreshTeamChanges: 'Refresh team changes';
+ refreshing: 'Refreshing';
+ refreshingChanges: 'Refreshing changes...';
+ reviewDiff: 'Review diff';
+ reviewTaskDiff: 'Review task diff';
+ scannedCandidateTasks: 'Scanned {{requested}} of {{eligible}} candidate tasks';
+ tasksDeferred: '{{count}} tasks deferred this pass';
+ tasksDeferred_few: '{{count}} tasks deferred this pass';
+ tasksDeferred_many: '{{count}} tasks deferred this pass';
+ tasksDeferred_one: '{{count}} tasks deferred this pass';
+ tasksDeferred_other: '{{count}} tasks deferred this pass';
+ title: 'Changes';
+ };
+ clarification: {
+ awaitingLead: 'Awaiting clarification from team lead';
+ awaitingUser: 'Awaiting clarification from you';
+ };
+ comments: {
+ actions: {
+ cancelReply: 'Cancel reply';
+ comment: 'Comment';
+ reply: 'Reply';
+ replyToComment: 'Reply to comment';
+ showMore: 'Show more comments ({{visible}}/{{total}})';
+ };
+ attachments: {
+ downloadFailed: 'Download failed';
+ previewAlt: 'Attachment preview';
+ };
+ badges: {
+ approved: 'Approved';
+ reviewRequested: 'Review requested';
+ };
+ input: {
+ charsLeft: '{{count}} chars left';
+ charsLeft_few: '{{count}} chars left';
+ charsLeft_many: '{{count}} chars left';
+ charsLeft_one: '{{count}} char left';
+ charsLeft_other: '{{count}} chars left';
+ placeholder: 'Add a comment... (Enter to send)';
+ };
+ renderLimit: 'Showing the most recent {{formattedCount}} comments to keep the UI responsive.';
+ replyingTo: 'Replying to';
+ unknownTime: 'unknown time';
+ };
+ description: {
+ add: 'Click to add description...';
+ edit: 'Edit description';
+ placeholder: 'Task description (supports markdown)';
+ };
+ loading: {
+ fetchingTeamData: 'Fetching team data';
+ title: 'Loading task...';
+ };
+ logs: {
+ newArriving: 'New task logs arriving';
+ };
+ notFound: 'Task not found';
+ related: {
+ blockedBy: 'Blocked by';
+ blocks: 'Blocks';
+ linkedFrom: 'Linked from';
+ links: 'Links';
+ title: 'Related tasks';
+ };
+ review: {
+ reviewer: 'Reviewer: {{reviewer}}';
+ };
+ reviewStates: {
+ approved: 'Approved';
+ inReview: 'In review';
+ needsFix: 'Needs fix';
+ };
+ sections: {
+ attachments: 'Attachments';
+ changes: 'Changes';
+ comments: 'Comments';
+ description: 'Description';
+ taskLogs: 'Task Logs';
+ workflowHistory: 'Workflow History';
+ };
+ unassigned: 'Unassigned';
+ workflow: {
+ implementationTimeTitle: 'Implementation time from persisted work intervals';
+ inProgressTime: 'In progress time {{duration}}';
+ };
+ workflowTimeline: {
+ approved: 'Approved';
+ assignedTo: 'Assigned to';
+ by: 'by';
+ changesRequested: 'Changes requested';
+ createdAs: 'Created as';
+ currentImplementationInterval: 'Current implementation interval';
+ empty: 'No workflow history recorded';
+ implementationIntervalEnded: 'Implementation interval ended at this transition';
+ ownerChanged: 'Owner changed';
+ reassigned: 'Reassigned';
+ reviewRequested: 'Review requested';
+ reviewStarted: 'Review started';
+ runningPrefix: 'running ';
+ unassignedFrom: 'Unassigned from';
+ unknownEvent: 'Unknown event';
+ };
+ };
+ taskLogs: {
+ exact: {
+ description: 'Exact transcript slices rendered with the same execution-log components used in Logs.';
+ emptyDescription: 'Exact transcript bundles will appear here when explicit task-linked transcript metadata is available.';
+ emptyTitle: 'No exact task logs yet';
+ loading: 'Loading exact task logs...';
+ summaryOnly: 'summary only';
+ title: 'Exact Task Logs';
+ };
+ executionSessions: {
+ description: 'Legacy session-centric transcript browsing and previews.';
+ online: 'Online';
+ title: 'Execution Sessions';
+ updating: 'Updating...';
+ };
+ stream: {
+ title: 'Task Log Stream';
+ };
+ };
+ tasks: {
+ createTask: {
+ assignee: 'Assignee';
+ assigneeOptional: 'Assignee (optional)';
+ blockedByOptional: 'Blocked by tasks (optional)';
+ blockedBySummary: 'Task will be blocked by: {{tasks}}';
+ cancel: 'Cancel';
+ create: 'Create';
+ creating: 'Creating...';
+ description: "The task will be created in the team's tasks/ directory and appear on the Kanban board.";
+ descriptionOptional: 'Description (optional)';
+ detailsPlaceholder: 'Task details (supports markdown)';
+ hideOptionalFields: 'Hide optional fields';
+ offlineNotice: {
+ after: '- launch the team to start execution.';
+ before: 'Team is offline. The task will be added to';
+ };
+ promptOptional: 'Prompt for assignee (optional)';
+ promptPlaceholder: 'Custom instructions for the team member...';
+ relatedOptional: 'Related tasks (optional)';
+ relatedSummary: 'Related: {{tasks}}';
+ saved: 'Saved';
+ searchTasks: 'Search tasks...';
+ selectMember: 'Select a member';
+ selectMemberOptional: 'Select member...';
+ showOptionalFields: 'Show optional fields';
+ startImmediately: 'Start immediately';
+ startOfflineHint: 'Team is offline. Launch the team first to start tasks immediately.';
+ subject: 'Subject';
+ subjectPlaceholder: 'What needs to be done?';
+ title: 'Create Task';
+ todo: 'TODO';
+ };
+ deleteConfirm: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Delete';
+ message: 'Move task #{{taskId}} to trash?';
+ title: 'Delete task';
+ };
+ list: {
+ columns: {
+ blockedBy: 'Blocked By';
+ blocks: 'Blocks';
+ id: 'ID';
+ owner: 'Owner';
+ status: 'Status';
+ subject: 'Subject';
+ };
+ empty: 'No tasks in this team';
+ filters: {
+ allOwners: 'All owners';
+ allStatuses: 'All statuses';
+ ownerAria: 'Filter tasks by owner';
+ statusAria: 'Filter tasks by status';
+ };
+ showing: 'Showing {{shown}} of {{total}}';
+ };
+ openTask: 'Open task';
+ status: {
+ completed: 'completed';
+ deleted: 'deleted';
+ inProgress: 'in_progress';
+ pending: 'pending';
+ };
+ statusSummary: {
+ completed: '{{count}} completed';
+ completed_few: '{{count}} completed';
+ completed_many: '{{count}} completed';
+ completed_one: '{{count}} completed';
+ completed_other: '{{count}} completed';
+ inProgress: '{{count}} in_progress';
+ inProgress_few: '{{count}} in_progress';
+ inProgress_many: '{{count}} in_progress';
+ inProgress_one: '{{count}} in_progress';
+ inProgress_other: '{{count}} in_progress';
+ pending: '{{count}} pending';
+ pending_few: '{{count}} pending';
+ pending_many: '{{count}} pending';
+ pending_one: '{{count}} pending';
+ pending_other: '{{count}} pending';
+ progressAria: 'Tasks {{completed}}/{{total}} completed';
+ };
+ teamPrefix: 'Team:';
+ unassigned: 'Unassigned';
+ };
+ toolApproval: {
+ after: 'after';
+ allow: 'Allow';
+ allowAll: 'Allow all';
+ autoActionIn: 'Auto-{{action}} in {{time}}';
+ autoAllowAllTools: 'Auto-allow all tools';
+ autoAllowFileEdits: 'Auto-allow file edits (Edit, Write, NotebookEdit)';
+ autoAllowSafeCommands: 'Auto-allow safe commands (git, pnpm, npm, ls...)';
+ deny: 'Deny';
+ diff: {
+ binaryFile: 'Binary file - cannot preview';
+ newFile: 'New file';
+ previewChanges: 'Preview changes';
+ readingFile: 'Reading file...';
+ truncated: 'File truncated at 2MB - diff may be incomplete';
+ };
+ onTimeout: 'On timeout:';
+ pendingCount: '{{count}} pending';
+ secondsShort: 'sec';
+ settings: 'Settings';
+ submit: 'Submit';
+ timeoutActions: {
+ allow: 'Allow';
+ deny: 'Deny';
+ wait: 'Wait forever';
+ };
+ };
+ worktreeGitReadiness: {
+ checking: 'Checking Git repository status for teammate worktrees...';
+ createInitialCommit: 'Create initial commit';
+ initialCommitMessage: 'chore: initial commit';
+ initialCommitNotice: 'The initial commit action stages and commits all current files with message';
+ initializeRepository: 'Initialize Git repository';
+ needsSetup: 'Worktree isolation needs Git setup';
+ ready: 'Git worktrees are ready.';
+ readyOnBranch: 'Git worktrees are ready on branch {{branch}}.';
+ };
+ };
+}
diff --git a/src/features/localization/renderer/ui/AppLanguageSelect.tsx b/src/features/localization/renderer/ui/AppLanguageSelect.tsx
new file mode 100644
index 00000000..e8e28a43
--- /dev/null
+++ b/src/features/localization/renderer/ui/AppLanguageSelect.tsx
@@ -0,0 +1,64 @@
+import { useCallback, useMemo } from 'react';
+
+import { Combobox } from '@renderer/components/ui/combobox';
+import { Check } from 'lucide-react';
+
+import { APP_LOCALE_PREFERENCES } from '../../contracts';
+import { resolveAppLocale } from '../../core/domain/localePolicy';
+import { getBrowserSystemLocale } from '../adapters/browserSystemLocaleAdapter';
+import { useAppTranslation } from '../hooks/useAppTranslation';
+
+import type { AppLocalePreference } from '../../contracts';
+
+interface AppLanguageSelectProps {
+ readonly value: AppLocalePreference;
+ readonly disabled?: boolean;
+ readonly onValueChange: (value: AppLocalePreference) => void;
+}
+
+export const AppLanguageSelect = ({
+ value,
+ disabled = false,
+ onValueChange,
+}: AppLanguageSelectProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
+ const systemLocale = getBrowserSystemLocale();
+ const resolvedSystemLocale = resolveAppLocale({ preference: 'system', systemLocale });
+ const options = useMemo(
+ () =>
+ APP_LOCALE_PREFERENCES.map((preference) => ({
+ label:
+ preference === 'system'
+ ? t('locales.systemWithResolved', {
+ locale: t(`locales.names.${resolvedSystemLocale}`),
+ })
+ : t(`locales.names.${preference}`),
+ value: preference,
+ })),
+ [resolvedSystemLocale, t]
+ );
+
+ const renderOption = useCallback(
+ (option: { value: string; label: string }, isSelected: boolean) => (
+ <>
+
+ {option.label}
+ >
+ ),
+ []
+ );
+
+ return (
+ onValueChange(nextValue as AppLocalePreference)}
+ placeholder={t('locales.selectPlaceholder')}
+ searchPlaceholder={t('locales.searchPlaceholder')}
+ emptyMessage={t('locales.emptyMessage')}
+ disabled={disabled}
+ className="min-w-[180px]"
+ renderOption={renderOption}
+ />
+ );
+};
diff --git a/src/features/localization/renderer/ui/LocalizationProvider.tsx b/src/features/localization/renderer/ui/LocalizationProvider.tsx
new file mode 100644
index 00000000..54146451
--- /dev/null
+++ b/src/features/localization/renderer/ui/LocalizationProvider.tsx
@@ -0,0 +1,40 @@
+import { useEffect, useMemo } from 'react';
+import { I18nextProvider } from 'react-i18next';
+
+import { resolveRuntimeLocale } from '../../core/application/resolveRuntimeLocale';
+import { normalizeAppLocalePreference } from '../../core/domain/localePolicy';
+import { getBrowserSystemLocale } from '../adapters/browserSystemLocaleAdapter';
+import { appI18n } from '../composition/createI18nextInstance';
+
+import type { AppConfig } from '@shared/types';
+
+interface LocalizationProviderProps {
+ readonly appConfig: AppConfig | null;
+ readonly children: React.ReactNode;
+}
+
+export const LocalizationProvider = ({
+ appConfig,
+ children,
+}: LocalizationProviderProps): React.JSX.Element => {
+ const resolvedLocale = useMemo(
+ () =>
+ resolveRuntimeLocale({
+ preference: normalizeAppLocalePreference(appConfig?.general.appLocale),
+ systemLocale: getBrowserSystemLocale(),
+ }),
+ [appConfig?.general.appLocale]
+ );
+
+ useEffect(() => {
+ if (appI18n.language !== resolvedLocale) {
+ void appI18n.changeLanguage(resolvedLocale);
+ }
+ }, [resolvedLocale]);
+
+ useEffect(() => {
+ document.documentElement.lang = resolvedLocale;
+ }, [resolvedLocale]);
+
+ return {children} ;
+};
diff --git a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx
index 6f973e58..b38401a6 100644
--- a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx
+++ b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
@@ -43,6 +44,7 @@ export function MemberLogStreamSection({
enabled = true,
onInitialLoadErrorChange,
}: Readonly): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const [selectedLogView, setSelectedLogView] = useState<'execution' | 'process'>('execution');
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled });
@@ -79,7 +81,7 @@ export function MemberLogStreamSection({
}`}
onClick={() => setSelectedLogView('execution')}
>
- Execution
+ {t('memberLogStream.tabs.execution')}
setSelectedLogView('process')}
>
- Process
+ {t('memberLogStream.tabs.process')}
{selectedLogView === 'execution' ? (
({
buildSegmentRenderKey,
getSegmentMetaLabel,
}: Readonly>): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const [selectedParticipantKey, setSelectedParticipantKey] = useState('all');
const appliedSelectionResetKeyRef = useRef(null);
const participants = stream?.participants ?? [];
@@ -329,7 +331,7 @@ export function ExecutionLogStreamView({
}`}
onClick={() => setSelectedParticipantKey('all')}
>
- All
+ {t('memberLogStream.filters.all')}
{participants.map((participant) => (
): React.JSX.Element {
+ const { t } = useAppTranslation('team');
+ const { t: tCommon } = useAppTranslation('common');
const [kind, setKind] = useState('stdout');
const [log, setLog] = useState(null);
const [loading, setLoading] = useState(false);
@@ -222,7 +225,7 @@ export function MemberRuntimeProcessLogsPanel({
checked={autoRefresh}
onChange={(event) => setAutoRefresh(event.target.checked)}
/>
- Auto-refresh
+ {t('members.runtimeLogs.autoRefresh')}
setWrapLines(event.target.checked)}
/>
- Wrap lines
+ {t('members.runtimeLogs.wrapLines')}
{loading ? : }
- Refresh
+ {tCommon('actions.refresh')}
- Loading process log tail...
+ {t('members.runtimeLogs.loadingTail')}
) : hasContent ? (
) : (
- {statusText ?? 'No process log file captured for this member yet.'}
+ {statusText ?? t('members.runtimeLogs.empty')}
)}
diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx
index 5f21e65f..829227ba 100644
--- a/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx
+++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx
@@ -1,3 +1,5 @@
+import { useAppTranslation } from '@features/localization/renderer';
+
import { toMemberWorkSyncStatusViewModel } from '../adapters/memberWorkSyncStatusViewModel';
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
@@ -22,6 +24,7 @@ export function MemberWorkSyncDetails({
status,
showDiagnostics = false,
}: MemberWorkSyncDetailsProps): React.ReactElement {
+ const { t } = useAppTranslation('team');
const viewModel = toMemberWorkSyncStatusViewModel(status);
const agendaItems = status?.agenda.items ?? [];
@@ -29,7 +32,9 @@ export function MemberWorkSyncDetails({
-
Member work sync
+
+ {t('memberWorkSync.details.title')}
+
{viewModel.tooltip}
@@ -37,25 +42,33 @@ export function MemberWorkSyncDetails({
-
Actionable items
+
+ {t('memberWorkSync.details.actionableItems')}
+
{viewModel.actionableCount}
-
Fingerprint
+
+ {t('memberWorkSync.details.fingerprint')}
+
{shortFingerprint(viewModel.fingerprint)}
-
Report
+ {t('memberWorkSync.details.report')}
- {viewModel.reportState ?? 'none'}
+ {viewModel.reportState ?? t('memberWorkSync.details.none')}
-
Shadow would nudge
+
+ {t('memberWorkSync.details.shadowWouldNudge')}
+
- {viewModel.wouldNudge ? 'yes' : 'no'}
+ {viewModel.wouldNudge
+ ? t('memberWorkSync.details.yes')
+ : t('memberWorkSync.details.no')}
@@ -69,7 +82,7 @@ export function MemberWorkSyncDetails({
))}
{agendaItems.length > 3 ? (
- {agendaItems.length - 3} more actionable item(s)
+ {t('memberWorkSync.details.moreActionableItems', { count: agendaItems.length - 3 })}
) : null}
@@ -77,7 +90,7 @@ export function MemberWorkSyncDetails({
{showDiagnostics && status?.diagnostics.length ? (
- Diagnostics: {status.diagnostics.join(', ')}
+ {t('memberWorkSync.details.diagnostics', { diagnostics: status.diagnostics.join(', ') })}
) : null}
diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx
index 919fcfa3..b9f697c6 100644
--- a/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx
+++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx
@@ -1,3 +1,5 @@
+import { useAppTranslation } from '@features/localization/renderer';
+
import { useMemberWorkSyncStatus } from '../hooks/useMemberWorkSyncStatus';
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
@@ -18,6 +20,7 @@ export function MemberWorkSyncStatusPanel({
enabled = true,
showDiagnostics = false,
}: MemberWorkSyncStatusPanelProps): React.ReactElement | null {
+ const { t } = useAppTranslation('team');
const { status, viewModel, loading, error } = useMemberWorkSyncStatus({
teamName,
memberName,
@@ -36,12 +39,14 @@ export function MemberWorkSyncStatusPanel({
-
Member work sync
+
+ {t('memberWorkSync.title')}
+
{loading
- ? 'Loading member work sync diagnostics.'
+ ? t('memberWorkSync.loadingDiagnostics')
: error
- ? 'Member work sync diagnostics are unavailable.'
+ ? t('memberWorkSync.diagnosticsUnavailable')
: viewModel.tooltip}
diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
index cdc03227..e754ea49 100644
--- a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
+++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -20,6 +21,8 @@ export const RecentProjectCard = ({
onClick,
onOpenPath,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+ const { t: tCommon } = useAppTranslation('common');
const color = useMemo(() => projectColor(card.name), [card.name]);
const isDeleted = card.filesystemState === 'deleted';
const FolderIcon = isDeleted ? FolderX : FolderGit2;
@@ -53,10 +56,12 @@ export const RecentProjectCard = ({
- Deleted
+ {t('recentProjects.card.deleted')}
- Project folder no longer exists
+
+ {t('recentProjects.card.projectFolderMissing')}
+
)}
{card.pathSummary && (
@@ -134,7 +139,7 @@ export const RecentProjectCard = ({
- {isDeleted ? 'Project folder no longer exists' : 'Open'}
+ {isDeleted ? t('recentProjects.card.projectFolderMissing') : tCommon('actions.open')}
@@ -164,17 +169,23 @@ export const RecentProjectCard = ({
<>
{card.taskCounts.inProgress > 0 && (
- {card.taskCounts.inProgress} active
+ {t('recentProjects.card.taskCounts.active', {
+ count: card.taskCounts.inProgress,
+ })}
)}
{card.taskCounts.pending > 0 && (
- {card.taskCounts.pending} pending
+ {t('recentProjects.card.taskCounts.pending', {
+ count: card.taskCounts.pending,
+ })}
)}
{card.taskCounts.completed > 0 && (
- {card.taskCounts.completed} done
+ {t('recentProjects.card.taskCounts.done', {
+ count: card.taskCounts.completed,
+ })}
)}
·
diff --git a/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx b/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx
index b8e27d50..7131a840 100644
--- a/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx
+++ b/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { FolderGit2, FolderOpen, Search } from 'lucide-react';
@@ -17,17 +18,18 @@ function SelectProjectFolderCard({
}: Readonly<{
onClick: () => void;
}>): React.JSX.Element {
+ const { t } = useAppTranslation('dashboard');
return (
- Select Folder
+ {t('recentProjects.selectFolder')}
);
@@ -36,6 +38,7 @@ function SelectProjectFolderCard({
export const RecentProjectsSection = ({
searchQuery,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const {
cards,
loading,
@@ -102,14 +105,14 @@ export const RecentProjectsSection = ({
-
Failed to load projects
+
{t('recentProjects.failedToLoad')}
{error}
void reload()}
className="rounded-sm border border-border bg-surface-raised px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
>
- Retry
+ {t('recentProjects.retry')}
);
@@ -121,8 +124,10 @@ export const RecentProjectsSection = ({
- No projects found
- No matches for "{searchQuery}"
+ {t('recentProjects.noProjects')}
+
+ {t('recentProjects.noMatches', { query: searchQuery })}
+
);
}
@@ -133,10 +138,8 @@ export const RecentProjectsSection = ({
- No recent projects found
-
- Recent Claude and Codex activity will appear here.
-
+ {t('recentProjects.noRecentProjects')}
+ {t('recentProjects.emptyDescription')}
);
}
@@ -162,7 +165,7 @@ export const RecentProjectsSection = ({
{canLoadMore && (
- Load more
+ {t('recentProjects.loadMore')}
)}
diff --git a/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx b/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx
index cecf9581..e78c04e4 100644
--- a/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx
+++ b/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { TeamTaskStatusSummary } from '@renderer/components/team/TeamTaskStatusSummary';
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
import { FolderOpen, UsersRound } from 'lucide-react';
@@ -18,6 +19,7 @@ function getRowTitle(row: RunningTeamRowModel): string {
export function RunningTeamsSection({
searchQuery,
}: Readonly): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
const { rows, hidden, openRunningTeam } = useRunningTeamsSection(searchQuery);
if (hidden) {
@@ -28,7 +30,7 @@ export function RunningTeamsSection({
- Running Teams
+ {t('runningTeams.title')}
{rows.length}
diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx
index abdab89c..7c475646 100644
--- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx
+++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -95,6 +96,7 @@ interface RuntimeProviderErrorAlertProps {
}
type OpenCodeSettingsSection = 'models' | 'providers';
+type SettingsT = ReturnType['t'];
const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__';
@@ -149,31 +151,36 @@ function getProjectContextName(projectPath: string | null | undefined): string |
return name || normalized;
}
-function getDefaultScopeDescription(scope: RuntimeProviderDefaultScopeDto): string {
+function getDefaultScopeDescription(scope: RuntimeProviderDefaultScopeDto, t: SettingsT): string {
return scope === 'all_projects'
- ? 'Default for every project that does not have its own OpenCode override.'
- : 'Override only the selected project. Running teams are not changed.';
+ ? t('runtimeProvider.defaults.scopeDescriptionAllProjects')
+ : t('runtimeProvider.defaults.scopeDescriptionProject');
}
-function getDefaultScopeButtonLabel(scope: RuntimeProviderDefaultScopeDto): string {
- return scope === 'all_projects' ? 'Set all-projects default' : 'Set project default';
+function getDefaultScopeButtonLabel(scope: RuntimeProviderDefaultScopeDto, t: SettingsT): string {
+ return scope === 'all_projects'
+ ? t('runtimeProvider.defaults.setAllProjectsDefault')
+ : t('runtimeProvider.defaults.setProjectDefault');
}
-function getContextControlLabel(scope: RuntimeProviderDefaultScopeDto): string {
- return scope === 'all_projects' ? 'Validation context' : 'Project override context';
+function getContextControlLabel(scope: RuntimeProviderDefaultScopeDto, t: SettingsT): string {
+ return scope === 'all_projects'
+ ? t('runtimeProvider.defaults.validationContext')
+ : t('runtimeProvider.defaults.projectOverrideContext');
}
function getContextControlHint(
scope: RuntimeProviderDefaultScopeDto,
- projectPath: string | null | undefined
+ projectPath: string | null | undefined,
+ t: SettingsT
): string {
const projectName = getProjectContextName(projectPath) ?? projectPath?.trim();
if (!projectName) {
- return 'Select a project before testing local models or saving defaults.';
+ return t('runtimeProvider.defaults.selectProjectHint');
}
return scope === 'all_projects'
- ? `Tests use ${projectName}. Default applies unless a project has an override.`
- : `Saving overrides only ${projectName}.`;
+ ? t('runtimeProvider.defaults.allProjectsHint', { project: projectName })
+ : t('runtimeProvider.defaults.projectHint', { project: projectName });
}
function getDefaultModelSourceLabel(
@@ -345,6 +352,7 @@ function ProviderSetupFormPanel({
readonly disabled: boolean;
readonly actions: RuntimeProviderManagementActions;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null;
const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId;
const error = state.setupFormError;
@@ -364,7 +372,7 @@ function ProviderSetupFormPanel({
{loading ? (
- Loading provider setup...
+ {t('runtimeProvider.setup.loading')}
) : null}
@@ -477,7 +485,7 @@ function ProviderSetupFormPanel({
disabled={busy}
onClick={actions.cancelConnect}
>
- Cancel
+ {t('runtimeProvider.actions.cancel')}
& {
onRefresh: () => void;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const runtime = state.view?.runtime;
const loadingWithoutRuntime = state.loading && !runtime;
const defaultSourceLabel = getDefaultModelSourceLabel(state.view?.defaultModelSource);
@@ -515,7 +524,7 @@ function RuntimeSummary({
- OpenCode runtime
+ {t('runtimeProvider.summary.title')}
- OpenCode default: {state.view.defaultModel}
+ {t('runtimeProvider.summary.defaultModel', { model: state.view.defaultModel })}
) : null}
{defaultSourceLabel ? (
- Source: {defaultSourceLabel}
+
+ {t('runtimeProvider.summary.source', { source: defaultSourceLabel })}
+
) : null}
{state.loading ? (
@@ -546,9 +557,7 @@ function RuntimeSummary({
style={{ color: 'var(--color-text-secondary)' }}
>
-
- Loading managed OpenCode runtime, connected providers, and model defaults...
-
+
{t('runtimeProvider.summary.loading')}
) : null}
{state.view?.diagnostics.length ? (
@@ -582,6 +591,7 @@ function RuntimeSummary({
}
function RuntimeProviderLoadingPlaceholder(): JSX.Element {
+ const { t } = useAppTranslation('settings');
return (
- Loading OpenCode providers
+ {t('runtimeProvider.providers.loading')}
{
+ const { t } = useAppTranslation('settings');
const [copied, setCopied] = useState(false);
const [headline = message, ...detailLines] = message.trim().split(/\r?\n/);
const fallbackDetails = detailLines.join('\n').trim();
@@ -824,22 +835,34 @@ const RuntimeProviderErrorAlert = ({
'h-6 shrink-0 px-2 text-[11px]',
!copied && 'member-launch-diagnostics-pulse'
)}
- title={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
- aria-label={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
+ title={
+ copied
+ ? t('runtimeProvider.diagnostics.copied')
+ : t('runtimeProvider.diagnostics.copy')
+ }
+ aria-label={
+ copied
+ ? t('runtimeProvider.diagnostics.copied')
+ : t('runtimeProvider.diagnostics.copy')
+ }
onClick={(event) => {
event.stopPropagation();
void copyDiagnostics();
}}
>
{copied ? : }
- {copied ? 'Copied' : 'Copy diagnostics'}
+ {copied
+ ? t('runtimeProvider.diagnostics.copiedShort')
+ : t('runtimeProvider.diagnostics.copy')}
{diagnostics ? (
{diagnostics.likelyCause ? (
- Likely cause:
+
+ {t('runtimeProvider.diagnostics.likelyCause')}{' '}
+
{diagnostics.likelyCause}
) : null}
@@ -855,7 +878,9 @@ const RuntimeProviderErrorAlert = ({
) : null}
{hints.length > 0 ? (
-
Hints
+
+ {t('runtimeProvider.diagnostics.hints')}
+
{hints.map((hint, index) => (
{provider.displayName}
- {provider.recommended ? Recommended : null}
+ {provider.recommended ? (
+ {t('runtimeProvider.providers.recommended')}
+ ) : null}
{provider.defaultModelId ? (
- OpenCode default: {provider.defaultModelId}
+ {t('runtimeProvider.summary.defaultModel', { model: provider.defaultModelId })}
) : null}
{provider.ownership.map((owner) => (
@@ -1171,6 +1199,7 @@ function DirectoryProviderRow({
readonly hasProjectContext: boolean;
readonly actions: RuntimeProviderManagementActions;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const connect = getDirectoryAction(provider, 'connect');
const configure = getDirectoryAction(provider, 'configure');
const forget = getDirectoryAction(provider, 'forget');
@@ -1227,7 +1256,9 @@ function DirectoryProviderRow({
{provider.displayName}
- {provider.recommended ? Recommended : null}
+ {provider.recommended ? (
+ {t('runtimeProvider.providers.recommended')}
+ ) : null}
@@ -1338,6 +1369,7 @@ function ModelBadges({
readonly model: RuntimeProviderModelDto;
readonly usedForNewTeams: boolean;
}): JSX.Element | null {
+ const { t } = useAppTranslation('settings');
const modelRecommendation = getOpenCodeTeamModelRecommendation(model.modelId);
const localRoute = model.routeKind === 'configured_local';
const connectedRoute = model.routeKind === 'connected_provider';
@@ -1403,39 +1435,53 @@ function ModelBadges({
{usedForNewTeams ? (
- Used in team picker
+ {t('runtimeProvider.badges.usedInTeamPicker')}
) : null}
{freeModel ? (
- free
+
+ {t('runtimeProvider.badges.free')}
+
) : null}
{localRoute ? (
<>
- local
- configured
+
+ {t('runtimeProvider.badges.local')}
+
+
+ {t('runtimeProvider.badges.configured')}
+
>
) : null}
{connectedRoute ? (
- connected
+ {t('runtimeProvider.badges.connected')}
) : null}
{verified ? (
- verified
+ {t('runtimeProvider.badges.verified')}
) : null}
{needsTest && !verified ? (
- needs test
+
+ {t('runtimeProvider.badges.needsTest')}
+
) : null}
{failed ? (
- failed
+
+ {t('runtimeProvider.badges.failed')}
+
) : null}
{unknown ? (
- unknown
+
+ {t('runtimeProvider.badges.unknown')}
+
) : null}
{model.default ? (
- default
+
+ {t('runtimeProvider.badges.default')}
+
) : null}
);
@@ -1546,6 +1592,7 @@ function ModelRow({
readonly result: RuntimeProviderModelTestResultDto | undefined;
readonly actions: RuntimeProviderManagementActions;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const chooseModel = (): void => {
if (!disabled) {
actions.useModelForNewTeams(model.modelId);
@@ -1607,7 +1654,7 @@ function ModelRow({
className="h-8 min-w-20 justify-center"
disabled={disabled || !hasProjectContext || testing}
title={
- hasProjectContext ? undefined : 'Select a project context before testing models.'
+ hasProjectContext ? undefined : t('runtimeProvider.models.selectProjectBeforeTesting')
}
onClick={(event) => {
event.stopPropagation();
@@ -1620,7 +1667,7 @@ function ModelRow({
) : (
)}
- Test
+ {t('runtimeProvider.actions.test')}
@@ -1646,6 +1693,7 @@ function OpenCodeModelScopeControls({
readonly error: string | null;
readonly onProjectContextChange?: (projectPath: string | null) => void;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const selectedValue = projectPath?.trim() || NO_PROJECT_CONTEXT_VALUE;
const projectOptions = useMemo(() => {
const seen = new Set
();
@@ -1671,10 +1719,10 @@ function OpenCodeModelScopeControls({
return options;
}, [projectPath, projects]);
const contextPlaceholder = loading
- ? 'Loading contexts...'
+ ? t('runtimeProvider.defaults.loadingContexts')
: defaultScope === 'all_projects'
- ? 'Select validation context'
- : 'Select project context';
+ ? t('runtimeProvider.defaults.selectValidationContext')
+ : t('runtimeProvider.defaults.selectProjectContext');
return (
-
OpenCode defaults
+
+ {t('runtimeProvider.defaults.title')}
+
- {getDefaultScopeDescription(defaultScope)}
+ {getDefaultScopeDescription(defaultScope, t)}
@@ -1703,7 +1753,9 @@ function OpenCodeModelScopeControls({
}`}
onClick={() => onDefaultScopeChange(scope)}
>
- {scope === 'all_projects' ? 'All projects' : 'This project'}
+ {scope === 'all_projects'
+ ? t('runtimeProvider.defaults.allProjects')
+ : t('runtimeProvider.defaults.thisProject')}
))}
@@ -1712,7 +1764,7 @@ function OpenCodeModelScopeControls({
- {getContextControlLabel(defaultScope)}
+ {getContextControlLabel(defaultScope, t)}
- {getContextControlHint(defaultScope, projectPath)}
+ {getContextControlHint(defaultScope, projectPath, t)}
@@ -1766,6 +1818,7 @@ function ConfiguredOpenCodeModelsPanel({
readonly defaultScope: RuntimeProviderDefaultScopeDto;
readonly hasProjectContext: boolean;
}): JSX.Element | null {
+ const { t } = useAppTranslation('settings');
const models = useMemo(() => state.view?.configuredModels ?? [], [state.view?.configuredModels]);
const [query, setQuery] = useState('');
const normalizedQuery = query.trim().toLowerCase();
@@ -1791,11 +1844,10 @@ function ConfiguredOpenCodeModelsPanel({
- Launchable OpenCode models
+ {t('runtimeProvider.models.launchableTitle')}
- Routes you can test or use in the team picker: local config, free built-in models, and
- current default.
+ {t('runtimeProvider.models.launchableDescription')}
@@ -1803,7 +1855,7 @@ function ConfiguredOpenCodeModelsPanel({
setQuery(event.target.value)}
- placeholder="Search model routes"
+ placeholder={t('runtimeProvider.modelRoutes.searchPlaceholder')}
className="h-9 pl-10 pr-3 text-sm leading-5"
style={{ paddingLeft: 40 }}
/>
@@ -1813,7 +1865,7 @@ function ConfiguredOpenCodeModelsPanel({
{visibleModels.length === 0 ? (
- No OpenCode model routes match “{query.trim()}”.
+ {t('runtimeProvider.models.noRoutesMatch', { query: query.trim() })}
) : null}
{visibleModels.map((model) => {
@@ -1824,7 +1876,7 @@ function ConfiguredOpenCodeModelsPanel({
const unavailableTitle = getOpenCodeRouteUnavailableTitle(model);
const contextRequiredTitle = hasProjectContext
? undefined
- : 'Select a project context before testing or saving OpenCode defaults.';
+ : t('runtimeProvider.models.selectProjectBeforeTestingDefaults');
const alreadyDefaultForScope = isDefaultForScope(model, state, defaultScope);
const canTest =
!disabled && hasProjectContext && !testing && canTestOpenCodeModelRoute(model);
@@ -1877,7 +1929,7 @@ function ConfiguredOpenCodeModelsPanel({
) : (
)}
- Test
+ {t('runtimeProvider.actions.test')}
- Use in team picker
+ {t('runtimeProvider.models.useInTeamPicker')}
{
@@ -1913,7 +1965,7 @@ function ConfiguredOpenCodeModelsPanel({
}}
>
{savingDefault ? : null}
- {getDefaultScopeButtonLabel(defaultScope)}
+ {getDefaultScopeButtonLabel(defaultScope, t)}
@@ -1939,6 +1991,7 @@ function ProviderModelList({
readonly disabled: boolean;
readonly hasProjectContext: boolean;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const pickerOpen = state.modelPickerProviderId === provider.providerId;
const [recommendedOnly, setRecommendedOnly] = useState(false);
const [freeOnly, setFreeOnly] = useState(false);
@@ -1981,11 +2034,11 @@ function ProviderModelList({
);
const emptyModelListMessage = recommendedOnly
? freeOnly
- ? 'No recommended free models found.'
- : 'No recommended models found.'
+ ? t('runtimeProvider.models.emptyRecommendedFree')
+ : t('runtimeProvider.models.emptyRecommended')
: freeOnly
- ? 'No free models found.'
- : 'No models found.';
+ ? t('runtimeProvider.models.emptyFree')
+ : t('runtimeProvider.models.empty');
return (
@@ -1999,7 +2052,7 @@ function ProviderModelList({
onChange={(event) => actions.setModelQuery(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
- placeholder="Search models"
+ placeholder={t('runtimeProvider.models.searchPlaceholder')}
className="h-10 pl-10 pr-3 text-sm leading-5"
style={{ paddingLeft: 42 }}
/>
@@ -2021,7 +2074,7 @@ function ProviderModelList({
htmlFor={`runtime-provider-${provider.providerId}-recommended-only`}
className="cursor-pointer text-xs font-normal text-[var(--color-text-secondary)]"
>
- Recommended only
+ {t('runtimeProvider.models.recommendedOnly')}
) : null}
@@ -2042,7 +2095,7 @@ function ProviderModelList({
htmlFor={`runtime-provider-${provider.providerId}-free-only`}
className="cursor-pointer text-xs font-normal text-[var(--color-text-secondary)]"
>
- Free only
+ {t('runtimeProvider.models.freeOnly')}
) : null}
@@ -2095,6 +2148,7 @@ export function RuntimeProviderManagementPanelView({
projectContextError = null,
onProjectContextChange,
}: RuntimeProviderManagementPanelViewProps): JSX.Element {
+ const { t } = useAppTranslation('settings');
const [selectedSection, setSelectedSection] = useState
(null);
const [defaultScope, setDefaultScope] = useState('all_projects');
const providerQuery = state.providerQuery.trim().toLowerCase();
@@ -2123,8 +2177,8 @@ export function RuntimeProviderManagementPanelView({
state.directoryTotalCount !== null
? formatOpenCodeProviderCount(state.directoryTotalCount)
: state.directorySupported
- ? 'OpenCode provider catalog'
- : 'OpenCode providers';
+ ? t('runtimeProvider.providers.catalog')
+ : t('runtimeProvider.providers.countFallback');
const launchableModelCount = state.view?.configuredModels?.length ?? 0;
const modelsLoading = state.loading && launchableModelCount === 0;
const activeSection =
@@ -2167,7 +2221,7 @@ export function RuntimeProviderManagementPanelView({
value="models"
className="rounded-b-none data-[state=active]:bg-[var(--color-surface)]"
>
- Models
+ {t('runtimeProvider.tabs.models')}
{launchableModelCount > 0 ? (
{launchableModelCount}
@@ -2178,7 +2232,7 @@ export function RuntimeProviderManagementPanelView({
value="providers"
className="rounded-b-none data-[state=active]:bg-[var(--color-surface)]"
>
- Providers
+ {t('runtimeProvider.tabs.providers')}
{state.directoryTotalCount !== null ? (
{state.directoryTotalCount}
@@ -2215,15 +2269,14 @@ export function RuntimeProviderManagementPanelView({
>
- Loading OpenCode model routes...
+ {t('runtimeProvider.models.loadingRoutes')}
) : null}
{!modelsLoading && launchableModelCount === 0 ? (
- No launchable OpenCode model routes were reported yet. Configure a local route in
- OpenCode or use the Providers tab to inspect catalog providers.
+ {t('runtimeProvider.models.noneReported')}
) : null}
@@ -2231,9 +2284,11 @@ export function RuntimeProviderManagementPanelView({
-
Providers
+
+ {t('runtimeProvider.tabs.providers')}
+
- {providerCountLabel}. Connected and recommended providers are shown first.
+ {t('runtimeProvider.providers.description', { count: providerCountLabel })}
{state.directorySupported ? (
@@ -2249,7 +2304,7 @@ export function RuntimeProviderManagementPanelView({
) : (
)}
- Refresh catalog
+ {t('runtimeProvider.providers.refreshCatalog')}
) : null}
@@ -2267,7 +2322,7 @@ export function RuntimeProviderManagementPanelView({
actions.searchAllProviders(state.providerQuery.trim());
}
}}
- placeholder="Search providers"
+ placeholder={t('runtimeProvider.providers.searchPlaceholder')}
className="h-9 pr-3 text-sm"
style={{ paddingLeft: 40 }}
/>
@@ -2313,7 +2368,7 @@ export function RuntimeProviderManagementPanelView({
{state.directoryRefreshing ? (
) : null}
- Load more providers
+ {t('runtimeProvider.providers.loadMore')}
) : null}
@@ -2351,7 +2406,7 @@ export function RuntimeProviderManagementPanelView({
color: 'var(--color-text-secondary)',
}}
>
- No providers match that search.
+ {t('runtimeProvider.providers.noMatches')}
) : null}
@@ -2366,7 +2421,7 @@ export function RuntimeProviderManagementPanelView({
color: 'var(--color-text-secondary)',
}}
>
- No providers match that search.
+ {t('runtimeProvider.providers.noMatches')}
) : null}
@@ -2378,7 +2433,7 @@ export function RuntimeProviderManagementPanelView({
color: 'var(--color-text-secondary)',
}}
>
- No OpenCode providers reported by the managed runtime.
+ {t('runtimeProvider.providers.noneReported')}
) : null}
diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx
index 4a57e3ae..e2f4309d 100644
--- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx
+++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
AlertTriangle,
ChevronDown,
@@ -12,7 +13,6 @@ import {
import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner';
-const SUMMARY_TITLE = 'tmux is not installed';
const BANNER_MIN_H = 'min-h-[4.25rem]';
const SourceLink = ({
@@ -36,6 +36,7 @@ const SourceLink = ({
);
export function TmuxInstallerBannerView(): React.JSX.Element | null {
+ const { t } = useAppTranslation('common');
const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } =
useTmuxInstallerBanner();
const [expanded, setExpanded] = React.useState(false);
@@ -78,6 +79,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded);
const primaryGuideUrl = viewModel.primaryGuideUrl;
const bannerPaddingClass = expanded ? `py-3 ${BANNER_MIN_H}` : 'py-2.5';
+ const summaryTitle = t('tmuxInstaller.summaryTitle');
return (
- {SUMMARY_TITLE}
+ {summaryTitle}
{!expanded && viewModel.benefitsBody && (
- {viewModel.title !== SUMMARY_TITLE && (
+ {viewModel.title !== summaryTitle && (
- Detected OS: {viewModel.platformLabel}
+ {t('tmuxInstaller.detectedOs', { os: viewModel.platformLabel })}
)}
{viewModel.locationLabel && (
@@ -187,7 +189,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
- Runtime path: {viewModel.locationLabel}
+ {t('tmuxInstaller.runtimePath', { path: viewModel.locationLabel })}
)}
{viewModel.runtimeReadyLabel && (
@@ -220,7 +222,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
- Phase: {viewModel.phase}
+ {t('tmuxInstaller.phase', { phase: viewModel.phase })}
)}
@@ -258,7 +260,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
style={{ borderColor: 'var(--color-border)' }}
>
- Cancel
+ {t('tmuxInstaller.actions.cancel')}
)}
{primaryGuideUrl && (
@@ -269,7 +271,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
style={{ borderColor: 'var(--color-border)' }}
>
- Manual guide
+ {t('tmuxInstaller.actions.manualGuide')}
)}
{viewModel.manualHintsCollapsible && (
@@ -285,8 +287,10 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
)}
{manualHintsExpanded
- ? 'Hide setup steps'
- : `Show setup steps (${viewModel.manualHints.length})`}
+ ? t('tmuxInstaller.actions.hideSetupSteps')
+ : t('tmuxInstaller.actions.showSetupSteps', {
+ count: viewModel.manualHints.length,
+ })}
)}
{viewModel.showRefreshButton && (
@@ -297,7 +301,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
style={{ borderColor: 'var(--color-border)' }}
>
- Re-check
+ {t('tmuxInstaller.actions.recheck')}
)}
@@ -305,7 +309,9 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
{viewModel.progressPercent !== null && (
-
Installer progress
+
+ {t('tmuxInstaller.installerProgress')}
+
{viewModel.progressPercent}%
@@ -343,7 +349,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
type={viewModel.inputSecret ? 'password' : 'text'}
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
- placeholder={viewModel.inputPrompt ?? 'Send input to the installer'}
+ placeholder={viewModel.inputPrompt ?? t('tmuxInstaller.input.placeholder')}
className="min-w-0 flex-1 rounded-md border px-3 py-2 text-sm"
style={{
borderColor: 'var(--color-border)',
@@ -357,13 +363,12 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
className="inline-flex items-center justify-center rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-60"
style={{ borderColor: 'var(--color-border)' }}
>
- Send input
+ {t('tmuxInstaller.input.send')}
{viewModel.inputSecret && (
- Password input is sent directly to the installer terminal and is not added to the
- log output.
+ {t('tmuxInstaller.input.passwordNotice')}
)}
@@ -409,7 +414,9 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
className="text-xs underline-offset-4 hover:underline"
style={{ color: 'var(--color-text-secondary)' }}
>
- {viewModel.detailsOpen ? 'Hide details' : 'Show details'}
+ {viewModel.detailsOpen
+ ? t('tmuxInstaller.details.hide')
+ : t('tmuxInstaller.details.show')}
{viewModel.detailsOpen && (
| V
'multimodelEnabled',
'claudeRootPath',
'agentLanguage',
+ 'appLocale',
'autoExpandAIGroups',
'useNativeTitleBar',
'telemetryEnabled',
@@ -407,6 +409,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
}
result.agentLanguage = value.trim();
break;
+ case 'appLocale':
+ if (!isAppLocalePreference(value)) {
+ return { valid: false, error: 'general.appLocale must be a supported app locale' };
+ }
+ result.appLocale = value;
+ break;
case 'autoExpandAIGroups':
if (typeof value !== 'boolean') {
return { valid: false, error: `general.${key} must be a boolean` };
diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts
index a1d8972d..dba96c79 100644
--- a/src/main/services/infrastructure/ConfigManager.ts
+++ b/src/main/services/infrastructure/ConfigManager.ts
@@ -9,6 +9,7 @@
* - Handle JSON parse errors gracefully
*/
+import { normalizeAppLocalePreference } from '@features/localization';
import { getClaudeBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { validateRegexPattern } from '@main/utils/regexValidation';
import { createLogger } from '@shared/utils/logger';
@@ -258,6 +259,7 @@ export interface GeneralConfig {
multimodelEnabled: boolean;
claudeRootPath: string | null;
agentLanguage: string;
+ appLocale: string;
autoExpandAIGroups: boolean;
useNativeTitleBar: boolean;
/** Paths manually added via "Select Folder" that persist across app restarts */
@@ -373,6 +375,7 @@ const DEFAULT_CONFIG: AppConfig = {
multimodelEnabled: true,
claudeRootPath: null,
agentLanguage: 'system',
+ appLocale: 'system',
autoExpandAIGroups: false,
useNativeTitleBar: false,
customProjectPaths: [],
@@ -598,6 +601,7 @@ export class ConfigManager {
};
mergedGeneral.multimodelEnabled = true;
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
+ mergedGeneral.appLocale = normalizeAppLocalePreference(mergedGeneral.appLocale);
// Merge triggers: preserve existing triggers, add missing builtin ones
const mergedTriggers = TriggerManager.mergeTriggers(loadedTriggers, DEFAULT_TRIGGERS);
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 52bd7695..a3089b46 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
+import { LocalizationProvider } from '@features/localization/renderer';
import { TooltipProvider } from '@renderer/components/ui/tooltip';
import { ConfirmDialog } from './components/common/ConfirmDialog';
@@ -33,6 +34,7 @@ const SPLASH_REDUCED_AVATAR_READY_MAX_WAIT_MS = 160;
export const App = (): React.JSX.Element => {
// Initialize theme on app load
useTheme();
+ const appConfig = useStore((s) => s.appConfig);
// Upgrade the static preload splash, then dismiss it after the scene is visible.
useEffect(() => {
@@ -104,13 +106,15 @@ export const App = (): React.JSX.Element => {
}, []);
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx
index 25209ee3..1bd51996 100644
--- a/src/renderer/components/chat/AIChatGroup.tsx
+++ b/src/renderer/components/chat/AIChatGroup.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useStore } from '@renderer/store';
@@ -125,6 +126,7 @@ const AIChatGroupInner = ({
highlightColor,
registerToolRef,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
// Per-tab UI state for expansion (completely isolated per tab)
const {
tabId,
@@ -396,7 +398,7 @@ const AIChatGroupInner = ({
className="shrink-0 text-xs font-semibold"
style={{ color: COLOR_TEXT_SECONDARY }}
>
- Claude
+ {t('brand.claude')}
{/* Main agent model */}
diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx
index ea7d0e5e..71e6b531 100644
--- a/src/renderer/components/chat/ChatHistory.tsx
+++ b/src/renderer/components/chat/ChatHistory.tsx
@@ -1,5 +1,6 @@
import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController';
import { useTabUI } from '@renderer/hooks/useTabUI';
@@ -39,6 +40,7 @@ interface ChatHistoryProps {
}
export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
+ const { t } = useAppTranslation('common');
const VIRTUALIZATION_THRESHOLD = 120;
const ESTIMATED_CHAT_ITEM_HEIGHT = 260;
@@ -914,12 +916,14 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
}}
>
{' '}
- ({remainingContext.remainingPct.toFixed(0)}% left)
+ {t('chat.context.remainingPercent', {
+ percent: remainingContext.remainingPct.toFixed(0),
+ })}
)}
>
) : (
- `Context (${allContextInjections.length})`
+ t('chat.context.count', { count: allContextInjections.length })
)}
@@ -1031,10 +1035,10 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border-emphasis)',
}}
- title="Scroll to bottom"
+ title={t('chat.scrollToBottom')}
>
- Bottom
+ {t('chat.bottom')}
)}
diff --git a/src/renderer/components/chat/ChatHistoryEmptyState.tsx b/src/renderer/components/chat/ChatHistoryEmptyState.tsx
index 82e959f0..3a3c4810 100644
--- a/src/renderer/components/chat/ChatHistoryEmptyState.tsx
+++ b/src/renderer/components/chat/ChatHistoryEmptyState.tsx
@@ -1,14 +1,19 @@
import type { JSX } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
/**
* Empty state for ChatHistory when no conversation exists.
*/
export const ChatHistoryEmptyState = (): JSX.Element => {
+ const { t } = useAppTranslation('common');
return (
-
💬
-
No conversation history
-
This session does not contain any messages yet.
+
+ {t('chat.empty.icon')}
+
+
{t('chat.empty.title')}
+
{t('chat.empty.description')}
);
diff --git a/src/renderer/components/chat/CompactBoundary.tsx b/src/renderer/components/chat/CompactBoundary.tsx
index 88f44216..0a195ff2 100644
--- a/src/renderer/components/chat/CompactBoundary.tsx
+++ b/src/renderer/components/chat/CompactBoundary.tsx
@@ -1,6 +1,7 @@
import React, { memo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CODE_BG,
CODE_BORDER,
@@ -31,6 +32,7 @@ interface CompactBoundaryProps {
export const CompactBoundary = memo(function CompactBoundary({
compactGroup,
}: Readonly): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const { timestamp, message } = compactGroup;
const [isExpanded, setIsExpanded] = useState(false);
@@ -62,7 +64,7 @@ export const CompactBoundary = memo(function CompactBoundary({
onClick={() => setIsExpanded(!isExpanded)}
className="group flex w-full cursor-pointer items-center transition-opacity hover:opacity-90"
aria-expanded={isExpanded}
- aria-label="Toggle compacted content"
+ aria-label={t('chat.compact.toggle')}
>
{/* Left line */}
@@ -82,7 +84,7 @@ export const CompactBoundary = memo(function CompactBoundary({
className="whitespace-nowrap text-[11px] font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
- Context compacted
+ {t('chat.compact.contextCompacted')}
{/* Token delta */}
@@ -95,7 +97,9 @@ export const CompactBoundary = memo(function CompactBoundary({
{formatTokens(compactGroup.tokenDelta.postCompactionTokens)}
{' '}
- ({formatTokens(Math.abs(compactGroup.tokenDelta.delta))} freed)
+ {t('chat.compact.freedTokens', {
+ tokens: formatTokens(Math.abs(compactGroup.tokenDelta.delta)),
+ })}
)}
@@ -109,7 +113,7 @@ export const CompactBoundary = memo(function CompactBoundary({
color: 'var(--compact-phase-text)',
}}
>
- Phase {compactGroup.startingPhaseNumber}
+ {t('chat.compact.phase', { phase: compactGroup.startingPhaseNumber })}
)}
@@ -152,12 +156,9 @@ export const CompactBoundary = memo(function CompactBoundary({
- Conversation Compacted
-
-
- Previous messages were summarized to save context. The full conversation history
- is preserved in the session file.
+ {t('chat.compact.conversationCompacted')}
+
{t('chat.compact.summary')}
)}
diff --git a/src/renderer/components/chat/ContextBadge.tsx b/src/renderer/components/chat/ContextBadge.tsx
index 2b4d5991..66e36c49 100644
--- a/src/renderer/components/chat/ContextBadge.tsx
+++ b/src/renderer/components/chat/ContextBadge.tsx
@@ -7,6 +7,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import {
COLOR_BORDER,
COLOR_BORDER_SUBTLE,
@@ -95,6 +96,7 @@ const PopoverSection = ({
children: React.ReactNode;
defaultExpanded?: boolean;
}>): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(defaultExpanded);
return (
@@ -121,7 +123,11 @@ const PopoverSection = ({
className={`size-3 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
/>
- {title} ({count}) ~{formatTokens(tokenCount)} tokens
+ {t('contextBadge.sectionSummary', {
+ title,
+ count,
+ tokens: formatTokens(tokenCount),
+ })}
{/* Section content */}
@@ -134,6 +140,7 @@ export const ContextBadge = ({
stats,
projectRoot,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
const [showPopover, setShowPopover] = useState(false);
const [popoverStyle, setPopoverStyle] = useState({});
const [arrowStyle, setArrowStyle] = useState({});
@@ -361,7 +368,7 @@ export const ContextBadge = ({
className="inline-flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={badgeStyle}
>
- Context
+ {t('contextBadge.badge')}
+{totalNew}
@@ -373,7 +380,7 @@ export const ContextBadge = ({
ref={popoverRef}
role="dialog"
aria-modal="false"
- aria-label="Context injection details"
+ aria-label={t('contextBadge.detailsAria')}
className="rounded-lg p-3 shadow-xl"
style={{
...popoverStyle,
@@ -395,7 +402,7 @@ export const ContextBadge = ({
borderBottom: `1px solid ${COLOR_BORDER_SUBTLE}`,
}}
>
- New Context Injected In This Turn
+ {t('contextBadge.title')}
{/* Sections */}
@@ -403,7 +410,7 @@ export const ContextBadge = ({
{/* User Messages section */}
{newUserMessageInjections.length > 0 && (
@@ -411,10 +418,12 @@ export const ContextBadge = ({
- Turn {injection.turnIndex + 1}
+ {t('contextBadge.turn', { turn: injection.turnIndex + 1 })}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
{injection.textPreview && (
@@ -433,7 +442,7 @@ export const ContextBadge = ({
{/* CLAUDE.md Files section */}
{newClaudeMdInjections.length > 0 && (
@@ -450,7 +459,9 @@ export const ContextBadge = ({
style={{ color: COLOR_TEXT_SECONDARY }}
/>
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
);
@@ -461,7 +472,7 @@ export const ContextBadge = ({
{/* Mentioned Files section */}
{newMentionedFileInjections.length > 0 && (
@@ -477,7 +488,9 @@ export const ContextBadge = ({
style={{ color: COLOR_TEXT_SECONDARY }}
/>
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
);
@@ -488,7 +501,7 @@ export const ContextBadge = ({
{/* Tool Outputs section */}
{newToolOutputInjections.length > 0 && (
@@ -500,7 +513,9 @@ export const ContextBadge = ({
>
{tool.toolName}
- ~{formatTokens(tool.tokenCount)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(tool.tokenCount),
+ })}
))
@@ -511,7 +526,7 @@ export const ContextBadge = ({
{/* Task Coordination section */}
{newTaskCoordinationInjections.length > 0 && (
@@ -523,7 +538,9 @@ export const ContextBadge = ({
>
{item.label}
- ~{formatTokens(item.tokenCount)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(item.tokenCount),
+ })}
))
@@ -534,14 +551,14 @@ export const ContextBadge = ({
{/* Thinking + Text section */}
{newThinkingTextInjections.length > 0 && (
{newThinkingTextInjections.map((injection) => (
- Turn {injection.turnIndex + 1}
+ {t('contextBadge.turn', { turn: injection.turnIndex + 1 })}
{injection.breakdown.map((item, idx) => (
@@ -550,10 +567,14 @@ export const ContextBadge = ({
className="flex items-center justify-between text-xs"
>
- {item.type === 'thinking' ? 'Thinking' : 'Text'}
+ {item.type === 'thinking'
+ ? t('contextBadge.breakdown.thinking')
+ : t('contextBadge.breakdown.text')}
- ~{formatTokens(item.tokenCount)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(item.tokenCount),
+ })}
))}
@@ -569,9 +590,9 @@ export const ContextBadge = ({
className="mt-2 flex items-center justify-between pt-2 text-xs"
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
>
-
Total new tokens
+
{t('contextBadge.totalNewTokens')}
- ~{formatTokens(totalNewTokens)} tokens
+ {t('contextBadge.tokenCount', { tokens: formatTokens(totalNewTokens) })}
,
diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx
index 8ae4e8c9..dc230401 100644
--- a/src/renderer/components/chat/DisplayItemList.tsx
+++ b/src/renderer/components/chat/DisplayItemList.tsx
@@ -1,5 +1,6 @@
import React, { memo, useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CODE_BG,
CODE_BORDER,
@@ -138,6 +139,7 @@ const DisplayItemRow = memo(function DisplayItemRow({
timestampFormat,
showItemMetaTooltip = false,
}: DisplayItemRowProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('common');
const handleClick = useCallback(() => onItemClick(itemKey), [onItemClick, itemKey]);
let element: React.ReactNode = null;
@@ -343,7 +345,7 @@ const DisplayItemRow = memo(function DisplayItemRow({
- Compacted
+ {t('chat.compact.compacted')}
{item.tokenDelta && (
{' '}
- ({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
+ {t('chat.compact.freedTokens', {
+ tokens: formatTokensCompact(Math.abs(item.tokenDelta.delta)),
+ })}
)}
@@ -365,7 +369,7 @@ const DisplayItemRow = memo(function DisplayItemRow({
color: '#818cf8',
}}
>
- Phase {item.phaseNumber}
+ {t('chat.compact.phase', { phase: item.phaseNumber })}
{format(new Date(item.timestamp), 'h:mm:ss a')}
@@ -438,6 +442,7 @@ export const DisplayItemList = React.memo(function DisplayItemList({
timestampFormat,
showItemMetaTooltip = false,
}: Readonly): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const [replyLinkToolId, setReplyLinkToolId] = useState(null);
const handleReplyHover = useCallback((toolId: string | null) => {
@@ -447,7 +452,7 @@ export const DisplayItemList = React.memo(function DisplayItemList({
if (!items || items.length === 0) {
return (
- No items to display
+ {t('chat.items.empty')}
);
}
diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx
index 3df2c96b..5074cfe9 100644
--- a/src/renderer/components/chat/LastOutputDisplay.tsx
+++ b/src/renderer/components/chat/LastOutputDisplay.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { AlertTriangle, CheckCircle, FileCheck, XCircle } from 'lucide-react';
@@ -41,6 +42,7 @@ export const LastOutputDisplay = ({
isLastGroup = false,
isSessionOngoing = false,
}: Readonly): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
// Only re-render if THIS AI group has search matches
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => {
@@ -152,7 +154,7 @@ export const LastOutputDisplay = ({
className="text-xs font-medium"
style={{ color: 'var(--tool-result-error-text)' }}
>
- Error
+ {t('states.error')}
)}
@@ -185,7 +187,7 @@ export const LastOutputDisplay = ({
style={{ color: 'var(--warning-text, #f59e0b)' }}
/>
- Request interrupted by user
+ {t('chat.lastOutput.requestInterrupted')}
);
@@ -234,7 +236,7 @@ export const LastOutputDisplay = ({
- Plan Ready for Approval
+ {t('chat.lastOutput.planReadyForApproval')}
diff --git a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx
index 28f06a05..5f94f08a 100644
--- a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx
@@ -4,6 +4,7 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CopyablePath } from '@renderer/components/common/CopyablePath';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { ChevronRight } from 'lucide-react';
@@ -24,6 +25,7 @@ export const DirectoryTreeNode = ({
depth = 0,
onNavigateToTurn,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(true);
const indent = depth * 12;
@@ -48,7 +50,9 @@ export const DirectoryTreeNode = ({
className="text-xs"
style={{ color: COLOR_TEXT_SECONDARY }}
/>
- (~{formatTokens(node.tokens ?? 0)})
+
+ {t('tokens.approxTokensParenthesized', { tokens: formatTokens(node.tokens ?? 0) })}
+
{node.firstSeenInGroup &&
(isClickable ? (
): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
// Group CLAUDE.md injections by category
const claudeMdGroups = useMemo(() => {
const groups = new Map();
@@ -65,7 +68,7 @@ export const ClaudeMdFilesSection = ({
return (
): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(true);
const sectionTokens = injections.reduce((sum, inj) => sum + inj.estimatedTokens, 0);
@@ -59,7 +61,9 @@ export const ClaudeMdSubSection = ({
>
{injections.length}
- (~{formatTokens(sectionTokens)})
+
+ {t('tokens.approxTokensParenthesized', { tokens: formatTokens(sectionTokens) })}
+
{expanded && (
diff --git a/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx
index bbceba0c..c79b891a 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { formatTokens } from '../utils/formatting';
@@ -25,6 +26,8 @@ export const CollapsibleSection = ({
onToggle,
children,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
+
return (
- ~{formatTokens(tokenCount)} tokens
+ {t('tokens.approxTokens', { tokens: formatTokens(tokenCount) })}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx
index 107c9c3b..9a849435 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx
@@ -6,6 +6,7 @@
import React, { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
@@ -169,6 +170,7 @@ export const FlatInjectionList = ({
onNavigateToTool,
onNavigateToUserGroup,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const rows = useMemo(() => flattenInjections(injections), [injections]);
return (
@@ -223,7 +225,7 @@ export const FlatInjectionList = ({
fontSize: '10px',
}}
>
- error
+ {t('states.error')}
)}
{/* Token count */}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx
index 75dd31a1..b497c71c 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MentionedFileItem } from '../items/MentionedFileItem';
import { CollapsibleSection } from './CollapsibleSection';
@@ -27,11 +28,13 @@ export const MentionedFilesSection = ({
projectRoot,
onNavigateToTurn,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
void;
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
}>): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const hasBreakdown = injection.toolBreakdown.length > 0;
const categoryInfo = CATEGORY_COLORS['tool-output'];
@@ -183,7 +185,7 @@ const ToolOutputRankedItem = ({
fontSize: '10px',
}}
>
- error
+ {t('states.error')}
)}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx
index b23b085e..a002cf1d 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
COLOR_BORDER,
COLOR_BORDER_SUBTLE,
@@ -53,6 +54,8 @@ export const SessionContextHeader = ({
viewMode,
onViewModeChange,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
+
const formatPercentLabel = (percent: number | null, suffix: string): string | null => {
if (percent === null) {
return null;
@@ -77,7 +80,7 @@ export const SessionContextHeader = ({
{tokens === null
- ? (options?.unavailableLabel ?? 'Unavailable')
+ ? (options?.unavailableLabel ?? t('sessionContext.metrics.unavailable'))
: `${options?.approximate ? '~' : ''}${formatTokens(tokens)}`}
{percentLabel && (
@@ -99,7 +102,7 @@ export const SessionContextHeader = ({
- Context
+ {t('sessionContext.header.title')}
@@ -132,27 +135,27 @@ export const SessionContextHeader = ({
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
>
{renderMetricValue(
- 'Context Used',
+ t('sessionContext.metrics.contextUsed'),
contextMetrics?.contextUsedTokens ?? null,
formatPercentLabel(
contextMetrics?.contextUsedPercentOfContextWindow ?? null,
- 'of context'
+ t('sessionContext.metrics.ofContext')
)
)}
{renderMetricValue(
- 'Prompt Input',
+ t('sessionContext.metrics.promptInput'),
contextMetrics?.promptInputTokens ?? null,
formatPercentLabel(
contextMetrics?.promptInputPercentOfContextWindow ?? null,
- 'of context'
+ t('sessionContext.metrics.ofContext')
)
)}
{renderMetricValue(
- 'Visible Context',
+ t('sessionContext.metrics.visibleContext'),
totalTokens,
formatPercentLabel(
contextMetrics?.visibleContextPercentOfPromptInput ?? null,
- 'of prompt'
+ t('sessionContext.metrics.ofPrompt')
),
{ approximate: true }
)}
@@ -166,8 +169,7 @@ export const SessionContextHeader = ({
color: COLOR_TEXT_MUTED,
}}
>
- Codex prompt-side usage is not exposed by the current runtime telemetry yet, so Prompt
- Input and Context Used stay unavailable instead of showing a fake zero.
+ {t('sessionContext.metrics.codexTelemetryUnavailable')}
)}
@@ -180,7 +182,9 @@ export const SessionContextHeader = ({
{/* Cost */}
{sessionMetrics.costUsd !== undefined && sessionMetrics.costUsd > 0 && (
- Session Cost:
+
+ {t('sessionContext.metrics.sessionCost')}{' '}
+
{formatCostUsd(sessionMetrics.costUsd + (subagentCostUsd ?? 0))}
@@ -188,9 +192,9 @@ export const SessionContextHeader = ({
{' ('}
{formatCostUsd(sessionMetrics.costUsd)}
- {' parent + '}
+ {` ${t('sessionContext.metrics.parentPlus')} `}
{formatCostUsd(subagentCostUsd)}
- {' subagents'}
+ {` ${t('sessionContext.metrics.subagents')}`}
{onViewReport && (
<>
{' · '}
@@ -199,7 +203,7 @@ export const SessionContextHeader = ({
className="underline"
style={{ color: COLOR_TEXT_SECONDARY }}
>
- details
+ {t('sessionContext.metrics.details')}
>
)}
@@ -218,7 +222,7 @@ export const SessionContextHeader = ({
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
>
- Phase:
+ {t('sessionContext.header.phase')}
{phaseInfo.phases.map((phase) => (
- Current
+ {t('sessionContext.header.current')}
)}
@@ -258,7 +262,7 @@ export const SessionContextHeader = ({
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
>
- View:
+ {t('sessionContext.header.view')}
onViewModeChange('category')}
@@ -270,7 +274,7 @@ export const SessionContextHeader = ({
}}
>
- Category
+ {t('sessionContext.header.category')}
onViewModeChange('ranked')}
@@ -282,7 +286,7 @@ export const SessionContextHeader = ({
}}
>
- By Size
+ {t('sessionContext.header.bySize')}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx
index 666a3f23..511c5e25 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx
@@ -5,9 +5,11 @@
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { HelpCircle } from 'lucide-react';
export const SessionContextHelpTooltip = (): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipStyle, setTooltipStyle] = useState({});
const [arrowStyle, setArrowStyle] = useState({});
@@ -119,41 +121,37 @@ export const SessionContextHelpTooltip = (): React.ReactElement => {
{/* Metric definitions */}
- Context Used
+ {t('sessionContext.help.contextUsed.title')}
- Prompt input plus output tokens currently occupying the model's context
- window.
+ {t('sessionContext.help.contextUsed.description')}
- Prompt Input
+ {t('sessionContext.help.promptInput.title')}
- Tokens sent to the model before generation. For Claude this includes `input_tokens
- + cache_creation_input_tokens + cache_read_input_tokens`.
+ {t('sessionContext.help.promptInput.description')}
- Visible Context
+ {t('sessionContext.help.visibleContext.title')}
- The inspectable subset of prompt input: files, CLAUDE.md, tool outputs, user
- messages, and similar injections that you can optimize directly.
+ {t('sessionContext.help.visibleContext.description')}
- Availability
+ {t('sessionContext.help.availability.title')}
- If a provider runtime does not expose prompt-side usage yet, the panel shows
- metrics as unavailable instead of pretending they are zero.
+ {t('sessionContext.help.availability.description')}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx
index 508eccdb..b71928f1 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { TaskCoordinationItem } from '../items/TaskCoordinationItem';
import { CollapsibleSection } from './CollapsibleSection';
@@ -25,11 +26,13 @@ export const TaskCoordinationSection = ({
onToggle,
onNavigateToTurn,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
): React.ReactElement => {
+ const { t } = useAppTranslation('common');
// View mode: category sections or ranked list
const [viewMode, setViewMode] = useState('category');
// Flat sub-toggle within "By Size" view
@@ -212,7 +214,7 @@ export const SessionContextPanel = ({
className="flex h-full items-center justify-center text-sm"
style={{ color: COLOR_TEXT_MUTED }}
>
- No context injections detected in this session
+ {t('sessionContext.empty')}
) : viewMode === 'category' ? (
<>
@@ -278,7 +280,7 @@ export const SessionContextPanel = ({
color: !flatMode ? '#818cf8' : COLOR_TEXT_MUTED,
}}
>
- Grouped
+ {t('sessionContext.view.grouped')}
setFlatMode(true)}
@@ -288,7 +290,7 @@ export const SessionContextPanel = ({
color: flatMode ? '#818cf8' : COLOR_TEXT_MUTED,
}}
>
- Flat
+ {t('sessionContext.view.flat')}
{flatMode ? (
diff --git a/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx
index 6b25072b..b36aa765 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CopyablePath } from '@renderer/components/common/CopyablePath';
import { resolveAbsolutePath, shortenDisplayPath } from '@renderer/utils/pathDisplay';
@@ -23,6 +24,7 @@ export const ClaudeMdItem = ({
projectRoot,
onNavigateToTurn,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const turnIndex = parseTurnIndex(injection.firstSeenInGroup);
const isClickable = onNavigateToTurn && turnIndex >= 0;
const displayPath = shortenDisplayPath(injection.path, projectRoot);
@@ -38,7 +40,7 @@ export const ClaudeMdItem = ({
/>
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('tokens.approxTokens', { tokens: formatTokens(injection.estimatedTokens) })}
{isClickable ? (
): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const turnIndex = injection.firstSeenTurnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
const displayPath = shortenDisplayPath(injection.path, projectRoot);
@@ -46,13 +48,15 @@ export const MentionedFileItem = ({
color: 'var(--color-error)',
}}
>
- missing
+ {t('sessionContext.items.missing')}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
{isClickable ? (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
diff --git a/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx
index 62832646..51fd720d 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx
@@ -4,6 +4,7 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { ChevronRight, Users } from 'lucide-react';
@@ -20,6 +21,7 @@ export const TaskCoordinationItem = ({
injection,
onNavigateToTurn,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const turnIndex = injection.turnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
@@ -56,15 +58,17 @@ export const TaskCoordinationItem = ({
}
}}
>
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
- {injection.breakdown.length} item{injection.breakdown.length !== 1 ? 's' : ''}
+ {t('sessionContext.items.itemsCount', { count: injection.breakdown.length })}
>
);
diff --git a/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx
index 92be27b0..491cd322 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx
@@ -4,6 +4,7 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { Brain, ChevronRight } from 'lucide-react';
@@ -20,6 +21,7 @@ export const ThinkingTextItem = ({
injection,
onNavigateToTurn,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const turnIndex = injection.turnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
@@ -65,15 +67,17 @@ export const ThinkingTextItem = ({
}
}}
>
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
@@ -82,7 +86,9 @@ export const ThinkingTextItem = ({
{injection.breakdown.map((item, idx) => (
- {item.type === 'thinking' ? 'Thinking' : 'Text'}
+ {item.type === 'thinking'
+ ? t('sessionContext.items.thinking')
+ : t('sessionContext.items.text')}
~{formatTokens(item.tokenCount)}
diff --git a/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx
index dec23d2c..beb7594f 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { formatTokens } from '../utils/formatting';
import type { ToolTokenBreakdown } from '@renderer/types/contextInjection';
@@ -15,6 +16,8 @@ interface ToolBreakdownItemProps {
export const ToolBreakdownItem = ({
tool,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
+
return (
{tool.toolName}
@@ -30,7 +33,7 @@ export const ToolBreakdownItem = ({
fontSize: '10px',
}}
>
- error
+ {t('states.error')}
)}
diff --git a/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx
index fe46a522..e5b2ddf8 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx
@@ -4,6 +4,7 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { ChevronRight, Wrench } from 'lucide-react';
@@ -22,6 +23,7 @@ export const ToolOutputItem = ({
injection,
onNavigateToTurn,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const turnIndex = injection.turnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
@@ -58,15 +60,17 @@ export const ToolOutputItem = ({
}
}}
>
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
- {injection.toolCount} tool{injection.toolCount !== 1 ? 's' : ''}
+ {t('sessionContext.items.toolsCount', { count: injection.toolCount })}
>
);
diff --git a/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx
index ce3219fa..111f95a5 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { MessageSquare } from 'lucide-react';
@@ -20,6 +21,7 @@ export const UserMessageItem = ({
injection,
onNavigateToTurn,
}: Readonly
): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const turnIndex = injection.turnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
@@ -45,15 +47,17 @@ export const UserMessageItem = ({
}
}}
>
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
{injection.textPreview && (
diff --git a/src/renderer/components/chat/SystemChatGroup.tsx b/src/renderer/components/chat/SystemChatGroup.tsx
index 92f03e01..8dd337b6 100644
--- a/src/renderer/components/chat/SystemChatGroup.tsx
+++ b/src/renderer/components/chat/SystemChatGroup.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { format } from 'date-fns';
import { Terminal } from 'lucide-react';
@@ -19,6 +20,7 @@ interface SystemChatGroupProps {
const SystemChatGroupInner = ({
systemGroup,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { commandOutput, timestamp } = systemGroup;
// Clean ANSI escape codes from output
@@ -34,7 +36,7 @@ const SystemChatGroupInner = ({
>
- System
+ {t('chat.system.label')}
·
{format(timestamp, 'h:mm:ss a')}
diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx
index ef7fa9a5..7d6db1a7 100644
--- a/src/renderer/components/chat/UserChatGroup.tsx
+++ b/src/renderer/components/chat/UserChatGroup.tsx
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
@@ -380,6 +381,7 @@ function createUserMarkdownComponents(
* - Shows image count indicator
*/
const UserChatGroupInner = ({ userGroup }: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { content, timestamp, id: groupId } = userGroup;
const [isManuallyExpanded, setIsManuallyExpanded] = useState(false);
const [validatedPaths, setValidatedPaths] = useState>({});
@@ -544,7 +546,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
{format(timestamp, 'h:mm:ss a')}
- You
+ {t('chat.user.you')}
@@ -578,7 +580,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
style={{ color: 'var(--color-text-muted)' }}
>
- Show more
+ {t('chat.user.showMore')}
)}
@@ -596,7 +598,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
}}
>
- Show less
+ {t('chat.user.showLess')}
) : null}
@@ -613,7 +615,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
: 'var(--color-text-muted)';
const commandMatch = /"([^"]+)"/.exec(notification.summary);
const commandName =
- commandMatch?.[1] ?? notification.summary.trim() ?? 'Background task';
+ commandMatch?.[1] ?? notification.summary.trim() ?? t('chat.user.backgroundTask');
const exitCodeMatch = /\(exit code (\d+)\)/.exec(notification.summary);
const outputFileName = notification.outputFile
? (notification.outputFile.split(/[\\/]/).pop() ?? notification.outputFile)
@@ -634,14 +636,16 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
className="text-xs font-medium leading-snug"
style={{ color: 'var(--color-text-secondary)' }}
>
- {commandName || 'Background task'}
+ {commandName || t('chat.user.backgroundTask')}
{notification.status || 'unknown'}
- {exitCodeMatch?.[1] ?
exit {exitCodeMatch[1]} : null}
+ {exitCodeMatch?.[1] ? (
+
{t('chat.user.exitCode', { code: exitCodeMatch[1] })}
+ ) : null}
{outputFileName ? (
@@ -657,7 +661,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
{/* Images indicator */}
{hasImages && (
- {content.images.length} image{content.images.length > 1 ? 's' : ''} attached
+ {t('chat.user.imagesAttached', { count: content.images.length })}
)}
diff --git a/src/renderer/components/chat/items/ExecutionTrace.tsx b/src/renderer/components/chat/items/ExecutionTrace.tsx
index aaf39889..872f385f 100644
--- a/src/renderer/components/chat/items/ExecutionTrace.tsx
+++ b/src/renderer/components/chat/items/ExecutionTrace.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CARD_ICON_MUTED,
CODE_BG,
@@ -56,6 +57,7 @@ export const ExecutionTrace: React.FC = React.memo(
searchExpandedItemId,
registerToolRef,
}): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [manualExpandedItemId, setManualExpandedItemId] = useState(null);
// Use searchExpandedItemId if set, otherwise use manually expanded item
@@ -68,7 +70,7 @@ export const ExecutionTrace: React.FC = React.memo(
if (!items || items.length === 0) {
return (
- No execution items
+ {t('chat.executionTrace.empty')}
);
}
@@ -157,7 +159,9 @@ export const ExecutionTrace: React.FC = React.memo(
className="px-2 py-1 text-xs"
style={{ color: CARD_ICON_MUTED }}
>
- Nested: {item.subagent.description ?? item.subagent.id}
+ {t('chat.executionTrace.nested', {
+ name: item.subagent.description ?? item.subagent.id,
+ })}
);
@@ -168,7 +172,7 @@ export const ExecutionTrace: React.FC = React.memo(
}
- label="Input"
+ label={t('chat.executionTrace.input')}
summary={truncateText(item.content, 80)}
tokenCount={item.tokenCount}
timestamp={item.timestamp}
@@ -222,7 +226,7 @@ export const ExecutionTrace: React.FC = React.memo(
className="shrink-0 text-xs font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
- Compacted
+ {t('chat.compact.compacted')}
{item.tokenDelta && (
= React.memo(
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
{' '}
- ({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
+ {t('chat.compact.freedTokens', {
+ tokens: formatTokensCompact(Math.abs(item.tokenDelta.delta)),
+ })}
)}
@@ -244,7 +250,7 @@ export const ExecutionTrace: React.FC = React.memo(
color: '#818cf8',
}}
>
- Phase {item.phaseNumber}
+ {t('chat.compact.phase', { phase: item.phaseNumber })}
{
+ const { t } = useAppTranslation('common');
const status = getToolStatus(linkedTool);
const { isLight } = useTheme();
const summary = getToolSummary(linkedTool.name, linkedTool.input);
@@ -107,7 +109,7 @@ export const LinkedToolItem = memo(
const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned';
if (isTeammateSpawned) {
const teamResult = linkedTool.result!.toolUseResult!;
- const name = (teamResult.name as string) || 'teammate';
+ const name = (teamResult.name as string) || t('members.teammateFallback');
const color = (teamResult.color as string) || '';
const colors = getTeamColorSet(color);
return (
@@ -120,7 +122,7 @@ export const LinkedToolItem = memo(
{name}
- Teammate spawned
+ {t('chat.tools.teammateSpawned')}
);
@@ -130,12 +132,12 @@ export const LinkedToolItem = memo(
const isShutdownRequest =
linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request';
if (isShutdownRequest) {
- const target = (linkedTool.input?.recipient as string) || 'teammate';
+ const target = (linkedTool.input?.recipient as string) || t('members.teammateFallback');
return (
- Shutdown requested →{' '}
+ {t('chat.tools.shutdownRequested')}{' '}
{target}
@@ -223,13 +225,13 @@ export const LinkedToolItem = memo(
style={{ color: 'var(--tool-item-muted)' }}
>
- No result received
+ {t('chat.tools.noResultReceived')}
)}
{/* Timing */}
- Duration: {formatDuration(linkedTool.durationMs)}
+ {t('chat.tools.duration', { duration: formatDuration(linkedTool.durationMs) })}
diff --git a/src/renderer/components/chat/items/MetricsPill.tsx b/src/renderer/components/chat/items/MetricsPill.tsx
index e6a31c61..b4e3652f 100644
--- a/src/renderer/components/chat/items/MetricsPill.tsx
+++ b/src/renderer/components/chat/items/MetricsPill.tsx
@@ -1,6 +1,7 @@
import React, { memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CARD_ICON_MUTED,
CARD_SEPARATOR,
@@ -49,6 +50,7 @@ export const MetricsPill = memo(
isolatedOverride,
phaseBreakdown,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipStyle, setTooltipStyle] = useState({});
const containerRef = useRef(null);
@@ -160,7 +162,9 @@ export const MetricsPill = memo(
{hasMainImpact && (
- Main Context
+
+ {t('chat.subagent.metrics.mainContext')}
+
{mainSessionImpact.totalTokens.toLocaleString()}
@@ -181,7 +185,7 @@ export const MetricsPill = memo(
className="flex items-center justify-between gap-3 pl-2"
>
- Phase {phase.phaseNumber}
+ {t('chat.subagent.metrics.phase', { phase: phase.phaseNumber })}
= React.memo(
notificationColorMap,
registerToolRef,
}) => {
- const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent';
+ const { t } = useAppTranslation('common');
+ const description =
+ subagent.description ?? step.content.subagentDescription ?? t('chat.subagent.fallbackName');
const subagentType = subagent.subagentType ?? 'Task';
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
@@ -142,10 +145,10 @@ export const SubagentItem: React.FC = React.memo(
Array.isArray(m.content) &&
m.content.some((b) => b.type === 'tool_use')
).length ?? 0;
- return toolCount > 0 ? `${toolCount} tools` : '';
+ return toolCount > 0 ? t('chat.subagent.summary.tools', { count: toolCount }) : '';
}
return buildSummary(displayItems);
- }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]);
+ }, [isExpanded, containsHighlightedError, displayItems, subagent.messages, t]);
// Model info
const modelInfo = useMemo(() => {
@@ -250,7 +253,7 @@ export const SubagentItem: React.FC = React.memo(
{subagent.team.memberName}
- Shutdown confirmed
+ {t('chat.subagent.shutdownConfirmed')}
= React.memo(
0 ? phaseData.totalConsumption : undefined
}
@@ -391,14 +394,14 @@ export const SubagentItem: React.FC = React.memo(
style={{ color: COLOR_TEXT_MUTED }}
>
- Type {' '}
+ {t('chat.subagent.meta.type')} {' '}
{subagentType}
•
- Duration {' '}
+ {t('chat.subagent.meta.duration')} {' '}
{formatDuration(subagent.durationMs)}
@@ -407,7 +410,7 @@ export const SubagentItem: React.FC = React.memo(
<>
•
- Model {' '}
+ {t('chat.subagent.meta.model')} {' '}
{modelInfo.name}
@@ -416,7 +419,7 @@ export const SubagentItem: React.FC = React.memo(
)}
•
- ID {' '}
+ {t('chat.subagent.meta.id')} {' '}
= React.memo(
className="mb-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: CARD_ICON_MUTED }}
>
- Context Usage
+ {t('chat.subagent.metrics.contextUsage')}
{/* Token rows - floating alignment */}
@@ -448,7 +451,7 @@ export const SubagentItem: React.FC
= React.memo(
style={{ color: 'rgba(251, 191, 36, 0.7)' }}
/>
- Main Context
+ {t('chat.subagent.metrics.mainContext')}
= React.memo(
- Total Output
+ {t('chat.subagent.metrics.totalOutput')}
= React.memo(
{cumulativeMetrics.outputTokens.toLocaleString()}
{' '}
- ({cumulativeMetrics.turnCount} turns)
+ {t('chat.subagent.metrics.turns', {
+ count: cumulativeMetrics.turnCount,
+ })}
@@ -489,7 +494,9 @@ export const SubagentItem: React.FC = React.memo(
style={{ color: 'rgba(56, 189, 248, 0.7)' }}
/>
- {subagent.team ? 'Context Window' : 'Subagent Context'}
+ {subagent.team
+ ? t('chat.subagent.metrics.contextWindow')
+ : t('chat.subagent.metrics.subagentContext')}
= React.memo(
className="flex items-center justify-between pl-5"
>
- Phase {phase.phaseNumber}
+ {t('chat.subagent.metrics.phase', { phase: phase.phaseNumber })}
= React.memo(
/>
- Execution Trace
+ {t('chat.subagent.trace.title')}
· {itemsSummary}
diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx
index 4bc8d309..a8c7b236 100644
--- a/src/renderer/components/chat/items/TeammateMessageItem.tsx
+++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx
@@ -1,5 +1,6 @@
import React, { memo, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CARD_BG,
CARD_BORDER_STYLE,
@@ -84,6 +85,7 @@ export const TeammateMessageItem = memo(
highlightClasses = '',
highlightStyle,
}: TeammateMessageItemProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const colors = getTeamColorSet(teammateMessage.color);
const { isLight } = useTheme();
@@ -200,7 +202,7 @@ export const TeammateMessageItem = memo(
{/* "Message" type label — parallels SubagentItem's model info */}
- Message
+ {t('chat.teammateMessage.message')}
{/* Reply indicator — shows which SendMessage triggered this response */}
@@ -226,13 +228,13 @@ export const TeammateMessageItem = memo(
style={{ color: CARD_ICON_MUTED }}
>
- Resent
+ {t('chat.teammateMessage.resent')}
)}
{/* Summary */}
- {truncatedSummary || 'Teammate message'}
+ {truncatedSummary || t('chat.teammateMessage.fallback')}
{/* Context impact — tokens injected into main session */}
@@ -241,7 +243,9 @@ export const TeammateMessageItem = memo(
className="shrink-0 font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
- ~{formatTokensCompact(teammateMessage.tokenCount)} tokens
+ {t('tokens.approxTokens', {
+ tokens: formatTokensCompact(teammateMessage.tokenCount),
+ })}
)}
diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx
index 8c4ba316..60918d14 100644
--- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx
+++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx
@@ -6,6 +6,8 @@
import React, { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
import { type ItemStatus } from '../BaseItem';
import { CollapsibleOutputSection } from './CollapsibleOutputSection';
@@ -27,6 +29,7 @@ export const DefaultToolViewer = memo(function DefaultToolViewer({
linkedTool,
status,
}: DefaultToolViewerProps) {
+ const { t } = useAppTranslation('common');
const displayOutputContent = linkedTool.result
? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content)
: null;
@@ -42,7 +45,7 @@ export const DefaultToolViewer = memo(function DefaultToolViewer({
{/* Input Section */}
- Input
+ {t('toolViewer.input')}
- {renderInput(linkedTool.name, linkedTool.input)}
+ {renderInput(linkedTool.name, linkedTool.input, {
+ replaceAll: t('toolViewer.replaceAll'),
+ agentAction: t('toolViewer.agent.action'),
+ agentTeammate: t('toolViewer.agent.teammate'),
+ agentTeam: t('toolViewer.agent.team'),
+ agentRuntime: t('toolViewer.agent.runtime'),
+ agentType: t('toolViewer.agent.type'),
+ startupInstructionsHidden: t('toolViewer.agent.startupInstructionsHidden'),
+ noInputRecorded: t('toolViewer.noInputRecorded'),
+ })}
diff --git a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx
index c537eb04..b2059cdb 100644
--- a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx
+++ b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx
@@ -6,6 +6,7 @@
import React, { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { DiffViewer } from '@renderer/components/chat/viewers';
import { type ItemStatus, StatusDot } from '../BaseItem';
@@ -24,6 +25,7 @@ export const EditToolViewer = memo(function EditToolViewer({
linkedTool,
status,
}: EditToolViewerProps) {
+ const { t } = useAppTranslation('common');
const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined;
const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string);
@@ -49,11 +51,11 @@ export const EditToolViewer = memo(function EditToolViewer({
className="mb-1 flex items-center gap-2 text-xs"
style={{ color: 'var(--tool-item-muted)' }}
>
- Result
+ {t('chat.tools.result')}
{linkedTool.result?.tokenCount !== undefined && linkedTool.result.tokenCount > 0 && (
- ~{formatTokens(linkedTool.result.tokenCount)} tokens
+ {t('tokens.approxTokens', { tokens: formatTokens(linkedTool.result.tokenCount) })}
)}
diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx
index c2d14b6b..e1fd4124 100644
--- a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx
+++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx
@@ -6,6 +6,7 @@
import React, { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
import type { LinkedToolItem } from '@renderer/types/groups';
@@ -15,6 +16,7 @@ interface ReadToolViewerProps {
}
export const ReadToolViewer = memo(function ReadToolViewer({ linkedTool }: ReadToolViewerProps) {
+ const { t } = useAppTranslation('common');
const filePath = linkedTool.input.file_path as string;
// Prefer enriched toolUseResult data
@@ -73,7 +75,7 @@ export const ReadToolViewer = memo(function ReadToolViewer({ linkedTool }: ReadT
border: '1px solid var(--tag-border)',
}}
>
- Code
+ {t('code.code')}
- Preview
+ {t('code.preview')}
)}
{isMarkdownFile && viewMode === 'preview' ? (
-
+
) : (
- Result
+ {t('chat.tools.result')}
- Skill Instructions
+ {t('chat.tools.skill.instructions')}
- Error
+ {t('states.error')}
| undefined;
const filePath =
@@ -37,7 +39,7 @@ export const WriteToolViewer = memo(function WriteToolViewer({ linkedTool }: Wri
return (
- {isCreate ? 'Created file' : 'Wrote to file'}
+ {isCreate ? t('chat.tools.write.createdFile') : t('chat.tools.write.wroteToFile')}
{isMarkdownFile && (
@@ -51,7 +53,7 @@ export const WriteToolViewer = memo(function WriteToolViewer({ linkedTool }: Wri
border: '1px solid var(--tag-border)',
}}
>
- Code
+ {t('code.code')}
- Preview
+ {t('code.preview')}
)}
{isMarkdownFile && viewMode === 'preview' ? (
-
+
) : (
)}
diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx
index 924e5725..47c5b1d9 100644
--- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx
+++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx
@@ -15,10 +15,25 @@ import {
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
import { getAgentToolDisplayDetails } from '@shared/utils/toolSummary';
+export interface RenderInputLabels {
+ replaceAll: string;
+ agentAction: string;
+ agentTeammate: string;
+ agentTeam: string;
+ agentRuntime: string;
+ agentType: string;
+ startupInstructionsHidden: string;
+ noInputRecorded: string;
+}
+
/**
* Renders the input section based on tool type with theme-aware styling.
*/
-export function renderInput(toolName: string, input: Record
): React.ReactElement {
+export function renderInput(
+ toolName: string,
+ input: Record,
+ labels: RenderInputLabels
+): React.ReactElement {
const normalizedToolName = toolName.toLowerCase();
// Special rendering for Edit tool - show diff-like format
if (normalizedToolName === 'edit') {
@@ -34,7 +49,7 @@ export function renderInput(toolName: string, input: Record): R
{filePath}
{replaceAll && (
- (replace all)
+ {labels.replaceAll}
)}
@@ -110,7 +125,7 @@ export function renderInput(toolName: string, input: Record
): R
- action
+ {labels.agentAction}
{details.action}
@@ -118,7 +133,7 @@ export function renderInput(toolName: string, input: Record
): R
{details.teammateName && (
- teammate
+ {labels.agentTeammate}
{details.teammateName}
@@ -127,7 +142,7 @@ export function renderInput(toolName: string, input: Record): R
{details.teamName && (
- team
+ {labels.agentTeam}
{details.teamName}
@@ -136,7 +151,7 @@ export function renderInput(toolName: string, input: Record): R
{details.runtime && (
- runtime
+ {labels.agentRuntime}
{details.runtime}
@@ -145,7 +160,7 @@ export function renderInput(toolName: string, input: Record): R
{details.subagentType && (
- type
+ {labels.agentType}
{details.subagentType}
@@ -160,7 +175,7 @@ export function renderInput(toolName: string, input: Record): R
color: COLOR_TEXT_MUTED,
}}
>
- Startup instructions are hidden in the UI.
+ {labels.startupInstructionsHidden}
);
@@ -180,7 +195,7 @@ export function renderInput(toolName: string, input: Record): R
))
) : (
- No input recorded for this tool call.
+ {labels.noInputRecorded}
)}
diff --git a/src/renderer/components/chat/session-panel.ts b/src/renderer/components/chat/session-panel.ts
new file mode 100644
index 00000000..5e636ab6
--- /dev/null
+++ b/src/renderer/components/chat/session-panel.ts
@@ -0,0 +1 @@
+export { SessionContextPanel as SessionPanel } from './SessionContextPanel/index';
diff --git a/src/renderer/components/chat/viewers/CodeBlockViewer.tsx b/src/renderer/components/chat/viewers/CodeBlockViewer.tsx
index 9edb7a64..997dcc41 100644
--- a/src/renderer/components/chat/viewers/CodeBlockViewer.tsx
+++ b/src/renderer/components/chat/viewers/CodeBlockViewer.tsx
@@ -1,5 +1,6 @@
import React, { memo, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getBaseName } from '@renderer/utils/pathUtils';
import { createLogger } from '@shared/utils/logger';
import { Check, Copy, FileCode } from 'lucide-react';
@@ -125,6 +126,7 @@ export const CodeBlockViewer = memo(function CodeBlockViewer({
endLine,
maxHeight = 'max-h-96',
}: CodeBlockViewerProps): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const [isCopied, setIsCopied] = useState(false);
// Infer language from file extension if not provided
@@ -178,7 +180,7 @@ export const CodeBlockViewer = memo(function CodeBlockViewer({
{(startLine > 1 || endLine) && (
- (lines {startLine}-{actualEndLine})
+ {t('code.linesParenthesized', { from: startLine, to: actualEndLine })}
)}
{isCopied ? (
diff --git a/src/renderer/components/chat/viewers/DiffViewer.tsx b/src/renderer/components/chat/viewers/DiffViewer.tsx
index efb74cd6..63611209 100644
--- a/src/renderer/components/chat/viewers/DiffViewer.tsx
+++ b/src/renderer/components/chat/viewers/DiffViewer.tsx
@@ -1,5 +1,6 @@
import React, { memo, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CODE_BG,
CODE_BORDER,
@@ -357,6 +358,7 @@ export const DiffViewer = memo(function DiffViewer({
tokenCount,
syntaxHighlight = false,
}: DiffViewerProps): React.JSX.Element {
+ const { t } = useAppTranslation('common');
// Compute diff
const oldLines = oldString.split(/\r?\n/);
const newLines = newString.split(/\r?\n/);
@@ -431,12 +433,12 @@ export const DiffViewer = memo(function DiffViewer({
)}
{stats.removed > 0 && -{stats.removed} }
{stats.added === 0 && stats.removed === 0 && (
- Changed
+ {t('diff.changed')}
)}
{tokenCount !== undefined && tokenCount > 0 && (
- ~{formatTokens(tokenCount)} tokens
+ {t('tokens.approxTokens', { tokens: formatTokens(tokenCount) })}
)}
@@ -449,7 +451,7 @@ export const DiffViewer = memo(function DiffViewer({
))}
{diffLines.length === 0 && (
- No changes detected
+ {t('diff.noChangesDetected')}
)}
diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
index cca878d4..61d3a7f2 100644
--- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx
+++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard';
@@ -338,6 +339,7 @@ const LocalImage = React.memo(function LocalImage({
alt,
baseDir,
}: LocalImageProps): React.ReactElement {
+ const { t } = useAppTranslation('common');
const [dataUrl, setDataUrl] = React.useState(null);
const [error, setError] = React.useState(false);
@@ -366,7 +368,7 @@ const LocalImage = React.memo(function LocalImage({
if (error) {
return (
- [Image: {alt || src}]
+ {t('markdown.imageFallback', { label: alt || src })}
);
}
@@ -959,6 +961,7 @@ export const MarkdownViewer: React.FC = React.memo(function
teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick,
}) {
+ const { t } = useAppTranslation('common');
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
@@ -1016,7 +1019,7 @@ export const MarkdownViewer: React.FC = React.memo(function
{label}
- Raw
+ {t('markdown.raw')}
= React.memo(function
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
- title={
- isTooLarge
- ? 'Large content is shown as raw to prevent UI freeze'
- : 'Render markdown'
- }
+ title={isTooLarge ? t('markdown.largeContentTitle') : t('markdown.renderMarkdown')}
>
- Render markdown
+ {t('markdown.renderMarkdown')}
{copyable && }
@@ -1042,28 +1041,23 @@ export const MarkdownViewer: React.FC = React.memo(function
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
- Raw preview
+ {t('markdown.rawPreview')}
setShowRaw(false)}
disabled={isTooLarge}
- title={
- isTooLarge
- ? 'Large content is shown as raw to prevent UI freeze'
- : 'Render markdown'
- }
+ title={isTooLarge ? t('markdown.largeContentTitle') : t('markdown.renderMarkdown')}
>
- Render markdown
+ {t('markdown.renderMarkdown')}
)}
{isTooLarge && (
- Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to
- keep the UI responsive.
+ {t('markdown.largeContentNotice', { count: content.length.toLocaleString() })}
)}
@@ -1077,7 +1071,10 @@ export const MarkdownViewer: React.FC = React.memo(function
{isTruncated && (
- Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
+ {t('markdown.showingChars', {
+ shown: shown.length.toLocaleString(),
+ total: content.length.toLocaleString(),
+ })}
= React.memo(function
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit((v) => Math.min(content.length, v * 2))}
>
- Show more
+ {t('markdown.showMore')}
= React.memo(function
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
- Show all
+ {t('markdown.showAll')}
@@ -1175,9 +1172,9 @@ export const MarkdownViewer: React.FC = React.memo(function
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(true)}
- title="Show raw"
+ title={t('markdown.showRaw')}
>
- Show raw
+ {t('markdown.showRaw')}
{copyable && }
@@ -1195,9 +1192,9 @@ export const MarkdownViewer: React.FC = React.memo(function
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(true)}
- title="Show raw"
+ title={t('markdown.showRaw')}
>
- Show raw
+ {t('markdown.showRaw')}
)}
diff --git a/src/renderer/components/chat/viewers/MermaidDiagram.tsx b/src/renderer/components/chat/viewers/MermaidDiagram.tsx
index 41833bdc..c69b6557 100644
--- a/src/renderer/components/chat/viewers/MermaidDiagram.tsx
+++ b/src/renderer/components/chat/viewers/MermaidDiagram.tsx
@@ -9,6 +9,7 @@
import React, { useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { PROSE_PRE_BG, PROSE_PRE_BORDER } from '@renderer/constants/cssVariables';
import DOMPurify from 'dompurify';
import mermaid from 'mermaid';
@@ -52,6 +53,7 @@ interface MermaidDiagramProps {
export const MermaidDiagram = React.memo(function MermaidDiagram({
code,
}: MermaidDiagramProps): React.ReactElement {
+ const { t } = useAppTranslation('common');
const containerRef = useRef(null);
const [error, setError] = useState(null);
@@ -97,7 +99,7 @@ export const MermaidDiagram = React.memo(function MermaidDiagram({
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
- Mermaid syntax error
+ {t('code.mermaidSyntaxError')}
{code}
);
diff --git a/src/renderer/components/common/CliInstallWarningBanner.tsx b/src/renderer/components/common/CliInstallWarningBanner.tsx
index 3a9cb1f1..eca46c3e 100644
--- a/src/renderer/components/common/CliInstallWarningBanner.tsx
+++ b/src/renderer/components/common/CliInstallWarningBanner.tsx
@@ -6,12 +6,14 @@
* Only rendered in Electron mode.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store';
import { AlertTriangle } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
export const CliInstallWarningBanner = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const cliStatus = useStore(useShallow((s) => s.cliStatus));
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const openDashboard = useStore((s) => s.openDashboard);
@@ -58,7 +60,7 @@ export const CliInstallWarningBanner = (): React.JSX.Element | null => {
color: 'var(--warning-text)',
}}
>
- Go to Dashboard
+ {t('actions.goToDashboard')}
);
diff --git a/src/renderer/components/common/ConfirmDialog.tsx b/src/renderer/components/common/ConfirmDialog.tsx
index 0633e5f1..1f2d3aec 100644
--- a/src/renderer/components/common/ConfirmDialog.tsx
+++ b/src/renderer/components/common/ConfirmDialog.tsx
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle } from 'lucide-react';
interface ConfirmDialogState {
@@ -67,6 +68,7 @@ export async function confirm(opts: {
* ConfirmDialog component. Mount once at the app root (e.g. in App.tsx).
*/
export const ConfirmDialog = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const [state, setState] = useState(initialState);
const dialogRef = useRef(null);
@@ -115,7 +117,7 @@ export const ConfirmDialog = (): React.JSX.Element | null => {
className="absolute inset-0 cursor-default"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={() => close(false)}
- aria-label="Close dialog"
+ aria-label={t('actions.closeDialog')}
tabIndex={-1}
/>
{
+ const { t } = useAppTranslation('common');
const isContextSwitching = useStore((state) => state.isContextSwitching);
const targetContextId = useStore((state) => state.targetContextId);
@@ -19,7 +21,9 @@ export const ContextSwitchOverlay: React.FC = () => {
// Format context label for display
const contextLabel =
- targetContextId === 'local' ? 'Local' : (targetContextId?.replace(/^ssh-/, '') ?? 'Unknown');
+ targetContextId === 'local'
+ ? t('context.local')
+ : (targetContextId?.replace(/^ssh-/, '') ?? t('states.unknown'));
return (
@@ -29,8 +33,8 @@ export const ContextSwitchOverlay: React.FC = () => {
{/* Text */}
-
Switching to {contextLabel}...
-
Loading workspace
+
{t('context.switchingTo', { workspace: contextLabel })}
+
{t('context.loadingWorkspace')}
diff --git a/src/renderer/components/common/CopyButton.tsx b/src/renderer/components/common/CopyButton.tsx
index 005a95ca..cee3ccea 100644
--- a/src/renderer/components/common/CopyButton.tsx
+++ b/src/renderer/components/common/CopyButton.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Check, Copy } from 'lucide-react';
interface CopyButtonProps {
@@ -26,6 +27,7 @@ export const CopyButton: React.FC = ({
bgColor = 'var(--code-bg)',
inline = false,
}) => {
+ const { t } = useAppTranslation('common');
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async (): Promise => {
@@ -49,7 +51,7 @@ export const CopyButton: React.FC = ({
{icon}
@@ -75,7 +77,7 @@ export const CopyButton: React.FC = ({
{icon}
diff --git a/src/renderer/components/common/ErrorBoundary.tsx b/src/renderer/components/common/ErrorBoundary.tsx
index 9c4b69cc..93f99b48 100644
--- a/src/renderer/components/common/ErrorBoundary.tsx
+++ b/src/renderer/components/common/ErrorBoundary.tsx
@@ -1,5 +1,6 @@
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { captureRendererException, isSentryRendererActive } from '@renderer/sentry';
import { useStore } from '@renderer/store';
import {
@@ -15,6 +16,19 @@ const logger = createLogger('Component:ErrorBoundary');
interface Props {
children: ReactNode;
fallback?: ReactNode;
+ labels?: ErrorBoundaryLabels;
+}
+
+interface ErrorBoundaryLabels {
+ title: string;
+ description: string;
+ componentStack: string;
+ tryAgain: string;
+ copied: string;
+ copyErrorDetails: string;
+ reportBugOnGitHub: string;
+ reloadApp: string;
+ diagnosticsNotice: string;
}
interface State {
@@ -24,7 +38,7 @@ interface State {
errorInfo: ErrorInfo | null;
}
-export class ErrorBoundary extends Component {
+class ErrorBoundaryInner extends Component {
private copyResetTimeout: ReturnType | null = null;
constructor(props: Props) {
@@ -136,7 +150,7 @@ export class ErrorBoundary extends Component {
// eslint-disable-next-line sonarjs/function-return-type -- Error boundaries inherently return different content based on error state
render(): ReactNode {
const { hasError, copiedReport, error, errorInfo } = this.state;
- const { children, fallback } = this.props;
+ const { children, fallback, labels } = this.props;
if (hasError) {
if (fallback) {
@@ -147,12 +161,11 @@ export class ErrorBoundary extends Component {
-
Something went wrong
+
{labels?.title}
- An unexpected error occurred in the application. You can try reloading the page or
- resetting the error state.
+ {labels?.description}
{error && (
@@ -161,7 +174,7 @@ export class ErrorBoundary extends Component
{
{errorInfo?.componentStack && (
- Component Stack
+ {labels?.componentStack}
{errorInfo.componentStack}
@@ -176,7 +189,7 @@ export class ErrorBoundary extends Component {
onClick={this.handleReset}
className="flex items-center gap-2 rounded-lg border border-claude-dark-border bg-claude-dark-surface px-4 py-2 transition-colors hover:bg-claude-dark-border"
>
- Try Again
+ {labels?.tryAgain}
void this.handleCopyErrorDetails()}
@@ -187,26 +200,25 @@ export class ErrorBoundary extends Component {
) : (
)}
- {copiedReport ? 'Copied' : 'Copy Error Details'}
+ {copiedReport ? labels?.copied : labels?.copyErrorDetails}
- Report Bug on GitHub
+ {labels?.reportBugOnGitHub}
- Reload App
+ {labels?.reloadApp}
- GitHub bug reports and copied diagnostics include the error message, stack traces, app
- version, active tab, selected team, task context, and environment details.
+ {labels?.diagnosticsNotice}
);
@@ -215,3 +227,24 @@ export class ErrorBoundary extends Component {
return children;
}
}
+
+export function ErrorBoundary(props: Omit): React.JSX.Element {
+ const { t } = useAppTranslation('common');
+
+ return (
+
+ );
+}
diff --git a/src/renderer/components/common/ExportDropdown.tsx b/src/renderer/components/common/ExportDropdown.tsx
index c35f71d9..def86290 100644
--- a/src/renderer/components/common/ExportDropdown.tsx
+++ b/src/renderer/components/common/ExportDropdown.tsx
@@ -7,6 +7,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { triggerDownload } from '@renderer/utils/sessionExporter';
import { Braces, Download, FileText, Type } from 'lucide-react';
@@ -33,6 +34,7 @@ const FORMAT_OPTIONS: FormatOption[] = [
export const ExportDropdown = ({
sessionDetail,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [isOpen, setIsOpen] = useState(false);
const [buttonHover, setButtonHover] = useState(false);
const [hoveredFormat, setHoveredFormat] = useState(null);
@@ -86,7 +88,7 @@ export const ExportDropdown = ({
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
}}
- title="Export session"
+ title={t('export.session')}
>
@@ -108,7 +110,7 @@ export const ExportDropdown = ({
borderBottom: '1px solid var(--color-border)',
}}
>
- Export Session
+ {t('export.sessionTitle')}
{/* Format options */}
diff --git a/src/renderer/components/common/OngoingIndicator.tsx b/src/renderer/components/common/OngoingIndicator.tsx
index 86008604..a00112cd 100644
--- a/src/renderer/components/common/OngoingIndicator.tsx
+++ b/src/renderer/components/common/OngoingIndicator.tsx
@@ -5,6 +5,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Loader2 } from 'lucide-react';
interface OngoingIndicatorProps {
@@ -50,6 +51,8 @@ export const OngoingIndicator = ({
* Shows animated spinner and text.
*/
export const OngoingBanner = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
+
return (
{
>
- Session is in progress...
+ {t('sessions.inProgress')}
);
diff --git a/src/renderer/components/common/ProviderActivityStatusStrip.tsx b/src/renderer/components/common/ProviderActivityStatusStrip.tsx
index 1c576dd7..c73ac632 100644
--- a/src/renderer/components/common/ProviderActivityStatusStrip.tsx
+++ b/src/renderer/components/common/ProviderActivityStatusStrip.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
@@ -236,8 +237,10 @@ export const ProviderActivityStatusStrip = ({
codexSnapshotPending = false,
providerIds,
className = '',
- label = 'Provider Activity',
+ label,
}: ProviderActivityStatusStripProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
+ const effectiveLabel = label ?? t('providerRuntime.connectionUi.status.providerActivity');
const { displayProviderIds, providerStateMap, shouldRender } = useProviderActivityDisplay({
cliStatus,
sourceCliStatus,
@@ -254,12 +257,12 @@ export const ProviderActivityStatusStrip = ({
return (
- {label ? (
+ {effectiveLabel ? (
- {label}
+ {effectiveLabel}
) : null}
@@ -277,10 +280,10 @@ export const ProviderActivityStatusStrip = ({
const styles = getActivityToneStyles(tone);
const statusText =
tone === 'loading'
- ? 'Checking...'
+ ? t('providerRuntime.connectionUi.status.checking')
: tone === 'error'
- ? formatProviderStatusText(providerState.provider)
- : 'Checked';
+ ? formatProviderStatusText(providerState.provider, t)
+ : t('providerRuntime.connectionUi.status.checked');
return (
): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
@@ -115,7 +117,7 @@ export const RepositoryDropdown = ({
>
- {isEmpty ? 'No repositories available' : placeholder}
+ {isEmpty ? t('repositories.noneAvailable') : placeholder}
void;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
return (
)}
- {item.totalSessions} session{item.totalSessions !== 1 ? 's' : ''}
+ {t('sessions.count', { count: item.totalSessions })}
{item.path}
@@ -190,6 +193,7 @@ const SelectedRepositoryItemInner = ({
onRemove: () => void;
disabled?: boolean;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
return (
@@ -212,7 +216,7 @@ const SelectedRepositoryItemInner = ({
onClick={onRemove}
disabled={disabled}
className={`shrink-0 rounded p-1 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 ${disabled ? 'cursor-not-allowed opacity-50' : ''} `}
- aria-label="Remove repository"
+ aria-label={t('repositories.remove')}
>
): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const { tokensByCategory } = contextStats;
@@ -139,13 +141,14 @@ const SessionContextSection = ({
- Visible Context
+ {t('tokens.visibleContext')}
- {formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% of prompt input)
+ {formatTokens(contextStats.totalEstimatedTokens)} (
+ {t('tokens.promptInputShare', { percent: contextPercent })})
@@ -156,11 +159,13 @@ const SessionContextSection = ({
{tokensByCategory.claudeMd > 0 && (
- CLAUDE.md ×{claudeMdCount}
+ {t('tokens.claudeMd')} ×{claudeMdCount}
{formatTokens(tokensByCategory.claudeMd)}{' '}
- ({claudeMdPercent}%)
+
+ {t('tokens.percentValue', { percent: claudeMdPercent })}
+
)}
@@ -169,11 +174,14 @@ const SessionContextSection = ({
{tokensByCategory.mentionedFiles > 0 && (
- @files ×{mentionedFilesCount}
+ {t('tokens.mentionedFiles')}{' '}
+ ×{mentionedFilesCount}
{formatTokens(tokensByCategory.mentionedFiles)}{' '}
- ({mentionedFilesPercent}%)
+
+ {t('tokens.percentValue', { percent: mentionedFilesPercent })}
+
)}
@@ -182,11 +190,13 @@ const SessionContextSection = ({
{tokensByCategory.toolOutputs > 0 && (
- Tool Outputs ×{toolOutputsCount}
+ {t('tokens.toolOutputs')} ×{toolOutputsCount}
{formatTokens(tokensByCategory.toolOutputs)}{' '}
- ({toolOutputsPercent}%)
+
+ {t('tokens.percentValue', { percent: toolOutputsPercent })}
+
)}
@@ -195,11 +205,14 @@ const SessionContextSection = ({
{tokensByCategory.taskCoordination > 0 && (
- Task Coordination ×{taskCoordinationCount}
+ {t('tokens.taskCoordination')}{' '}
+ ×{taskCoordinationCount}
{formatTokens(tokensByCategory.taskCoordination)}{' '}
- ({taskCoordinationPercent}%)
+
+ {t('tokens.percentValue', { percent: taskCoordinationPercent })}
+
)}
@@ -208,11 +221,13 @@ const SessionContextSection = ({
{tokensByCategory.userMessages > 0 && (
- User Messages ×{userMessagesCount}
+ {t('tokens.userMessages')} ×{userMessagesCount}
{formatTokens(tokensByCategory.userMessages)}{' '}
- ({userMessagesPercent}%)
+
+ {t('tokens.percentValue', { percent: userMessagesPercent })}
+
)}
@@ -220,10 +235,12 @@ const SessionContextSection = ({
{/* Thinking + Text */}
{tokensByCategory.thinkingText > 0 && (
- Thinking + Text
+ {t('tokens.thinkingText')}
{formatTokens(tokensByCategory.thinkingText)}{' '}
- ({thinkingTextPercent}%)
+
+ {t('tokens.percentValue', { percent: thinkingTextPercent })}
+
)}
@@ -233,7 +250,7 @@ const SessionContextSection = ({
className="pt-0.5 text-[9px] italic"
style={{ color: COLOR_TEXT_MUTED, opacity: 0.7 }}
>
- Accumulated across entire session without duplication
+ {t('tokens.accumulatedWithoutDuplication')}
)}
@@ -255,6 +272,7 @@ export const TokenUsageDisplay = ({
totalPhases,
costUsd,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens;
// Total prompt-side tokens only (without output) - used as denominator for visible context %
const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
@@ -391,7 +409,7 @@ export const TokenUsageDisplay = ({
className="rounded px-1 py-0.5 text-[10px]"
style={{ backgroundColor: 'rgba(99, 102, 241, 0.15)', color: '#818cf8' }}
>
- Phase {phaseNumber}/{totalPhases}
+ {t('tokens.phase', { phase: phaseNumber, total: totalPhases })}
)}
{/* Input Tokens */}
-
Input Tokens
+
{t('tokens.inputTokens')}
- Cache Read
+ {t('tokens.cacheRead')}
- Cache Write
+ {t('tokens.cacheWrite')}
- Output Tokens
+ {t('tokens.outputTokens')}
- Total
+ {t('tokens.total')}
0 && (
-
Cost (USD)
+
{t('tokens.costUsd')}
- incl. CLAUDE.md ×{claudeMdStats.accumulatedCount}
+ {t('tokens.includesClaudeMd', { count: claudeMdStats.accumulatedCount })}
{totalInputTokens > 0
@@ -561,7 +579,7 @@ export const TokenUsageDisplay = ({
style={{ borderTop: '1px solid var(--color-border-subtle)' }}
/>
-
Model
+
{t('tokens.model')}
{
+ const { t } = useAppTranslation('common');
const {
showUpdateBanner,
updateStatus,
@@ -57,7 +59,7 @@ export const UpdateBanner = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-secondary)' }}
>
- Updating app
+ {t('updates.updatingApp')}
{clampedPercent}%
@@ -76,7 +78,7 @@ export const UpdateBanner = (): React.JSX.Element | null => {
- Update ready
+ {t('updates.updateReady')}
{availableVersion ? (
v{availableVersion}
@@ -94,7 +96,7 @@ export const UpdateBanner = (): React.JSX.Element | null => {
} as React.CSSProperties
}
>
- Restart now
+ {t('updates.restartNow')}
)}
diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx
index 72fa533d..82e5c846 100644
--- a/src/renderer/components/common/UpdateDialog.tsx
+++ b/src/renderer/components/common/UpdateDialog.tsx
@@ -9,6 +9,7 @@
import { useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { markdownComponents } from '@renderer/components/chat/markdownComponents';
import { useStore } from '@renderer/store';
@@ -19,6 +20,7 @@ import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';
export const UpdateDialog = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const {
showUpdateDialog,
updateStatus,
@@ -117,7 +119,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
className="absolute inset-0 cursor-default"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={dismissUpdateDialog}
- aria-label="Close dialog"
+ aria-label={t('updateDialog.closeDialog')}
tabIndex={-1}
/>
{
className="relative mx-4 w-full max-w-2xl rounded-md border p-5 shadow-lg"
role="dialog"
aria-modal="true"
- aria-label="Update available"
+ aria-label={t('updateDialog.updateAvailable')}
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border-emphasis)',
@@ -142,7 +144,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
- {isDownloaded ? 'Update Ready' : 'Update Available'}
+ {isDownloaded ? t('updateDialog.updateReady') : t('updateDialog.updateAvailable')}
{availableVersion && (
{
) : (
- No release notes available.
+ {t('updateDialog.noReleaseNotes')}
)}
@@ -192,7 +194,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-muted)' }}
>
- View on GitHub
+ {t('updateDialog.viewOnGitHub')}
)}
@@ -204,21 +206,21 @@ export const UpdateDialog = (): React.JSX.Element | null => {
color: 'var(--color-text-secondary)',
}}
>
- Later
+ {t('updateDialog.later')}
{isDownloaded ? (
- Restart now
+ {t('updateDialog.restartNow')}
) : (
- Download
+ {t('updateDialog.download')}
)}
diff --git a/src/renderer/components/common/WorkspaceIndicator.tsx b/src/renderer/components/common/WorkspaceIndicator.tsx
index fbd70624..b5355eb8 100644
--- a/src/renderer/components/common/WorkspaceIndicator.tsx
+++ b/src/renderer/components/common/WorkspaceIndicator.tsx
@@ -8,6 +8,7 @@
import { useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { Check, ChevronDown } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -15,6 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
import { ConnectionStatusBadge } from './ConnectionStatusBadge';
export const WorkspaceIndicator = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const { activeContextId, isContextSwitching, availableContexts, switchContext } = useStore(
useShallow((s) => ({
activeContextId: s.activeContextId,
@@ -109,7 +111,7 @@ export const WorkspaceIndicator = (): React.JSX.Element | null => {
className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
- Switch Workspace
+ {t('context.switchWorkspace')}
{/* Context list */}
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx
index 4f3a4134..ed06f95b 100644
--- a/src/renderer/components/dashboard/CliStatusBanner.tsx
+++ b/src/renderer/components/dashboard/CliStatusBanner.tsx
@@ -15,6 +15,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import atlasCloudLogo from '@renderer/assets/atlascloud-logo.svg';
import { confirm } from '@renderer/components/common/ConfirmDialog';
@@ -110,9 +111,6 @@ const ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS = 60 * 1000;
const SHOW_ATLAS_CLOUD_OPENCODE_BANNER = false;
const ATLAS_CLOUD_OPENCODE_PROVIDER_ID = 'atlascloud';
const ATLAS_CLOUD_CODING_PLAN_URL = 'https://www.atlascloud.ai/console/coding-plan';
-const ATLAS_CLOUD_DESCRIPTION =
- "Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access.";
-
const ProviderRuntimeSettingsDialog = lazy(() =>
import('@renderer/components/runtime/ProviderRuntimeSettingsDialog').then((module) => ({
default: module.ProviderRuntimeSettingsDialog,
@@ -135,78 +133,92 @@ const DashboardRateLimitChips = ({
}: {
providerId: CliProviderId;
items: DashboardRateLimitItem[];
-}): React.JSX.Element => (
-
- {items.map((item) => (
-
-
-
- {item.label}
-
-
- {item.remaining}
-
-
- • resets {item.resetsAt}
-
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+
+ return (
+
+ {items.map((item) => (
+
+
+
+ {item.label}
+
+
+ {item.remaining}
+
+
+ • {t('cliStatus.labels.resets', { time: item.resetsAt })}
+
+
-
- ))}
-
-);
+ ))}
+
+ );
+};
const RATE_LIMIT_SKELETON_LABELS = ['5h left', 'Weekly left'] as const;
-const DashboardRateLimitSkeletonChips = (): React.JSX.Element => (
-
- {RATE_LIMIT_SKELETON_LABELS.map((label, index) => (
-
- ))}
-
-);
+const DashboardRateLimitSkeletonChips = (): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
-function getCodexDashboardHint(provider: CliProviderStatus): string | null {
+ return (
+
+ {RATE_LIMIT_SKELETON_LABELS.map((label, index) => (
+
+ ))}
+
+ );
+};
+
+function getCodexDashboardHint(
+ provider: CliProviderStatus,
+ t: ReturnType
['t']
+): string | null {
if (provider.providerId !== 'codex') {
return null;
}
@@ -217,25 +229,23 @@ function getCodexDashboardHint(provider: CliProviderStatus): string | null {
}
if (codex.login.status === 'starting' || codex.login.status === 'pending') {
- return codex.login.authUrl
- ? 'Finish ChatGPT login in the browser. Enter the shown code if prompted.'
- : null;
+ return codex.login.authUrl ? t('cliStatus.hints.codexFinishLogin') : null;
}
const usageHint = codex.localActiveChatgptAccountPresent
- ? 'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect.'
+ ? t('cliStatus.hints.codexReconnectNeeded')
: codex.localAccountArtifactsPresent
- ? 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Local Codex account data exists, but no active managed session is selected right now.'
- : 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login.';
+ ? t('cliStatus.hints.codexNoActiveManagedSession')
+ : t('cliStatus.hints.codexNoActiveLogin');
if (
provider.connection?.configuredAuthMode === 'chatgpt' &&
provider.connection.apiKeyConfigured
) {
- return `${usageHint} API key fallback is available if you switch auth mode.`;
+ return t('cliStatus.hints.codexApiKeyFallback', { hint: usageHint });
}
if (provider.connection?.configuredAuthMode === 'auto' && provider.connection.apiKeyConfigured) {
- return `${usageHint} Auto will keep using the API key until ChatGPT is connected.`;
+ return t('cliStatus.hints.codexAutoApiKey', { hint: usageHint });
}
return provider.connection?.configuredAuthMode === 'chatgpt' ? usageHint : null;
@@ -261,20 +271,27 @@ const InstallCompletedNotice = ({
}: {
version: string | null;
runtimeDisplayName: string;
-}): React.JSX.Element => (
-
-
-
- Successfully installed {runtimeDisplayName} v{version ?? 'latest'}
-
-
-);
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+
+ return (
+
+
+
+ {t('cliStatus.installer.success', {
+ runtime: runtimeDisplayName,
+ version: version ?? 'latest',
+ })}
+
+
+ );
+};
/** Error display with multi-line support */
const ErrorDisplay = ({
@@ -284,6 +301,7 @@ const ErrorDisplay = ({
error: string;
onRetry: () => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const lines = error.split('\n');
const title = lines[0];
const details = lines.slice(1).filter(Boolean);
@@ -321,7 +339,7 @@ const ErrorDisplay = ({
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Retry
+ {t('cliStatus.actions.retry')}
@@ -341,6 +359,7 @@ const CliCheckingSpinner = ({
styles: { border: string; bg: string };
label: string;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const [showHint, setShowHint] = useState(false);
useEffect(() => {
@@ -363,7 +382,7 @@ const CliCheckingSpinner = ({
{showHint && (
- First check may take up to 30 seconds
+ {t('cliStatus.hints.firstCheckSlow')}
)}
@@ -506,7 +525,8 @@ function isPendingMultimodelProviderStatus(provider: CliProviderStatus): boolean
function formatRuntimeAuthSummary(
cliStatus: NonNullable['cliStatus']>,
- visibleProviders: readonly CliProviderStatus[]
+ visibleProviders: readonly CliProviderStatus[],
+ t: ReturnType['t']
): string | null {
if (isMultimodelRuntimeStatus(cliStatus)) {
if (visibleProviders.length === 0) {
@@ -514,20 +534,20 @@ function formatRuntimeAuthSummary(
}
if (visibleProviders.every(isPendingMultimodelProviderStatus)) {
- return 'Checking providers...';
+ return t('cliStatus.provider.checkingProviders');
}
const denominator = visibleProviders.length;
const connected = visibleProviders.filter((provider) => provider.authenticated).length;
- return `Providers: ${connected}/${denominator} connected`;
+ return t('cliStatus.provider.connectedCount', { connected, denominator });
}
if (cliStatus.authStatusChecking) {
- return 'Checking authentication...';
+ return t('cliStatus.provider.checkingAuthentication');
}
if (cliStatus.authLoggedIn) {
- return 'Authenticated';
+ return t('cliStatus.provider.authenticated');
}
return null;
@@ -625,32 +645,35 @@ function isRuntimeInstalling(
);
}
-function getRuntimeInstallLabel(status: OpenCodeRuntimeStatus | CodexRuntimeStatus | null): string {
+function getRuntimeInstallLabel(
+ status: OpenCodeRuntimeStatus | CodexRuntimeStatus | null,
+ t: ReturnType['t']
+): string {
if (status?.state === 'downloading') {
const percent = status.progress?.percent;
- return typeof percent === 'number' ? `Downloading ${percent}%` : 'Downloading';
+ return typeof percent === 'number'
+ ? t('cliStatus.runtimeInstall.downloadingPercent', { percent })
+ : t('cliStatus.runtimeInstall.downloading');
}
if (status?.state === 'installing') {
- return 'Installing';
+ return t('cliStatus.runtimeInstall.installing');
}
if (status?.state === 'checking') {
- return 'Checking';
+ return t('cliStatus.runtimeInstall.checking');
}
if (status?.state === 'failed') {
- return 'Retry install';
+ return t('cliStatus.runtimeInstall.retryInstall');
}
- return 'Install';
+ return t('cliStatus.runtimeInstall.install');
}
-const OPENCODE_PROVIDER_FREE_BADGE_TITLE =
- 'OpenCode includes free model options such as Big Pickle when available in your setup. OpenRouter through OpenCode can also expose free models, but not every OpenCode/OpenRouter model is free. Availability and limits may change.';
-
function shouldShowOpenCodeProviderFreeBadge(provider: CliProviderStatus): boolean {
return provider.providerId === 'opencode';
}
function getOpenCodeDashboardChips(
- provider: CliProviderStatus
+ provider: CliProviderStatus,
+ t: ReturnType['t']
): { label: string; title?: string }[] {
if (!shouldShowOpenCodeProviderFreeBadge(provider)) {
return [];
@@ -670,22 +693,24 @@ function getOpenCodeDashboardChips(
return [
{
- label: 'Free models',
- title: OPENCODE_PROVIDER_FREE_BADGE_TITLE,
+ label: t('cliStatus.provider.freeModels'),
+ title: t('cliStatus.provider.freeModelsTitle'),
},
...(configuredLocalCount > 0
? [
{
- label: `${configuredLocalCount} configured local`,
- title: 'Local OpenCode routes imported from your OpenCode config.',
+ label: t('cliStatus.provider.configuredLocalCount', {
+ count: configuredLocalCount,
+ }),
+ title: t('cliStatus.provider.configuredLocalTitle'),
},
]
: []),
...(verifiedCount > 0
? [
{
- label: `${verifiedCount} verified`,
- title: 'OpenCode routes with a successful execution proof.',
+ label: t('cliStatus.provider.verifiedCount', { count: verifiedCount }),
+ title: t('cliStatus.provider.verifiedTitle'),
},
]
: []),
@@ -698,93 +723,97 @@ const OpenCodeAtlasCloudBanner = ({
}: {
disabled: boolean;
onConnect: () => void;
-}): React.JSX.Element => (
-
-
-
-
-
- Atlas Cloud coding plan
-
-
- Sponsor
-
-
- OpenCode provider
-
-
-
-
-
- Connect
-
-
void api.openExternal(ATLAS_CLOUD_CODING_PLAN_URL)}
- className="flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
- style={{
- borderColor: 'var(--color-border)',
- color: 'var(--color-text-muted)',
- }}
- >
-
- Plan
-
-
-
- Become a sponsor
-
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+
+ return (
+
+
+
+
+
+ {t('cliStatus.atlas.plan')}
+
+
+ {t('cliStatus.atlas.sponsor')}
+
+
+ {t('cliStatus.atlas.openCodeProvider')}
+
+
+
+
+
+ {t('cliStatus.actions.connect')}
+
+ void api.openExternal(ATLAS_CLOUD_CODING_PLAN_URL)}
+ className="flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
+ style={{
+ borderColor: 'var(--color-border)',
+ color: 'var(--color-text-muted)',
+ }}
+ >
+
+ {t('cliStatus.actions.plan')}
+
+
+
+ {t('cliStatus.actions.becomeSponsor')}
+
+
+
+ {t('cliStatus.atlas.description')}
+
-
- {ATLAS_CLOUD_DESCRIPTION}
-
-
-);
+ );
+};
const InstalledBanner = ({
cliStatus,
@@ -817,6 +846,8 @@ const InstalledBanner = ({
codexReconnectBusy,
variant,
}: InstalledBannerProps): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+ const { t: settingsT } = useAppTranslation('settings');
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
const styles = VARIANT_STYLES[variant];
const visibleProviders = useMemo(
@@ -825,7 +856,7 @@ const InstalledBanner = ({
);
const canOpenExtensions = cliStatus.installed;
const runtimeLabel = formatRuntimeLabel(cliStatus);
- const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders);
+ const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders, t);
const showCollapseControl = visibleProviders.length > 0;
const showExpandedContent = !providersCollapsed;
@@ -845,10 +876,16 @@ const InstalledBanner = ({
className="flex items-center justify-center rounded-md p-1 transition-colors hover:bg-white/5"
style={{ color: 'var(--color-text-muted)' }}
aria-label={
- providersCollapsed ? 'Expand provider details' : 'Collapse provider details'
+ providersCollapsed
+ ? t('cliStatus.labels.expandProviderDetails')
+ : t('cliStatus.labels.collapseProviderDetails')
}
aria-expanded={!providersCollapsed}
- title={providersCollapsed ? 'Expand provider details' : 'Collapse provider details'}
+ title={
+ providersCollapsed
+ ? t('cliStatus.labels.expandProviderDetails')
+ : t('cliStatus.labels.collapseProviderDetails')
+ }
>
{providersCollapsed ? (
@@ -875,7 +912,7 @@ const InstalledBanner = ({
style={{ backgroundColor: '#3b82f6' }}
>
- Update to v{cliStatus.latestVersion}
+ {t('cliStatus.actions.updateTo', { version: cliStatus.latestVersion })}
) : cliStatus.supportsSelfUpdate ? (
- {cliStatusLoading ? 'Checking...' : 'Check for Updates'}
+ {cliStatusLoading
+ ? t('cliStatus.actions.checking')
+ : t('cliStatus.actions.checkUpdates')}
) : null}
@@ -917,14 +956,14 @@ const InstalledBanner = ({
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Extensions
+ {t('cliStatus.actions.extensions')}
)}
{showExpandedContent && cliStatusError && !cliStatusLoading && (
- Failed to check for updates. Check your network connection and try again.
+ {t('cliStatus.errors.refreshFailed')}
)}
{showExpandedContent && visibleProviders.length > 0 && (
@@ -935,10 +974,10 @@ const InstalledBanner = ({
{visibleProviders.map((provider) => {
const actionDisabled = isBusy || !cliStatus.binaryPath;
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
- ? getProviderCurrentRuntimeSummary(provider)
+ ? getProviderCurrentRuntimeSummary(provider, settingsT)
: getProviderRuntimeBackendSummary(provider);
- const connectionModeSummary = getProviderConnectionModeSummary(provider);
- const credentialSummary = getProviderCredentialSummary(provider);
+ const connectionModeSummary = getProviderConnectionModeSummary(provider, settingsT);
+ const credentialSummary = getProviderCredentialSummary(provider, settingsT);
const dashboardRateLimits = getDashboardRateLimitsForProvider(provider);
const hasDashboardRateLimits = Boolean(dashboardRateLimits?.length);
const isSubscriptionRateLimitMode = isDashboardRateLimitSubscriptionMode({
@@ -946,7 +985,7 @@ const InstalledBanner = ({
sourceProvider: sourceProviderMap.get(provider.providerId) ?? null,
configuredAuthModes: providerConnectionAuthModes,
});
- const codexDashboardHint = getCodexDashboardHint(provider);
+ const codexDashboardHint = getCodexDashboardHint(provider, t);
const codexNeedsReconnect =
provider.providerId === 'codex' &&
Boolean(provider.connection?.codex?.localActiveChatgptAccountPresent) &&
@@ -956,7 +995,7 @@ const InstalledBanner = ({
const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null;
const codexLoginUserCode = provider.connection?.codex?.login.userCode ?? null;
const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl);
- const disconnectAction = getProviderDisconnectAction(provider);
+ const disconnectAction = getProviderDisconnectAction(provider, settingsT);
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState(
@@ -982,7 +1021,9 @@ const InstalledBanner = ({
hasRateLimits: hasDashboardRateLimits,
loading: rateLimitsLoading,
});
- const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
+ const statusText = showSkeleton
+ ? t('cliStatus.actions.checking')
+ : formatProviderStatusText(provider, settingsT);
const modelCatalogLoading =
provider.modelCatalogRefreshState === 'loading' ||
isOpenCodeCatalogHydrating(provider);
@@ -991,7 +1032,7 @@ const InstalledBanner = ({
? getVisibleTeamProviderModels(provider.providerId, provider.models, provider)
.length > 0
: provider.models.length > 0;
- const openCodeDashboardChips = getOpenCodeDashboardChips(provider);
+ const openCodeDashboardChips = getOpenCodeDashboardChips(provider, t);
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
@@ -1050,20 +1091,24 @@ const InstalledBanner = ({
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && !runtimeSummary && (
- Backend: {provider.backend.label}
+
+ {t('cliStatus.provider.backend', { backend: provider.backend.label })}
+
)}
{runtimeSummary ? (
{isConnectionManagedRuntimeProvider(provider)
? runtimeSummary
- : `Runtime: ${runtimeSummary}`}
+ : t('cliStatus.provider.runtime', { runtime: runtimeSummary })}
) : null}
{connectionModeSummary ? {connectionModeSummary} : null}
{credentialSummary ? {credentialSummary} : null}
- {modelCatalogLoading ? Loading models... : null}
+ {modelCatalogLoading ? (
+ {t('cliStatus.provider.loadingModels')}
+ ) : null}
{!hasProviderModels && !modelCatalogLoading && (
- Models unavailable for this runtime build
+ {t('cliStatus.provider.modelsUnavailable')}
)}
) : null}
@@ -1099,7 +1144,7 @@ const InstalledBanner = ({
color: '#fbbf24',
}}
>
- Use code
+ {t('cliStatus.actions.useCode')}
) : null}
- {codexLoginAuthUrl ? 'Open login' : 'Generate link'}
+ {codexLoginAuthUrl
+ ? t('cliStatus.labels.openLogin')
+ : t('cliStatus.labels.generateLink')}
>
) : null}
@@ -1144,7 +1191,7 @@ const InstalledBanner = ({
title={
codexRuntimeStatus?.error ??
codexRuntimeStatus?.progress?.detail ??
- 'Install Codex CLI into app data'
+ t('cliStatus.runtimeInstall.codexTitle')
}
>
{isRuntimeInstalling(codexRuntimeStatus, codexRuntimeStatusLoading) ? (
@@ -1152,7 +1199,7 @@ const InstalledBanner = ({
) : (
)}
- {getRuntimeInstallLabel(codexRuntimeStatus)}
+ {getRuntimeInstallLabel(codexRuntimeStatus, t)}
) : null}
{shouldShowOpenCodeInstallAction(
@@ -1175,7 +1222,7 @@ const InstalledBanner = ({
title={
openCodeRuntimeStatus?.error ??
openCodeRuntimeStatus?.progress?.detail ??
- 'Install OpenCode runtime into app data'
+ t('cliStatus.runtimeInstall.openCodeTitle')
}
>
{isRuntimeInstalling(
@@ -1186,7 +1233,7 @@ const InstalledBanner = ({
) : (
)}
- {getRuntimeInstallLabel(openCodeRuntimeStatus)}
+ {getRuntimeInstallLabel(openCodeRuntimeStatus, t)}
) : null}
- Manage
+ {t('cliStatus.actions.manage')}
{disconnectAction ? (
- {getProviderConnectLabel(provider)}
+ {getProviderConnectLabel(provider, settingsT)}
) : null}
{
+ const { t } = useAppTranslation('dashboard');
+ const { t: settingsT } = useAppTranslation('settings');
const isElectron = useMemo(() => isElectronMode(), []);
const appConfig = useStore((s) => s.appConfig);
const selectedProjectId = useStore((s) => s.selectedProjectId);
@@ -1543,7 +1594,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
void (async () => {
const provider =
effectiveCliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
- const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
+ const disconnectAction = provider ? getProviderDisconnectAction(provider, settingsT) : null;
if (!disconnectAction) {
return;
}
@@ -1552,7 +1603,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
title: disconnectAction.title,
message: disconnectAction.message,
confirmLabel: disconnectAction.confirmLabel,
- cancelLabel: 'Cancel',
+ cancelLabel: t('cliStatus.actions.cancel'),
variant: 'danger',
});
@@ -1563,7 +1614,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
setProviderTerminal({ providerId, action: 'logout' });
})();
},
- [effectiveCliStatus?.providers]
+ [effectiveCliStatus?.providers, settingsT, t]
);
const handleProviderManage = useCallback((providerId: CliProviderId) => {
@@ -1616,10 +1667,10 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
try {
await fetchCliProviderStatus(providerId);
} catch {
- throw new Error('Runtime updated, but failed to refresh provider status.');
+ throw new Error(t('cliStatus.errors.runtimeUpdatedRefreshFailed'));
}
},
- [appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig]
+ [appConfig?.runtime?.providerBackends, fetchCliProviderStatus, t, updateConfig]
);
if (!isElectron) return null;
@@ -1693,7 +1744,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
{
autoCloseOnSuccessMs={3000}
successMessage={
providerTerminal.action === 'login'
- ? 'Authentication updated'
- : 'Provider logged out'
+ ? t('cliStatus.labels.loginAuthUpdated')
+ : t('cliStatus.labels.loggedOut')
}
failureMessage={
- providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
+ providerTerminal.action === 'login'
+ ? t('cliStatus.labels.loginAuthFailed')
+ : t('cliStatus.labels.logoutFailed')
}
/>
@@ -1736,7 +1791,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
- Failed to check CLI status
+ {t('cliStatus.errors.checkStatusFailed')}
{
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Retry
+ {t('cliStatus.actions.retry')}
@@ -1761,7 +1816,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
- {runtimeDisplayName} status will be checked in the background.
+ {t('cliStatus.hints.backgroundStatus', { runtime: runtimeDisplayName })}
{
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Check now
+ {t('cliStatus.actions.checkNow')}
);
@@ -1816,7 +1871,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
return (
);
}
@@ -1832,7 +1889,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
- Downloading {runtimeDisplayName}...
+ {t('cliStatus.installer.downloading', { runtime: runtimeDisplayName })}
@@ -1864,7 +1921,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// ── Checking / Verifying ───────────────────────────────────────────────
if (installerState === 'checking' || installerState === 'verifying') {
const label =
- installerState === 'checking' ? 'Checking latest version...' : 'Verifying checksum...';
+ installerState === 'checking'
+ ? t('cliStatus.installer.checkingLatest')
+ : t('cliStatus.installer.verifying');
return (
{
- Installing {runtimeDisplayName}...
+ {t('cliStatus.installer.installing', { runtime: runtimeDisplayName })}
@@ -1919,7 +1978,10 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
-
+
);
}
@@ -1943,13 +2005,17 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
{cliLaunchIssue
- ? `${runtimeDisplayName} was found but failed to start`
- : `${runtimeDisplayName} is required`}
+ ? t('cliStatus.runtime.foundButFailed', { runtime: runtimeDisplayName })
+ : t('cliStatus.runtime.isRequired', { runtime: runtimeDisplayName })}
{cliLaunchIssue
- ? `The app found the configured ${runtimeDisplayName}, but its startup health check failed. Repair or reinstall it, then retry.`
- : `${runtimeDisplayName} is required for team provisioning and session management. Install it to get started.`}
+ ? t('cliStatus.runtime.healthCheckFailedDescription', {
+ runtime: runtimeDisplayName,
+ })
+ : t('cliStatus.runtime.installRequiredDescription', {
+ runtime: runtimeDisplayName,
+ })}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
{
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Re-check
+ {t('cliStatus.actions.recheck')}
{renderCliStatus.supportsSelfUpdate ? (
{
>
{cliLaunchIssue
- ? `Reinstall ${runtimeDisplayName}`
- : `Install ${runtimeDisplayName}`}
+ ? t('cliStatus.runtime.reinstall', { runtime: runtimeDisplayName })
+ : t('cliStatus.runtime.install', { runtime: runtimeDisplayName })}
) : (
{cliLaunchIssue
- ? `The configured ${runtimeDisplayName} failed its startup health check.`
- : `The configured ${runtimeDisplayName} was not found.`}
+ ? t('cliStatus.runtime.configuredHealthCheckFailed', {
+ runtime: runtimeDisplayName,
+ })
+ : t('cliStatus.runtime.configuredNotFound', { runtime: runtimeDisplayName })}
)}
@@ -2071,18 +2139,22 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
hasApiKeyModeIssue && apiKeyMissingProviders.length === apiKeyActionRequiredProviders.length;
const warningTitle = hasApiKeyModeIssue
? allApiKeyIssuesAreMissingKeys
- ? 'API key required'
- : 'Provider action required'
- : 'Not logged in';
+ ? t('cliStatus.labels.apiKeyRequired')
+ : t('cliStatus.labels.providerActionRequired')
+ : t('cliStatus.labels.notLoggedIn');
const warningMessage = hasApiKeyModeIssue
? allApiKeyIssuesAreMissingKeys
? apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider
- ? `${primaryApiKeyProvider.displayName} is set to API key mode, but no API key is configured. Open Manage Providers to add a key or switch the connection mode.`
- : 'One or more providers are set to API key mode, but no API key is configured. Open Manage Providers to add keys or switch the connection mode.'
+ ? t('cliStatus.warnings.singleApiKeyMissing', {
+ provider: primaryApiKeyProvider.displayName,
+ })
+ : t('cliStatus.warnings.multipleApiKeysMissing')
: apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider
- ? `${primaryApiKeyProvider.displayName} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.`
- : 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.'
- : `${runtimeDisplayName} is installed but you are not authenticated. Login is required for team provisioning and AI features.`;
+ ? t('cliStatus.warnings.singleApiKeyNeedsAttention', {
+ provider: primaryApiKeyProvider.displayName,
+ })
+ : t('cliStatus.warnings.multipleApiKeysNeedAttention')
+ : t('cliStatus.warnings.notAuthenticated', { runtime: runtimeDisplayName });
return (
<>
@@ -2146,7 +2218,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
style={{ backgroundColor: '#f59e0b' }}
>
- Manage Providers
+ {t('cliStatus.actions.manageProviders')}
) : (
<>
@@ -2159,7 +2231,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}}
>
- Already logged in?
+ {t('cliStatus.actions.alreadyLoggedIn')}
{showTroubleshoot ? (
) : (
@@ -2172,7 +2244,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
style={{ backgroundColor: '#f59e0b' }}
>
- Login
+ {t('cliStatus.actions.login')}
>
)}
@@ -2191,14 +2263,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
className="mb-2 text-xs font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
- If you're sure you're logged in, try these steps:
+ {t('cliStatus.hints.troubleshootTitle')}
- Click{' '}
+ {t('cliStatus.troubleshoot.click')}{' '}
{
setIsVerifyingAuth(true);
@@ -2220,36 +2292,36 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}}
>
- Re-check
+ {t('cliStatus.actions.recheck')}
{' '}
- — sometimes the status is cached for a few seconds
+ {t('cliStatus.troubleshoot.statusCacheHint')}
- Open your terminal and run:{' '}
+ {t('cliStatus.troubleshoot.openTerminal')}{' '}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
? `"${renderCliStatus.binaryPath}" auth status`
- : 'your configured CLI auth status command'}
+ : t('cliStatus.troubleshoot.authStatusCommand')}
{' '}
- — check if it shows "Logged in"
+ {t('cliStatus.troubleshoot.checkLoggedIn')}
- If it says logged in but the app doesn't see it, try:{' '}
+ {t('cliStatus.troubleshoot.reloginPrefix')}{' '}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
? `"${renderCliStatus.binaryPath}" auth logout`
- : 'the runtime logout command'}
+ : t('cliStatus.troubleshoot.logoutCommand')}
{' '}
- then{' '}
+ {t('cliStatus.troubleshoot.then')}{' '}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
? `"${renderCliStatus.binaryPath}" auth login`
- : 'the runtime login command'}
+ : t('cliStatus.troubleshoot.loginCommand')}
{' '}
- again
+ {t('cliStatus.troubleshoot.again')}
- Make sure the CLI in your terminal is the same runtime the app uses
+ {t('cliStatus.troubleshoot.sameRuntime')}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
:{' '}
@@ -2261,8 +2333,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
- Browsing sessions and projects works without login. Login is only needed to run
- agent teams.
+ {t('cliStatus.hints.loginRequiredForTeams')}
)}
@@ -2271,7 +2342,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
{showLoginTerminal && renderCliStatus.binaryPath && (
{
@@ -2306,8 +2379,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
})();
}}
autoCloseOnSuccessMs={4000}
- successMessage="Login complete"
- failureMessage="Login failed"
+ successMessage={t('cliStatus.labels.loginComplete')}
+ failureMessage={t('cliStatus.labels.loginFailed')}
/>
)}
diff --git a/src/renderer/components/dashboard/DashboardUpdateBanner.tsx b/src/renderer/components/dashboard/DashboardUpdateBanner.tsx
index 598049fd..7479f2b8 100644
--- a/src/renderer/components/dashboard/DashboardUpdateBanner.tsx
+++ b/src/renderer/components/dashboard/DashboardUpdateBanner.tsx
@@ -8,6 +8,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { ArrowUpCircle, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -15,6 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
const DISMISSED_KEY = 'update:dashboard-dismissed-version';
export const DashboardUpdateBanner = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('dashboard');
const { updateStatus, availableVersion, openUpdateDialog, installUpdate } = useStore(
useShallow((s) => ({
updateStatus: s.updateStatus,
@@ -57,7 +59,7 @@ export const DashboardUpdateBanner = (): React.JSX.Element | null => {
>
- New version available{' '}
+ {t('updateBanner.newVersionAvailable')}{' '}
{availableVersion && (
v{availableVersion}
)}
@@ -70,7 +72,7 @@ export const DashboardUpdateBanner = (): React.JSX.Element | null => {
color: '#4ade80',
}}
>
- {isDownloaded ? 'Restart now' : 'View details'}
+ {isDownloaded ? t('updateBanner.restartNow') : t('updateBanner.viewDetails')}
): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef(null);
const { openCommandPalette, selectedProjectId } = useStore(
@@ -76,7 +78,7 @@ const CommandSearch = ({ value, onChange }: Readonly): React
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
- placeholder="Search projects..."
+ placeholder={t('recentProjects.searchPlaceholder')}
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
@@ -103,6 +105,7 @@ const CommandSearch = ({ value, onChange }: Readonly): React
};
export const DashboardView = (): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const [searchQuery, setSearchQuery] = useState('');
const openTeamsTab = useStore((state) => state.openTeamsTab);
@@ -126,9 +129,9 @@ export const DashboardView = (): React.JSX.Element => {
className="flex shrink-0 items-center gap-2 rounded-sm border border-border bg-surface-raised px-4 py-3 text-sm text-text-secondary transition-all duration-200 hover:border-zinc-500 hover:text-text"
>
- Select Team
+ {t('actions.selectTeam')}
- or
+ {t('actions.or')}
@@ -138,14 +141,14 @@ export const DashboardView = (): React.JSX.Element => {
- {searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
+ {searchQuery.trim() ? t('recentProjects.searchResults') : t('recentProjects.title')}
{searchQuery.trim() && (
setSearchQuery('')}
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
>
- Clear search
+ {t('actions.clearSearch')}
)}
diff --git a/src/renderer/components/dashboard/WebPreviewBanner.tsx b/src/renderer/components/dashboard/WebPreviewBanner.tsx
index 1c7a7cdd..1d63ffc5 100644
--- a/src/renderer/components/dashboard/WebPreviewBanner.tsx
+++ b/src/renderer/components/dashboard/WebPreviewBanner.tsx
@@ -1,7 +1,9 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { FlaskConical } from 'lucide-react';
export const WebPreviewBanner = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('dashboard');
if (isElectronMode()) {
return null;
}
@@ -16,13 +18,8 @@ export const WebPreviewBanner = (): React.JSX.Element | null => {
>
-
- Open the desktop app for full functionality
-
-
- The browser version is still in development. Project actions, integrations, and live
- status updates may be limited here. Use the desktop app to access all features reliably.
-
+
{t('webPreview.title')}
+
{t('webPreview.description')}
);
diff --git a/src/renderer/components/dashboard/WindowsAdministratorBanner.tsx b/src/renderer/components/dashboard/WindowsAdministratorBanner.tsx
index 633b708a..eda3b776 100644
--- a/src/renderer/components/dashboard/WindowsAdministratorBanner.tsx
+++ b/src/renderer/components/dashboard/WindowsAdministratorBanner.tsx
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { AlertTriangle } from 'lucide-react';
import type { WindowsElevationStatus } from '@shared/types/api';
export const WindowsAdministratorBanner = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('dashboard');
const [status, setStatus] = useState(null);
useEffect(() => {
@@ -51,12 +53,9 @@ export const WindowsAdministratorBanner = (): React.JSX.Element | null => {
>
-
- Windows Administrator mode recommended
-
+
{t('windowsAdmin.title')}
- OpenCode runtime checks can time out when Agent Teams AI is not elevated. Restart the app
- with Run as administrator before launching OpenCode teams.
+ {t('windowsAdmin.description')}
diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx
index d635f6bf..df7e310c 100644
--- a/src/renderer/components/extensions/ExtensionStoreView.tsx
+++ b/src/renderer/components/extensions/ExtensionStoreView.tsx
@@ -10,6 +10,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Badge } from '@renderer/components/ui/badge';
@@ -63,33 +64,36 @@ const ProviderCapabilityCardSkeleton = ({
}: {
providerId: 'anthropic' | 'codex' | 'gemini' | 'opencode';
displayName: string;
-}): React.JSX.Element => (
-
-
-
-
-
- {displayName}
-
-
-
-
Checking provider status...
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
+ return (
+
+
+
+
+
+ {displayName}
+
+
+
+ {t('store.provider.checkingStatus')}
+
+
+ {t('store.provider.loading')}
+
+
+
+ {Array.from({ length: 3 }, (_, index) => (
+
+ ))}
-
- Loading...
-
-
- {Array.from({ length: 3 }, (_, index) => (
-
- ))}
-
-
-);
+ );
+};
function isProviderCapabilityCardLoading(
provider: CliProviderStatus,
@@ -112,6 +116,7 @@ function isCodexSnapshotPending(
}
export const ExtensionStoreView = (): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const isElectron = useMemo(() => isElectronMode(), []);
const tabId = useTabIdOptional();
const {
@@ -222,34 +227,30 @@ export const ExtensionStoreView = (): React.JSX.Element => {
() => [
{
value: 'plugins' as const,
- label: 'Plugins',
+ label: t('store.tabs.plugins.label'),
icon: Puzzle,
- description:
- 'Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported. Broader provider support is in development.',
+ description: t('store.tabs.plugins.description'),
},
{
value: 'mcp-servers' as const,
- label: 'MCP Servers',
+ label: t('store.tabs.mcpServers.label'),
icon: Server,
- description:
- 'Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.',
+ description: t('store.tabs.mcpServers.description'),
},
{
value: 'skills' as const,
- label: 'Skills',
+ label: t('store.tabs.skills.label'),
icon: BookOpen,
- description:
- 'Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.',
+ description: t('store.tabs.skills.description'),
},
{
value: 'api-keys' as const,
- label: 'API Keys',
+ label: t('store.tabs.apiKeys.label'),
icon: Key,
- description:
- 'Secret keys for online services. Add them here so plugins, servers, and integrations can connect and work.',
+ description: t('store.tabs.apiKeys.description'),
},
],
- []
+ [t]
);
// Fetch plugin catalog on mount
@@ -343,11 +344,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
- Checking extensions runtime availability
+ {t('store.runtime.checkingAvailabilityTitle')}
- Extensions need the configured runtime to manage plugins, MCP servers, skills, and
- provider connections.
+ {t('store.runtime.checkingAvailabilityDescription')}
@@ -364,13 +364,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{cliLaunchIssue
- ? 'The configured runtime was found but failed to start'
- : 'The configured runtime is not available'}
+ ? t('store.runtime.failedToStartTitle')
+ : t('store.runtime.notAvailableTitle')}
{cliLaunchIssue
- ? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.'
- : 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}
+ ? t('store.runtime.failedToStartDescription')
+ : t('store.runtime.notAvailableDescription')}
{cliLaunchIssue && effectiveCliStatus.launchError && (
@@ -379,7 +379,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
)}
- Open Dashboard
+ {t('store.actions.openDashboard')}
);
@@ -390,17 +390,20 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
{runtimeDisplayName} needs sign-in
+
+ {t('store.runtime.needsSignInTitle', { runtime: runtimeDisplayName })}
+
- {runtimeDisplayName} was found
- {effectiveCliStatus.installedVersion
- ? ` (${effectiveCliStatus.installedVersion})`
- : ''}
- , but plugin installs are disabled until you sign in from the Dashboard.
+ {t('store.runtime.needsSignInDescription', {
+ runtime: runtimeDisplayName,
+ version: effectiveCliStatus.installedVersion
+ ? ` (${effectiveCliStatus.installedVersion})`
+ : '',
+ })}
- Open Dashboard
+ {t('store.actions.openDashboard')}
);
@@ -412,10 +415,11 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
Multimodel runtime capabilities
+
+ {t('store.runtime.multimodelCapabilitiesTitle')}
+
- Provider support can differ by section. Plugins are shown only where the runtime
- explicitly declares support.
+ {t('store.runtime.multimodelCapabilitiesDescription')}
@@ -444,8 +448,11 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const statusLabel = provider.authenticated
? 'Connected'
: provider.supported
- ? 'Needs setup'
- : 'Unsupported';
+ ? t('store.provider.needsSetup')
+ : t('store.provider.unsupported');
+ const finalStatusLabel = provider.authenticated
+ ? t('store.provider.connected')
+ : statusLabel;
const extensionCapabilities = getCliProviderExtensionCapabilities(provider);
const pluginStatus = extensionCapabilities.plugins.status;
@@ -466,11 +473,11 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{provider.statusMessage ??
provider.backend?.label ??
- 'Ready to configure'}
+ t('store.provider.readyToConfigure')}
- {statusLabel}
+ {finalStatusLabel}
@@ -482,13 +489,21 @@ export const ExtensionStoreView = (): React.JSX.Element => {
: undefined
}
>
- Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)}
+ {t('store.capabilities.plugins', {
+ status: formatCliExtensionCapabilityStatus(pluginStatus),
+ })}
- MCP: {formatCliExtensionCapabilityStatus(extensionCapabilities.mcp.status)}
+ {t('store.capabilities.mcp', {
+ status: formatCliExtensionCapabilityStatus(
+ extensionCapabilities.mcp.status
+ ),
+ })}
- Skills: {extensionCapabilities.skills.ownership}
+ {t('store.capabilities.skills', {
+ status: extensionCapabilities.skills.ownership,
+ })}
@@ -504,13 +519,16 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
{runtimeDisplayName} is ready
+
+ {t('store.runtime.readyTitle', { runtime: runtimeDisplayName })}
+
- Plugins can be installed from this page
- {effectiveCliStatus.installedVersion
- ? ` using ${runtimeDisplayName} ${effectiveCliStatus.installedVersion}`
- : ''}
- .
+ {t('store.runtime.readyDescription', {
+ runtime: runtimeDisplayName,
+ versionSuffix: effectiveCliStatus.installedVersion
+ ? ` using ${runtimeDisplayName} ${effectiveCliStatus.installedVersion}`
+ : '',
+ })}
@@ -522,6 +540,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
effectiveCliStatusLoading,
openDashboard,
runtimeDisplayName,
+ t,
]);
// Browser mode guard
@@ -530,8 +549,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
Extensions
-
Available in the desktop app only.
+
{t('store.title')}
+
{t('store.desktopOnly')}
);
@@ -546,7 +565,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
Extensions
+
{t('store.title')}
@@ -554,7 +573,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
- Refresh catalog
+ {t('store.actions.refreshCatalog')}
@@ -564,15 +583,14 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{!cliInstalled && (
- The configured runtime is required to install or uninstall extensions. Install or
- repair it from the Dashboard.
+ {t('store.runtime.requiredForMutations')}
)}
{/* Active sessions warning */}
{hasOngoingSessions && (
- Running sessions won't pick up extension changes until restarted.
+ {t('store.sessionsRestartWarning')}
)}
{
disabled={Boolean(mcpMutationDisableReason)}
>
- Add Custom
+ {t('store.actions.addCustom')}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
index a946adec..80940929 100644
--- a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
+++ b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
@@ -4,6 +4,7 @@
import { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -23,6 +24,7 @@ interface ApiKeyCardProps {
}
export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const deleteApiKey = useStore((s) => s.deleteApiKey);
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -117,7 +119,7 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
- Edit
+ {t('apiKeys.actions.edit')}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
index f2b53396..45035a46 100644
--- a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
+++ b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
@@ -5,6 +5,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@@ -39,11 +40,6 @@ interface ApiKeyFormDialogProps {
type Scope = 'user' | 'project';
-const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
- { value: 'user', label: 'User (global)' },
- { value: 'project', label: 'Project' },
-];
-
export const ApiKeyFormDialog = ({
open,
editingKey,
@@ -51,6 +47,7 @@ export const ApiKeyFormDialog = ({
currentProjectLabel,
onClose,
}: ApiKeyFormDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const saveApiKey = useStore((s) => s.saveApiKey);
const apiKeySaving = useStore((s) => s.apiKeySaving);
const storageStatus = useStore((s) => s.apiKeyStorageStatus);
@@ -101,7 +98,7 @@ export const ApiKeyFormDialog = ({
return;
}
if (!ENV_KEY_RE.test(v)) {
- setEnvVarError('Use letters, digits, underscores. Must start with a letter or underscore.');
+ setEnvVarError(t('apiKeys.form.errors.invalidEnvVarFormat'));
} else {
setEnvVarError(null);
}
@@ -112,23 +109,23 @@ export const ApiKeyFormDialog = ({
setError(null);
if (!name.trim()) {
- setError('Name is required');
+ setError(t('apiKeys.form.errors.nameRequired'));
return;
}
if (!envVarName.trim()) {
- setError('Environment variable name is required');
+ setError(t('apiKeys.form.errors.envVarRequired'));
return;
}
if (!ENV_KEY_RE.test(envVarName)) {
- setError('Invalid environment variable name');
+ setError(t('apiKeys.form.errors.invalidEnvVar'));
return;
}
if (!value) {
- setError('Key value is required');
+ setError(t('apiKeys.form.errors.valueRequired'));
return;
}
if (scope === 'project' && !effectiveProjectPath) {
- setError('Project-scoped API keys require an active project');
+ setError(t('apiKeys.form.errors.projectScopeRequiresProject'));
return;
}
@@ -143,7 +140,7 @@ export const ApiKeyFormDialog = ({
});
onClose();
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to save');
+ setError(err instanceof Error ? err.message : t('apiKeys.form.errors.saveFailed'));
}
};
@@ -165,11 +162,11 @@ export const ApiKeyFormDialog = ({
- {isEdit ? 'Edit API Key' : 'Add API Key'}
+
+ {isEdit ? t('apiKeys.form.editTitle') : t('apiKeys.form.addTitle')}
+
- {isEdit
- ? 'Update the key details. You must re-enter the value.'
- : 'Store an API key for auto-filling in MCP server installations.'}
+ {isEdit ? t('apiKeys.form.editDescription') : t('apiKeys.form.addDescription')}
@@ -178,8 +175,7 @@ export const ApiKeyFormDialog = ({
{storageStatus && storageStatus.encryptionMethod !== 'os-keychain' && (
- OS keychain unavailable — keys encrypted with AES-256 locally. Install gnome-keyring for
- OS-level protection.
+ {t('apiKeys.form.keychainUnavailable')}
)}
@@ -187,13 +183,13 @@ export const ApiKeyFormDialog = ({
{/* Name */}
- Name
+ {t('apiKeys.form.name')}
setName(e.target.value)}
- placeholder="e.g. OpenAI Production"
+ placeholder={t('apiKeys.form.namePlaceholder')}
className="h-8 text-sm"
autoFocus
/>
@@ -202,7 +198,7 @@ export const ApiKeyFormDialog = ({
{/* Env var name */}
- Environment Variable Name
+ {t('apiKeys.form.environmentVariableName')}
{envVarError &&
{envVarError}
}
@@ -220,43 +216,47 @@ export const ApiKeyFormDialog = ({
{/* Value */}
- Value
+ {t('apiKeys.form.value')}
setValue(e.target.value)}
- placeholder={isEdit ? 'Re-enter key value' : 'sk-...'}
+ placeholder={
+ isEdit ? t('apiKeys.form.reenterValue') : t('apiKeys.form.valuePlaceholder')
+ }
className="h-8 text-sm"
/>
{/* Scope */}
-
Scope
+
{t('apiKeys.form.scope')}
setScope(v as Scope)}>
- {SCOPE_OPTIONS.map((opt) => (
+ {(['user', 'project'] as const).map((scopeOption) => (
- {opt.value === 'project'
+ {scopeOption === 'project'
? effectiveProjectPath
- ? `Project: ${effectiveProjectLabel}`
- : 'Project unavailable'
- : opt.label}
+ ? t('apiKeys.form.projectScopeLabel', { project: effectiveProjectLabel })
+ : t('apiKeys.form.projectUnavailable')
+ : t('apiKeys.form.userScopeLabel')}
))}
{scope === 'project' && effectiveProjectPath && (
-
Bound to {effectiveProjectPath}
+
+ {t('apiKeys.form.boundTo', { path: effectiveProjectPath })}
+
)}
@@ -270,10 +270,14 @@ export const ApiKeyFormDialog = ({
{/* Actions */}
- Cancel
+ {t('apiKeys.form.cancel')}
- {apiKeySaving ? 'Saving...' : isEdit ? 'Update' : 'Save'}
+ {apiKeySaving
+ ? t('apiKeys.form.saving')
+ : isEdit
+ ? t('apiKeys.form.update')
+ : t('apiKeys.form.save')}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
index 4d70cc9d..8b663758 100644
--- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
+++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
@@ -8,6 +8,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -30,6 +31,7 @@ export const ApiKeysPanel = ({
projectPath,
projectLabel,
}: ApiKeysPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const isElectron = useMemo(() => isElectronMode(), []);
const {
apiKeys,
@@ -188,7 +190,7 @@ export const ApiKeysPanel = ({
{/* Header row */}
- Securely store API keys for auto-filling when installing MCP servers.
+ {t('apiKeys.description')}
{storageStatus && (
@@ -200,15 +202,9 @@ export const ApiKeysPanel = ({
{isOsKeychain ? (
-
- Keys are encrypted via {storageStatus.backend} and stored with restricted file
- permissions (owner-only).
-
+ {t('apiKeys.storage.osKeychain', { backend: storageStatus.backend })}
) : (
-
- OS keychain unavailable — keys are encrypted locally with AES-256. For stronger
- protection, install a keyring service (gnome-keyring, kwallet).
-
+ {t('apiKeys.storage.localEncryption')}
)}
@@ -216,7 +212,7 @@ export const ApiKeysPanel = ({
- Add API Key
+ {t('apiKeys.actions.add')}
@@ -250,13 +246,11 @@ export const ApiKeysPanel = ({
-
No API keys saved
-
- Add keys to auto-fill environment variables when installing MCP servers.
-
+
{t('apiKeys.empty.title')}
+
{t('apiKeys.empty.description')}
- Add your first key
+ {t('apiKeys.actions.addFirst')}
)}
diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx
index 653b9ef2..e509c439 100644
--- a/src/renderer/components/extensions/common/InstallButton.tsx
+++ b/src/renderer/components/extensions/common/InstallButton.tsx
@@ -5,6 +5,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Tooltip,
@@ -48,6 +49,7 @@ export const InstallButton = ({
cliStatus: cliStatusOverride,
cliStatusLoading: cliStatusLoadingOverride,
}: InstallButtonProps) => {
+ const { t } = useAppTranslation('extensions');
const { cliStatus: storedCliStatus, cliStatusLoading: storedCliStatusLoading } = useStore(
useShallow((s) => ({
cliStatus: s.cliStatus,
@@ -77,7 +79,9 @@ export const InstallButton = ({
- {pendingAction === 'uninstall' ? 'Removing...' : 'Installing...'}
+ {pendingAction === 'uninstall'
+ ? t('installButton.removing')
+ : t('installButton.installing')}
);
@@ -87,7 +91,7 @@ export const InstallButton = ({
return (
- Done
+ {t('installButton.done')}
);
}
@@ -111,7 +115,7 @@ export const InstallButton = ({
}}
disabled={isDisabled}
>
-
Retry
+
{t('installButton.retry')}
);
@@ -152,7 +156,7 @@ export const InstallButton = ({
disabled={isDisabled}
>
-
Uninstall
+
{t('installButton.uninstall')}
) : (
- Install
+ {t('installButton.install')}
);
diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
index 114f9cb8..1f029337 100644
--- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
+++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
@@ -5,6 +5,7 @@
import { useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
@@ -75,6 +76,7 @@ export const CustomMcpServerDialog = ({
cliStatus: cliStatusOverride,
cliStatusLoading: cliStatusLoadingOverride,
}: CustomMcpServerDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
const storedCliStatus = useStore((s) => s.cliStatus);
const storedCliStatusLoading = useStore((s) => s.cliStatusLoading);
@@ -231,11 +233,11 @@ export const CustomMcpServerDialog = ({
}
if (!serverName.trim()) {
- setError('Server name is required');
+ setError(t('customMcp.errors.serverNameRequired'));
return;
}
if (!SERVER_NAME_RE.test(serverName)) {
- setError('Invalid server name. Use alphanumeric characters, dashes, underscores, dots.');
+ setError(t('customMcp.errors.invalidServerName'));
return;
}
@@ -243,7 +245,7 @@ export const CustomMcpServerDialog = ({
if (transportMode === 'stdio') {
if (!npmPackage.trim()) {
- setError('npm package name is required');
+ setError(t('customMcp.errors.npmPackageRequired'));
return;
}
installSpec = {
@@ -253,7 +255,7 @@ export const CustomMcpServerDialog = ({
};
} else {
if (!httpUrl.trim()) {
- setError('Server URL is required');
+ setError(t('customMcp.errors.serverUrlRequired'));
return;
}
installSpec = {
@@ -284,7 +286,7 @@ export const CustomMcpServerDialog = ({
await installCustomMcpServer(request);
onClose();
} catch (err) {
- setError(err instanceof Error ? err.message : 'Install failed');
+ setError(err instanceof Error ? err.message : t('customMcp.errors.installFailed'));
} finally {
setInstalling(false);
}
@@ -316,8 +318,8 @@ export const CustomMcpServerDialog = ({
- Add Custom MCP Server
- Add a server manually without the catalog.
+ {t('customMcp.title')}
+ {t('customMcp.description')}
@@ -326,13 +328,13 @@ export const CustomMcpServerDialog = ({
{/* Server name */}
- Server Name
+ {t('customMcp.fields.serverName')}
setServerName(e.target.value)}
- placeholder="my-server"
+ placeholder={t('customMcp.placeholders.serverName')}
className="h-8 text-sm"
autoFocus
/>
@@ -340,7 +342,7 @@ export const CustomMcpServerDialog = ({
{/* Transport toggle */}
-
Transport
+
{t('customMcp.fields.transport')}
setTransportMode('stdio')}
>
- Stdio (npm)
+ {t('customMcp.transport.stdio')}
setTransportMode('http')}
>
- HTTP / SSE
+ {t('customMcp.transport.httpSse')}
@@ -366,7 +368,7 @@ export const CustomMcpServerDialog = ({
- npm Package
+ {t('customMcp.fields.npmPackage')}
- Version (optional)
+ {t('customMcp.fields.versionOptional')}
setNpmVersion(e.target.value)}
- placeholder="latest"
+ placeholder={t('customMcp.placeholders.latest')}
className="h-8 text-sm"
/>
@@ -396,18 +398,18 @@ export const CustomMcpServerDialog = ({
- Server URL
+ {t('customMcp.fields.serverUrl')}
setHttpUrl(e.target.value)}
- placeholder="https://api.example.com/mcp"
+ placeholder={t('customMcp.placeholders.serverUrl')}
className="h-8 text-sm"
/>
-
Transport Type
+
{t('customMcp.fields.transportType')}
setHttpTransport(v as HttpTransport)}
@@ -428,7 +430,7 @@ export const CustomMcpServerDialog = ({
{/* Headers */}
-
Headers
+
{t('customMcp.fields.headers')}
- Add
+ {t('customMcp.actions.add')}
{headers.length > 0 && (
@@ -447,13 +449,13 @@ export const CustomMcpServerDialog = ({
value={header.key}
onChange={(e) => updateHeader(i, 'key', e.target.value)}
className="h-7 w-32 text-xs"
- placeholder="Header-Name"
+ placeholder={t('customMcp.placeholders.headerName')}
/>
updateHeader(i, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
- placeholder="value"
+ placeholder={t('customMcp.placeholders.value')}
/>
- Scope
+ {t('customMcp.fields.scope')}
setScope(v as Scope)}>
@@ -495,10 +497,10 @@ export const CustomMcpServerDialog = ({
{/* Environment variables */}
-
Environment Variables
+
{t('customMcp.fields.environmentVariables')}
- Add
+ {t('customMcp.actions.add')}
{envVars.length > 0 && (
@@ -509,14 +511,14 @@ export const CustomMcpServerDialog = ({
value={entry.key}
onChange={(e) => updateEnvVar(i, 'key', e.target.value)}
className="h-7 w-40 font-mono text-xs"
- placeholder="ENV_VAR_NAME"
+ placeholder={t('customMcp.placeholders.envVarName')}
/>
updateEnvVar(i, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
- placeholder="value"
+ placeholder={t('customMcp.placeholders.value')}
/>
- Cancel
+ {t('customMcp.actions.cancel')}
void handleInstall()}>
- {installing ? 'Installing...' : 'Install'}
+ {installing ? t('customMcp.actions.installing') : t('customMcp.actions.install')}
diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx
index 3e6d0f6e..0a60fec0 100644
--- a/src/renderer/components/extensions/mcp/McpServerCard.tsx
+++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx
@@ -5,6 +5,7 @@
import { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -56,6 +57,7 @@ export const McpServerCard = ({
cliStatus: cliStatusOverride,
cliStatusLoading,
}: McpServerCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const storedCliStatus = useStore((s) => s.cliStatus);
const cliStatus = cliStatusOverride ?? storedCliStatus;
const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
@@ -179,19 +181,19 @@ export const McpServerCard = ({
{server.tools.length > 0 && (
- {server.tools.length} {server.tools.length === 1 ? 'tool' : 'tools'}
+ {t('mcpCard.toolsCount', { count: server.tools.length })}
)}
{server.envVars.length > 0 && (
- {server.envVars.length} {server.envVars.length === 1 ? 'env' : 'envs'}
+ {t('mcpCard.envCount', { count: server.envVars.length })}
)}
{server.requiresAuth && (
- Auth
+ {t('mcpCard.auth')}
)}
{server.version && (
@@ -206,23 +208,25 @@ export const McpServerCard = ({
{formatRelativeTime(server.updatedAt)}
)}
- {server.author && by {server.author} }
+ {server.author && (
+ {t('mcpCard.byAuthor', { author: server.author })}
+ )}
{server.hostingType === 'remote' && (
- Remote
+ {t('mcpCard.hosting.remote')}
)}
{server.hostingType === 'local' && (
- Local
+ {t('mcpCard.hosting.local')}
)}
{server.hostingType === 'both' && (
- Both
+ {t('mcpCard.hosting.both')}
)}
{/* External links + stars */}
@@ -245,7 +249,7 @@ export const McpServerCard = ({
)}
- Repository
+ {t('mcpCard.repository')}
)}
{server.websiteUrl && (
@@ -261,7 +265,7 @@ export const McpServerCard = ({
- Website
+ {t('mcpCard.website')}
)}
diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx
index 0cd87512..59bfeb3e 100644
--- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx
+++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx
@@ -5,6 +5,7 @@
import { useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -82,6 +83,7 @@ export const McpServerDetailDialog = ({
cliStatus: cliStatusOverride,
cliStatusLoading,
}: McpServerDetailDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const storedCliStatus = useStore((s) => s.cliStatus);
const cliStatus = cliStatusOverride ?? storedCliStatus;
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
@@ -115,8 +117,8 @@ export const McpServerDetailDialog = ({
normalizedInstalledEntries.some((entry) => entry.scope === 'user')
? [{ value: 'user' as const, label: getMcpScopeLabel('user', cliStatus?.flavor) }]
: []),
- { value: 'project', label: 'Project' },
- { value: 'local', label: 'Local' },
+ { value: 'project', label: t('mcpDetail.scope.project') },
+ { value: 'local', label: t('mcpDetail.scope.local') },
];
const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries);
const selectedInstalledEntry =
@@ -336,12 +338,12 @@ export const McpServerDetailDialog = ({
{/* Metadata grid */}
-
Source
+
{t('mcpDetail.metadata.source')}
{server.source}
{stars != null && (
-
GitHub Stars
+
{t('mcpDetail.metadata.githubStars')}
{stars.toLocaleString()}
@@ -350,55 +352,57 @@ export const McpServerDetailDialog = ({
)}
{server.version && (
-
Version
+
{t('mcpDetail.metadata.version')}
{server.version}
)}
{server.license && (
-
License
+
{t('mcpDetail.metadata.license')}
{server.license}
)}
-
Install Type
+
{t('mcpDetail.metadata.installType')}
{server.installSpec?.type === 'stdio' ? (
void api.openExternal(npmPackageUrl!)}
>
- npm: {server.installSpec.npmPackage}
+ {t('mcpDetail.install.npmPackage', { package: server.installSpec.npmPackage })}
) : (
{server.installSpec
- ? `HTTP: ${server.installSpec.transportType}`
- : 'Manual setup required'}
+ ? t('mcpDetail.install.httpTransport', {
+ transport: server.installSpec.transportType,
+ })
+ : t('mcpDetail.install.manualSetupRequired')}
)}
{server.author && (
-
Author
+
{t('mcpDetail.metadata.author')}
{server.author}
)}
{server.hostingType && (
-
Hosting
+
{t('mcpDetail.metadata.hosting')}
{server.hostingType}
)}
{server.publishedAt && (
-
Published
+
{t('mcpDetail.metadata.published')}
{new Date(server.publishedAt).toLocaleDateString()}
)}
{server.updatedAt && (
-
Updated
+
{t('mcpDetail.metadata.updated')}
{new Date(server.updatedAt).toLocaleDateString()}
)}
@@ -408,13 +412,12 @@ export const McpServerDetailDialog = ({
{server.requiresAuth && (
- This server requires authentication
+ {t('mcpDetail.auth.required')}
)}
{isHttp && !server.requiresAuth && (server.authHeaders?.length ?? 0) === 0 && (
- Remote MCP servers may still require custom headers or API keys even when the registry
- does not describe them. If connection fails after install, check the provider docs.
+ {t('mcpDetail.auth.remoteMayNeedHeaders')}
)}
{isInstalledForScope && (
@@ -443,7 +446,9 @@ export const McpServerDetailDialog = ({
{diagnostic?.target && (
-
Launch Target
+
+ {t('mcpDetail.diagnostics.launchTarget')}
+
{diagnostic.target}
@@ -456,19 +461,19 @@ export const McpServerDetailDialog = ({
{canAutoInstall && (
- {isInstalledForScope ? 'Manage Installation' : 'Install Server'}
+ {isInstalledForScope ? t('mcpDetail.install.manage') : t('mcpDetail.install.install')}
{/* Server name */}
- Server Name
+ {t('mcpDetail.form.serverName')}
setServerName(e.target.value)}
- placeholder="my-server"
+ placeholder={t('mcpDetail.placeholders.serverName')}
className="h-8 text-sm"
disabled={isInstalledForScope}
/>
@@ -476,7 +481,7 @@ export const McpServerDetailDialog = ({
{/* Scope */}
-
Scope
+
{t('mcpDetail.form.scope')}
setScope(v as Scope)}>
@@ -498,7 +503,7 @@ export const McpServerDetailDialog = ({
{/* Environment variables */}
{server.envVars.length > 0 && (
-
Environment Variables
+
{t('mcpDetail.form.environmentVariables')}
{server.envVars.map((env) => (
@@ -515,7 +520,9 @@ export const McpServerDetailDialog = ({
placeholder={env.description ?? env.name}
/>
{autoFilledFields.has(env.name) && envValues[env.name] && (
- Auto-filled
+
+ {t('mcpDetail.form.autoFilled')}
+
)}
))}
@@ -527,7 +534,7 @@ export const McpServerDetailDialog = ({
{isHttp && (
- Headers
+ {t('mcpDetail.form.headers')}
updateHeader(index, 'key', e.target.value)}
className="h-7 w-32 text-xs"
- placeholder="Header-Name"
+ placeholder={t('customMcp.placeholders.headerName')}
/>
)}
- This server requires manual setup. Check the repository for installation instructions.
+ {t('mcpDetail.install.manualSetupDescription')}
)}
@@ -619,7 +626,7 @@ export const McpServerDetailDialog = ({
- Tools ({server.tools.length})
+ {t('mcpDetail.tools.title', { count: server.tools.length })}
{server.tools.map((tool) => (
@@ -641,7 +648,7 @@ export const McpServerDetailDialog = ({
onClick={() => void api.openExternal(server.repositoryUrl!)}
>
- Repository
+ {t('mcpDetail.links.repository')}
)}
{server.glamaUrl && (
@@ -651,7 +658,7 @@ export const McpServerDetailDialog = ({
onClick={() => void api.openExternal(server.glamaUrl!)}
>
- Glama
+ {t('mcpDetail.links.glama')}
)}
{server.websiteUrl && (
@@ -661,7 +668,7 @@ export const McpServerDetailDialog = ({
onClick={() => void api.openExternal(server.websiteUrl!)}
>
- Website
+ {t('mcpDetail.links.website')}
)}
diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx
index 39eb99ee..6146109a 100644
--- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx
+++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx
@@ -4,6 +4,7 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -46,6 +47,20 @@ const MCP_SORT_OPTIONS: { value: McpSortValue; label: string }[] = [
{ value: 'tools-desc', label: 'Most tools' },
];
+function getMcpSortLabel(
+ value: McpSortValue,
+ t: ReturnType
['t']
+): string {
+ switch (value) {
+ case 'name-asc':
+ return t('mcpPanel.sort.nameAsc');
+ case 'name-desc':
+ return t('mcpPanel.sort.nameDesc');
+ case 'tools-desc':
+ return t('mcpPanel.sort.toolsDesc');
+ }
+}
+
function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatalogItem[] {
return [...servers].sort((a, b) => {
switch (sort) {
@@ -95,6 +110,7 @@ export const McpServersPanel = ({
cliStatus: cliStatusOverride,
cliStatusLoading: cliStatusLoadingOverride,
}: McpServersPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const projectStateKey = getMcpProjectStateKey(projectPath);
const {
browseCatalog,
@@ -163,18 +179,20 @@ export const McpServersPanel = ({
const diagnosticsDisableReason = useMemo(() => {
if (cliStatus === null || typeof cliStatus === 'undefined') {
- return cliStatusLoading ? 'Checking runtime status...' : 'Checking runtime availability...';
+ return cliStatusLoading
+ ? t('mcpPanel.diagnostics.disableReasons.checkingRuntimeStatus')
+ : t('mcpPanel.diagnostics.disableReasons.checkingRuntimeAvailability');
}
if (cliStatus?.installed === false) {
if (cliStatus.binaryPath && cliStatus.launchError) {
- return 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.';
+ return t('mcpPanel.diagnostics.disableReasons.runtimeFailedToStart');
}
- return 'The configured runtime is required. Install or repair it from the Dashboard.';
+ return t('mcpPanel.diagnostics.disableReasons.runtimeRequired');
}
return null;
- }, [cliStatus, cliStatusLoading]);
+ }, [cliStatus, cliStatusLoading, t]);
useEffect(() => {
if (diagnosticsDisableReason) {
@@ -270,17 +288,19 @@ export const McpServersPanel = ({
-
MCP Health Status
+
{t('mcpPanel.health.title')}
- {mcpDiagnosticsLoading ? (
- <>Checking installed MCP servers via {runtimeLabel} ...>
- ) : diagnosticsDisableReason ? (
- diagnosticsDisableReason
- ) : mcpDiagnosticsLastCheckedAt ? (
- `Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}`
- ) : (
- <>Run diagnostics from this page to verify installed MCP connectivity.>
- )}
+ {mcpDiagnosticsLoading
+ ? t('mcpPanel.health.checkingViaRuntime', { runtime: runtimeLabel })
+ : diagnosticsDisableReason
+ ? diagnosticsDisableReason
+ : mcpDiagnosticsLastCheckedAt
+ ? t('mcpPanel.health.lastChecked', {
+ time: formatRelativeTime(
+ new Date(mcpDiagnosticsLastCheckedAt).toISOString()
+ ),
+ })
+ : t('mcpPanel.health.description')}
- {mcpDiagnosticsLoading ? 'Checking...' : 'Check Status'}
+ {mcpDiagnosticsLoading
+ ? t('mcpPanel.health.checking')
+ : t('mcpPanel.health.checkStatus')}
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
-
Runtime MCP Diagnostics
+
{t('mcpPanel.diagnostics.title')}
{allDiagnostics.length > 0 && (
-
{allDiagnostics.length} servers
+
+ {t('mcpPanel.diagnostics.serversCount', { count: allDiagnostics.length })}
+
)}
{allDiagnostics.length > 0 ? (
@@ -335,7 +359,7 @@ export const McpServersPanel = ({
))}
) : (
-
Waiting for diagnostics results...
+
{t('mcpPanel.diagnostics.waiting')}
)}
)}
@@ -347,7 +371,7 @@ export const McpServersPanel = ({
setMcpSort(v as McpSortValue)}>
@@ -357,7 +381,7 @@ export const McpServersPanel = ({
{MCP_SORT_OPTIONS.map((opt) => (
- {opt.label}
+ {getMcpSortLabel(opt.value, t)}
))}
@@ -421,12 +445,11 @@ export const McpServersPanel = ({
{cliStatus?.flavor === 'agent_teams_orchestrator'
- ? `${runtimeLabel} not available`
- : `${runtimeLabel} not installed`}
+ ? t('mcpPanel.runtime.notAvailable', { runtime: runtimeLabel })
+ : t('mcpPanel.runtime.notInstalled', { runtime: runtimeLabel })}
- MCP health checks require {runtimeLabel}. Go to the Dashboard to install or repair
- it.
+ {t('mcpPanel.runtime.requiredDescription', { runtime: runtimeLabel })}
@@ -447,10 +470,10 @@ export const McpServersPanel = ({
)}
- {isSearching ? 'No servers found' : 'No MCP servers available'}
+ {isSearching ? t('mcpPanel.empty.searchTitle') : t('mcpPanel.empty.title')}
- {isSearching ? 'Try a different search term' : 'Check back later for new servers'}
+ {isSearching ? t('mcpPanel.empty.searchDescription') : t('mcpPanel.empty.description')}
)}
@@ -483,7 +506,7 @@ export const McpServersPanel = ({
disabled={browseLoading}
onClick={() => void mcpBrowse(browseNextCursor)}
>
- Load more
+ {t('mcpPanel.loadMore')}
)}
diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx
index fe1344b2..0f07965f 100644
--- a/src/renderer/components/extensions/plugins/PluginCard.tsx
+++ b/src/renderer/components/extensions/plugins/PluginCard.tsx
@@ -3,6 +3,7 @@
*/
import { Badge } from '@renderer/components/ui/badge';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import {
getCapabilityLabel,
@@ -38,6 +39,7 @@ export const PluginCard = ({
cliStatus,
cliStatusLoading,
}: PluginCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const operationKey = getPluginOperationKey(plugin.pluginId, 'user');
@@ -73,7 +75,7 @@ export const PluginCard = ({
{plugin.source === 'official' && (
- Official
+ {t('pluginCard.official')}
)}
diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
index a4ef3fa2..bae3f364 100644
--- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
+++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
@@ -4,6 +4,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { Badge } from '@renderer/components/ui/badge';
@@ -54,11 +55,23 @@ interface PluginDetailDialogProps {
cliStatusLoading?: boolean;
}
-const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
- { value: 'user', label: 'User (global)' },
- { value: 'project', label: 'Project (shared)' },
- { value: 'local', label: 'Local (gitignored)' },
-];
+const SCOPE_OPTIONS: InstallScope[] = ['user', 'project', 'local'];
+
+function getScopeOptionLabel(
+ scope: InstallScope,
+ t: ReturnType
['t']
+): string {
+ switch (scope) {
+ case 'user':
+ return t('pluginDetail.scope.options.user');
+ case 'project':
+ return t('pluginDetail.scope.options.project');
+ case 'local':
+ return t('pluginDetail.scope.options.local');
+ default:
+ return String(scope);
+ }
+}
export const PluginDetailDialog = ({
plugin,
@@ -68,6 +81,7 @@ export const PluginDetailDialog = ({
cliStatus,
cliStatusLoading,
}: PluginDetailDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
useShallow((s) => ({
fetchPluginReadme: s.fetchPluginReadme,
@@ -142,25 +156,25 @@ export const PluginDetailDialog = ({
{/* Metadata grid */}
-
Author
-
{plugin.author?.name ?? 'Unknown'}
+
{t('pluginDetail.metadata.author')}
+
{plugin.author?.name ?? t('pluginDetail.unknown')}
-
Category
+
{t('pluginDetail.metadata.category')}
{category}
-
Source
+
{t('pluginDetail.metadata.source')}
{plugin.source}
{plugin.version && (
-
Version
+
{t('pluginDetail.metadata.version')}
{plugin.version}
)}
-
Capabilities
+
{t('pluginDetail.metadata.capabilities')}
{capabilities.map((cap) => (
-
Installs
+
{t('pluginDetail.metadata.installs')}
@@ -184,19 +198,19 @@ export const PluginDetailDialog = ({
{/* Install controls */}
- Scope:
+ {t('pluginDetail.scope.label')}
setScope(v as InstallScope)}>
- {SCOPE_OPTIONS.map((opt) => (
+ {SCOPE_OPTIONS.map((scopeOption) => (
- {opt.label}
+ {getScopeOptionLabel(scopeOption, t)}
))}
@@ -236,7 +250,7 @@ export const PluginDetailDialog = ({
onClick={() => void api.openExternal(plugin.homepage!)}
>
- Homepage
+ {t('pluginDetail.links.homepage')}
)}
{plugin.author?.email && (
@@ -246,7 +260,7 @@ export const PluginDetailDialog = ({
onClick={() => void api.openExternal(`mailto:${plugin.author!.email}`)}
>
- Contact
+ {t('pluginDetail.links.contact')}
)}
@@ -256,14 +270,14 @@ export const PluginDetailDialog = ({
{isReadmeLoading && (
- Loading README...
+ {t('pluginDetail.readme.loading')}
)}
{!isReadmeLoading && readme && (
)}
{!isReadmeLoading && !readme && (
-
No README available.
+
{t('pluginDetail.readme.empty')}
)}
diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx
index bb5bbf1f..69633e9e 100644
--- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx
+++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx
@@ -4,6 +4,7 @@
import { useEffect, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -56,12 +57,7 @@ interface PluginsPanelProps {
cliStatusLoading?: boolean;
}
-const SORT_OPTIONS: { value: string; label: string }[] = [
- { value: 'popularity:desc', label: 'Popular' },
- { value: 'name:asc', label: 'Name A-Z' },
- { value: 'name:desc', label: 'Name Z-A' },
- { value: 'category:asc', label: 'Category' },
-];
+const SORT_OPTIONS = ['popularity:desc', 'name:asc', 'name:desc', 'category:asc'] as const;
/** Pure function: filter + sort the catalog */
function selectFilteredPlugins(
@@ -134,6 +130,7 @@ export const PluginsPanel = ({
cliStatus: cliStatusOverride,
cliStatusLoading,
}: PluginsPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const {
catalog,
loading,
@@ -191,6 +188,20 @@ export const PluginsPanel = ({
}
return counts.size;
}, [catalog]);
+
+ const getSortLabel = (value: (typeof SORT_OPTIONS)[number]): string => {
+ switch (value) {
+ case 'popularity:desc':
+ return t('pluginsPanel.sort.popular');
+ case 'name:asc':
+ return t('pluginsPanel.sort.nameAsc');
+ case 'name:desc':
+ return t('pluginsPanel.sort.nameDesc');
+ case 'category:asc':
+ return t('pluginsPanel.sort.category');
+ }
+ };
+
return (
{cliStatus?.flavor === 'agent_teams_orchestrator' &&
@@ -206,8 +217,7 @@ export const PluginsPanel = ({
return (
- Plugin support is currently guaranteed for Anthropic (Claude) sessions only.
- We're working to support plugins across all agents.
+ {t('pluginsPanel.providerSupportNotice')}
);
})()}
@@ -217,7 +227,7 @@ export const PluginsPanel = ({
@@ -233,9 +243,9 @@ export const PluginsPanel = ({
- {SORT_OPTIONS.map((opt) => (
-
- {opt.label}
+ {SORT_OPTIONS.map((value) => (
+
+ {getSortLabel(value)}
))}
@@ -249,7 +259,7 @@ export const PluginsPanel = ({
checked={pluginFilters.installedOnly}
onCheckedChange={toggleInstalledOnly}
/>
- Installed only
+ {t('pluginsPanel.installedOnly')}
@@ -265,25 +275,25 @@ export const PluginsPanel = ({
-
Browse by fit
+
+ {t('pluginsPanel.browseByFit')}
+
- {activeFilterCount} active
+ {t('pluginsPanel.activeFilters', { count: activeFilterCount })}
-
- Narrow the catalog by category, capability, or installed state.
-
+
{t('pluginsPanel.filterDescription')}
- {catalog.length} plugins
+ {t('pluginsPanel.counts.plugins', { count: catalog.length })}
- {totalCategoryCount} categories
+ {t('pluginsPanel.counts.categories', { count: totalCategoryCount })}
- {totalCapabilityCount} capabilities
+ {t('pluginsPanel.counts.capabilities', { count: totalCapabilityCount })}
@@ -294,7 +304,7 @@ export const PluginsPanel = ({
onClick={clearFilters}
className="justify-start rounded-lg border border-border px-3 text-xs text-text-secondary hover:text-text lg:justify-center"
>
- Clear all filters
+ {t('pluginsPanel.clearAllFilters')}
)}
@@ -304,10 +314,12 @@ export const PluginsPanel = ({
- Categories
+ {t('pluginsPanel.categories')}
- {pluginFilters.categories.length} selected
+ {t('pluginsPanel.selectedCount', {
+ count: pluginFilters.categories.length,
+ })}
- Capabilities
+ {t('pluginsPanel.capabilities')}
- {pluginFilters.capabilities.length} selected
+ {t('pluginsPanel.selectedCount', {
+ count: pluginFilters.capabilities.length,
+ })}
0 && (
- Showing {filtered.length} of {catalog.length} plugin{catalog.length !== 1 ? 's' : ''}
+ {t('pluginsPanel.showing', { shown: filtered.length, total: catalog.length })}
{hasActiveFilters && (
-
- Results update instantly as you refine filters.
-
+
{t('pluginsPanel.resultsUpdateInstantly')}
)}
)}
@@ -397,16 +409,18 @@ export const PluginsPanel = ({
)}
- {hasActiveFilters ? 'No plugins match your filters' : 'No plugins available'}
+ {hasActiveFilters
+ ? t('pluginsPanel.empty.filteredTitle')
+ : t('pluginsPanel.empty.title')}
{hasActiveFilters
- ? 'Try adjusting your search or filter criteria'
- : 'Check back later for new plugins'}
+ ? t('pluginsPanel.empty.filteredDescription')
+ : t('pluginsPanel.empty.description')}
{hasActiveFilters && (
- Clear filters
+ {t('pluginsPanel.clearFilters')}
)}
diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx
index 40df90fd..751ad77f 100644
--- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx
+++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { CodeBlockViewer } from '@renderer/components/chat/viewers/CodeBlockViewer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
@@ -48,6 +49,7 @@ export const SkillDetailDialog = ({
onEdit,
onDeleted,
}: SkillDetailDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
const deleteSkill = useStore((s) => s.deleteSkill);
const detail = useStore(useShallow((s) => (skillId ? s.skillsDetailsById[skillId] : undefined)));
@@ -86,13 +88,15 @@ export const SkillDetailDialog = ({
const issuesTone = item?.issues.length ? getIssuesTone(item.issues) : null;
function formatScopeLabel(scope: 'user' | 'project'): string {
- return scope === 'project' ? 'This project only' : 'Your personal skills';
+ return scope === 'project'
+ ? t('skillDetail.scope.projectOnly')
+ : t('skillDetail.scope.personal');
}
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
return invocationMode === 'manual-only'
- ? 'Only runs when you explicitly ask for it.'
- : 'Runs automatically when it matches the task.';
+ ? t('skillDetail.invocation.manualOnly')
+ : t('skillDetail.invocation.auto');
}
function getIssuesTone(issues: SkillValidationIssue[]): {
@@ -104,14 +108,14 @@ export const SkillDetailDialog = ({
if (informationalOnly) {
return {
className: 'border-blue-500/30 bg-blue-500/5',
- title: 'This skill includes bundled scripts',
+ title: t('skillDetail.issues.bundledScripts'),
Icon: Info,
};
}
return {
className: 'border-amber-500/30 bg-amber-500/5',
- title: 'Review this skill carefully before using it',
+ title: t('skillDetail.issues.reviewCarefully'),
Icon: AlertTriangle,
};
}
@@ -128,7 +132,7 @@ export const SkillDetailDialog = ({
setDeleteConfirmOpen(false);
onDeleted();
} catch (error) {
- setDeleteError(error instanceof Error ? error.message : 'Failed to delete skill');
+ setDeleteError(error instanceof Error ? error.message : t('skillDetail.errors.deleteFailed'));
} finally {
setDeleteLoading(false);
}
@@ -138,14 +142,14 @@ export const SkillDetailDialog = ({
!next && onClose()}>
- {item?.name ?? 'Skill details'}
+ {item?.name ?? t('skillDetail.titleFallback')}
- {item?.description ?? 'Inspect discovered skill metadata and raw instructions.'}
+ {item?.description ?? t('skillDetail.descriptionFallback')}
{(loading || (open && skillId && detail === undefined)) && (
- Loading skill details...
+ {t('skillDetail.loading')}
)}
{!loading && detailError && (
@@ -159,7 +163,7 @@ export const SkillDetailDialog = ({
void fetchSkillDetail(skillId, effectiveProjectPath).catch(() => undefined);
}}
>
- Retry
+ {t('skillDetail.actions.retry')}
)}
@@ -167,7 +171,7 @@ export const SkillDetailDialog = ({
{!loading && !detailError && detail === null && (
- Unable to load this skill.
+ {t('skillDetail.errors.loadFailed')}
)}
@@ -180,14 +184,24 @@ export const SkillDetailDialog = ({
)}
{formatScopeLabel(item.scope)}
- Stored in {formatSkillRootKind(item.rootKind)}
+
+ {t('skillDetail.badges.storedIn', { root: formatSkillRootKind(item.rootKind) })}
+
{getSkillAudienceLabel(item.rootKind)}
- {item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
+ {item.invocationMode === 'manual-only'
+ ? t('skillDetail.badges.manualUse')
+ : t('skillDetail.badges.autoUse')}
- {item.flags.hasScripts && Has scripts }
- {item.flags.hasReferences && References }
- {item.flags.hasAssets && Assets }
+ {item.flags.hasScripts && (
+ {t('skillDetail.badges.hasScripts')}
+ )}
+ {item.flags.hasReferences && (
+ {t('skillDetail.badges.references')}
+ )}
+ {item.flags.hasAssets && (
+ {t('skillDetail.badges.assets')}
+ )}
{item.issues.length > 0 && (
@@ -224,28 +238,28 @@ export const SkillDetailDialog = ({
- Who can use it
+ {t('skillDetail.summary.whoCanUse')}
{formatScopeLabel(item.scope)}
- How it is used
+ {t('skillDetail.summary.howUsed')}
{formatInvocationLabel(item.invocationMode)}
- What comes with it
+ {t('skillDetail.summary.included')}
{[
- item.flags.hasReferences ? 'references' : null,
- item.flags.hasScripts ? 'scripts' : null,
- item.flags.hasAssets ? 'assets' : null,
+ item.flags.hasReferences ? t('skillDetail.includes.references') : null,
+ item.flags.hasScripts ? t('skillDetail.includes.scripts') : null,
+ item.flags.hasAssets ? t('skillDetail.includes.assets') : null,
]
.filter(Boolean)
- .join(', ') || 'Just the skill instructions'}
+ .join(', ') || t('skillDetail.includes.instructionsOnly')}
@@ -253,7 +267,7 @@ export const SkillDetailDialog = ({
- Edit Skill
+ {t('skillDetail.actions.editSkill')}
- {deleteLoading ? 'Deleting...' : 'Delete'}
+ {deleteLoading
+ ? t('skillDetail.actions.deleting')
+ : t('skillDetail.actions.delete')}
@@ -279,13 +295,13 @@ export const SkillDetailDialog = ({
-
Stored at
+
{t('skillDetail.files.storedAt')}
{item.skillDir}
{detail.scriptFiles.length > 0 && (
-
Scripts
+
{t('skillDetail.files.scripts')}
{detail.scriptFiles.map((file) => (
{file}
@@ -296,7 +312,7 @@ export const SkillDetailDialog = ({
{detail.referencesFiles.length > 0 && (
-
References
+
{t('skillDetail.files.references')}
{detail.referencesFiles.map((file) => (
{file}
@@ -307,7 +323,7 @@ export const SkillDetailDialog = ({
{detail.assetFiles.length > 0 && (
-
Assets
+
{t('skillDetail.files.assets')}
{detail.assetFiles.map((file) => (
{file}
@@ -319,7 +335,7 @@ export const SkillDetailDialog = ({
- Advanced file details
+ {t('skillDetail.files.advancedDetails')}
@@ -329,7 +345,7 @@ export const SkillDetailDialog = ({
onClick={() => void api.showInFolder(item.skillFile)}
>
- Open Folder
+ {t('skillDetail.actions.openFolder')}
void api.openPath(item.skillFile, effectiveProjectPath)}
>
- Open SKILL.md
+ {t('skillDetail.actions.openSkillFile')}
- Delete skill?
+ {t('skillDetail.deleteDialog.title')}
{item
- ? `Delete "${item.name}" and move it to Trash? You can restore it later from Trash if needed.`
- : 'Delete this skill and move it to Trash?'}
+ ? t('skillDetail.deleteDialog.descriptionWithName', { name: item.name })
+ : t('skillDetail.deleteDialog.description')}
- Cancel
+
+ {t('skillDetail.actions.cancel')}
+
void handleDelete()} disabled={deleteLoading}>
- {deleteLoading ? 'Deleting...' : 'Delete Skill'}
+ {deleteLoading
+ ? t('skillDetail.actions.deleting')
+ : t('skillDetail.actions.deleteSkill')}
diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx
index 65885733..fd59eb80 100644
--- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx
+++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownPreviewPane } from '@renderer/components/team/editor/MarkdownPreviewPane';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -45,6 +46,8 @@ import type {
SkillRootKind,
} from '@shared/types/extensions';
+const SKILL_MARKDOWN_FILENAME = ['SKILL', 'md'].join('.');
+
type EditorMode = 'create' | 'edit';
interface SkillEditorDialogProps {
@@ -76,6 +79,7 @@ export const SkillEditorDialog = ({
onClose,
onSaved,
}: SkillEditorDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const containerRef = useRef(null);
const editorScrollRef = useRef(null);
const rawContentRef = useRef('');
@@ -294,7 +298,7 @@ export const SkillEditorDialog = ({
[request.files]
);
const auxiliaryDraftFilePaths = useMemo(
- () => draftFilePaths.filter((filePath) => filePath !== 'SKILL.md'),
+ () => draftFilePaths.filter((filePath) => filePath !== SKILL_MARKDOWN_FILENAME),
[draftFilePaths]
);
@@ -308,11 +312,9 @@ export const SkillEditorDialog = ({
[allowCodexRootKind, detail?.item.rootKind]
);
const instructionsLocked = manualRawEdit || customMarkdownDetected;
- const title = mode === 'create' ? 'Create skill' : 'Edit skill';
+ const title = mode === 'create' ? t('skillEditor.title.create') : t('skillEditor.title.edit');
const descriptionText =
- mode === 'create'
- ? 'Describe the workflow in plain language, review the files that will be created, then save it.'
- : 'Update this skill, review the resulting file changes, then save it.';
+ mode === 'create' ? t('skillEditor.description.create') : t('skillEditor.description.edit');
function validateBeforeReview(): string | null {
if (!name.trim()) {
@@ -412,16 +414,15 @@ export const SkillEditorDialog = ({
- 1. Basics
-
- Give this skill a clear name, choose who can use it, and decide where it should
- live.
-
+
+ {t('skillEditor.basics.title')}
+
+ {t('skillEditor.basics.description')}
- Who can use it
+ {t('skillEditor.fields.scope')}
setScope(value as 'user' | 'project')}
@@ -431,18 +432,20 @@ export const SkillEditorDialog = ({
- User
+ {t('skillEditor.scope.user')}
{canUseProjectScope
- ? `Project: ${projectLabel ?? projectPath}`
- : 'Project unavailable'}
+ ? t('skillEditor.scope.project', {
+ project: projectLabel ?? projectPath,
+ })
+ : t('skillEditor.scope.projectUnavailable')}
- Where to store it
+ {t('skillEditor.fields.root')}
setRootKind(value as SkillRootKind)}
@@ -455,7 +458,9 @@ export const SkillEditorDialog = ({
{visibleRootDefinitions.map((definition) => (
{definition.directoryName}
- {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
+ {definition.audience === 'codex'
+ ? t('skillEditor.root.codexOnly')
+ : t('skillEditor.root.shared')}
))}
@@ -463,7 +468,7 @@ export const SkillEditorDialog = ({
-
Folder name
+
{t('skillEditor.fields.folderName')}
{mode === 'create' && (
- We suggest this automatically from the skill name so review works right
- away.
+ {t('skillEditor.fields.folderNameHint')}
)}
- How it should be used
+ {t('skillEditor.fields.invocation')}
{
@@ -495,8 +499,10 @@ export const SkillEditorDialog = ({
- Can be used automatically
- Only when you ask for it
+ {t('skillEditor.invocation.auto')}
+
+ {t('skillEditor.invocation.manualOnly')}
+
@@ -504,7 +510,7 @@ export const SkillEditorDialog = ({
@@ -566,16 +574,19 @@ export const SkillEditorDialog = ({
{!customMarkdownDetected && (
<>
- 2. Instructions
+
+ {t('skillEditor.instructions.title')}
+
- These sections generate the skill file for you, so you do not need to edit
- markdown unless you want to.
+ {t('skillEditor.instructions.description')}
- When to reach for this
+
+ {t('skillEditor.fields.whenToUse')}
+
- Main steps to follow
+ {t('skillEditor.fields.steps')}
-
Extra notes or guardrails
+
{t('skillEditor.fields.notes')}
{instructionsLocked && (
- Structured fields are locked because you switched to manual `SKILL.md`
- editing below.
+ {t('skillEditor.instructions.locked')}
)}
@@ -634,24 +642,27 @@ export const SkillEditorDialog = ({
)}
- 3. Extra files
+
+ {t('skillEditor.extraFiles.title')}
+
- Add supporting docs, scripts, or assets only if this skill really needs them.
+ {t('skillEditor.extraFiles.description')}
-
Optional files
+
+ {t('skillEditor.extraFiles.optionalTitle')}
+
- Add starter files that will be included in the review and written together
- with `SKILL.md`.
+ {t('skillEditor.extraFiles.optionalDescription')}
{mode === 'edit' && (
- Root and folder are locked for edits
+ {t('skillEditor.extraFiles.lockedForEdits')}
)}
@@ -664,9 +675,11 @@ export const SkillEditorDialog = ({
className="mt-0.5"
/>
-
References
+
+ {t('skillEditor.extraFiles.references')}
+
- Add supporting docs, links, or examples the runtime can look at.
+ {t('skillEditor.extraFiles.referencesDescription')}
@@ -678,10 +691,11 @@ export const SkillEditorDialog = ({
className="mt-0.5"
/>
-
Scripts
+
+ {t('skillEditor.extraFiles.scripts')}
+
- Add helper commands or setup notes. Review carefully before sharing this
- skill.
+ {t('skillEditor.extraFiles.scriptsDescription')}
@@ -693,9 +707,11 @@ export const SkillEditorDialog = ({
className="mt-0.5"
/>
-
Assets
+
+ {t('skillEditor.extraFiles.assets')}
+
- Add screenshots or bundled media only if they help explain the workflow.
+ {t('skillEditor.extraFiles.assetsDescription')}
@@ -704,7 +720,7 @@ export const SkillEditorDialog = ({
{auxiliaryDraftFilePaths.length > 0 && (
- Added files:
+ {t('skillEditor.extraFiles.addedFiles')}
{auxiliaryDraftFilePaths.map((filePath) => (
@@ -728,13 +744,13 @@ export const SkillEditorDialog = ({
{customMarkdownDetected
- ? '2. SKILL.md editor'
- : '4. Advanced SKILL.md editor'}
+ ? t('skillEditor.advanced.customTitle')
+ : t('skillEditor.advanced.title')}
{customMarkdownDetected
- ? 'This skill uses a custom markdown format, so edit it directly here.'
- : 'Most people can skip this. Open it only if you want direct control over the raw markdown file.'}
+ ? t('skillEditor.advanced.customDescription')
+ : t('skillEditor.advanced.description')}
{!customMarkdownDetected && (
@@ -743,7 +759,9 @@ export const SkillEditorDialog = ({
size="sm"
onClick={() => setShowAdvancedEditor((prev) => !prev)}
>
- {showAdvancedEditor ? 'Hide Advanced Editor' : 'Show Advanced Editor'}
+ {showAdvancedEditor
+ ? t('skillEditor.advanced.hide')
+ : t('skillEditor.advanced.show')}
)}
@@ -751,7 +769,7 @@ export const SkillEditorDialog = ({
{showAdvancedEditor && (
- SKILL.md
+ {SKILL_MARKDOWN_FILENAME}
- Reset From Structured Fields
+ {t('skillEditor.advanced.resetFromStructuredFields')}
@@ -835,21 +853,19 @@ export const SkillEditorDialog = ({
- Cancel
+ {t('skillEditor.actions.cancel')}
-
- Review the file changes first, then confirm save in the next step.
-
+
{t('skillEditor.review.hint')}
{mutationError &&
{mutationError}
}
void handleReview()} disabled={reviewLoading || saveLoading}>
{reviewLoading
- ? 'Preparing...'
+ ? t('skillEditor.actions.preparing')
: mode === 'create'
- ? 'Review And Create'
- : 'Review And Save'}
+ ? t('skillEditor.actions.reviewAndCreate')
+ : t('skillEditor.actions.reviewAndSave')}
@@ -863,8 +879,14 @@ export const SkillEditorDialog = ({
error={mutationError}
onClose={() => setReviewOpen(false)}
onConfirm={() => void handleConfirmSave()}
- confirmLabel={mode === 'create' ? 'Create Skill' : 'Save Skill'}
- reviewLabel={mode === 'create' ? 'Creating a skill' : 'Saving this skill'}
+ confirmLabel={
+ mode === 'create'
+ ? t('skillEditor.actions.createSkill')
+ : t('skillEditor.actions.saveSkill')
+ }
+ reviewLabel={
+ mode === 'create' ? t('skillEditor.review.creating') : t('skillEditor.review.saving')
+ }
/>
>
);
diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx
index 5f1ed04e..a49ab650 100644
--- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx
+++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
@@ -30,24 +31,26 @@ import { validateSkillFolderName, validateSkillImportSourceDir } from './skillVa
import type { SkillReviewPreview, SkillRootKind } from '@shared/types/extensions';
-function getFriendlyImportError(message: string): string {
+type ExtensionsT = ReturnType
['t'];
+
+function getFriendlyImportError(message: string, t: ExtensionsT): string {
if (message.includes('valid skill file')) {
- return 'This folder does not look like a skill yet. It needs a SKILL.md, Skill.md, or skill.md file.';
+ return t('skillImport.errors.missingSkillFile');
}
if (message.includes('symbolic links')) {
- return 'This folder contains symbolic links. Import the real files instead of links.';
+ return t('skillImport.errors.symbolicLinks');
}
if (message.includes('too many files')) {
- return 'This skill folder is too large to import at once. Remove extra files and try again.';
+ return t('skillImport.errors.tooManyFiles');
}
if (message.includes('too large')) {
- return 'This skill folder is too large to import safely. Trim large assets and try again.';
+ return t('skillImport.errors.tooLarge');
}
if (message.includes('Invalid folder name')) {
- return 'Pick a simpler destination folder name using letters, numbers, dots, dashes, or underscores.';
+ return t('skillImport.errors.invalidFolderName');
}
if (message.includes('must be a directory')) {
- return 'Choose a folder to import, not a single file.';
+ return t('skillImport.errors.mustBeDirectory');
}
return message;
}
@@ -69,6 +72,7 @@ export const SkillImportDialog = ({
onClose,
onImported,
}: SkillImportDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const previewSkillImport = useStore((s) => s.previewSkillImport);
const applySkillImport = useStore((s) => s.applySkillImport);
@@ -170,7 +174,8 @@ export const SkillImportDialog = ({
} catch (error) {
setMutationError(
getFriendlyImportError(
- error instanceof Error ? error.message : 'Failed to review import changes'
+ error instanceof Error ? error.message : t('skillImport.errors.reviewFailed'),
+ t
)
);
} finally {
@@ -198,7 +203,10 @@ export const SkillImportDialog = ({
onClose();
} catch (error) {
setMutationError(
- getFriendlyImportError(error instanceof Error ? error.message : 'Failed to import skill')
+ getFriendlyImportError(
+ error instanceof Error ? error.message : t('skillImport.errors.importFailed'),
+ t
+ )
);
} finally {
setImportLoading(false);
@@ -211,24 +219,24 @@ export const SkillImportDialog = ({
- Import skill
-
- Pick an existing skill folder, review what will be copied, then import it into one
- of your supported skill locations.
-
+ {t('skillImport.title')}
+ {t('skillImport.description')}
- 1. Choose a skill folder
+
+ {t('skillImport.steps.chooseFolder.title')}
+
- This should be a folder that already contains a `SKILL.md`, `Skill.md`, or
- `skill.md` file.
+ {t('skillImport.steps.chooseFolder.description')}
-
Source folder
+
+ {t('skillImport.fields.sourceFolder')}
+
void handleChooseFolder()}>
- Browse
+ {t('skillImport.actions.browse')}
- Destination folder name
+
+ {t('skillImport.fields.destinationFolderName')}
+
- 2. Decide where it belongs
+
+ {t('skillImport.steps.location.title')}
+
- Personal skills work everywhere. Project skills only show up for one codebase.
+ {t('skillImport.steps.location.description')}
- Who can use it
+ {t('skillImport.fields.audience')}
setScope(value as 'user' | 'project')}
@@ -272,18 +284,20 @@ export const SkillImportDialog = ({
- User
+ {t('skillImport.scope.user')}
{projectPath
- ? `Project: ${projectLabel ?? projectPath}`
- : 'Project unavailable'}
+ ? t('skillImport.scope.project', {
+ project: projectLabel ?? projectPath,
+ })
+ : t('skillImport.scope.projectUnavailable')}
-
Where to store it
+
{t('skillImport.fields.storage')}
setRootKind(value as SkillRootKind)}
@@ -295,7 +309,9 @@ export const SkillImportDialog = ({
{visibleRootDefinitions.map((definition) => (
{definition.directoryName}
- {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
+ {definition.audience === 'codex'
+ ? t('skillImport.rootSuffix.codexOnly')
+ : t('skillImport.rootSuffix.shared')}
))}
@@ -314,17 +330,19 @@ export const SkillImportDialog = ({
- Cancel
+ {t('skillImport.actions.cancel')}
- Review the copied files first, then confirm the import in the next step.
+ {t('skillImport.reviewHint')}
void handleReview()}
disabled={!sourceDir.trim() || reviewLoading || importLoading}
>
- {reviewLoading ? 'Preparing...' : 'Review And Import'}
+ {reviewLoading
+ ? t('skillImport.actions.preparing')
+ : t('skillImport.actions.reviewAndImport')}
@@ -338,9 +356,9 @@ export const SkillImportDialog = ({
error={mutationError}
onClose={() => setReviewOpen(false)}
onConfirm={() => void handleConfirmImport()}
- confirmLabel="Import Skill"
- reviewLabel="Importing this skill"
- backLabel="Back To Import"
+ confirmLabel={t('skillImport.actions.importSkill')}
+ reviewLabel={t('skillImport.reviewLabel')}
+ backLabel={t('skillImport.actions.backToImport')}
/>
>
);
diff --git a/src/renderer/components/extensions/skills/SkillReviewDialog.tsx b/src/renderer/components/extensions/skills/SkillReviewDialog.tsx
index 9120bbfb..53ca7d4c 100644
--- a/src/renderer/components/extensions/skills/SkillReviewDialog.tsx
+++ b/src/renderer/components/extensions/skills/SkillReviewDialog.tsx
@@ -1,4 +1,5 @@
import { DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -36,6 +37,7 @@ export const SkillReviewDialog = ({
reviewLabel,
backLabel = 'Back To Editor',
}: SkillReviewDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const hasChanges = Boolean(preview && preview.changes.length > 0);
return (
@@ -43,41 +45,48 @@ export const SkillReviewDialog = ({
- Review skill changes
-
- {reviewLabel} previews the filesystem changes first. Nothing is written until you
- confirm below.
-
+ {t('skillReview.title')}
+ {t('skillReview.description', { reviewLabel })}
- {!preview &&
No preview available.
}
+ {!preview &&
{t('skillReview.noPreview')}
}
{preview && (
- {preview.changes.length} file changes
+
+ {t('skillReview.summary.fileChanges', { count: preview.changes.length })}
+
{preview.summary.created > 0 && (
- {preview.summary.created} new
+
+ {t('skillReview.summary.new', { count: preview.summary.created })}
+
)}
{preview.summary.updated > 0 && (
- {preview.summary.updated} updated
+
+ {t('skillReview.summary.updated', { count: preview.summary.updated })}
+
)}
{preview.summary.deleted > 0 && (
- {preview.summary.deleted} removed
+
+ {t('skillReview.summary.removed', { count: preview.summary.deleted })}
+
)}
{preview.summary.binary > 0 && (
- {preview.summary.binary} binary
+
+ {t('skillReview.summary.binary', { count: preview.summary.binary })}
+
)}
{preview.targetSkillDir}
- Review the diff below, then use{' '}
- {confirmLabel} to apply these
- changes.
+ {t('skillReview.confirmPromptPrefix')}{' '}
+ {confirmLabel} {' '}
+ {t('skillReview.confirmPromptSuffix')}
@@ -97,7 +106,7 @@ export const SkillReviewDialog = ({
{!hasChanges && (
- No file changes detected yet.
+ {t('skillReview.noChanges')}
)}
@@ -112,12 +121,14 @@ export const SkillReviewDialog = ({
{change.action}
{change.relativePath}
- {change.isBinary &&
binary }
+ {change.isBinary && (
+
{t('skillReview.binaryBadge')}
+ )}
{change.isBinary ? (
- Binary file preview is not shown. The file will be copied as-is.
+ {t('skillReview.binaryPreviewHidden')}
) : (
diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx
index 0ee032da..875f7486 100644
--- a/src/renderer/components/extensions/skills/SkillsPanel.tsx
+++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx
@@ -4,6 +4,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -81,26 +82,6 @@ function sortSkills(skills: SkillCatalogItem[], sort: SkillsSortState): SkillCat
return next;
}
-function getScopeLabel(skill: SkillCatalogItem): string {
- return skill.scope === 'project' ? 'This project' : 'Personal';
-}
-
-function getInvocationLabel(skill: SkillCatalogItem): string {
- return skill.invocationMode === 'manual-only'
- ? 'Only runs when you explicitly ask for it'
- : 'Runs automatically when it fits';
-}
-
-function getSkillStatus(skill: SkillCatalogItem): string {
- if (!skill.isValid) {
- return 'Needs attention before you rely on it';
- }
- if (skill.flags.hasScripts) {
- return 'Includes scripts, so review it carefully';
- }
- return 'Ready to use';
-}
-
function getPrimarySkillIssue(skill: SkillCatalogItem): SkillValidationIssue | null {
return (
skill.issues.find((issue) => issue.severity === 'error') ??
@@ -150,6 +131,7 @@ export const SkillsPanel = ({
selectedSkillId,
setSelectedSkillId,
}: SkillsPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
@@ -344,14 +326,29 @@ export const SkillsPanel = ({
[visibleSkills]
);
const isRefreshing = skillsLoading && mergedSkills.length > 0;
+ const getScopeLabel = (skill: SkillCatalogItem): string =>
+ skill.scope === 'project' ? t('skillsPanel.scope.project') : t('skillsPanel.scope.user');
+ const getInvocationLabel = (skill: SkillCatalogItem): string =>
+ skill.invocationMode === 'manual-only'
+ ? t('skillsPanel.invocation.manualOnly')
+ : t('skillsPanel.invocation.auto');
+ const getSkillStatus = (skill: SkillCatalogItem): string => {
+ if (!skill.isValid) {
+ return t('skillsPanel.status.needsAttention');
+ }
+ if (skill.flags.hasScripts) {
+ return t('skillsPanel.status.hasScripts');
+ }
+ return t('skillsPanel.status.ready');
+ };
return (
{effectiveCliStatus?.flavor === 'agent_teams_orchestrator' && (
- Shared skills in `.claude`, `.cursor`, and `.agents` are available to{' '}
- {skillsAudienceLabel ?? 'the configured runtime'}. Skills stored in `.codex` stay
- Codex-only when Codex support is available.
+ {t('skillsPanel.runtimeAudience', {
+ audience: skillsAudienceLabel ?? t('skillsPanel.configuredRuntime'),
+ })}
)}
@@ -359,21 +356,19 @@ export const SkillsPanel = ({
-
Teach repeatable work
+ {t('skillsPanel.hero.title')}
- Skills are reusable instructions that help the runtime handle the same kind of task
- more consistently.{' '}
+ {t('skillsPanel.hero.description')}{' '}
{projectPath
- ? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.`
- : 'You are seeing only your personal skills right now.'}
+ ? t('skillsPanel.hero.projectContext', { project: projectLabel ?? projectPath })
+ : t('skillsPanel.hero.personalContext')}
- Use personal skills for habits you want everywhere. Use project skills for workflows
- that only make sense inside one codebase.
+ {t('skillsPanel.hero.guidance')}
{codexSkillOverlayAvailable
- ? ' Use `.codex` when a skill should stay Codex-only.'
- : ' Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.'}
+ ? ` ${t('skillsPanel.hero.codexAvailable')}`
+ : ` ${t('skillsPanel.hero.codexUnavailable')}`}
@@ -383,17 +378,17 @@ export const SkillsPanel = ({
setCreateOpen(true)}>
- Create Skill
+ {t('skillsPanel.actions.createSkill')}
setImportOpen(true)}>
- Import
+ {t('skillsPanel.actions.import')}
@@ -403,13 +398,13 @@ export const SkillsPanel = ({
variant="outline"
size="icon"
className="size-9 shrink-0"
- aria-label="Sort skills"
+ aria-label={t('skillsPanel.sort.label')}
>
- Sort skills
+ {t('skillsPanel.sort.label')}
- Name
+ {t('skillsPanel.sort.name')}
{skillsSort === 'name-asc' && }
- Recent
+ {t('skillsPanel.sort.recent')}
{skillsSort === 'recent-desc' && }
@@ -443,20 +438,20 @@ export const SkillsPanel = ({
- {mergedSkills.length} total
+ {t('skillsPanel.counts.total', { count: mergedSkills.length })}
- {projectSkills.length} project
+ {t('skillsPanel.counts.project', { count: projectSkills.length })}
- {userSkills.length} personal
+ {t('skillsPanel.counts.personal', { count: userSkills.length })}
- {sharedSkillsCount} shared
+ {t('skillsPanel.counts.shared', { count: sharedSkillsCount })}
{showCodexOnlyUi && (
- {codexOnlySkillsCount} Codex only
+ {t('skillsPanel.counts.codexOnly', { count: codexOnlySkillsCount })}
)}
@@ -467,15 +462,18 @@ export const SkillsPanel = ({
{(
[
- ['all', 'All skills'],
- ['project', 'Project'],
- ['personal', 'Personal'],
- ['shared', 'Shared'],
+ ['all', t('skillsPanel.filters.all')],
+ ['project', t('skillsPanel.filters.project')],
+ ['personal', t('skillsPanel.filters.personal')],
+ ['shared', t('skillsPanel.filters.shared')],
...(showCodexOnlyUi
- ? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][])
+ ? ([['codex-only', t('skillsPanel.filters.codexOnly')]] as [
+ SkillsQuickFilter,
+ string,
+ ][])
: []),
- ['needs-attention', 'Needs attention'],
- ['has-scripts', 'Has scripts'],
+ ['needs-attention', t('skillsPanel.filters.needsAttention')],
+ ['has-scripts', t('skillsPanel.filters.hasScripts')],
] as [SkillsQuickFilter, string][]
).map(([value, label]) => (
- Refreshing skills...
+ {t('skillsPanel.loading.refreshing')}
)}
{skillsLoading && visibleSkills.length === 0 && (
- Loading skills...
+ {t('skillsPanel.loading.loading')}
)}
@@ -521,12 +519,12 @@ export const SkillsPanel = ({
- {skillsSearchQuery ? 'No skills match your search' : 'No skills yet'}
+ {skillsSearchQuery ? t('skillsPanel.empty.noMatches') : t('skillsPanel.empty.noSkills')}
{skillsSearchQuery
- ? 'Try a different search term or switch filters.'
- : 'Create your first skill to teach a repeatable workflow, or import one you already use.'}
+ ? t('skillsPanel.empty.noMatchesDescription')
+ : t('skillsPanel.empty.noSkillsDescription')}
)}
@@ -537,9 +535,11 @@ export const SkillsPanel = ({
-
Project skills
+
+ {t('skillsPanel.sections.project.title')}
+
- Workflows that only make sense for this codebase.
+ {t('skillsPanel.sections.project.description')}
@@ -573,7 +573,7 @@ export const SkillsPanel = ({
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
- Needs attention
+ {t('skillsPanel.badges.needsAttention')}
)}
@@ -591,24 +591,26 @@ export const SkillsPanel = ({
- Stored in {formatSkillRootKind(skill.rootKind)}
+ {t('skillsPanel.badges.storedIn', {
+ root: formatSkillRootKind(skill.rootKind),
+ })}
{getSkillAudienceLabel(skill.rootKind)}
{skill.flags.hasScripts && (
- Has scripts
+ {t('skillsPanel.badges.hasScripts')}
)}
{skill.flags.hasReferences && (
- References
+ {t('skillsPanel.badges.references')}
)}
{skill.flags.hasAssets && (
- Assets
+ {t('skillsPanel.badges.assets')}
)}
@@ -632,9 +634,11 @@ export const SkillsPanel = ({
-
Personal skills
+
+ {t('skillsPanel.sections.personal.title')}
+
- Habits and instructions you want available everywhere.
+ {t('skillsPanel.sections.personal.description')}
@@ -668,7 +672,7 @@ export const SkillsPanel = ({
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
- Needs attention
+ {t('skillsPanel.badges.needsAttention')}
)}
@@ -686,24 +690,26 @@ export const SkillsPanel = ({
- Stored in {formatSkillRootKind(skill.rootKind)}
+ {t('skillsPanel.badges.storedIn', {
+ root: formatSkillRootKind(skill.rootKind),
+ })}
{getSkillAudienceLabel(skill.rootKind)}
{skill.flags.hasScripts && (
- Has scripts
+ {t('skillsPanel.badges.hasScripts')}
)}
{skill.flags.hasReferences && (
- References
+ {t('skillsPanel.badges.references')}
)}
{skill.flags.hasAssets && (
- Assets
+ {t('skillsPanel.badges.assets')}
)}
@@ -749,7 +755,7 @@ export const SkillsPanel = ({
onClose={() => setCreateOpen(false)}
onSaved={(skillId) => {
setCreateOpen(false);
- setSuccessMessage('Skill created successfully.');
+ setSuccessMessage(t('skillsPanel.success.created'));
setHighlightedSkillId(skillId);
setSelectedSkillId(null);
}}
@@ -769,7 +775,7 @@ export const SkillsPanel = ({
onSaved={(skillId) => {
setEditOpen(false);
setEditingDetail(null);
- setSuccessMessage('Skill saved successfully.');
+ setSuccessMessage(t('skillsPanel.success.saved'));
setSelectedSkillId(skillId);
}}
/>
@@ -782,7 +788,7 @@ export const SkillsPanel = ({
onClose={() => setImportOpen(false)}
onImported={(skillId) => {
setImportOpen(false);
- setSuccessMessage('Skill imported successfully.');
+ setSuccessMessage(t('skillsPanel.success.imported'));
setSelectedSkillId(skillId);
}}
/>
diff --git a/src/renderer/components/layout/CustomTitleBar.tsx b/src/renderer/components/layout/CustomTitleBar.tsx
index 7a1593dc..4fa2f303 100644
--- a/src/renderer/components/layout/CustomTitleBar.tsx
+++ b/src/renderer/components/layout/CustomTitleBar.tsx
@@ -7,6 +7,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import faviconUrl from '@renderer/favicon.png';
@@ -30,6 +31,7 @@ function needsCustomTitleBar(): boolean {
}
export const CustomTitleBar = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const [isMaximized, setIsMaximized] = useState(false);
const useNativeTitleBar = useStore((s) => s.appConfig?.general?.useNativeTitleBar ?? false);
const showTitleBar = needsCustomTitleBar() && !useNativeTitleBar;
@@ -76,12 +78,12 @@ export const CustomTitleBar = (): React.JSX.Element | null => {
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void minimize()}
- aria-label="Minimize"
+ aria-label={t('window.minimize')}
>
- Minimize
+ {t('window.minimize')}
@@ -90,12 +92,14 @@ export const CustomTitleBar = (): React.JSX.Element | null => {
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void handleMaximize()}
- aria-label={isMaximized ? 'Restore' : 'Maximize'}
+ aria-label={isMaximized ? t('window.restore') : t('window.maximize')}
>
- {isMaximized ? 'Restore' : 'Maximize'}
+
+ {isMaximized ? t('window.restore') : t('window.maximize')}
+
@@ -104,12 +108,12 @@ export const CustomTitleBar = (): React.JSX.Element | null => {
className={`${buttonBase} hover:bg-red-500/90 hover:text-white`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void close()}
- aria-label="Close"
+ aria-label={t('actions.close')}
>
- Close
+ {t('actions.close')}
diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx
index c1bbb2c7..5069b98c 100644
--- a/src/renderer/components/layout/MoreMenu.tsx
+++ b/src/renderer/components/layout/MoreMenu.tsx
@@ -7,6 +7,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
@@ -52,6 +53,7 @@ export const MoreMenu = ({
activeTabSessionDetail,
activeTabId,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [isOpen, setIsOpen] = useState(false);
const [buttonHover, setButtonHover] = useState(false);
const [hoveredId, setHoveredId] = useState(null);
@@ -128,7 +130,7 @@ export const MoreMenu = ({
const topItems: MenuItem[] = [
{
id: 'teams',
- label: 'Teams',
+ label: t('layout.menu.teams'),
icon: Users,
onClick: () => {
openTeamsTab();
@@ -137,7 +139,7 @@ export const MoreMenu = ({
},
{
id: 'settings',
- label: 'Settings',
+ label: t('layout.menu.settings'),
icon: Settings,
onClick: () => {
openSettingsTab();
@@ -146,7 +148,7 @@ export const MoreMenu = ({
},
{
id: 'extensions',
- label: 'Extensions',
+ label: t('layout.menu.extensions'),
icon: Puzzle,
onClick: () => {
openExtensionsTab();
@@ -155,7 +157,7 @@ export const MoreMenu = ({
},
{
id: 'search',
- label: 'Search',
+ label: t('layout.menu.search'),
icon: Search,
shortcut: formatShortcut('K'),
onClick: () => {
@@ -165,7 +167,7 @@ export const MoreMenu = ({
},
{
id: 'schedules',
- label: 'Schedules',
+ label: t('layout.menu.schedules'),
icon: Calendar,
onClick: () => {
openSchedulesTab();
@@ -174,7 +176,7 @@ export const MoreMenu = ({
},
{
id: 'docs',
- label: 'Docs',
+ label: t('layout.menu.docs'),
icon: BookOpen,
onClick: () => {
void handleOpenDocs();
@@ -186,28 +188,28 @@ export const MoreMenu = ({
? [
{
id: 'export-md',
- label: 'Export as Markdown',
+ label: t('layout.menu.exportMarkdown'),
icon: FileText,
shortcut: '.md',
onClick: () => handleExport('markdown'),
},
{
id: 'export-json',
- label: 'Export as JSON',
+ label: t('layout.menu.exportJson'),
icon: Braces,
shortcut: '.json',
onClick: () => handleExport('json'),
},
{
id: 'export-txt',
- label: 'Export as Plain Text',
+ label: t('layout.menu.exportPlainText'),
icon: Type,
shortcut: '.txt',
onClick: () => handleExport('plaintext'),
},
{
id: 'analyze',
- label: 'Analyze Session',
+ label: t('layout.menu.analyzeSession'),
icon: Activity,
onClick: () => {
if (activeTabId) openSessionReport(activeTabId);
@@ -258,12 +260,12 @@ export const MoreMenu = ({
backgroundColor:
buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="More actions"
+ aria-label={t('actions.moreActions')}
>
- More actions
+ {t('actions.moreActions')}
{/* Dropdown menu */}
diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx
index d7d71e43..4ab940d5 100644
--- a/src/renderer/components/layout/PaneContent.tsx
+++ b/src/renderer/components/layout/PaneContent.tsx
@@ -5,6 +5,7 @@
import { lazy, Suspense, useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { TabUIProvider } from '@renderer/contexts/TabUIContext';
import { DashboardView } from '../dashboard/DashboardView';
@@ -69,15 +70,19 @@ interface PaneTabSlotProps {
isPaneFocused: boolean;
}
-const PaneLazyFallback = (): React.JSX.Element => (
-
-);
+const PaneLazyFallback = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
+
+ return (
+
+ );
+};
const PaneTabSlot = ({ tab, isActive, isPaneFocused }: PaneTabSlotProps): React.JSX.Element => {
const [hasActivated, setHasActivated] = useState(isActive);
diff --git a/src/renderer/components/layout/PaneView.tsx b/src/renderer/components/layout/PaneView.tsx
index 51039bf3..0e7f217a 100644
--- a/src/renderer/components/layout/PaneView.tsx
+++ b/src/renderer/components/layout/PaneView.tsx
@@ -5,6 +5,7 @@
*/
import { useDndContext } from '@dnd-kit/core';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { MAX_PANES } from '@renderer/types/panes';
import { useShallow } from 'zustand/react/shallow';
@@ -17,6 +18,7 @@ interface PaneViewProps {
}
export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { pane, isFocused, paneCount, focusPane } = useStore(
useShallow((s) => ({
pane: s.paneLayout.panes.find((p) => p.id === paneId),
@@ -66,7 +68,7 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => {
color: 'var(--color-text-muted)',
}}
>
- Maximum {MAX_PANES} panes reached
+ {t('layout.maxPanesReached', { count: MAX_PANES })}
)}
diff --git a/src/renderer/components/layout/SessionTabContent.tsx b/src/renderer/components/layout/SessionTabContent.tsx
index 3bf89e98..581de5f4 100644
--- a/src/renderer/components/layout/SessionTabContent.tsx
+++ b/src/renderer/components/layout/SessionTabContent.tsx
@@ -5,6 +5,7 @@
import { useEffect } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -17,6 +18,7 @@ export const SessionTabContent = ({
tab,
isActive,
}: Readonly<{ tab: Tab; isActive: boolean }>): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { fetchSessionDetail, closeTab, initTabUIState } = useStore(
useShallow((s) => ({
fetchSessionDetail: s.fetchSessionDetail,
@@ -55,7 +57,9 @@ export const SessionTabContent = ({
-
Failed to load session
+
+ {t('sessions.failedToLoad')}
+
{sessionDetailError}
@@ -69,13 +73,13 @@ export const SessionTabContent = ({
className="flex items-center gap-2 rounded-md border border-claude-dark-border bg-claude-dark-surface px-4 py-2 text-sm transition-colors hover:bg-claude-dark-border"
>
- Retry
+ {t('actions.retry')}
closeTab(tab.id)}
className="px-4 py-2 text-sm text-claude-dark-text-secondary transition-colors hover:text-claude-dark-text"
>
- Close tab
+ {t('layout.closeTab')}
@@ -88,7 +92,7 @@ export const SessionTabContent = ({
-
Loading session...
+
{t('sessions.loading')}
);
diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx
index 989a8e27..fbc41594 100644
--- a/src/renderer/components/layout/Sidebar.tsx
+++ b/src/renderer/components/layout/Sidebar.tsx
@@ -10,6 +10,7 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { PanelLeft } from 'lucide-react';
@@ -33,6 +34,7 @@ const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 280;
export const Sidebar = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { sidebarCollapsed, toggleSidebar } = useStore(
useShallow((s) => ({
sidebarCollapsed: s.sidebarCollapsed,
@@ -129,13 +131,13 @@ export const Sidebar = (): React.JSX.Element => {
color: isCollapseHovered ? 'var(--color-text-secondary)' : 'var(--color-text-muted)',
backgroundColor: isCollapseHovered ? 'var(--color-surface-raised)' : 'transparent',
}}
- title={`Collapse sidebar (${formatShortcut('B')})`}
+ title={t('layout.collapseSidebarShortcut', { shortcut: formatShortcut('B') })}
>
-
+
{
}
onClick={() => setSidebarTab('tasks')}
>
- Tasks
+ {t('tasksPanel.title')}
{
}
onClick={() => setSidebarTab('sessions')}
>
- Sessions
+ {t('sessions.title')}
@@ -219,7 +221,7 @@ export const Sidebar = (): React.JSX.Element => {
{!sidebarCollapsed && (
{
+ const { t } = useAppTranslation('common');
const [isHovered, setIsHovered] = useState(false);
const { isLight } = useTheme();
@@ -185,12 +187,12 @@ export const SortableTab = ({
>
{tab.fromSearch && (
-
+
)}
{isPinned && (
-
+
)}
@@ -222,12 +224,12 @@ export const SortableTab = ({
onClose(tab.id);
}}
onPointerDown={(e) => e.stopPropagation()}
- aria-label="Close tab"
+ aria-label={t('layout.closeTab')}
>
-
Close tab
+
{t('layout.closeTab')}
);
diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx
index 2b7586be..7848f04c 100644
--- a/src/renderer/components/layout/TabBar.tsx
+++ b/src/renderer/components/layout/TabBar.tsx
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
@@ -26,6 +27,7 @@ interface TabBarProps {
}
export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
pane,
isFocused,
@@ -305,12 +307,14 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
onMouseEnter={() => setRefreshHover(true)}
onMouseLeave={() => setRefreshHover(false)}
onClick={handleRefresh}
- aria-label="Refresh session"
+ aria-label={t('layout.refreshSession')}
>
-
{`Refresh Session (${formatShortcut('R')})`}
+
+ {t('layout.refreshSessionWithShortcut', { shortcut: formatShortcut('R') })}
+
)}
diff --git a/src/renderer/components/layout/TabBarActions.tsx b/src/renderer/components/layout/TabBarActions.tsx
index 7a017892..41bf7205 100644
--- a/src/renderer/components/layout/TabBarActions.tsx
+++ b/src/renderer/components/layout/TabBarActions.tsx
@@ -6,6 +6,7 @@
import { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
@@ -15,6 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
import { MoreMenu } from './MoreMenu';
export const TabBarActions = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
unreadCount,
openNotificationsTab,
@@ -74,13 +76,15 @@ export const TabBarActions = (): React.JSX.Element => {
backgroundColor: updateHover ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
}}
>
- {updateStatus === 'downloaded' ? 'Restart to update' : 'Update app'}
+ {updateStatus === 'downloaded'
+ ? t('updates.restartToUpdate')
+ : t('updates.updateApp')}
{updateStatus === 'downloaded'
- ? 'Update downloaded, restart to apply'
- : 'New version available'}
+ ? t('updates.downloadedRestartTooltip')
+ : t('updates.newVersionAvailable')}
)}
@@ -97,7 +101,7 @@ export const TabBarActions = (): React.JSX.Element => {
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="Notifications"
+ aria-label={t('notifications.title')}
>
{unreadCount > 0 && (
@@ -107,7 +111,7 @@ export const TabBarActions = (): React.JSX.Element => {
)}
-
Notifications
+
{t('notifications.title')}
{/* GitHub link */}
@@ -135,14 +139,14 @@ export const TabBarActions = (): React.JSX.Element => {
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="GitHub"
+ aria-label={t('layout.github')}
>
-
GitHub
+
{t('layout.github')}
{/* Discord link */}
@@ -164,14 +168,14 @@ export const TabBarActions = (): React.JSX.Element => {
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="Discord"
+ aria-label={t('layout.discord')}
>
-
Discord
+
{t('layout.discord')}
{/* More menu (Teams, Settings, Extensions, Search, Schedules, Docs, Export, Analyze) */}
@@ -194,12 +198,12 @@ export const TabBarActions = (): React.JSX.Element => {
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="Expand sidebar"
+ aria-label={t('layout.expandSidebar')}
>
-
Expand sidebar
+
{t('layout.expandSidebar')}
)}
diff --git a/src/renderer/components/layout/TabBarRow.tsx b/src/renderer/components/layout/TabBarRow.tsx
index 7f1ef8a8..4816e3a6 100644
--- a/src/renderer/components/layout/TabBarRow.tsx
+++ b/src/renderer/components/layout/TabBarRow.tsx
@@ -6,6 +6,7 @@
import { Fragment, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
@@ -17,6 +18,7 @@ import { TabBar } from './TabBar';
import { TabBarActions } from './TabBarActions';
export const TabBarRow = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { panes, focusedPaneId, openDashboard } = useStore(
useShallow((s) => ({
panes: s.paneLayout.panes,
@@ -85,12 +87,12 @@ export const TabBarRow = (): React.JSX.Element => {
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
} as React.CSSProperties
}
- aria-label="New tab"
+ aria-label={t('layout.newTab')}
>
-
New tab (Dashboard)
+
{t('layout.newTabDashboard')}
diff --git a/src/renderer/components/layout/TabContextMenu.tsx b/src/renderer/components/layout/TabContextMenu.tsx
index a58e0de3..6a2e6059 100644
--- a/src/renderer/components/layout/TabContextMenu.tsx
+++ b/src/renderer/components/layout/TabContextMenu.tsx
@@ -7,6 +7,7 @@
import { useEffect, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { formatShortcut } from '@renderer/utils/stringUtils';
interface TabContextMenuProps {
@@ -53,6 +54,7 @@ export const TabContextMenu = ({
isHidden,
onToggleHide,
}: TabContextMenuProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const menuRef = useRef(null);
// Close on click-outside and Escape
@@ -98,43 +100,54 @@ export const TabContextMenu = ({
>
{selectedCount > 1 && onCloseSelectedTabs ? (
) : (
)}
-
+
-
+
{isSessionTab && onTogglePin && (
<>
>
)}
{isSessionTab && onToggleHide && (
)}
diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx
index 4cd48f64..871f708f 100644
--- a/src/renderer/components/layout/TeamTabSectionNav.tsx
+++ b/src/renderer/components/layout/TeamTabSectionNav.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { ChevronDown, Columns3, History, MessageSquare, Terminal, Users } from 'lucide-react';
@@ -11,18 +12,21 @@ interface TeamTabSectionNavProps {
onActivate?: () => void;
}
-const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [
- { id: 'team', label: 'Team', icon: Users },
- { id: 'sessions', label: 'Sessions', icon: History },
- { id: 'kanban', label: 'Kanban', icon: Columns3 },
- { id: 'claude-logs', label: 'Claude Logs', icon: Terminal },
- { id: 'messages', label: 'Messages', icon: MessageSquare },
+const SECTIONS: readonly { id: string; labelKey: TeamSectionLabelKey; icon: LucideIcon }[] = [
+ { id: 'team', labelKey: 'team', icon: Users },
+ { id: 'sessions', labelKey: 'sessions', icon: History },
+ { id: 'kanban', labelKey: 'kanban', icon: Columns3 },
+ { id: 'claude-logs', labelKey: 'claudeLogs', icon: Terminal },
+ { id: 'messages', labelKey: 'messages', icon: MessageSquare },
];
+type TeamSectionLabelKey = 'team' | 'sessions' | 'kanban' | 'claudeLogs' | 'messages';
+
export const TeamTabSectionNav = ({
teamName,
onActivate,
}: TeamTabSectionNavProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const messagesPanelMode = useStore((s) => s.messagesPanelMode);
const [open, setOpen] = useState(false);
const [hoveredId, setHoveredId] = useState(null);
@@ -91,7 +95,7 @@ export const TeamTabSectionNav = ({
e.stopPropagation();
setOpen((prev) => !prev);
}}
- title="Jump to section"
+ title={t('layout.jumpToSection')}
>
@@ -134,7 +138,7 @@ export const TeamTabSectionNav = ({
}}
>
- {section.label}
+ {t(`layout.sections.${section.labelKey}`)}
);
})}
diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx
index c5e8285e..af12876a 100644
--- a/src/renderer/components/notifications/NotificationRow.tsx
+++ b/src/renderer/components/notifications/NotificationRow.tsx
@@ -5,6 +5,7 @@
import { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getTriggerColorDef } from '@shared/constants/triggerColors';
import { formatDistanceToNow } from 'date-fns';
import { ArrowRight, Bot, Check, Trash2, Users } from 'lucide-react';
@@ -32,6 +33,7 @@ export const NotificationRow = ({
onArchive,
onDelete,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [isHovered, setIsHovered] = useState(false);
const isUnread = !error.isRead;
const projectName = error.context?.projectName || 'Unknown Project';
@@ -113,7 +115,7 @@ export const NotificationRow = ({
}}
>
- team
+ {t('notifications.row.team')}
)}
{error.subagentId && (
@@ -126,7 +128,7 @@ export const NotificationRow = ({
}}
>
- subagent
+ {t('notifications.row.subagent')}
)}
@@ -174,6 +176,7 @@ const HoverActions = ({
onDeleteClick,
onNavigateClick,
}: HoverActionsProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [hoveredButton, setHoveredButton] = useState
(null);
const getButtonStyle = (buttonId: string, isDelete = false): React.CSSProperties => ({
@@ -196,7 +199,7 @@ const HoverActions = ({
onMouseLeave={() => setHoveredButton(null)}
className="rounded p-1.5 transition-colors"
style={getButtonStyle('archive')}
- title="Mark as read"
+ title={t('notifications.row.markAsRead')}
>
@@ -208,7 +211,7 @@ const HoverActions = ({
onMouseLeave={() => setHoveredButton(null)}
className="rounded p-1.5 transition-colors"
style={getButtonStyle('delete', true)}
- title="Delete"
+ title={t('notifications.row.delete')}
>
@@ -219,7 +222,7 @@ const HoverActions = ({
onMouseLeave={() => setHoveredButton(null)}
className="rounded p-1.5 transition-colors"
style={getButtonStyle('navigate')}
- title="View in session"
+ title={t('notifications.row.viewInSession')}
>
diff --git a/src/renderer/components/notifications/NotificationsView.tsx b/src/renderer/components/notifications/NotificationsView.tsx
index 839cd45b..6cc42b6c 100644
--- a/src/renderer/components/notifications/NotificationsView.tsx
+++ b/src/renderer/components/notifications/NotificationsView.tsx
@@ -6,6 +6,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { getTriggerColorDef } from '@shared/constants/triggerColors';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -21,7 +22,7 @@ const ROW_HEIGHT = 56;
const OVERSCAN = 5;
/** Label used for notifications without a triggerName */
-const OTHER_LABEL = 'Other';
+const OTHER_LABEL = '__other__';
interface FilterChip {
label: string;
@@ -30,6 +31,7 @@ interface FilterChip {
}
export const NotificationsView = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
notifications,
unreadCount,
@@ -188,7 +190,7 @@ export const NotificationsView = (): React.JSX.Element => {
style={{ color: 'var(--color-text-muted)' }}
/>
- Loading notifications...
+ {t('notifications.loading')}
@@ -207,17 +209,17 @@ export const NotificationsView = (): React.JSX.Element => {
- Notifications
+ {t('notifications.title')}
{notifications.length > 0 && (
{activeFilter !== null
? filteredUnreadCount > 0
- ? `${filteredUnreadCount} unread in filter`
- : `${filteredNotifications.length} in filter`
+ ? t('notifications.counts.unreadInFilter', { count: filteredUnreadCount })
+ : t('notifications.counts.inFilter', { count: filteredNotifications.length })
: unreadCount > 0
- ? `${unreadCount} unread`
- : `${notifications.length} total`}
+ ? t('notifications.counts.unread', { count: unreadCount })
+ : t('notifications.counts.total', { count: notifications.length })}
)}
@@ -231,11 +233,17 @@ export const NotificationsView = (): React.JSX.Element => {
onClick={handleMarkAllRead}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors hover:opacity-80"
style={{ color: 'var(--color-text-muted)' }}
- title={activeFilter !== null ? 'Mark filtered as read' : 'Mark all as read'}
+ title={
+ activeFilter !== null
+ ? t('notifications.actions.markFilteredAsRead')
+ : t('notifications.actions.markAllAsRead')
+ }
>
- {activeFilter !== null ? 'Mark filtered read' : 'Mark all read'}
+ {activeFilter !== null
+ ? t('notifications.actions.markFilteredRead')
+ : t('notifications.actions.markAllRead')}
)}
@@ -249,16 +257,18 @@ export const NotificationsView = (): React.JSX.Element => {
}`}
style={showClearConfirm ? undefined : { color: 'var(--color-text-muted)' }}
title={
- activeFilter !== null ? 'Clear filtered notifications' : 'Clear all notifications'
+ activeFilter !== null
+ ? t('notifications.actions.clearFilteredNotifications')
+ : t('notifications.actions.clearAllNotifications')
}
>
{showClearConfirm
- ? 'Click to confirm'
+ ? t('notifications.actions.clickToConfirm')
: activeFilter !== null
- ? 'Clear filtered'
- : 'Clear all'}
+ ? t('notifications.actions.clearFiltered')
+ : t('notifications.actions.clearAll')}
@@ -286,7 +296,7 @@ export const NotificationsView = (): React.JSX.Element => {
: '1px solid var(--color-border)',
}}
>
- All
+ {t('list.all')}
({sortedNotifications.length})
{/* Trigger chips */}
@@ -307,7 +317,7 @@ export const NotificationsView = (): React.JSX.Element => {
}}
>
- {chip.label}
+ {chip.label === OTHER_LABEL ? t('notifications.filters.other') : chip.label}
({chip.count})
))}
@@ -324,10 +334,14 @@ export const NotificationsView = (): React.JSX.Element => {
>
- {activeFilter !== null ? 'No matching notifications' : 'No notifications'}
+ {activeFilter !== null
+ ? t('notifications.empty.noMatching')
+ : t('notifications.empty.noNotifications')}
- {activeFilter !== null ? 'Try a different filter' : "You're all caught up!"}
+ {activeFilter !== null
+ ? t('notifications.empty.tryDifferentFilter')
+ : t('notifications.empty.allCaughtUp')}
) : (
diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx
index df2e9aa0..997b6d77 100644
--- a/src/renderer/components/report/SessionReportTab.tsx
+++ b/src/renderer/components/report/SessionReportTab.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { computeTakeaways } from '@renderer/utils/reportAssessments';
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
@@ -25,6 +26,7 @@ interface SessionReportTabProps {
}
export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
+ const { t } = useAppTranslation('report');
// Find session data from any session tab with matching sessionId
const sessionDetail = useStore(
useShallow((s) => {
@@ -44,14 +46,14 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
if (!report) {
return (
- No session data available. Open the session tab first.
+ {t('sessionReport.noSessionData')}
);
}
return (
-
Session Analysis Report
+
{t('sessionReport.title')}
{takeaways.length > 0 &&
}
diff --git a/src/renderer/components/report/sections/CostSection.tsx b/src/renderer/components/report/sections/CostSection.tsx
index c1fdd2e0..97f97f0c 100644
--- a/src/renderer/components/report/sections/CostSection.tsx
+++ b/src/renderer/components/report/sections/CostSection.tsx
@@ -1,5 +1,6 @@
import { Fragment, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getPricing } from '@renderer/utils/sessionAnalyzer';
import { DollarSign } from 'lucide-react';
@@ -34,22 +35,31 @@ interface BreakdownLine {
const CostBreakdownCard = ({
stats,
pricing,
+ labels,
}: {
stats: ModelTokenStats;
pricing: ModelPricing;
+ labels: {
+ input: string;
+ output: string;
+ cacheRead: string;
+ cacheWrite: string;
+ breakdownTitle: string;
+ total: string;
+ };
}) => {
const lines: BreakdownLine[] = [
- { label: 'Input', tokens: stats.inputTokens, ratePerM: pricing.input },
- { label: 'Output', tokens: stats.outputTokens, ratePerM: pricing.output },
- { label: 'Cache Read', tokens: stats.cacheRead, ratePerM: pricing.cache_read },
- { label: 'Cache Write', tokens: stats.cacheCreation, ratePerM: pricing.cache_creation },
+ { label: labels.input, tokens: stats.inputTokens, ratePerM: pricing.input },
+ { label: labels.output, tokens: stats.outputTokens, ratePerM: pricing.output },
+ { label: labels.cacheRead, tokens: stats.cacheRead, ratePerM: pricing.cache_read },
+ { label: labels.cacheWrite, tokens: stats.cacheCreation, ratePerM: pricing.cache_creation },
];
const total = lines.reduce((sum, l) => sum + lineCost(l.tokens, l.ratePerM), 0);
return (
- Cost Breakdown (per 1M tokens)
+ {labels.breakdownTitle}
{lines.map((l) => {
@@ -64,7 +74,7 @@ const CostBreakdownCard = ({
);
})}
- Total
+ {labels.total}
{fmt(total)}
@@ -79,6 +89,7 @@ export const CostSection = ({
linesChanged,
defaultCollapsed,
}: CostSectionProps) => {
+ const { t } = useAppTranslation('report');
const [expandedModel, setExpandedModel] = useState
(null);
const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]);
const showStackedBar = data.subagentCostUsd > 0;
@@ -88,7 +99,7 @@ export const CostSection = ({
: 100;
return (
-
+
{fmt(data.totalSessionCostUsd)}
{/* Parent/Subagent stacked bar */}
@@ -110,14 +121,18 @@ export const CostSection = ({
className="inline-block size-2 rounded-full"
style={{ backgroundColor: '#60a5fa' }}
/>
- Parent: {fmt(data.parentCostUsd)}
+
+ {t('cost.parent', { cost: fmt(data.parentCostUsd) })}
+
- Subagent: {fmt(data.subagentCostUsd)}
+
+ {t('cost.subagent', { cost: fmt(data.subagentCostUsd) })}
+
@@ -127,24 +142,22 @@ export const CostSection = ({
{!showStackedBar && (
<>
-
Parent Cost
+
{t('cost.parentCost')}
{fmt(data.parentCostUsd)}
-
Subagent Cost
+
{t('cost.subagentCost')}
{fmt(data.subagentCostUsd)}
>
)}
-
Per Commit
+
{t('cost.perCommit')}
{commitCount > 0 ? (
- <>
- total cost {'\u00F7'} {commitCount} commit{commitCount !== 1 ? 's' : ''}
- >
+ <>{t('cost.perCommitFormula', { count: commitCount })}>
) : (
- 'no commits'
+ t('cost.noCommits')
)}
@@ -160,15 +173,12 @@ export const CostSection = ({
-
Per Line Changed
+
{t('cost.perLineChanged')}
{linesChanged > 0 ? (
- <>
- total cost {'\u00F7'} {linesChanged.toLocaleString()} line
- {linesChanged !== 1 ? 's' : ''}
- >
+ <>{t('cost.perLineFormula', { count: linesChanged })}>
) : (
- 'no lines changed'
+ t('cost.noLinesChanged')
)}
@@ -186,12 +196,12 @@ export const CostSection = ({
- Model
- Input
- Output
- Cache Read
- Cache Write
- Cost
+ {t('tokens.model')}
+ {t('cost.input')}
+ {t('cost.output')}
+ {t('cost.cacheRead')}
+ {t('cost.cacheWrite')}
+ {t('cost.cost')}
@@ -245,7 +255,18 @@ export const CostSection = ({
{isExpanded && stats && pricing && (
-
+
)}
diff --git a/src/renderer/components/report/sections/ErrorSection.tsx b/src/renderer/components/report/sections/ErrorSection.tsx
index 88832434..c14846f0 100644
--- a/src/renderer/components/report/sections/ErrorSection.tsx
+++ b/src/renderer/components/report/sections/ErrorSection.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
import { ReportSection } from '../ReportSection';
@@ -11,6 +12,7 @@ interface ErrorItemProps {
}
const ErrorItem = ({ error }: ErrorItemProps) => {
+ const { t } = useAppTranslation('report');
const [expanded, setExpanded] = useState(false);
return (
@@ -33,17 +35,19 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
color: 'var(--assess-danger)',
}}
>
- Permission Denied
+ {t('errors.permissionDenied')}
)}
- msg #{error.messageIndex}
+
+ {t('errors.messageIndex', { index: error.messageIndex })}
+
{expanded && (
{error.inputPreview && (
- Input
+ {t('errors.input')}
{error.inputPreview}
@@ -52,7 +56,7 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
)}
- Error
+ {t('errors.error')}
{
+ const { t } = useAppTranslation('report');
+
return (
-
+
{
color: 'var(--assess-danger)',
}}
>
- {data.errors.length} error{data.errors.length !== 1 ? 's' : ''}
+ {t('errors.count', { count: data.errors.length })}
{data.permissionDenials.count > 0 && (
- {data.permissionDenials.count} permission denial
- {data.permissionDenials.count !== 1 ? 's' : ''}
+ {t('errors.permissionDenialCount', { count: data.permissionDenials.count })}
)}
diff --git a/src/renderer/components/report/sections/FrictionSection.tsx b/src/renderer/components/report/sections/FrictionSection.tsx
index 147b74ed..69a61dbe 100644
--- a/src/renderer/components/report/sections/FrictionSection.tsx
+++ b/src/renderer/components/report/sections/FrictionSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { severityColor } from '@renderer/utils/reportAssessments';
import { MessageSquareWarning } from 'lucide-react';
@@ -13,13 +14,14 @@ interface FrictionSectionProps {
}
export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => {
+ const { t } = useAppTranslation('report');
const frictionSeverity =
data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger';
const frictionColor = severityColor(frictionSeverity);
return (
@@ -31,16 +33,18 @@ export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionS
color: frictionColor,
}}
>
- Friction Rate: {(data.frictionRate * 100).toFixed(1)}%
+ {t('friction.rate', { rate: (data.frictionRate * 100).toFixed(1) })}
- {data.correctionCount} correction{data.correctionCount !== 1 ? 's' : ''}
+ {t('friction.correctionsCount', { count: data.correctionCount })}
{data.corrections.length > 0 && (
-
Corrections
+
+ {t('friction.corrections')}
+
{data.corrections.map((corr, idx) => (
@@ -63,13 +67,17 @@ export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionS
{(thrashing.bashNearDuplicates.length > 0 || thrashing.editReworkFiles.length > 0) && (
-
Thrashing Signals
+
+ {t('friction.thrashingSignals')}
+
{thrashing.bashNearDuplicates.length > 0 && (
-
Repeated Bash Commands
+
+ {t('friction.repeatedBashCommands')}
+
{thrashing.bashNearDuplicates.map((dup, idx) => (
{dup.count}x
@@ -81,7 +89,7 @@ export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionS
{thrashing.editReworkFiles.length > 0 && (
-
Reworked Files (3+ edits)
+
{t('friction.reworkedFiles')}
{thrashing.editReworkFiles.map((file, idx) => (
{file.editIndices.length}x
diff --git a/src/renderer/components/report/sections/GitSection.tsx b/src/renderer/components/report/sections/GitSection.tsx
index 5481d835..13d59122 100644
--- a/src/renderer/components/report/sections/GitSection.tsx
+++ b/src/renderer/components/report/sections/GitSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { GitBranch } from 'lucide-react';
import { ReportSection } from '../ReportSection';
@@ -10,25 +11,27 @@ interface GitSectionProps {
}
export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
+ const { t } = useAppTranslation('report');
+
return (
-
+
-
Commits
+
{t('git.commits')}
{data.commitCount}
-
Pushes
+
{t('git.pushes')}
{data.pushCount}
-
Lines Added
+
{t('git.linesAdded')}
+{data.linesAdded.toLocaleString()}
-
Lines Removed
+
{t('git.linesRemoved')}
-{data.linesRemoved.toLocaleString()}
@@ -37,7 +40,7 @@ export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
{data.commits.length > 0 && (
-
Commits
+
{t('git.commits')}
{data.commits.map((commit, idx) => (
{
{data.branchCreations.length > 0 && (
-
Branches Created
+
{t('git.branchesCreated')}
{data.branchCreations.map((branch, idx) => (
{
+ const { t } = useAppTranslation('report');
+ const agentUnit = t('insights.agent', { count: agentTree.agentCount });
+
return (
-
+
{/* Skills invoked */}
{skills.length > 0 && (
- Skills Invoked ({skills.length})
+ {t('insights.skillsInvoked', { count: skills.length })}
{skills.map((s, idx) => (
@@ -53,18 +57,18 @@ export const InsightsSection = ({
{/* Bash commands */}
-
Bash Commands
+
{t('insights.bashCommands')}
-
Total
+
{t('insights.total')}
{bash.total}
-
Unique
+
{t('insights.unique')}
{bash.unique}
-
Repeated
+
{t('insights.repeated')}
{Object.keys(bash.repeated).length}
@@ -86,7 +90,7 @@ export const InsightsSection = ({
{subagentsList.length > 0 && (
- Task Dispatches ({subagentsList.length})
+ {t('insights.taskDispatches', { count: subagentsList.length })}
{subagentsList.map((s, idx) => (
@@ -95,7 +99,9 @@ export const InsightsSection = ({
{s.subagentType}
{s.description}
- {s.runInBackground && (background) }
+ {s.runInBackground && (
+ {t('insights.background')}
+ )}
))}
@@ -106,7 +112,7 @@ export const InsightsSection = ({
{lifecycleTasks.length > 0 && (
- Tasks Created ({lifecycleTasks.length})
+ {t('insights.tasksCreated', { count: lifecycleTasks.length })}
{lifecycleTasks.map((task, idx) => (
@@ -122,7 +128,7 @@ export const InsightsSection = ({
{userQuestions.length > 0 && (
- Questions Asked ({userQuestions.length})
+ {t('insights.questionsAsked', { count: userQuestions.length })}
{userQuestions.map((q, idx) => (
@@ -151,16 +157,16 @@ export const InsightsSection = ({
{agentTree.agentCount > 0 && (
- Agent Tree ({agentTree.agentCount} agent{agentTree.agentCount !== 1 ? 's' : ''})
+ {t('insights.agentTree', { count: agentTree.agentCount, unit: agentUnit })}
{agentTree.hasTeamMode && (
- Team Mode
+ {t('insights.teamMode')}
)}
{agentTree.teamNames.length > 0 && (
- Teams: {agentTree.teamNames.join(', ')}
+ {t('insights.teams', { teams: agentTree.teamNames.join(', ') })}
)}
@@ -182,7 +188,7 @@ export const InsightsSection = ({
{outOfScope.length > 0 && (
- Out-of-Scope Findings ({outOfScope.length})
+ {t('insights.outOfScopeFindings', { count: outOfScope.length })}
{outOfScope.map((f, idx) => (
diff --git a/src/renderer/components/report/sections/KeyTakeawaysSection.tsx b/src/renderer/components/report/sections/KeyTakeawaysSection.tsx
index 9eee50b0..17d15959 100644
--- a/src/renderer/components/report/sections/KeyTakeawaysSection.tsx
+++ b/src/renderer/components/report/sections/KeyTakeawaysSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { severityColor } from '@renderer/utils/reportAssessments';
import { AlertTriangle, CheckCircle, ChevronRight, Info, XCircle } from 'lucide-react';
@@ -23,9 +24,11 @@ interface KeyTakeawaysSectionProps {
}
export const KeyTakeawaysSection = ({ takeaways }: KeyTakeawaysSectionProps) => {
+ const { t } = useAppTranslation('report');
+
return (
-
Key Takeaways
+
{t('insights.keyTakeaways')}
{takeaways.map((t, idx) => {
const Icon = SEVERITY_ICONS[t.severity];
diff --git a/src/renderer/components/report/sections/OverviewSection.tsx b/src/renderer/components/report/sections/OverviewSection.tsx
index d871b02d..da39b9cb 100644
--- a/src/renderer/components/report/sections/OverviewSection.tsx
+++ b/src/renderer/components/report/sections/OverviewSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { assessmentColor } from '@renderer/utils/reportAssessments';
import { Activity } from 'lucide-react';
@@ -10,20 +11,21 @@ interface OverviewSectionProps {
}
export const OverviewSection = ({ data }: OverviewSectionProps) => {
+ const { t } = useAppTranslation('report');
return (
-
+
{data.firstMessage}
-
Duration
+
{t('overview.metrics.duration')}
{data.durationHuman}
-
Messages
+
{t('overview.metrics.messages')}
{data.totalMessages.toLocaleString()}
-
Context Usage
+
{t('overview.metrics.contextUsage')}
{
-
Compactions
+
{t('overview.metrics.compactions')}
{data.compactionCount}
-
Branch
+
{t('overview.metrics.branch')}
{data.gitBranch}
-
Subagents
-
{data.hasSubagents ? 'Yes' : 'No'}
+
{t('overview.metrics.subagents')}
+
+ {data.hasSubagents ? t('overview.yes') : t('overview.no')}
+
-
Project
+
{t('overview.metrics.project')}
{data.projectPath}
-
Session ID
+
{t('overview.metrics.sessionId')}
{data.sessionId.slice(0, 12)}...
diff --git a/src/renderer/components/report/sections/QualitySection.tsx b/src/renderer/components/report/sections/QualitySection.tsx
index fa1794cb..d9b83458 100644
--- a/src/renderer/components/report/sections/QualitySection.tsx
+++ b/src/renderer/components/report/sections/QualitySection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { severityColor } from '@renderer/utils/reportAssessments';
import { BarChart3 } from 'lucide-react';
@@ -26,32 +27,35 @@ export const QualitySection = ({
fileReadRedundancy,
defaultCollapsed,
}: QualitySectionProps) => {
+ const { t } = useAppTranslation('report');
+ const snapshotUnit = t('quality.snapshot', { count: testProgression.snapshotCount });
+
return (
-
+
{/* Prompt quality */}
-
Prompt Quality
+
{t('quality.promptQuality')}
{prompt.note}
-
First Message
+
{t('quality.firstMessage')}
- {prompt.firstMessageLengthChars.toLocaleString()} chars
+ {prompt.firstMessageLengthChars.toLocaleString()} {t('quality.chars')}
-
User Messages
+
{t('quality.userMessages')}
{prompt.userMessageCount}
-
Corrections
+
{t('quality.corrections')}
{prompt.correctionCount}
-
Friction Rate
+
{t('quality.frictionRate')}
{(prompt.frictionRate * 100).toFixed(1)}%
@@ -62,22 +66,24 @@ export const QualitySection = ({
{/* Startup overhead */}
-
Startup Overhead
+
+ {t('quality.startupOverhead')}
+
-
Messages Before Work
+
{t('quality.messagesBeforeWork')}
{startup.messagesBeforeFirstWork}
-
Tokens Before Work
+
{t('quality.tokensBeforeWork')}
{startup.tokensBeforeFirstWork.toLocaleString()}
-
% of Total
+
{t('quality.percentOfTotal')}
{startup.pctOfTotal}%
@@ -86,7 +92,9 @@ export const QualitySection = ({
{/* File read redundancy */}
-
File Read Redundancy
+
+ {t('quality.fileReadRedundancy')}
+
-
Total Reads
+
{t('quality.totalReads')}
{fileReadRedundancy.totalReads}
-
Unique Files
+
{t('quality.uniqueFiles')}
{fileReadRedundancy.uniqueFiles}
-
Reads/Unique File
+
{t('quality.readsPerUniqueFile')}
{fileReadRedundancy.readsPerUniqueFile}x
@@ -112,36 +120,38 @@ export const QualitySection = ({
{/* Test progression */}
-
Test Progression
+
+ {t('quality.testProgression')}
+
- {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}
+ {testProgression.snapshotCount} {snapshotUnit}
{testProgression.firstSnapshot && testProgression.lastSnapshot && (
-
First Run
+
{t('quality.firstRun')}
- {testProgression.firstSnapshot.passed} passed
+ {testProgression.firstSnapshot.passed} {t('quality.passed')}
{' / '}
- {testProgression.firstSnapshot.failed} failed
+ {testProgression.firstSnapshot.failed} {t('quality.failed')}
-
Last Run
+
{t('quality.lastRun')}
- {testProgression.lastSnapshot.passed} passed
+ {testProgression.lastSnapshot.passed} {t('quality.passed')}
{' / '}
- {testProgression.lastSnapshot.failed} failed
+ {testProgression.lastSnapshot.failed} {t('quality.failed')}
diff --git a/src/renderer/components/report/sections/SubagentSection.tsx b/src/renderer/components/report/sections/SubagentSection.tsx
index 7ce3dd60..bf311f78 100644
--- a/src/renderer/components/report/sections/SubagentSection.tsx
+++ b/src/renderer/components/report/sections/SubagentSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { severityColor } from '@renderer/utils/reportAssessments';
import { Users } from 'lucide-react';
@@ -19,23 +20,24 @@ interface SubagentSectionProps {
}
export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => {
+ const { t } = useAppTranslation('report');
return (
-
+
-
Count
+
{t('subagents.metrics.count')}
{data.count}
-
Total Tokens
+
{t('subagents.metrics.totalTokens')}
{data.totalTokens.toLocaleString()}
-
Total Duration
+
{t('subagents.metrics.totalDuration')}
{fmtDuration(data.totalDurationMs)}
-
Total Cost
+
{t('subagents.metrics.totalCost')}
{fmtCost(data.totalCostUsd)}
@@ -45,11 +47,11 @@ export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps
- Description
- Type
- Tokens
- Duration
- Cost
+ {t('subagents.table.description')}
+ {t('subagents.table.type')}
+ {t('subagents.table.tokens')}
+ {t('subagents.table.duration')}
+ {t('subagents.table.cost')}
diff --git a/src/renderer/components/report/sections/TimelineSection.tsx b/src/renderer/components/report/sections/TimelineSection.tsx
index 395974dd..58141cf2 100644
--- a/src/renderer/components/report/sections/TimelineSection.tsx
+++ b/src/renderer/components/report/sections/TimelineSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
import { Clock } from 'lucide-react';
@@ -23,31 +24,32 @@ export const TimelineSection = ({
keyEvents,
defaultCollapsed,
}: TimelineSectionProps) => {
+ const { t } = useAppTranslation('report');
const idleColor = assessmentColor(idle.idleAssessment);
return (
-
+
{/* Idle stats */}
-
Idle Analysis
+
{t('timeline.idleAnalysis')}
-
Idle Gaps
+
{t('timeline.metrics.idleGaps')}
{idle.idleGapCount}
-
Total Idle
+
{t('timeline.metrics.totalIdle')}
{idle.totalIdleHuman}
-
Active Time
+
{t('timeline.metrics.activeTime')}
{idle.activeWorkingHuman}
-
Idle %
+
{t('timeline.metrics.idlePercent')}
{idle.idlePct}%
@@ -60,7 +62,7 @@ export const TimelineSection = ({
- Model Switches ({modelSwitches.count})
+ {t('timeline.modelSwitches', { count: modelSwitches.count })}
{modelSwitches.switchPattern && (
{sw.from}
→
{sw.to}
- msg #{sw.messageIndex}
+
+ {t('timeline.messageNumber', { number: sw.messageIndex })}
+
))}
@@ -90,7 +94,7 @@ export const TimelineSection = ({
{/* Key events */}
{keyEvents.length > 0 && (
-
Key Events
+
{t('timeline.keyEvents')}
{keyEvents.map((event, idx) => (
diff --git a/src/renderer/components/report/sections/TokenSection.tsx b/src/renderer/components/report/sections/TokenSection.tsx
index f3aeaa27..5b3724c2 100644
--- a/src/renderer/components/report/sections/TokenSection.tsx
+++ b/src/renderer/components/report/sections/TokenSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Coins } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
@@ -15,22 +16,23 @@ interface TokenSectionProps {
}
export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => {
+ const { t } = useAppTranslation('report');
const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);
return (
-
+
{/* By-model table */}
- Model
- API Calls
- Input
- Output
- Cache Read
- Cache Create
- Cost
+ {t('tokens.model')}
+ {t('tokens.apiCalls')}
+ {t('tokens.input')}
+ {t('tokens.output')}
+ {t('tokens.cacheRead')}
+ {t('tokens.cacheCreate')}
+ {t('tokens.cost')}
@@ -47,7 +49,7 @@ export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSe
))}
{/* Totals row */}
- Total
+ {t('tokens.total')}
{fmt(modelEntries.reduce((s, [, st]) => s + st.apiCalls, 0))}
@@ -66,7 +68,7 @@ export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSe
{/* Cache economics */}
-
Cache Efficiency
+
{t('tokens.cacheEfficiency')}
{cacheEconomics.cacheEfficiencyPct}%
@@ -80,7 +82,7 @@ export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSe
-
R/W Ratio
+
{t('tokens.readWriteRatio')}
{cacheEconomics.cacheReadToWriteRatio}x
@@ -94,11 +96,11 @@ export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSe
-
Cache Read %
+
{t('tokens.cacheReadPct')}
{data.totals.cacheReadPct}%
-
Cold Start
+
{t('tokens.coldStart')}
- {cacheEconomics.coldStartDetected ? 'Yes' : 'No'}
+ {cacheEconomics.coldStartDetected ? t('tokens.yes') : t('tokens.no')}
diff --git a/src/renderer/components/report/sections/ToolSection.tsx b/src/renderer/components/report/sections/ToolSection.tsx
index fd15c852..d3aaa474 100644
--- a/src/renderer/components/report/sections/ToolSection.tsx
+++ b/src/renderer/components/report/sections/ToolSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { assessmentColor } from '@renderer/utils/reportAssessments';
import { Wrench } from 'lucide-react';
@@ -12,15 +13,20 @@ interface ToolSectionProps {
}
export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
+ const { t } = useAppTranslation('report');
const toolEntries = Object.entries(data.successRates).sort(
(a, b) => b[1].totalCalls - a[1].totalCalls
);
return (
-
+
- {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools
+ {t('tools.summary', {
+ count: data.totalCalls,
+ formattedCount: data.totalCalls.toLocaleString(),
+ toolCount: toolEntries.length,
+ })}
@@ -28,11 +34,11 @@ export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
- Tool
- Calls
- Errors
- Success %
- Health
+ {t('tools.columns.tool')}
+ {t('tools.columns.calls')}
+ {t('tools.columns.errors')}
+ {t('tools.columns.successPercent')}
+ {t('tools.columns.health')}
diff --git a/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx b/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx
index 8836e0c8..b84d8513 100644
--- a/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx
+++ b/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Check, Copy } from 'lucide-react';
interface CodexLoginLinkCopyButtonProps {
@@ -15,6 +16,7 @@ export const CodexLoginLinkCopyButton = ({
disabled = false,
size = 'sm',
}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
useEffect(() => {
@@ -31,7 +33,7 @@ export const CodexLoginLinkCopyButton = ({
return;
}
- const text = userCode ? `${authUrl}\nCode: ${userCode}` : authUrl;
+ const text = userCode ? `${authUrl}\n${t('code.code')}: ${userCode}` : authUrl;
void navigator.clipboard.writeText(text).then(
() => setCopyState('copied'),
() => setCopyState('failed')
@@ -50,16 +52,16 @@ export const CodexLoginLinkCopyButton = ({
borderColor: 'rgba(245, 158, 11, 0.28)',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
}}
- title={userCode ? 'Copy ChatGPT login link and code' : 'Copy ChatGPT login link'}
+ title={userCode ? t('codexLogin.copyLoginLinkAndCode') : t('codexLogin.copyLoginLink')}
>
{copyState === 'copied' ? : }
{copyState === 'copied'
- ? 'Copied'
+ ? t('actions.copied')
: copyState === 'failed'
- ? 'Copy failed'
+ ? t('codexLogin.copyFailed')
: userCode
- ? 'Copy link + code'
- : 'Copy link'}
+ ? t('codexLogin.copyLinkAndCode')
+ : t('codexLogin.copyLink')}
);
};
@@ -69,6 +71,7 @@ export const CodexLoginUserCodeBadge = ({
}: {
userCode?: string | null;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
if (!userCode) {
return null;
}
@@ -81,9 +84,9 @@ export const CodexLoginUserCodeBadge = ({
backgroundColor: 'rgba(245, 158, 11, 0.06)',
color: '#fbbf24',
}}
- title="Enter this code on the ChatGPT login page"
+ title={t('codexLogin.enterCodeOnLoginPage')}
>
- Code {userCode}
+ {t('code.code')} {userCode}
);
};
diff --git a/src/renderer/components/runtime/ProviderModelBadges.tsx b/src/renderer/components/runtime/ProviderModelBadges.tsx
index 08f1a8bc..7e8845bc 100644
--- a/src/renderer/components/runtime/ProviderModelBadges.tsx
+++ b/src/renderer/components/runtime/ProviderModelBadges.tsx
@@ -1,5 +1,6 @@
import { useLayoutEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import {
getTeamModelBadgeLabel,
@@ -32,14 +33,17 @@ function getAvailabilityReason(
return modelAvailability?.find((item) => item.modelId === model)?.reason ?? null;
}
-function getAvailabilityChip(status: CliProviderModelAvailabilityStatus | null): string | null {
+function getAvailabilityChip(
+ status: CliProviderModelAvailabilityStatus | null,
+ t: ReturnType['t']
+): string | null {
switch (status) {
case 'checking':
- return 'Checking';
+ return t('providerModelBadges.checking');
case 'unavailable':
- return 'Unavailable';
+ return t('providerModelBadges.unavailable');
case 'unknown':
- return 'Check failed';
+ return t('providerModelBadges.checkFailed');
case 'available':
default:
return null;
@@ -108,6 +112,7 @@ export const ProviderModelBadges = ({
readonly collapseAfter?: number;
readonly maxCollapsedRows?: number;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const [collapsedModelLimit, setCollapsedModelLimit] = useState(null);
const [measureTick, setMeasureTick] = useState(0);
@@ -188,15 +193,17 @@ export const ProviderModelBadges = ({
const renderModelBadge = (model: string, index: number): React.JSX.Element => {
const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability);
const availabilityReason = getAvailabilityReason(model, displayModelAvailability);
- const availabilityChip = getAvailabilityChip(availabilityStatus);
+ const availabilityChip = getAvailabilityChip(availabilityStatus, t);
const modelLabel = formatModelBadgeLabel(providerId, model);
const catalogBadgeLabel = getCatalogBadgeLabel(model, providerStatus);
+ const catalogBadgeIsFree = catalogBadgeLabel === 'Free';
+ const localizedCatalogBadgeLabel = catalogBadgeIsFree
+ ? t('providerModelBadges.free')
+ : catalogBadgeLabel;
const showCatalogBadge = shouldRenderCatalogBadge(modelLabel, catalogBadgeLabel);
const title = [
availabilityReason ?? availabilityChip,
- showCatalogBadge && catalogBadgeLabel === 'Free'
- ? 'Reported by OpenCode metadata. Availability and limits may change.'
- : null,
+ showCatalogBadge && catalogBadgeIsFree ? t('providerModelBadges.freeTooltip') : null,
]
.filter(Boolean)
.join(' - ');
@@ -211,7 +218,7 @@ export const ProviderModelBadges = ({
{modelLabel}
{showCatalogBadge ? (
- {catalogBadgeLabel}
+ {localizedCatalogBadgeLabel}
) : null}
{availabilityChip ? (
@@ -243,14 +250,14 @@ export const ProviderModelBadges = ({
{shouldCollapse && !expanded ? (
setExpanded(true)}>
- +{hiddenCount} more
+ {t('list.moreCount', { count: hiddenCount })}
) : null}
{shouldCollapse && expanded ? (
setExpanded(false)}>
- Hide
+ {t('actions.hide')}
) : null}
diff --git a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx
index ffd4b966..3da2b320 100644
--- a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx
+++ b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx
@@ -1,10 +1,5 @@
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@renderer/components/ui/select';
+import { useAppTranslation } from '@features/localization/renderer';
+import { Select, SelectContent, SelectItem, SelectTrigger } from '@renderer/components/ui/select';
import {
Tooltip,
TooltipContent,
@@ -111,6 +106,7 @@ export const ProviderRuntimeBackendSelector = ({
disabled = false,
onSelect,
}: Props): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const options = getVisibleProviderRuntimeBackendOptions(provider);
if (options.length === 0) {
return null;
@@ -123,15 +119,52 @@ export const ProviderRuntimeBackendSelector = ({
const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? '';
const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0];
const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null;
- const selectedLabel = getOptionDisplayLabel(provider, selectedOption, resolvedOption);
- const selectedStateLabel = getProviderRuntimeBackendStateLabel(selectedOption);
- const selectedAudienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption);
+ const localizeStateLabel = (
+ option: NonNullable[number]
+ ): string | null => {
+ switch (getProviderRuntimeBackendStateLabel(option)) {
+ case 'Locked':
+ return t('runtimeBackendSelector.states.locked');
+ case 'Disabled':
+ return t('runtimeBackendSelector.states.disabled');
+ case 'Auth required':
+ return t('runtimeBackendSelector.states.authRequired');
+ case 'Runtime missing':
+ return t('runtimeBackendSelector.states.runtimeMissing');
+ case 'Degraded':
+ return t('runtimeBackendSelector.states.degraded');
+ case 'Unavailable':
+ return t('runtimeBackendSelector.states.unavailable');
+ default:
+ return null;
+ }
+ };
+ const localizeAudienceLabel = (
+ option: NonNullable[number]
+ ): string | null =>
+ getProviderRuntimeBackendAudienceLabel(option)
+ ? t('runtimeBackendSelector.audience.internal')
+ : null;
+ const localizeOptionDisplayLabel = (
+ option: NonNullable[number]
+ ): string => {
+ if (option.id === 'auto') {
+ if (resolvedOption?.label) {
+ return t('runtimeBackendSelector.autoCurrently', { backend: resolvedOption.label });
+ }
+ return t('runtimeBackendSelector.auto');
+ }
+ return getOptionDisplayLabel(provider, option, resolvedOption);
+ };
+ const selectedLabel = localizeOptionDisplayLabel(selectedOption);
+ const selectedStateLabel = localizeStateLabel(selectedOption);
+ const selectedAudienceLabel = localizeAudienceLabel(selectedOption);
return (
- Runtime backend
+ {t('runtimeBackendSelector.label')}
{provider.resolvedBackendId &&
provider.resolvedBackendId !== provider.selectedBackendId && (
@@ -142,7 +175,9 @@ export const ProviderRuntimeBackendSelector = ({
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
- Resolved: {resolvedOption?.label ?? provider.resolvedBackendId}
+ {t('runtimeBackendSelector.resolved', {
+ backend: resolvedOption?.label ?? provider.resolvedBackendId,
+ })}
)}
@@ -154,7 +189,7 @@ export const ProviderRuntimeBackendSelector = ({
- Current
+ {t('runtimeBackendSelector.current')}
{selectedLabel}
@@ -172,9 +207,7 @@ export const ProviderRuntimeBackendSelector = ({
>
-
- {getOptionDisplayLabel(provider, option, resolvedOption)}
-
+ {localizeOptionDisplayLabel(option)}
{option.recommended ? (
- Recommended
+ {t('runtimeBackendSelector.recommended')}
) : null}
- {getProviderRuntimeBackendAudienceLabel(option) ? (
+ {localizeAudienceLabel(option) ? (
- {getProviderRuntimeBackendAudienceLabel(option)}
+ {localizeAudienceLabel(option)}
) : null}
- {getProviderRuntimeBackendStateLabel(option) ? (
+ {localizeStateLabel(option) ? (
- {getProviderRuntimeBackendStateLabel(option)}
+ {localizeStateLabel(option)}
) : null}
@@ -251,7 +284,7 @@ export const ProviderRuntimeBackendSelector = ({
backgroundColor: 'rgba(74, 222, 128, 0.14)',
}}
>
- Recommended
+ {t('runtimeBackendSelector.recommended')}
) : null}
{selectedAudienceLabel ? (
@@ -276,11 +309,13 @@ export const ProviderRuntimeBackendSelector = ({
backgroundColor: 'rgba(248, 113, 113, 0.14)',
}}
>
- Unavailable
+ {t('runtimeBackendSelector.unavailable')}
- {selectedOption.detailMessage ?? selectedOption.statusMessage ?? 'Unavailable'}
+ {selectedOption.detailMessage ??
+ selectedOption.statusMessage ??
+ t('runtimeBackendSelector.unavailable')}
@@ -307,7 +342,7 @@ export const ProviderRuntimeBackendSelector = ({
{selectedOption.detailMessage ??
selectedOption.statusMessage ??
- 'This backend cannot be selected yet.'}
+ t('runtimeBackendSelector.cannotSelectYet')}
diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx
index 22f145de..63048b5c 100644
--- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx
+++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx
@@ -4,7 +4,6 @@ import {
formatCodexCreditsValue,
formatCodexRemainingPercent,
formatCodexResetWindowLabel,
- formatCodexUsageExplanation,
formatCodexUsagePercent,
formatCodexUsageWindowLabel,
formatCodexWindowDurationLong,
@@ -19,6 +18,7 @@ import {
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer';
import { api } from '@renderer/api';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
@@ -127,6 +127,35 @@ const API_KEY_PROVIDER_CONFIG: Record<
},
};
+const API_KEY_PROVIDER_TRANSLATION_KEYS = {
+ anthropic: {
+ name: 'providerRuntime.apiKey.providers.anthropic.name',
+ title: 'providerRuntime.apiKey.providers.anthropic.title',
+ description: 'providerRuntime.apiKey.providers.anthropic.description',
+ placeholder: 'providerRuntime.apiKey.providers.anthropic.placeholder',
+ },
+ codex: {
+ name: 'providerRuntime.apiKey.providers.codex.name',
+ title: 'providerRuntime.apiKey.providers.codex.title',
+ description: 'providerRuntime.apiKey.providers.codex.description',
+ placeholder: 'providerRuntime.apiKey.providers.codex.placeholder',
+ },
+ gemini: {
+ name: 'providerRuntime.apiKey.providers.gemini.name',
+ title: 'providerRuntime.apiKey.providers.gemini.title',
+ description: 'providerRuntime.apiKey.providers.gemini.description',
+ placeholder: 'providerRuntime.apiKey.providers.gemini.placeholder',
+ },
+} as const satisfies Record<
+ ApiKeyProviderId,
+ {
+ name: string;
+ title: string;
+ description: string;
+ placeholder: string;
+ }
+>;
+
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR = 'ANTHROPIC_AUTH_TOKEN';
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_NAME = 'Anthropic-compatible Auth Token';
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
@@ -147,18 +176,21 @@ function isCodexRuntimeInstalling(
);
}
-function getCodexRuntimeInstallLabel(status: CodexRuntimeStatus | null | undefined): string {
+function getCodexRuntimeInstallLabel(
+ status: CodexRuntimeStatus | null | undefined,
+ t: ReturnType
['t']
+): string {
switch (status?.state) {
case 'checking':
- return 'Checking';
+ return t('providerRuntime.codex.install.checking');
case 'downloading':
- return 'Downloading';
+ return t('providerRuntime.codex.install.downloading');
case 'installing':
- return 'Installing';
+ return t('providerRuntime.codex.install.installing');
case 'failed':
- return 'Retry install';
+ return t('providerRuntime.codex.install.retryInstall');
default:
- return 'Install Codex CLI';
+ return t('providerRuntime.codex.install.installCli');
}
}
@@ -167,76 +199,89 @@ function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): A
return matches.find((entry) => entry.scope === 'user') ?? null;
}
-function validateAnthropicCompatibleBaseUrl(value: string): string | null {
+function validateAnthropicCompatibleBaseUrl(
+ value: string,
+ t: ReturnType['t']
+): string | null {
const trimmed = value.trim();
if (!trimmed) {
- return 'Base URL is required';
+ return t('providerRuntime.compatibleEndpoint.validation.baseUrlRequired');
}
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
- return 'Base URL must use http:// or https://';
+ return t('providerRuntime.compatibleEndpoint.validation.httpRequired');
}
if (url.username || url.password) {
- return 'Base URL must not include credentials';
+ return t('providerRuntime.compatibleEndpoint.validation.noCredentials');
}
if (FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)) {
- return 'Use Auto, Subscription, or API key for first-party Anthropic';
+ return t('providerRuntime.compatibleEndpoint.validation.firstPartyAnthropic');
}
} catch {
- return 'Invalid URL';
+ return t('providerRuntime.compatibleEndpoint.validation.invalidUrl');
}
return null;
}
-function getConnectionDescription(provider: CliProviderStatus): string {
+function getConnectionDescription(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string {
switch (provider.providerId) {
case 'anthropic':
- return 'Choose how app-launched Anthropic sessions authenticate.';
+ return t('providerRuntime.connection.descriptions.anthropic');
case 'codex':
- return 'Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.';
+ return t('providerRuntime.connection.descriptions.codex');
case 'gemini':
- return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.';
+ return t('providerRuntime.connection.descriptions.gemini');
case 'opencode':
- return 'OpenCode authentication and provider inventory are managed by the OpenCode runtime.';
+ return t('providerRuntime.connection.descriptions.opencode');
}
}
-function getRuntimeDescription(provider: CliProviderStatus): string {
+function getRuntimeDescription(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string {
switch (provider.providerId) {
case 'anthropic':
- return 'Anthropic currently has no separate runtime backend selector.';
+ return t('providerRuntime.runtime.descriptions.anthropic');
case 'codex':
- return 'Codex now runs only through the native runtime path.';
+ return t('providerRuntime.runtime.descriptions.codex');
case 'gemini':
- return 'Choose which Gemini runtime backend multimodel should use.';
+ return t('providerRuntime.runtime.descriptions.gemini');
case 'opencode':
- return 'OpenCode uses its own managed runtime host. Desktop currently exposes status only.';
+ return t('providerRuntime.runtime.descriptions.opencode');
}
}
-function getAuthModeDescription(providerId: CliProviderId, authMode: CliProviderAuthMode): string {
+function getAuthModeDescription(
+ providerId: CliProviderId,
+ authMode: CliProviderAuthMode,
+ t: ReturnType['t']
+): string {
if (providerId === 'anthropic') {
switch (authMode) {
case 'auto':
- return 'Use the runtime default behavior. Saved API keys in this app are only used after you switch to API key mode.';
+ return t('providerRuntime.authModeDescriptions.anthropic.auto');
case 'oauth':
- return 'Force app-launched Anthropic sessions to use the local Anthropic subscription session.';
+ return t('providerRuntime.authModeDescriptions.anthropic.oauth');
case 'api_key':
- return 'Force app-launched Anthropic sessions to use an API key credential.';
+ return t('providerRuntime.authModeDescriptions.anthropic.apiKey');
}
}
if (providerId === 'codex') {
switch (authMode) {
case 'auto':
- return 'Prefer your ChatGPT account when it is available. Fall back to API key mode only when needed.';
+ return t('providerRuntime.authModeDescriptions.codex.auto');
case 'chatgpt':
- return 'Force native Codex launches to use your connected ChatGPT account and subscription.';
+ return t('providerRuntime.authModeDescriptions.codex.chatgpt');
case 'api_key':
- return 'Force native Codex launches to use OPENAI_API_KEY / CODEX_API_KEY billing.';
+ return t('providerRuntime.authModeDescriptions.codex.apiKey');
default:
return '';
}
@@ -245,7 +290,10 @@ function getAuthModeDescription(providerId: CliProviderId, authMode: CliProvider
return '';
}
-function getConnectionAlert(provider: CliProviderStatus): string | null {
+function getConnectionAlert(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string | null {
const authMode = provider.connection?.configuredAuthMode;
const hasAnthropicSubscriptionSession =
provider.authMethod === 'oauth_token' || provider.authMethod === 'claude.ai';
@@ -253,7 +301,7 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
if (provider.providerId === 'anthropic' && provider.connection?.compatibleEndpoint?.enabled) {
return provider.connection.compatibleEndpoint.tokenConfigured
? null
- : 'Auth token is not configured. Many local Anthropic-compatible endpoints require a non-empty token.';
+ : t('providerRuntime.alerts.authTokenMissing');
}
if (
@@ -261,7 +309,7 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
authMode === 'api_key' &&
!provider.connection?.apiKeyConfigured
) {
- return 'API key mode is selected, but no Anthropic API credential is available yet.';
+ return t('providerRuntime.alerts.anthropicApiKeyMissing');
}
if (
@@ -269,7 +317,7 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
authMode === 'oauth' &&
!hasAnthropicSubscriptionSession
) {
- return 'Anthropic subscription mode is selected. Sign in with Anthropic to use this provider.';
+ return t('providerRuntime.alerts.anthropicSubscriptionMissing');
}
if (
@@ -277,17 +325,17 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
authMode === 'auto' &&
provider.connection?.apiKeySource === 'stored'
) {
- return 'A saved API key is available, but app-launched Anthropic sessions use it only after you switch to API key mode.';
+ return t('providerRuntime.alerts.anthropicStoredKeyAvailable');
}
if (provider.providerId === 'codex') {
const codex = provider.connection?.codex;
if (codex?.login.status === 'starting') {
- return 'Starting ChatGPT login...';
+ return t('providerRuntime.alerts.chatgptLoginStarting');
}
if (codex?.login.status === 'pending') {
- return 'Waiting for ChatGPT account login to finish...';
+ return t('providerRuntime.alerts.chatgptLoginPending');
}
if (codex?.login.status === 'failed' && codex.login.error) {
@@ -296,19 +344,19 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
if (provider.connection?.configuredAuthMode === 'api_key') {
if (!provider.connection?.apiKeyConfigured) {
- return 'API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.';
+ return t('providerRuntime.alerts.codexApiKeyMissing');
}
return null;
}
if (provider.connection?.configuredAuthMode === 'chatgpt' && !codex?.managedAccount) {
const missingChatgptMessage = codex?.localActiveChatgptAccountPresent
- ? 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.'
+ ? t('providerRuntime.alerts.codexNeedsReconnect')
: codex?.localAccountArtifactsPresent
- ? 'Codex CLI currently has no active ChatGPT account. Local Codex account data exists, but no active managed session is selected.'
- : 'Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription.';
+ ? t('providerRuntime.alerts.codexLocalArtifactsNoSession')
+ : t('providerRuntime.alerts.codexNoChatgptAccount');
return provider.connection.apiKeyConfigured
- ? `${missingChatgptMessage} Switch to API key mode to use the detected API key.`
+ ? t('providerRuntime.alerts.withApiKeyFallback', { message: missingChatgptMessage })
: missingChatgptMessage;
}
@@ -321,7 +369,7 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
}
if (!provider.connection?.apiKeyConfigured && !codex?.managedAccount) {
- return 'No ChatGPT account or API key is available yet.';
+ return t('providerRuntime.alerts.codexNoCredential');
}
return null;
@@ -331,27 +379,38 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
provider.providerId === 'gemini' &&
provider.availableBackends?.some((option) => option.id === 'api' && !option.available)
) {
- return 'Gemini API is currently unavailable. Configure `GEMINI_API_KEY` here or use valid Google ADC credentials.';
+ return t('providerRuntime.alerts.geminiApiUnavailable');
}
return null;
}
-function getProviderUsageLabel(provider: CliProviderStatus): string {
+function getProviderUsageLabel(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string {
if (provider.providerId === 'anthropic' && provider.connection?.compatibleEndpoint?.enabled) {
- return 'Using compatible endpoint';
+ return t('providerRuntime.usage.compatibleEndpoint');
}
if (
provider.providerId === 'anthropic' &&
provider.connection?.configuredAuthMode === 'api_key'
) {
- return provider.connection.apiKeyConfigured ? 'Using API key' : 'API key required';
+ return provider.connection.apiKeyConfigured
+ ? t('providerRuntime.usage.apiKey')
+ : t('providerRuntime.usage.apiKeyRequired');
}
return provider.authenticated
- ? `Using ${formatProviderAuthMethodLabelForProvider(provider.providerId, provider.authMethod)}`
- : provider.statusMessage || 'Not connected';
+ ? t('providerRuntime.usage.usingMethod', {
+ method: formatProviderAuthMethodLabelForProvider(
+ provider.providerId,
+ provider.authMethod,
+ t
+ ),
+ })
+ : provider.statusMessage || t('providerRuntime.usage.notConnected');
}
function getCompactOpenCodeProviderDetailMessage(detailMessage?: string | null): string | null {
@@ -374,7 +433,8 @@ function getCompactOpenCodeProviderDetailMessage(detailMessage?: string | null):
function getCodexAccountPanelHint(
provider: CliProviderStatus | null,
- configuredAuthMode: CliProviderAuthMode | undefined
+ configuredAuthMode: CliProviderAuthMode | undefined,
+ t: ReturnType['t']
): string | null {
if (provider?.providerId !== 'codex') {
return null;
@@ -390,23 +450,27 @@ function getCodexAccountPanelHint(
if (hasActiveChatgptSession) {
if (!codex.rateLimits) {
- return 'Usage limits appear here after Codex reports them for the connected ChatGPT account.';
+ return t('providerRuntime.codex.account.hints.usageLimitsAfterReport');
}
return null;
}
const usageSentence = codex.localActiveChatgptAccountPresent
- ? 'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here.'
+ ? t('providerRuntime.codex.account.hints.reconnectBeforeUsage')
: codex.localAccountArtifactsPresent
- ? 'Codex CLI currently reports no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Usage limits appear here only after Codex CLI sees one.'
- : 'Codex CLI currently reports no active ChatGPT account. Usage limits appear here only after Codex CLI sees one.';
+ ? t('providerRuntime.codex.account.hints.localArtifactsNoSession')
+ : t('providerRuntime.codex.account.hints.noActiveAccount');
if (configuredAuthMode === 'chatgpt' && provider.connection?.apiKeyConfigured) {
- return `${usageSentence} The detected API key is only used after you switch Codex to API key mode.`;
+ return t('providerRuntime.codex.account.hints.detectedApiKeyNeedsApiMode', {
+ message: usageSentence,
+ });
}
if (configuredAuthMode === 'auto' && provider.connection?.apiKeyConfigured) {
- return `${usageSentence} Auto will keep using the detected API key until ChatGPT is connected.`;
+ return t('providerRuntime.codex.account.hints.autoUsesApiKeyUntilChatgpt', {
+ message: usageSentence,
+ });
}
return usageSentence;
@@ -424,9 +488,63 @@ function getProviderStatusColor(statusText: string | null, authenticated: boolea
return authenticated ? '#4ade80' : 'var(--color-text-muted)';
}
-function formatCodexResetDateTime(timestampSeconds: number | null | undefined): string {
+function formatCodexResetDateTime(
+ timestampSeconds: number | null | undefined,
+ t: ReturnType['t']
+): string {
const normalized = normalizeCodexResetTimestamp(timestampSeconds);
- return normalized ? new Date(normalized).toLocaleString() : 'Unknown';
+ return normalized ? new Date(normalized).toLocaleString() : t('providerRuntime.status.unknown');
+}
+
+function formatLocalizedCodexUsageWindowLabel(
+ title: 'Primary used' | 'Secondary used' | 'Weekly used',
+ windowDurationMins: number | null | undefined,
+ t: ReturnType['t']
+): string {
+ const titleByKey = {
+ 'Primary used': t('providerRuntime.codex.rateLimits.primaryUsed'),
+ 'Secondary used': t('providerRuntime.codex.rateLimits.secondaryUsed'),
+ 'Weekly used': t('providerRuntime.codex.rateLimits.weeklyUsed'),
+ };
+ return formatCodexUsageWindowLabel(title, windowDurationMins).replace(title, titleByKey[title]);
+}
+
+function formatLocalizedCodexResetWindowLabel(
+ title: 'Primary reset' | 'Secondary reset' | 'Weekly reset',
+ windowDurationMins: number | null | undefined,
+ t: ReturnType['t']
+): string {
+ const titleByKey = {
+ 'Primary reset': t('providerRuntime.codex.rateLimits.primaryReset'),
+ 'Secondary reset': t('providerRuntime.codex.rateLimits.secondaryReset'),
+ 'Weekly reset': t('providerRuntime.codex.rateLimits.weeklyReset'),
+ };
+ return formatCodexResetWindowLabel(title, windowDurationMins).replace(title, titleByKey[title]);
+}
+
+function formatLocalizedCodexUsageExplanation(
+ usedPercent: number | null | undefined,
+ windowDurationMins: number | null | undefined,
+ t: ReturnType['t']
+): string {
+ const windowLabel = formatCodexWindowDurationLong(windowDurationMins);
+ const remaining = formatCodexRemainingPercent(usedPercent);
+
+ if (windowLabel && remaining) {
+ return t('providerRuntime.codex.rateLimits.usageExplanationWithRemaining', {
+ used: formatCodexUsagePercent(usedPercent),
+ remaining,
+ window: windowLabel,
+ });
+ }
+
+ if (windowLabel) {
+ return t('providerRuntime.codex.rateLimits.usageExplanationWindowOnly', {
+ window: windowLabel,
+ });
+ }
+
+ return t('providerRuntime.codex.rateLimits.usageExplanationGeneric');
}
const CodexRateLimitWindowCard = ({
@@ -446,6 +564,7 @@ const CodexRateLimitWindowCard = ({
resetValue: string;
accent: 'primary' | 'secondary';
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const accentStyles =
accent === 'primary'
? {
@@ -496,7 +615,7 @@ const CodexRateLimitWindowCard = ({
{usedValue}
- {remainingValue} left
+ {t('providerRuntime.codex.rateLimits.remainingLeft', { value: remainingValue })}
@@ -517,44 +636,44 @@ const CodexRateLimitWindowCard = ({
};
function getConnectionMethodCardOptions(
- provider: CliProviderStatus
+ provider: CliProviderStatus,
+ t: ReturnType['t']
): ConnectionMethodCardOption[] | null {
switch (provider.providerId) {
case 'anthropic':
return [
{
authMode: 'auto',
- title: 'Auto',
- description: 'Use Anthropic runtime defaults and the best local credential available.',
+ title: t('providerRuntime.connectionCards.auto.title'),
+ description: t('providerRuntime.connectionCards.anthropic.autoDescription'),
},
{
authMode: 'oauth',
- title: 'Anthropic subscription',
- description: 'Use your local Anthropic sign-in session and subscription access.',
+ title: t('providerRuntime.connectionCards.anthropic.subscriptionTitle'),
+ description: t('providerRuntime.connectionCards.anthropic.subscriptionDescription'),
},
{
authMode: 'api_key',
- title: 'API key',
- description: 'Use ANTHROPIC_API_KEY and Anthropic API billing.',
+ title: t('providerRuntime.connectionCards.apiKey.title'),
+ description: t('providerRuntime.connectionCards.anthropic.apiKeyDescription'),
},
];
case 'codex':
return [
{
authMode: 'auto',
- title: 'Auto',
- description:
- 'Prefer your ChatGPT account and subscription. Use API key mode only if needed.',
+ title: t('providerRuntime.connectionCards.auto.title'),
+ description: t('providerRuntime.connectionCards.codex.autoDescription'),
},
{
authMode: 'chatgpt',
- title: 'ChatGPT account',
- description: 'Use your connected ChatGPT account and Codex subscription.',
+ title: t('providerRuntime.connectionCards.codex.chatgptTitle'),
+ description: t('providerRuntime.connectionCards.codex.chatgptDescription'),
},
{
authMode: 'api_key',
- title: 'API key',
- description: 'Use OPENAI_API_KEY and CODEX_API_KEY billing for native Codex launches.',
+ title: t('providerRuntime.connectionCards.apiKey.title'),
+ description: t('providerRuntime.connectionCards.codex.apiKeyDescription'),
},
];
default:
@@ -562,13 +681,16 @@ function getConnectionMethodCardOptions(
}
}
-function getConnectionMethodCardsHint(provider: CliProviderStatus): string | null {
+function getConnectionMethodCardsHint(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string | null {
if (provider.providerId === 'codex') {
- return 'Codex always runs through the native runtime. Auto prefers your ChatGPT account before falling back to API-key credentials.';
+ return t('providerRuntime.connectionCards.codex.hint');
}
if (provider.providerId === 'anthropic') {
- return 'Auto keeps Anthropic on its default local credential resolution.';
+ return t('providerRuntime.connectionCards.anthropic.hint');
}
return null;
@@ -589,6 +711,7 @@ const ConnectionMethodCards = ({
pendingConnectionAction: PendingConnectionAction;
onSelect: (authMode: CliProviderAuthMode) => void;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const gridClassName =
options.length === 3 ? 'grid gap-2 md:grid-cols-3' : 'grid gap-2 sm:grid-cols-2';
@@ -622,7 +745,7 @@ const ConnectionMethodCards = ({
}}
>
- Switching...
+ {t('providerRuntime.connection.switching')}
) : selected ? (
- Selected
+ {t('providerRuntime.connection.selected')}
) : null}
@@ -663,6 +786,7 @@ export const ProviderRuntimeSettingsDialog = ({
onRefreshProvider,
onRequestLogin,
}: Props): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const [selectedProviderId, setSelectedProviderId] = useState(initialProviderId);
const [activeApiKeyFormProviderId, setActiveApiKeyFormProviderId] =
useState(null);
@@ -800,7 +924,9 @@ export const ProviderRuntimeSettingsDialog = ({
if (selectedCompatibleToken) {
nextConnection.compatibleEndpoint.tokenConfigured = true;
nextConnection.compatibleEndpoint.tokenSource = 'stored';
- nextConnection.compatibleEndpoint.tokenSourceLabel = 'Stored in app';
+ nextConnection.compatibleEndpoint.tokenSourceLabel = t(
+ 'providerRuntime.apiKey.storedInApp'
+ );
}
}
@@ -814,11 +940,13 @@ export const ProviderRuntimeSettingsDialog = ({
if (nextConnection.apiKeySource === 'stored') {
nextConnection.apiKeyConfigured = Boolean(selectedApiKey);
nextConnection.apiKeySource = selectedApiKey ? 'stored' : null;
- nextConnection.apiKeySourceLabel = selectedApiKey ? 'Stored in app' : null;
+ nextConnection.apiKeySourceLabel = selectedApiKey
+ ? t('providerRuntime.apiKey.storedInApp')
+ : null;
} else if (!nextConnection.apiKeyConfigured && selectedApiKey) {
nextConnection.apiKeyConfigured = true;
nextConnection.apiKeySource = 'stored';
- nextConnection.apiKeySourceLabel = 'Stored in app';
+ nextConnection.apiKeySourceLabel = t('providerRuntime.apiKey.storedInApp');
}
}
@@ -836,6 +964,7 @@ export const ProviderRuntimeSettingsDialog = ({
selectedApiKey,
statusApiKeyConfig,
statusSelectedProvider,
+ t,
]);
useEffect(() => {
@@ -869,12 +998,12 @@ export const ProviderRuntimeSettingsDialog = ({
const configuredAuthMode: CliProviderAuthMode | undefined =
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
const connectionMethodCardOptions = selectedProvider
- ? getConnectionMethodCardOptions(selectedProvider)
+ ? getConnectionMethodCardOptions(selectedProvider, t)
: null;
const showConnectionMethodCards =
connectionMethodCardOptions !== null && typeof configuredAuthMode !== 'undefined';
const managedRuntimeSummary = selectedProvider
- ? getProviderCurrentRuntimeSummary(selectedProvider)
+ ? getProviderCurrentRuntimeSummary(selectedProvider, t)
: null;
const connectionManagedRuntime = selectedProvider
? isConnectionManagedRuntimeProvider(selectedProvider)
@@ -888,10 +1017,22 @@ export const ProviderRuntimeSettingsDialog = ({
? getVisibleProviderRuntimeBackendOptions(selectedProvider).length > 1
: false);
- const apiKeyConfig =
+ const apiKeyProviderId =
selectedProvider && isApiKeyProviderId(selectedProvider.providerId)
- ? API_KEY_PROVIDER_CONFIG[selectedProvider.providerId]
+ ? selectedProvider.providerId
: null;
+ const apiKeyConfig = apiKeyProviderId ? API_KEY_PROVIDER_CONFIG[apiKeyProviderId] : null;
+ const apiKeyTranslationKeys = apiKeyProviderId
+ ? API_KEY_PROVIDER_TRANSLATION_KEYS[apiKeyProviderId]
+ : null;
+ const apiKeyDisplayConfig = apiKeyTranslationKeys
+ ? {
+ title: t(apiKeyTranslationKeys.title),
+ description: t(apiKeyTranslationKeys.description),
+ name: t(apiKeyTranslationKeys.name),
+ placeholder: t(apiKeyTranslationKeys.placeholder),
+ }
+ : null;
const showApiKeyForm =
selectedProvider &&
isApiKeyProviderId(selectedProvider.providerId) &&
@@ -900,7 +1041,7 @@ export const ProviderRuntimeSettingsDialog = ({
apiKeyConfig &&
(selectedProvider?.providerId !== 'codex' || !selectedProvider.connection?.supportsOAuth)
);
- const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider) : null;
+ const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider, t) : null;
const connectionLoading =
selectedProviderLoading ||
connectionSaving ||
@@ -929,14 +1070,15 @@ export const ProviderRuntimeSettingsDialog = ({
const anthropicFastModeDisabledReason =
anthropicFastModeCapability?.reason ??
(anthropicFastModeSupported
- ? 'Fast mode is currently unavailable for this Anthropic runtime.'
- : 'This Anthropic runtime does not expose Fast mode.');
+ ? t('providerRuntime.fastMode.unavailableForRuntime')
+ : t('providerRuntime.fastMode.notExposed'));
const connectionMethodCardsHint = selectedProvider
- ? getConnectionMethodCardsHint(selectedProvider)
+ ? getConnectionMethodCardsHint(selectedProvider, t)
: null;
const codexAccountPanelHint = getCodexAccountPanelHint(
selectedProvider ?? null,
- configuredAuthMode
+ configuredAuthMode,
+ t
);
const codexFastCapability = useMemo(() => {
if (selectedProvider?.providerId !== 'codex') {
@@ -986,7 +1128,7 @@ export const ProviderRuntimeSettingsDialog = ({
const anthropicCompatibleTokenStatus =
selectedCompatibleToken?.maskedValue ??
anthropicCompatibleEndpoint?.tokenSourceLabel ??
- (anthropicCompatibleTokenConfigured ? 'Configured' : null);
+ (anthropicCompatibleTokenConfigured ? t('providerRuntime.status.configured') : null);
const anthropicCompatibleMissingToken =
anthropicCompatibleEndpointEnabled && !anthropicCompatibleTokenConfigured;
@@ -1005,9 +1147,9 @@ export const ProviderRuntimeSettingsDialog = ({
let connectionStatusLabel: string | null = null;
if (selectedProvider) {
if (!hideConnectionMethodMeta && selectedProvider.authenticated) {
- connectionStatusLabel = getProviderUsageLabel(selectedProvider);
+ connectionStatusLabel = getProviderUsageLabel(selectedProvider, t);
} else if (!hideConnectionMethodMeta) {
- connectionStatusLabel = 'Not connected';
+ connectionStatusLabel = t('providerRuntime.usage.notConnected');
}
}
const showSelectedProviderSummary = Boolean(selectedProvider) && !connectionManagedRuntime;
@@ -1029,36 +1171,36 @@ export const ProviderRuntimeSettingsDialog = ({
if (selectedProvider.providerId === 'anthropic') {
switch (pendingConnectionAction) {
case 'api_key':
- return 'Switching to API key...';
+ return t('providerRuntime.progress.switchingApiKey');
case 'oauth':
- return 'Switching to Anthropic subscription...';
+ return t('providerRuntime.progress.switchingAnthropicSubscription');
case 'auto':
- return 'Switching to Auto...';
+ return t('providerRuntime.progress.switchingAuto');
case 'compatible':
- return 'Saving compatible endpoint...';
+ return t('providerRuntime.progress.savingCompatibleEndpoint');
default:
- return 'Applying connection changes...';
+ return t('providerRuntime.progress.applyingConnectionChanges');
}
}
if (selectedProvider.providerId === 'codex') {
switch (pendingConnectionAction) {
case 'chatgpt':
- return 'Switching to ChatGPT account mode...';
+ return t('providerRuntime.progress.switchingChatgpt');
case 'api_key':
- return 'Switching to API key mode...';
+ return t('providerRuntime.progress.switchingApiKeyMode');
case 'auto':
- return 'Switching to Auto...';
+ return t('providerRuntime.progress.switchingAuto');
default:
- return 'Applying connection changes...';
+ return t('providerRuntime.progress.applyingConnectionChanges');
}
}
- return 'Applying connection changes...';
+ return t('providerRuntime.progress.applyingConnectionChanges');
}
- return 'Refreshing provider status...';
- }, [connectionLoading, connectionSaving, pendingConnectionAction, selectedProvider]);
+ return t('providerRuntime.progress.refreshingProviderStatus');
+ }, [connectionLoading, connectionSaving, pendingConnectionAction, selectedProvider, t]);
const handleStartApiKeyEdit = (): void => {
if (!selectedProvider || !isApiKeyProviderId(selectedProvider.providerId) || !apiKeyConfig) {
@@ -1084,7 +1226,7 @@ export const ProviderRuntimeSettingsDialog = ({
}
if (!apiKeyValue.trim()) {
- setApiKeyError('API key is required');
+ setApiKeyError(t('providerRuntime.errors.apiKeyRequired'));
return;
}
@@ -1099,7 +1241,9 @@ export const ProviderRuntimeSettingsDialog = ({
scope: apiKeyScope,
});
} catch (error) {
- setApiKeyError(error instanceof Error ? error.message : 'Failed to save API key');
+ setApiKeyError(
+ error instanceof Error ? error.message : t('providerRuntime.errors.saveApiKey')
+ );
return;
}
@@ -1109,7 +1253,7 @@ export const ProviderRuntimeSettingsDialog = ({
try {
await onRefreshProvider?.(selectedProvider.providerId);
} catch {
- setConnectionError('API key saved, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.apiKeySavedRefreshFailed'));
}
};
@@ -1123,7 +1267,9 @@ export const ProviderRuntimeSettingsDialog = ({
try {
await deleteApiKey(selectedApiKey.id);
} catch (error) {
- setApiKeyError(error instanceof Error ? error.message : 'Failed to delete API key');
+ setApiKeyError(
+ error instanceof Error ? error.message : t('providerRuntime.errors.deleteApiKey')
+ );
return;
}
@@ -1133,7 +1279,7 @@ export const ProviderRuntimeSettingsDialog = ({
try {
await onRefreshProvider?.(selectedProvider.providerId);
} catch {
- setConnectionError('API key deleted, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.apiKeyDeletedRefreshFailed'));
}
};
@@ -1169,13 +1315,15 @@ export const ProviderRuntimeSettingsDialog = ({
updateSucceeded = true;
} catch (error) {
- setConnectionError(error instanceof Error ? error.message : 'Failed to update connection');
+ setConnectionError(
+ error instanceof Error ? error.message : t('providerRuntime.errors.updateConnection')
+ );
} finally {
if (updateSucceeded) {
try {
await onRefreshProvider?.(selectedProvider.providerId);
} catch {
- setConnectionError('Connection updated, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.connectionUpdatedRefreshFailed'));
}
}
@@ -1190,7 +1338,7 @@ export const ProviderRuntimeSettingsDialog = ({
}
const baseUrl = compatibleBaseUrl.trim();
- const validationError = validateAnthropicCompatibleBaseUrl(baseUrl);
+ const validationError = validateAnthropicCompatibleBaseUrl(baseUrl, t);
if (validationError) {
setCompatibleEndpointError(validationError);
setCompatibleEndpointStatus(null);
@@ -1227,19 +1375,19 @@ export const ProviderRuntimeSettingsDialog = ({
setCompatibleTokenValue('');
setCompatibleEndpointStatus(
compatibleTokenValue.trim() || anthropicCompatibleTokenConfigured
- ? 'Endpoint saved'
- : 'Endpoint saved. Auth token is not configured.'
+ ? t('providerRuntime.compatibleEndpoint.status.endpointSaved')
+ : t('providerRuntime.compatibleEndpoint.status.endpointSavedTokenMissing')
);
} catch (error) {
setCompatibleEndpointError(
- error instanceof Error ? error.message : 'Failed to save endpoint'
+ error instanceof Error ? error.message : t('providerRuntime.errors.saveEndpoint')
);
} finally {
if (updateSucceeded) {
try {
await onRefreshProvider?.('anthropic');
} catch {
- setConnectionError('Endpoint saved, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.endpointSavedRefreshFailed'));
}
}
@@ -1271,17 +1419,19 @@ export const ProviderRuntimeSettingsDialog = ({
});
updateSucceeded = true;
setCompatibleTokenValue('');
- setCompatibleEndpointStatus('Endpoint disabled. Saved token was kept.');
+ setCompatibleEndpointStatus(
+ t('providerRuntime.compatibleEndpoint.status.endpointDisabledTokenKept')
+ );
} catch (error) {
setCompatibleEndpointError(
- error instanceof Error ? error.message : 'Failed to disable endpoint'
+ error instanceof Error ? error.message : t('providerRuntime.errors.disableEndpoint')
);
} finally {
if (updateSucceeded) {
try {
await onRefreshProvider?.('anthropic');
} catch {
- setConnectionError('Endpoint disabled, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.endpointDisabledRefreshFailed'));
}
}
@@ -1297,7 +1447,7 @@ export const ProviderRuntimeSettingsDialog = ({
await onRefreshProvider?.('codex');
} catch (error) {
setConnectionError(
- error instanceof Error ? error.message : 'Failed to refresh Codex account'
+ error instanceof Error ? error.message : t('providerRuntime.errors.refreshCodexAccount')
);
}
};
@@ -1341,7 +1491,9 @@ export const ProviderRuntimeSettingsDialog = ({
try {
await onSelectBackend(providerId, backendId);
} catch (error) {
- setRuntimeError(error instanceof Error ? error.message : 'Failed to update runtime backend');
+ setRuntimeError(
+ error instanceof Error ? error.message : t('providerRuntime.errors.updateRuntimeBackend')
+ );
} finally {
setRuntimeSaving(false);
}
@@ -1363,7 +1515,7 @@ export const ProviderRuntimeSettingsDialog = ({
await onRefreshProvider?.('anthropic');
} catch (error) {
setConnectionError(
- error instanceof Error ? error.message : 'Failed to update Anthropic Fast mode'
+ error instanceof Error ? error.message : t('providerRuntime.errors.updateAnthropicFastMode')
);
} finally {
setConnectionSaving(false);
@@ -1374,17 +1526,14 @@ export const ProviderRuntimeSettingsDialog = ({
- Provider Settings
-
- Manage how each provider connects and, when supported, which backend the multimodel
- runtime should use.
-
+ {t('providerRuntime.title')}
+ {t('providerRuntime.description')}
- Provider
+ {t('providerRuntime.provider')}
- {getProviderUsageLabel(selectedProvider)}
+ {getProviderUsageLabel(selectedProvider, t)}
{managedRuntimeSummary && !hideConnectionMethodMeta ? (
@@ -1444,7 +1593,7 @@ export const ProviderRuntimeSettingsDialog = ({
) : runtimeSummary ? (
- Runtime: {runtimeSummary}
+ {t('providerRuntime.runtimeSummary', { runtime: runtimeSummary })}
) : null}
@@ -1492,10 +1641,10 @@ export const ProviderRuntimeSettingsDialog = ({
- Connection
+ {t('providerRuntime.connection.title')}
- {getConnectionDescription(selectedProvider)}
+ {getConnectionDescription(selectedProvider, t)}
{connectionProgressMessage ? (
) : null}
{showConnectionMethodCards ? (
-
Connection method
+
{t('providerRuntime.connection.method')}
{selectedProvider.providerId === 'codex'
- ? 'Connection method'
- : 'Authentication method'}
+ ? t('providerRuntime.connection.method')
+ : t('providerRuntime.connection.authenticationMethod')}
{formatProviderAuthModeLabelForProvider(
selectedProvider.providerId,
- authMode
+ authMode,
+ t
)}
))}
- {getAuthModeDescription(selectedProvider.providerId, configuredAuthMode)}
+ {getAuthModeDescription(selectedProvider.providerId, configuredAuthMode, t)}
) : null}
@@ -1581,10 +1731,10 @@ export const ProviderRuntimeSettingsDialog = ({
- Local / compatible endpoint
+ {t('providerRuntime.compatibleEndpoint.title')}
- Use an Anthropic-compatible local runtime endpoint.
+ {t('providerRuntime.compatibleEndpoint.description')}
- {anthropicCompatibleEndpointEnabled ? 'Enabled' : 'Off'}
+ {anthropicCompatibleEndpointEnabled
+ ? t('providerRuntime.status.enabled')
+ : t('providerRuntime.status.off')}
- Base URL
+ {t('providerRuntime.compatibleEndpoint.baseUrl')}
@@ -1623,7 +1775,7 @@ export const ProviderRuntimeSettingsDialog = ({
) : null}
@@ -1718,7 +1874,7 @@ export const ProviderRuntimeSettingsDialog = ({
disabled={connectionBusy}
onClick={() => void handleDisableAnthropicCompatibleEndpoint()}
>
- Disable
+ {t('providerRuntime.actions.disable')}
) : null}
)}
- Save endpoint
+ {t('providerRuntime.actions.saveEndpoint')}
@@ -1747,11 +1903,13 @@ export const ProviderRuntimeSettingsDialog = ({
backgroundColor: 'rgba(255, 255, 255, 0.05)',
}}
>
- Mode:{' '}
- {formatProviderAuthModeLabelForProvider(
- selectedProvider.providerId,
- configuredAuthMode
- )}
+ {t('providerRuntime.connection.mode', {
+ mode: formatProviderAuthModeLabelForProvider(
+ selectedProvider.providerId,
+ configuredAuthMode,
+ t
+ ),
+ })}
) : null}
{connectionStatusLabel ? (
@@ -1782,17 +1940,16 @@ export const ProviderRuntimeSettingsDialog = ({
style={{ borderColor: 'var(--color-border-subtle)' }}
>
- Fast mode default
+ {t('providerRuntime.fastMode.title')}
- Apply Claude Code Fast mode by default for new Anthropic team launches when
- the resolved model and runtime allow it.
+ {t('providerRuntime.fastMode.description')}
{anthropicFastModeSupported ? (
{[
- { enabled: false, label: 'Default Off' },
- { enabled: true, label: 'Prefer Fast' },
+ { enabled: false, label: t('providerRuntime.fastMode.defaultOff') },
+ { enabled: true, label: t('providerRuntime.fastMode.preferFast') },
].map((option) => (
{anthropicFastModeSupported && anthropicFastModeAvailable
? anthropicFastModeEnabled
- ? 'New Anthropic launches will request Fast mode by default when the resolved model supports it.'
- : 'New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.'
+ ? t('providerRuntime.fastMode.enabledHint')
+ : t('providerRuntime.fastMode.disabledHint')
: anthropicFastModeDisabledReason}
@@ -1830,11 +1987,10 @@ export const ProviderRuntimeSettingsDialog = ({
- ChatGPT account
+ {t('providerRuntime.codex.account.title')}
- Manage the local Codex app-server account session that powers
- subscription-backed native launches.
+ {t('providerRuntime.codex.account.description')}
@@ -1844,7 +2000,7 @@ export const ProviderRuntimeSettingsDialog = ({
disabled={codexActionBusy}
onClick={() => void handleCodexAccountRefresh()}
>
- Refresh
+ {t('providerRuntime.actions.refresh')}
{showCodexRuntimeInstallAction ? (
void onInstallCodexRuntime?.()}
>
@@ -1863,7 +2019,7 @@ export const ProviderRuntimeSettingsDialog = ({
) : (
)}
- {getCodexRuntimeInstallLabel(codexRuntimeStatus)}
+ {getCodexRuntimeInstallLabel(codexRuntimeStatus, t)}
) : null}
{codexLoginPending ? (
@@ -1882,7 +2038,7 @@ export const ProviderRuntimeSettingsDialog = ({
onClick={() => void api.openExternal(codexLoginAuthUrl)}
>
- Open login
+ {t('providerRuntime.actions.openLogin')}
) : null}
void handleCodexCancelLogin()}
>
- Cancel login
+ {t('providerRuntime.actions.cancelLogin')}
>
) : codexHasActiveChatgptSession ? (
@@ -1901,7 +2057,7 @@ export const ProviderRuntimeSettingsDialog = ({
disabled={codexActionBusy}
onClick={() => void handleCodexLogout()}
>
- Disconnect account
+ {t('providerRuntime.actions.disconnectAccount')}
) : (
<>
@@ -1918,7 +2074,7 @@ export const ProviderRuntimeSettingsDialog = ({
onClick={() => void handleCodexStartLogin('device_code')}
>
- Use code
+ {t('providerRuntime.actions.useCode')}
void handleCodexStartLogin('browser')}
>
- {codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'}
+ {codexNeedsReconnect
+ ? t('providerRuntime.actions.generateLink')
+ : t('providerRuntime.actions.connectChatGpt')}
>
)}
@@ -1951,12 +2109,12 @@ export const ProviderRuntimeSettingsDialog = ({
}}
>
{codexHasActiveChatgptSession
- ? 'Connected'
+ ? t('providerRuntime.codex.account.connected')
: codexNeedsReconnect
- ? 'Reconnect required'
+ ? t('providerRuntime.codex.account.reconnectRequired')
: codexLoginPending
- ? 'Login in progress'
- : 'Not connected'}
+ ? t('providerRuntime.codex.account.loginInProgress')
+ : t('providerRuntime.usage.notConnected')}
{codexConnection ? (
- App-server: {codexConnection.appServerState}
+ {t('providerRuntime.codex.account.appServer', {
+ state: codexConnection.appServerState,
+ })}
) : null}
{codexConnection?.managedAccount?.planType ? (
- Plan: {codexConnection.managedAccount.planType}
+ {t('providerRuntime.codex.account.plan', {
+ plan: codexConnection.managedAccount.planType,
+ })}
) : null}
{codexConnection?.managedAccount?.email ? (
@@ -2031,27 +2193,30 @@ export const ProviderRuntimeSettingsDialog = ({
color: 'var(--color-text-secondary)',
}}
>
- These percentages show used quota, not remaining quota.{' '}
- {formatCodexUsageExplanation(
+ {t('providerRuntime.codex.rateLimits.usedQuotaNote')}{' '}
+ {formatLocalizedCodexUsageExplanation(
codexConnection.rateLimits.primary?.usedPercent,
- codexConnection.rateLimits.primary?.windowDurationMins
+ codexConnection.rateLimits.primary?.windowDurationMins,
+ t
)}
{codexConnection.rateLimits.secondary
- ? ` Weekly limits are shown separately in the ${
- formatCodexWindowDurationLong(
- codexConnection.rateLimits.secondary.windowDurationMins
- ) ?? 'secondary'
- } window.`
+ ? t('providerRuntime.codex.rateLimits.secondaryWindowNote', {
+ window:
+ formatCodexWindowDurationLong(
+ codexConnection.rateLimits.secondary.windowDurationMins
+ ) ?? t('providerRuntime.codex.rateLimits.secondaryFallback'),
+ })
: ''}
@@ -2075,14 +2242,15 @@ export const ProviderRuntimeSettingsDialog = ({
@@ -2115,25 +2285,25 @@ export const ProviderRuntimeSettingsDialog = ({
className="text-sm font-medium"
style={{ color: 'var(--color-text)' }}
>
- Weekly window
+ {t('providerRuntime.codex.rateLimits.weeklyWindow')}
- Weekly used (1w)
+ {t('providerRuntime.codex.rateLimits.weeklyUsedOneWeek')}
- Not reported
+ {t('providerRuntime.codex.rateLimits.notReported')}
- Codex did not return a secondary window for this account snapshot.
+ {t('providerRuntime.codex.rateLimits.noSecondaryWindow')}
)}
@@ -2152,7 +2322,7 @@ export const ProviderRuntimeSettingsDialog = ({
className="text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
- Credits
+ {t('providerRuntime.codex.rateLimits.credits')}
- Credits are shown separately from window-based subscription usage
- and may be unavailable for plan-backed ChatGPT sessions.
+ {t('providerRuntime.codex.rateLimits.creditsDescription')}
@@ -2202,17 +2371,19 @@ export const ProviderRuntimeSettingsDialog = ({
className="text-sm font-medium"
style={{ color: 'var(--color-text)' }}
>
- {apiKeyConfig.title}
+ {apiKeyDisplayConfig?.title ?? apiKeyConfig.title}
- {apiKeyConfig.description}
+ {apiKeyDisplayConfig?.description ?? apiKeyConfig.description}
{!showApiKeyForm ? (
- {selectedApiKey ? 'Replace key' : 'Set API key'}
+ {selectedApiKey
+ ? t('providerRuntime.actions.replaceKey')
+ : t('providerRuntime.actions.setApiKey')}
) : null}
@@ -2232,8 +2403,8 @@ export const ProviderRuntimeSettingsDialog = ({
}}
>
{selectedProvider.connection?.apiKeyConfigured || selectedApiKey
- ? 'Configured'
- : 'Not configured'}
+ ? t('providerRuntime.status.configured')
+ : t('providerRuntime.status.notConfigured')}
{selectedApiKey ? (
@@ -2246,7 +2417,9 @@ export const ProviderRuntimeSettingsDialog = ({
) : null}
{apiKeyStorageStatus && selectedApiKey ? (
- Stored in {apiKeyStorageStatus.backend}
+ {t('providerRuntime.apiKey.storedIn', {
+ backend: apiKeyStorageStatus.backend,
+ })}
) : null}
@@ -2261,7 +2434,7 @@ export const ProviderRuntimeSettingsDialog = ({
htmlFor={`${selectedProvider.providerId}-api-key`}
className="text-xs"
>
- {apiKeyConfig.name}
+ {apiKeyDisplayConfig?.name ?? apiKeyConfig.name}
setApiKeyValue(e.target.value)}
- placeholder={apiKeyConfig.placeholder}
+ placeholder={
+ apiKeyDisplayConfig?.placeholder ?? apiKeyConfig.placeholder
+ }
className="h-9 text-sm"
autoFocus
/>
- Scope
+ {t('providerRuntime.apiKey.scope')}
setApiKeyScope(value as 'user' | 'project')}
@@ -2285,8 +2460,12 @@ export const ProviderRuntimeSettingsDialog = ({
- User
- Project
+
+ {t('providerRuntime.apiKey.userScope')}
+
+
+ {t('providerRuntime.apiKey.projectScope')}
+
@@ -2314,7 +2493,7 @@ export const ProviderRuntimeSettingsDialog = ({
disabled={apiKeySaving}
>
- Delete
+ {t('providerRuntime.actions.delete')}
) : (
@@ -2326,7 +2505,7 @@ export const ProviderRuntimeSettingsDialog = ({
size="sm"
onClick={handleCancelApiKeyEdit}
>
- Cancel
+ {t('providerRuntime.actions.cancel')}
{apiKeySaving
- ? 'Saving...'
+ ? t('providerRuntime.actions.saving')
: selectedApiKey
- ? 'Update key'
- : 'Save key'}
+ ? t('providerRuntime.actions.updateKey')
+ : t('providerRuntime.actions.saveKey')}
@@ -2377,7 +2556,7 @@ export const ProviderRuntimeSettingsDialog = ({
{apiKeysLoading && !selectedApiKey ? (
- Loading stored credentials...
+ {t('providerRuntime.apiKey.loadingStoredCredentials')}
) : null}
@@ -2394,10 +2573,10 @@ export const ProviderRuntimeSettingsDialog = ({
>
- Runtime
+ {t('providerRuntime.runtime.title')}
- {getRuntimeDescription(selectedProvider)}
+ {getRuntimeDescription(selectedProvider, t)}
@@ -2415,7 +2594,7 @@ export const ProviderRuntimeSettingsDialog = ({
style={{ color: 'var(--color-text-secondary)' }}
>
- Updating runtime...
+ {t('providerRuntime.runtime.updating')}
) : null}
diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts
index da56e2db..d1b7c22d 100644
--- a/src/renderer/components/runtime/providerConnectionUi.ts
+++ b/src/renderer/components/runtime/providerConnectionUi.ts
@@ -2,6 +2,24 @@ import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller
import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types';
+type ProviderConnectionTranslator = unknown;
+
+function translateProviderConnection(
+ t: ProviderConnectionTranslator | undefined,
+ key: string,
+ fallback: string,
+ options?: Record
+): string {
+ if (!t) {
+ return fallback;
+ }
+
+ return (t as (translationKey: string, options?: Record) => string)(key, {
+ defaultValue: fallback,
+ ...options,
+ });
+}
+
const CODEX_NATIVE_LABEL = 'Codex native';
const ANTHROPIC_SUBSCRIPTION_LABEL = 'Anthropic subscription';
@@ -12,55 +30,114 @@ const AUTH_MODE_LABELS: Record = {
api_key: 'API key',
};
-export function formatProviderAuthModeLabel(authMode: CliProviderAuthMode | null): string | null {
- return authMode ? AUTH_MODE_LABELS[authMode] : null;
+const AUTH_MODE_LABEL_KEYS: Record = {
+ auto: 'providerRuntime.connectionUi.authMode.auto',
+ oauth: 'providerRuntime.connectionUi.authMode.oauth',
+ chatgpt: 'providerRuntime.connectionUi.authMode.chatgpt',
+ api_key: 'providerRuntime.connectionUi.authMode.apiKey',
+};
+
+export function formatProviderAuthModeLabel(
+ authMode: CliProviderAuthMode | null,
+ t?: ProviderConnectionTranslator
+): string | null {
+ return authMode
+ ? translateProviderConnection(t, AUTH_MODE_LABEL_KEYS[authMode], AUTH_MODE_LABELS[authMode])
+ : null;
}
export function formatProviderAuthModeLabelForProvider(
providerId: CliProviderStatus['providerId'],
- authMode: CliProviderAuthMode | null
+ authMode: CliProviderAuthMode | null,
+ t?: ProviderConnectionTranslator
): string | null {
if (!authMode) {
return null;
}
if (providerId === 'anthropic' && authMode === 'oauth') {
- return ANTHROPIC_SUBSCRIPTION_LABEL;
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMode.anthropicSubscription',
+ ANTHROPIC_SUBSCRIPTION_LABEL
+ );
}
- return formatProviderAuthModeLabel(authMode);
+ return formatProviderAuthModeLabel(authMode, t);
}
-export function formatProviderAuthMethodLabel(authMethod: string | null): string {
+export function formatProviderAuthMethodLabel(
+ authMethod: string | null,
+ t?: ProviderConnectionTranslator
+): string {
switch (authMethod) {
case 'api_key':
- return 'API key';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.apiKey',
+ 'API key'
+ );
case 'api_key_helper':
- return 'API key helper';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.apiKeyHelper',
+ 'API key helper'
+ );
case 'oauth_token':
- return 'OAuth';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.oauth',
+ 'OAuth'
+ );
case 'claude.ai':
- return 'Claude subscription';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.claudeSubscription',
+ 'Claude subscription'
+ );
case 'cli_oauth_personal':
- return 'Gemini CLI';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.geminiCli',
+ 'Gemini CLI'
+ );
case 'gemini_adc_authorized_user':
- return 'Google account';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.googleAccount',
+ 'Google account'
+ );
case 'gemini_adc_service_account':
- return 'service account';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.serviceAccount',
+ 'service account'
+ );
default:
- return authMethod ? authMethod.replaceAll('_', ' ') : 'Not connected';
+ return authMethod
+ ? authMethod.replaceAll('_', ' ')
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.notConnected',
+ 'Not connected'
+ );
}
}
export function formatProviderAuthMethodLabelForProvider(
providerId: CliProviderStatus['providerId'],
- authMethod: string | null
+ authMethod: string | null,
+ t?: ProviderConnectionTranslator
): string {
if (providerId === 'anthropic' && (authMethod === 'oauth_token' || authMethod === 'claude.ai')) {
- return ANTHROPIC_SUBSCRIPTION_LABEL;
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMode.anthropicSubscription',
+ ANTHROPIC_SUBSCRIPTION_LABEL
+ );
}
- return formatProviderAuthMethodLabel(authMethod);
+ return formatProviderAuthMethodLabel(authMethod, t);
}
function isCodexNativeLane(provider: CliProviderStatus): boolean {
@@ -178,20 +255,38 @@ export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus):
return provider.providerId === 'codex';
}
-function getCodexCurrentRuntimeLabel(): string {
- return CODEX_NATIVE_LABEL;
+function getCodexCurrentRuntimeLabel(t?: ProviderConnectionTranslator): string {
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.runtime.codexNative',
+ CODEX_NATIVE_LABEL
+ );
}
-function getCodexApiKeyAvailabilitySummary(provider: CliProviderStatus): string | null {
+function getCodexApiKeyAvailabilitySummary(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (provider.providerId !== 'codex' || !provider.connection?.apiKeyConfigured) {
return null;
}
if (provider.connection.apiKeySource === 'stored') {
- return 'Saved API key available in Manage';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.savedApiKeyAvailable',
+ 'Saved API key available in Manage'
+ );
}
- return provider.connection.apiKeySourceLabel ?? 'API key is configured';
+ return (
+ provider.connection.apiKeySourceLabel ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfigured',
+ 'API key is configured'
+ )
+ );
}
function isAnthropicApiKeyModeReady(provider: CliProviderStatus): boolean {
@@ -213,7 +308,10 @@ function isAnthropicApiKeyModeMissingCredential(provider: CliProviderStatus): bo
);
}
-function getCodexMissingManagedAccountStatus(provider: CliProviderStatus): string | null {
+function getCodexMissingManagedAccountStatus(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (provider.providerId !== 'codex') {
return null;
}
@@ -229,43 +327,95 @@ function getCodexMissingManagedAccountStatus(provider: CliProviderStatus): strin
if (codexConnection.requiresOpenaiAuth) {
if (codexConnection.localActiveChatgptAccountPresent) {
- return 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexLocalAccountNeedsReconnect',
+ 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.'
+ );
}
return codexConnection.localAccountArtifactsPresent
- ? 'Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.'
- : 'Codex CLI reports no active ChatGPT login';
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexNoActiveManagedSession',
+ 'Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexNoActiveChatGptLogin',
+ 'Codex CLI reports no active ChatGPT login'
+ );
}
return (
codexConnection.launchIssueMessage ??
- 'Connect a ChatGPT account to use your Codex subscription.'
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.connectChatGptForSubscription',
+ 'Connect a ChatGPT account to use your Codex subscription.'
+ )
);
}
-export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): string | null {
+export function getProviderCurrentRuntimeSummary(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (provider.providerId !== 'codex' || !isConnectionManagedRuntimeProvider(provider)) {
return null;
}
- const prefix = provider.authenticated ? 'Current runtime' : 'Selected runtime';
- return `${prefix}: ${getCodexCurrentRuntimeLabel()}`;
+ const prefix = provider.authenticated
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.runtime.currentRuntime',
+ 'Current runtime'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.runtime.selectedRuntime',
+ 'Selected runtime'
+ );
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.runtime.summary',
+ '{{prefix}}: {{runtime}}',
+ {
+ prefix,
+ runtime: getCodexCurrentRuntimeLabel(t),
+ }
+ );
}
-export function formatProviderStatusText(provider: CliProviderStatus): string {
+export function formatProviderStatusText(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string {
if (isProviderInventoryOnlyFallback(provider)) {
- return 'Checking...';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.checking',
+ 'Checking...'
+ );
}
const selectedBackendOption = getSelectedRuntimeBackendOption(provider);
if (provider.providerId === 'codex') {
if (provider.connection?.codex?.login.status === 'starting') {
- return 'Starting ChatGPT login...';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.startingChatGptLogin',
+ 'Starting ChatGPT login...'
+ );
}
if (provider.connection?.codex?.login.status === 'pending') {
- return 'Waiting for ChatGPT account login...';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.waitingForChatGptLogin',
+ 'Waiting for ChatGPT account login...'
+ );
}
if (
@@ -282,21 +432,33 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
) {
return (
provider.connection.codex.launchIssueMessage ??
- 'ChatGPT account detected - account verification is currently degraded.'
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.chatGptVerificationDegraded',
+ 'ChatGPT account detected - account verification is currently degraded.'
+ )
);
}
if (provider.connection?.codex?.launchAllowed) {
if (provider.connection.codex.effectiveAuthMode === 'chatgpt') {
- return 'ChatGPT account ready';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.chatGptAccountReady',
+ 'ChatGPT account ready'
+ );
}
if (provider.connection.codex.effectiveAuthMode === 'api_key') {
- return 'API key ready';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.apiKeyReady',
+ 'API key ready'
+ );
}
}
- const missingManagedAccountStatus = getCodexMissingManagedAccountStatus(provider);
+ const missingManagedAccountStatus = getCodexMissingManagedAccountStatus(provider, t);
if (missingManagedAccountStatus) {
return missingManagedAccountStatus;
}
@@ -309,7 +471,18 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
return selectedBackendOption.statusMessage;
}
return (
- provider.statusMessage ?? (provider.authenticated ? 'Codex native ready' : 'Not connected')
+ provider.statusMessage ??
+ (provider.authenticated
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexNativeReady',
+ 'Codex native ready'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.notConnected',
+ 'Not connected'
+ ))
);
}
@@ -319,7 +492,13 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
selectedBackendOption.state !== 'ready'
) {
return (
- selectedBackendOption.statusMessage ?? provider.statusMessage ?? 'Codex native unavailable'
+ selectedBackendOption.statusMessage ??
+ provider.statusMessage ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexNativeUnavailable',
+ 'Codex native unavailable'
+ )
);
}
@@ -332,11 +511,22 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
}
if (!provider.supported) {
- return provider.statusMessage ?? 'Unavailable in current runtime';
+ return (
+ provider.statusMessage ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.unavailableInCurrentRuntime',
+ 'Unavailable in current runtime'
+ )
+ );
}
if (isAnthropicApiKeyModeReady(provider)) {
- return 'Connected via API key';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.connectedViaApiKey',
+ 'Connected via API key'
+ );
}
if (
@@ -348,28 +538,61 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
if (statusMessage && !/^connected\b/i.test(statusMessage)) {
return statusMessage;
}
- return 'API key configured, but not verified yet';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.apiKeyConfiguredNotVerified',
+ 'API key configured, but not verified yet'
+ );
}
if (isAnthropicApiKeyModeMissingCredential(provider)) {
- return 'API key mode selected, but no API key is configured';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.apiKeyModeMissingCredential',
+ 'API key mode selected, but no API key is configured'
+ );
}
if (provider.authenticated) {
- return `Connected via ${formatProviderAuthMethodLabelForProvider(
- provider.providerId,
- provider.authMethod
- )}`;
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.connectedVia',
+ 'Connected via {{method}}',
+ {
+ method: formatProviderAuthMethodLabelForProvider(
+ provider.providerId,
+ provider.authMethod,
+ t
+ ),
+ }
+ );
}
if (provider.verificationState === 'offline') {
- return provider.statusMessage ?? 'Unable to verify';
+ return (
+ provider.statusMessage ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.unableToVerify',
+ 'Unable to verify'
+ )
+ );
}
- return provider.statusMessage ?? 'Not connected';
+ return (
+ provider.statusMessage ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.notConnected',
+ 'Not connected'
+ )
+ );
}
-export function getProviderConnectionModeSummary(provider: CliProviderStatus): string | null {
+export function getProviderConnectionModeSummary(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (provider.providerId !== 'anthropic' && provider.providerId !== 'codex') {
return null;
}
@@ -390,24 +613,45 @@ export function getProviderConnectionModeSummary(provider: CliProviderStatus): s
const authModeLabel = formatProviderAuthModeLabelForProvider(
provider.providerId,
- provider.connection?.configuredAuthMode ?? null
+ provider.connection?.configuredAuthMode ?? null,
+ t
);
if (!authModeLabel) {
return null;
}
return provider.providerId === 'codex'
- ? `Selected auth: ${authModeLabel}`
- : `Preferred auth: ${authModeLabel}`;
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.mode.selectedAuth',
+ 'Selected auth: {{authMode}}',
+ { authMode: authModeLabel }
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.mode.preferredAuth',
+ 'Preferred auth: {{authMode}}',
+ { authMode: authModeLabel }
+ );
}
-export function getProviderCredentialSummary(provider: CliProviderStatus): string | null {
+export function getProviderCredentialSummary(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (!provider.connection?.apiKeyConfigured) {
return null;
}
if (isAnthropicApiKeyModeReady(provider)) {
- return provider.connection?.apiKeySourceLabel ?? 'API key is configured';
+ return (
+ provider.connection?.apiKeySourceLabel ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfigured',
+ 'API key is configured'
+ )
+ );
}
if (
@@ -415,7 +659,11 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
provider.connection.apiKeySource === 'stored' &&
provider.connection.configuredAuthMode === 'auto'
) {
- return 'Saved API key available in Manage';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.savedApiKeyAvailable',
+ 'Saved API key available in Manage'
+ );
}
if (
@@ -424,18 +672,36 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
provider.authMethod !== 'api_key_helper'
) {
return provider.connection.apiKeySource === 'stored'
- ? 'API key also configured in Manage'
- : (provider.connection.apiKeySourceLabel ?? 'API key is configured');
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyAlsoConfigured',
+ 'API key also configured in Manage'
+ )
+ : (provider.connection.apiKeySourceLabel ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfigured',
+ 'API key is configured'
+ ));
}
if (provider.authMethod !== 'api_key' && provider.providerId === 'gemini') {
return provider.connection.apiKeySource === 'stored'
- ? 'API key is configured in Manage'
- : (provider.connection.apiKeySourceLabel ?? 'API key is configured');
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfiguredInManage',
+ 'API key is configured in Manage'
+ )
+ : (provider.connection.apiKeySourceLabel ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfigured',
+ 'API key is configured'
+ ));
}
if (provider.providerId === 'codex') {
- const apiKeyAvailabilitySummary = getCodexApiKeyAvailabilitySummary(provider);
+ const apiKeyAvailabilitySummary = getCodexApiKeyAvailabilitySummary(provider, t);
if (!apiKeyAvailabilitySummary) {
return null;
}
@@ -445,18 +711,41 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
provider.connection.codex?.effectiveAuthMode === 'chatgpt'
) {
return provider.connection.apiKeySource === 'stored'
- ? 'API key also available in Manage as fallback'
- : `${apiKeyAvailabilitySummary} - available as fallback`;
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyFallbackInManage',
+ 'API key also available in Manage as fallback'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.availableAsFallback',
+ '{{summary}} - available as fallback',
+ { summary: apiKeyAvailabilitySummary }
+ );
}
if (provider.connection.configuredAuthMode === 'chatgpt') {
return provider.connection.apiKeySource === 'stored'
- ? 'Saved API key available in Manage if you switch to API key mode'
- : `${apiKeyAvailabilitySummary} - available if you switch to API key mode`;
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.savedApiKeyAvailableIfSwitch',
+ 'Saved API key available in Manage if you switch to API key mode'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.availableIfSwitch',
+ '{{summary}} - available if you switch to API key mode',
+ { summary: apiKeyAvailabilitySummary }
+ );
}
if (provider.connection.configuredAuthMode === 'auto') {
- return `${apiKeyAvailabilitySummary} - Auto will use this until ChatGPT is connected`;
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.autoWillUseUntilChatGpt',
+ '{{summary}} - Auto will use this until ChatGPT is connected',
+ { summary: apiKeyAvailabilitySummary }
+ );
}
return apiKeyAvailabilitySummary;
@@ -470,6 +759,24 @@ export function getProviderDisconnectAction(provider: CliProviderStatus): {
confirmLabel: string;
title: string;
message: string;
+} | null;
+export function getProviderDisconnectAction(
+ provider: CliProviderStatus,
+ t: ProviderConnectionTranslator
+): {
+ label: string;
+ confirmLabel: string;
+ title: string;
+ message: string;
+} | null;
+export function getProviderDisconnectAction(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): {
+ label: string;
+ confirmLabel: string;
+ title: string;
+ message: string;
} | null {
if (!provider.authenticated) {
return null;
@@ -481,42 +788,92 @@ export function getProviderDisconnectAction(provider: CliProviderStatus): {
}
return {
- label: 'Disconnect',
- confirmLabel: 'Disconnect',
- title: 'Disconnect Anthropic subscription?',
+ label: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.disconnect',
+ 'Disconnect'
+ ),
+ confirmLabel: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.disconnect',
+ 'Disconnect'
+ ),
+ title: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.anthropicTitle',
+ 'Disconnect Anthropic subscription?'
+ ),
message: provider.connection?.apiKeyConfigured
- ? 'This removes the local Anthropic subscription session from the Claude CLI runtime. Saved API keys in Manage stay available.'
- : 'This removes the local Anthropic subscription session from the Claude CLI runtime.',
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.anthropicWithApiKey',
+ 'This removes the local Anthropic subscription session from the Claude CLI runtime. Saved API keys in Manage stay available.'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.anthropic',
+ 'This removes the local Anthropic subscription session from the Claude CLI runtime.'
+ ),
};
}
if (provider.providerId === 'gemini' && provider.authMethod === 'cli_oauth_personal') {
return {
- label: 'Disconnect',
- confirmLabel: 'Disconnect',
- title: 'Disconnect Gemini CLI?',
- message:
- 'This clears the local Gemini CLI session metadata. External ADC credentials and saved API keys are not removed.',
+ label: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.disconnect',
+ 'Disconnect'
+ ),
+ confirmLabel: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.disconnect',
+ 'Disconnect'
+ ),
+ title: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.geminiTitle',
+ 'Disconnect Gemini CLI?'
+ ),
+ message: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.gemini',
+ 'This clears the local Gemini CLI session metadata. External ADC credentials and saved API keys are not removed.'
+ ),
};
}
return null;
}
-export function getProviderConnectLabel(provider: CliProviderStatus): string {
+export function getProviderConnectLabel(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string {
if (provider.providerId === 'anthropic') {
- return 'Connect Anthropic';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.connectAnthropic',
+ 'Connect Anthropic'
+ );
}
if (provider.providerId === 'codex') {
- return 'Connect ChatGPT';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.connectChatGpt',
+ 'Connect ChatGPT'
+ );
}
if (provider.providerId === 'gemini') {
- return 'Open Login';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.openLogin',
+ 'Open Login'
+ );
}
- return 'Connect';
+ return translateProviderConnection(t, 'providerRuntime.connectionUi.actions.connect', 'Connect');
}
export function shouldShowProviderConnectAction(provider: CliProviderStatus): boolean {
diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx
index c8a2b2f0..99860842 100644
--- a/src/renderer/components/schedules/SchedulesView.tsx
+++ b/src/renderer/components/schedules/SchedulesView.tsx
@@ -1,5 +1,6 @@
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
@@ -40,11 +41,11 @@ const LaunchTeamDialog = lazy(() =>
// Constants
// =============================================================================
-const STATUS_OPTIONS: { value: ScheduleStatus | 'all'; label: string }[] = [
- { value: 'all', label: 'All' },
- { value: 'active', label: 'Active' },
- { value: 'paused', label: 'Paused' },
- { value: 'disabled', label: 'Disabled' },
+const STATUS_OPTIONS: { value: ScheduleStatus | 'all' }[] = [
+ { value: 'all' },
+ { value: 'active' },
+ { value: 'paused' },
+ { value: 'disabled' },
];
// =============================================================================
@@ -72,6 +73,7 @@ const ScheduleListItem = ({
onTeamClick,
teamColor,
}: ScheduleListItemProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const [selectedRun, setSelectedRun] = useState(null);
const runs = useStore(useShallow((s) => s.scheduleRuns[schedule.id] ?? []));
@@ -130,7 +132,7 @@ const ScheduleListItem = ({
- Next: {formatNextRun(schedule.nextRunAt)}
+ {t('schedules.item.nextRun', { value: formatNextRun(schedule.nextRunAt) })}
{schedule.nextRunAt ? (
@@ -159,7 +161,7 @@ const ScheduleListItem = ({
- Run now
+ {t('schedules.actions.runNow')}
@@ -175,7 +177,7 @@ const ScheduleListItem = ({
onClick={() => onEdit(schedule)}
>
- Edit
+ {t('schedules.actions.edit')}
{schedule.status === 'active' ? (
onPause(schedule.id)}
>
- Pause
+ {t('schedules.actions.pause')}
) : (
onResume(schedule.id)}
>
- Resume
+ {t('schedules.actions.resume')}
)}
onDelete(schedule.id)}
>
- Delete
+ {t('schedules.actions.delete')}
@@ -214,11 +216,11 @@ const ScheduleListItem = ({
{runsLoading ? (
- Loading run history...
+ {t('schedules.item.loadingRunHistory')}
) : runs.length === 0 ? (
- No runs yet
+ {t('schedules.item.noRunsYet')}
) : (
@@ -246,6 +248,7 @@ const ScheduleListItem = ({
// =============================================================================
export const SchedulesView = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
schedules,
schedulesLoading,
@@ -392,6 +395,22 @@ export const SchedulesView = (): React.JSX.Element => {
},
[openTeamTab]
);
+ const getStatusLabel = useCallback(
+ (status: ScheduleStatus | 'all'): string => {
+ switch (status) {
+ case 'active':
+ return t('schedules.status.active');
+ case 'paused':
+ return t('schedules.status.paused');
+ case 'disabled':
+ return t('schedules.status.disabled');
+ case 'all':
+ default:
+ return t('schedules.status.all');
+ }
+ },
+ [t]
+ );
return (
@@ -400,7 +419,9 @@ export const SchedulesView = (): React.JSX.Element => {
-
Schedules
+
+ {t('schedules.title')}
+
{schedules.length > 0 && (
{schedules.length}
@@ -409,7 +430,7 @@ export const SchedulesView = (): React.JSX.Element => {
- Add Schedule
+ {t('schedules.actions.addSchedule')}
@@ -420,7 +441,7 @@ export const SchedulesView = (): React.JSX.Element => {
setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
@@ -440,7 +461,7 @@ export const SchedulesView = (): React.JSX.Element => {
}`}
onClick={() => setStatusFilter(opt.value)}
>
- {opt.label}
+ {getStatusLabel(opt.value)}
{statusCounts[opt.value] > 0 && (
{statusCounts[opt.value]}
)}
@@ -463,7 +484,7 @@ export const SchedulesView = (): React.JSX.Element => {
{teamFilter}
>
) : (
- 'All teams'
+ t('schedules.filters.allTeams')
)}
@@ -477,7 +498,7 @@ export const SchedulesView = (): React.JSX.Element => {
} hover:bg-[var(--color-surface-raised)]`}
onClick={() => setTeamFilter(null)}
>
- All teams
+ {t('schedules.filters.allTeams')}
{teamNames.map((name) => (
{
{schedulesLoading && schedules.length === 0 ? (
- Loading schedules...
+ {t('schedules.loading')}
) : schedules.length === 0 ? (
/* Global empty state */
@@ -516,16 +537,15 @@ export const SchedulesView = (): React.JSX.Element => {
- No scheduled tasks
+ {t('schedules.empty.title')}
- Create a schedule on any team to automate Claude task execution with cron
- expressions. Schedules from all teams will appear here.
+ {t('schedules.empty.description')}
- Create Schedule
+ {t('schedules.actions.createSchedule')}
) : filteredSchedules.length === 0 ? (
@@ -533,7 +553,7 @@ export const SchedulesView = (): React.JSX.Element => {
- No schedules match the current filters
+ {t('schedules.empty.noMatches')}
{
setTeamFilter(null);
}}
>
- Clear filters
+ {t('schedules.actions.clearFilters')}
) : (
diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx
index 91adbb1b..cf44e50e 100644
--- a/src/renderer/components/search/CommandPalette.tsx
+++ b/src/renderer/components/search/CommandPalette.tsx
@@ -9,6 +9,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { formatModifierShortcut } from '@renderer/utils/keyboardUtils';
@@ -52,9 +53,10 @@ const ProjectResultItemInner = ({
isSelected,
onClick,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const lastActivity = repo.mostRecentSession
? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true })
- : 'No recent activity';
+ : t('commandPalette.noRecentActivity');
return (
- {repo.totalSessions} sessions
+ {t('commandPalette.sessionsCount', { count: repo.totalSessions })}
·
{lastActivity}
@@ -155,6 +157,7 @@ const SessionResultItem = React.memo(SessionResultItemInner);
// =============================================================================
export const CommandPalette = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const {
commandPaletteOpen,
closeCommandPalette,
@@ -454,13 +457,17 @@ export const CommandPalette = (): React.JSX.Element | null => {
{searchMode === 'projects' ? (
<>
- Search projects
+
+ {t('commandPalette.mode.searchProjects')}
+
>
) : (
<>
- {globalSearchEnabled ? 'Search across all projects' : 'Search in project'}
+ {globalSearchEnabled
+ ? t('commandPalette.mode.searchAcrossProjects')
+ : t('commandPalette.mode.searchInProject')}
{!globalSearchEnabled && selectedProjectId && (
<>
@@ -472,7 +479,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
{repositoryGroups.find((r) =>
r.worktrees.some((w) => w.id === selectedProjectId)
- )?.name ?? 'Current project'}
+ )?.name ?? t('commandPalette.currentProject')}
@@ -495,7 +502,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
}
>
- Global
+ {t('commandPalette.global')}
@@ -510,7 +517,9 @@ export const CommandPalette = (): React.JSX.Element | null => {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
- searchMode === 'projects' ? 'Search projects...' : 'Search conversations...'
+ searchMode === 'projects'
+ ? t('commandPalette.placeholders.projects')
+ : t('commandPalette.placeholders.conversations')
}
className="placeholder:text-text-muted/50 flex-1 bg-transparent text-base text-text focus:outline-none"
/>
@@ -529,7 +538,9 @@ export const CommandPalette = (): React.JSX.Element | null => {
// Project search results
filteredProjects.length === 0 ? (
- {query.trim() ? `No projects found for "${query}"` : 'No projects found'}
+ {query.trim()
+ ? t('commandPalette.empty.noProjectsForQuery', { query })
+ : t('commandPalette.empty.noProjects')}
) : (
@@ -546,13 +557,13 @@ export const CommandPalette = (): React.JSX.Element | null => {
) : // Session search results
query.trim().length < 2 ? (
- Type at least 2 characters to search
+ {t('commandPalette.empty.minChars')}
) : sessionResults.length === 0 && !loading ? (
{searchIsPartial
- ? `No fast results in recent sessions for "${query}"`
- : `No results found for "${query}"`}
+ ? t('commandPalette.empty.noFastResults', { query })
+ : t('commandPalette.empty.noResults', { query })}
) : (
@@ -583,28 +594,43 @@ export const CommandPalette = (): React.JSX.Element | null => {
{searchMode === 'projects'
- ? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}`
+ ? t('commandPalette.footer.projectsCount', { count: filteredProjects.length })
: totalMatches > 0
- ? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}${globalSearchEnabled ? ' across all projects' : ''}`
- : 'Type to search'}
+ ? t(
+ globalSearchEnabled
+ ? 'commandPalette.footer.resultsAcrossProjects'
+ : 'commandPalette.footer.results',
+ {
+ count: totalMatches,
+ speed: searchIsPartial ? t('commandPalette.footer.fastPrefix') : '',
+ }
+ )
+ : t('commandPalette.footer.typeToSearch')}
- ↑↓ {' '}
- navigate
+
+ {t('commandPalette.footer.upDownKey')}
+ {' '}
+ {t('commandPalette.footer.navigate')}
↵ {' '}
- {searchMode === 'projects' ? 'select' : 'open'}
+ {searchMode === 'projects'
+ ? t('commandPalette.footer.select')
+ : t('commandPalette.footer.open')}
{formatModifierShortcut('G')}
{' '}
- global
+ {t('commandPalette.footer.global')}
- esc close
+
+ {t('commandPalette.footer.escapeKey')}
+ {' '}
+ {t('commandPalette.footer.close')}
diff --git a/src/renderer/components/search/SearchBar.tsx b/src/renderer/components/search/SearchBar.tsx
index ab620535..882c0e13 100644
--- a/src/renderer/components/search/SearchBar.tsx
+++ b/src/renderer/components/search/SearchBar.tsx
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { ChevronDown, ChevronUp, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -19,6 +20,7 @@ interface SearchBarProps {
}
export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const {
searchQuery,
searchVisible,
@@ -115,8 +117,14 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
}
const resultLabel = searchResultsCapped
- ? `${currentSearchIndex + 1} of ${searchResultCount}+`
- : `${currentSearchIndex + 1} of ${searchResultCount}`;
+ ? t('search.resultCountCapped', {
+ current: currentSearchIndex + 1,
+ total: searchResultCount,
+ })
+ : t('search.resultCount', {
+ current: currentSearchIndex + 1,
+ total: searchResultCount,
+ });
return (
@@ -127,14 +135,14 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
value={localQuery}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
- placeholder="Find in conversation..."
+ placeholder={t('search.findInConversation')}
className="w-48 rounded border border-border bg-surface-raised px-3 py-1.5 text-sm text-text focus:border-text-secondary focus:outline-none"
/>
{/* Result count */}
{searchQuery && (
- {searchResultCount > 0 ? resultLabel : 'No results'}
+ {searchResultCount > 0 ? resultLabel : t('search.noResults')}
)}
@@ -144,7 +152,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
onClick={previousSearchResult}
disabled={searchResultCount === 0}
className="rounded p-1 text-text-secondary transition-colors hover:bg-surface-raised hover:text-text disabled:cursor-not-allowed disabled:opacity-30"
- title="Previous result (Shift+Enter)"
+ title={t('search.previousResultShortcut')}
>
@@ -152,7 +160,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
onClick={nextSearchResult}
disabled={searchResultCount === 0}
className="rounded p-1 text-text-secondary transition-colors hover:bg-surface-raised hover:text-text disabled:cursor-not-allowed disabled:opacity-30"
- title="Next result (Enter)"
+ title={t('search.nextResultShortcut')}
>
@@ -162,7 +170,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx
index bf824f94..eab7d36c 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx
@@ -4,6 +4,7 @@
import { useCallback } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ChevronDown, ChevronUp, Loader2, Plus } from 'lucide-react';
import { useAddTriggerFormHandlers } from '../hooks/useAddTriggerFormHandlers';
@@ -32,6 +33,7 @@ export const AddTriggerForm = ({
saving,
onAdd,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
// Use form state hook
const formState = useAddTriggerFormState();
const {
@@ -131,7 +133,9 @@ export const AddTriggerForm = ({
>
-
Add Custom Trigger
+
+ {t('notificationTriggers.add.title')}
+
{isExpanded ? (
@@ -154,13 +158,13 @@ export const AddTriggerForm = ({
{/* Dot Color */}
-
+
{/* Section 2: Trigger Condition */}
-
+
@@ -215,7 +219,7 @@ export const AddTriggerForm = ({
disabled={saving}
className={`rounded bg-surface-raised px-3 py-1.5 text-sm text-text-secondary transition-colors hover:bg-surface-overlay ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
>
- Cancel
+ {t('notificationTriggers.add.cancel')}
{saving && }
- Add Trigger
+ {t('notificationTriggers.add.submit')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx
index f5d250b3..13c86a8a 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx
@@ -8,6 +8,7 @@
import { useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
isPresetColorKey,
resolveColorHex,
@@ -28,6 +29,7 @@ export const ColorPaletteSelector = ({
onChange,
disabled,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const isCustom = !!value && !isPresetColorKey(value);
const [hexInput, setHexInput] = useState(isCustom ? value : '');
const [showHexInput, setShowHexInput] = useState(isCustom);
@@ -104,7 +106,7 @@ export const ColorPaletteSelector = ({
{/* Custom hex toggle */}
{hexInput && !HEX_RE.test(hexInput) && (
- Invalid hex
+
+ {t('notificationTriggers.color.invalidHex')}
+
)}
)}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx
index 5ca1cc8a..120c5784 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx
@@ -3,6 +3,7 @@
* Renders different UI based on the selected trigger mode.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import {
getCursorClass,
SELECT_INPUT_BASE,
@@ -10,7 +11,11 @@ import {
} from '@renderer/constants/cssVariables';
import { AlertCircle } from 'lucide-react';
-import { CONTENT_TYPE_OPTIONS } from '../utils/constants';
+import {
+ CONTENT_TYPE_OPTIONS,
+ getContentTypeLabelKey,
+ getMatchFieldLabelKey,
+} from '../utils/constants';
import { getAvailableMatchFields } from '../utils/trigger';
import { SectionHeader } from './SectionHeader';
@@ -50,18 +55,19 @@ export const DynamicConfigSection = ({
onTokenThresholdChange,
onTokenTypeChange,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
// Get available match fields based on content type and tool name
const availableMatchFields = getAvailableMatchFields(contentType, toolName || undefined);
return (
-
+
{/* Error Status Mode */}
{mode === 'error_status' && (
- Triggers when a tool execution reports an error (is_error: true).
+ {t('notificationTriggers.configuration.errorStatusDescription')}
)}
@@ -72,7 +78,7 @@ export const DynamicConfigSection = ({
{/* Content Type */}
- Content Type
+ {t('notificationTriggers.fields.contentType')}
{CONTENT_TYPE_OPTIONS.map((option) => (
- {option.label}
+ {t(getContentTypeLabelKey(option.value))}
))}
@@ -93,7 +99,7 @@ export const DynamicConfigSection = ({
{availableMatchFields.length > 0 && (
- Match Field
+ {t('notificationTriggers.fields.matchField')}
{availableMatchFields.map((option) => (
- {option.label}
+ {t(getMatchFieldLabelKey(option.value))}
))}
@@ -115,7 +121,7 @@ export const DynamicConfigSection = ({
- Match Pattern (Regex)
+ {t('notificationTriggers.fields.matchPattern')}
onMatchPatternChange(e.target.value)}
- placeholder="e.g., error|failed|exception"
+ placeholder={t('notificationTriggers.configuration.matchPatternPlaceholder')}
disabled={saving}
className={`w-full rounded border bg-transparent px-2 py-1.5 font-mono text-sm text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${patternError ? 'border-red-500' : 'border-border'} ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
/>
@@ -134,7 +140,7 @@ export const DynamicConfigSection = ({
)}
- Leave empty to match all content. Uses JavaScript regex syntax.
+ {t('notificationTriggers.configuration.emptyPatternHint')}
@@ -145,7 +151,7 @@ export const DynamicConfigSection = ({
- Token Type
+ {t('notificationTriggers.fields.tokenType')}
- Total Tokens
+ {t('notificationTriggers.options.tokenTypes.total')}
- Input Tokens
+ {t('notificationTriggers.options.tokenTypes.input')}
- Output Tokens
+ {t('notificationTriggers.options.tokenTypes.output')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx
index 4975b687..ab1f12a0 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx
@@ -2,6 +2,8 @@
* GeneralInfoSection - Name input and tool select for AddTriggerForm.
*/
+import { useAppTranslation } from '@features/localization/renderer';
+
import { TOOL_NAME_OPTIONS } from '../utils/constants';
import { SectionHeader } from './SectionHeader';
@@ -21,15 +23,17 @@ export const GeneralInfoSection = ({
onNameChange,
onToolNameChange,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
-
+
{/* Trigger Name */}
- Trigger Name *
+ {t('notificationTriggers.fields.triggerNameRequired')}
onNameChange(e.target.value)}
- placeholder="e.g., Build Failure Alert"
+ placeholder={t('notificationTriggers.fields.triggerNamePlaceholder')}
disabled={saving}
required
className={`w-full rounded border border-border bg-transparent px-2 py-1.5 text-sm text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
@@ -47,7 +51,7 @@ export const GeneralInfoSection = ({
{/* Scope/Tool Name */}
- Scope / Tool Name (optional)
+ {t('notificationTriggers.fields.scopeToolNameOptional')}
{TOOL_NAME_OPTIONS.map((option) => (
- {option.label}
+ {option.value ? option.label : t('notificationTriggers.options.toolNames.anyTool')}
))}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx
index a15d2a2b..7851f63d 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx
@@ -2,6 +2,7 @@
* IgnorePatternsSection - Collapsible section for ignore patterns - Linear style.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { X } from 'lucide-react';
interface IgnorePatternsSectionProps {
@@ -17,14 +18,16 @@ export const IgnorePatternsSection = ({
onRemove,
disabled,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
- Advanced: Exclusion Rules
+ {t('notificationTriggers.ignorePatterns.summary')}
- Ignore Patterns (skip if matches)
+ {t('notificationTriggers.ignorePatterns.title')}
{patterns.map((pattern, idx) => (
@@ -36,7 +39,7 @@ export const IgnorePatternsSection = ({
onClick={() => onRemove(idx)}
disabled={disabled}
className={`rounded p-1 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 ${disabled ? 'cursor-not-allowed opacity-50' : ''} `}
- aria-label="Remove ignore pattern"
+ aria-label={t('notificationTriggers.ignorePatterns.removeAriaLabel')}
>
@@ -45,7 +48,7 @@ export const IgnorePatternsSection = ({
{
@@ -65,7 +68,7 @@ export const IgnorePatternsSection = ({
/>
- Press Enter to add. Notification is skipped if any pattern matches.
+ {t('notificationTriggers.ignorePatterns.hint')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx
index c8e06f2c..1efec294 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx
@@ -2,7 +2,9 @@
* ModeSelector - Segmented control for selecting trigger mode - Linear style.
*/
-import { MODE_OPTIONS } from '../utils/constants';
+import { useAppTranslation } from '@features/localization/renderer';
+
+import { getModeLabelKey, MODE_OPTIONS } from '../utils/constants';
import type { TriggerMode } from '@renderer/types/data';
@@ -17,6 +19,8 @@ export const ModeSelector = ({
onChange,
disabled = false,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
{MODE_OPTIONS.map((mode) => {
@@ -36,7 +40,7 @@ export const ModeSelector = ({
} ${disabled ? 'cursor-not-allowed opacity-50' : ''} `}
>
- {mode.label}
+ {t(getModeLabelKey(mode.value))}
);
})}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx
index bd4df655..32c35e9a 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx
@@ -3,6 +3,7 @@
* Uses the shared RepositoryDropdown component.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import {
RepositoryDropdown,
SelectedRepositoryItem,
@@ -25,18 +26,20 @@ export const RepositoryScopeSection = ({
onRemove,
disabled,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
- Advanced: Repository Scope
+ {t('notificationTriggers.repositoryScope.summary')}
- Limit to Repositories (applies only to selected repositories)
+ {t('notificationTriggers.repositoryScope.title')}
{selectedItems.length === 0 ? (
- No repositories selected - trigger applies to all repositories
+ {t('notificationTriggers.repositoryScope.empty')}
) : (
selectedItems.map((item, idx) => (
@@ -53,13 +56,13 @@ export const RepositoryScopeSection = ({
- When repositories are selected, this trigger only fires for errors in those repositories.
+ {t('notificationTriggers.repositoryScope.hint')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx
index 7f122a22..33700f0e 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx
@@ -2,11 +2,12 @@
* TriggerCardHeader - Header row for TriggerCard with name, badges, toggle, and actions.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { SettingsToggle } from '@renderer/components/settings/components';
import { getTriggerColorDef } from '@shared/constants/triggerColors';
import { ChevronDown, ChevronUp, Pencil, Shield, X } from 'lucide-react';
-import { CONTENT_TYPE_OPTIONS, MODE_OPTIONS } from '../utils/constants';
+import { CONTENT_TYPE_OPTIONS, getContentTypeLabelKey, getModeLabelKey } from '../utils/constants';
import type { NotificationTrigger, TriggerMode } from '@renderer/types/data';
@@ -39,6 +40,9 @@ export const TriggerCardHeader = ({
onToggleExpanded,
onRemove,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+ const contentTypeOption = CONTENT_TYPE_OPTIONS.find((o) => o.value === trigger.contentType);
+
return (
{/* Left side: Name and badges */}
@@ -70,7 +74,7 @@ export const TriggerCardHeader = ({
{trigger.isBuiltin && (
- Builtin
+ {t('notificationTriggers.card.builtinBadge')}
)}
{!trigger.isBuiltin && (
@@ -78,7 +82,7 @@ export const TriggerCardHeader = ({
onClick={() => onSetEditingName(true)}
disabled={saving}
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text-secondary"
- aria-label="Edit name"
+ aria-label={t('notificationTriggers.card.editNameAriaLabel')}
>
@@ -87,11 +91,12 @@ export const TriggerCardHeader = ({
)}
{/* Description line showing mode and content type */}
- {MODE_OPTIONS.find((m) => m.value === localMode)?.label ?? localMode}
+ {t(getModeLabelKey(localMode))}
-
- {CONTENT_TYPE_OPTIONS.find((o) => o.value === trigger.contentType)?.label ??
- trigger.contentType}
+ {contentTypeOption
+ ? t(getContentTypeLabelKey(contentTypeOption.value))
+ : trigger.contentType}
@@ -104,7 +109,11 @@ export const TriggerCardHeader = ({
{isExpanded ? : }
@@ -114,7 +123,7 @@ export const TriggerCardHeader = ({
onClick={onRemove}
disabled={saving}
className={`rounded p-1 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
- aria-label="Delete trigger"
+ aria-label={t('notificationTriggers.card.deleteAriaLabel')}
>
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx
index a05e5027..48da1df3 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx
@@ -3,6 +3,7 @@
* Handles error status, content match, and token threshold mode configurations.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import {
getCursorClass,
SELECT_INPUT_BASE,
@@ -10,7 +11,12 @@ import {
} from '@renderer/constants/cssVariables';
import { AlertCircle } from 'lucide-react';
-import { CONTENT_TYPE_OPTIONS, TOOL_NAME_OPTIONS } from '../utils/constants';
+import {
+ CONTENT_TYPE_OPTIONS,
+ getContentTypeLabelKey,
+ getMatchFieldLabelKey,
+ TOOL_NAME_OPTIONS,
+} from '../utils/constants';
import { getAvailableMatchFields } from '../utils/trigger';
import { ColorPaletteSelector } from './ColorPaletteSelector';
@@ -64,13 +70,14 @@ export const TriggerConfiguration = ({
onTokenTypeChange,
onColorChange,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const availableMatchFields = getAvailableMatchFields(trigger.contentType, trigger.toolName);
return (
<>
{/* Section 1: General Info */}
-
+
{/* Scope/Tool Name */}
{(trigger.contentType === 'tool_use' || trigger.contentType === 'tool_result') && (
@@ -79,7 +86,7 @@ export const TriggerConfiguration = ({
htmlFor={`trigger-${trigger.id}-tool-name`}
className="text-sm text-text-secondary"
>
- Scope / Tool Name
+ {t('notificationTriggers.fields.scopeToolName')}
{TOOL_NAME_OPTIONS.map((option) => (
- {option.label}
+ {option.value
+ ? option.label
+ : t('notificationTriggers.options.toolNames.anyTool')}
))}
@@ -100,25 +109,25 @@ export const TriggerConfiguration = ({
{/* Dot Color */}
-
+
{/* Section 2: Trigger Condition (Mode Selector) */}
-
+
{/* Section 3: Dynamic Configuration */}
-
+
{/* Error Status Mode */}
{localMode === 'error_status' && (
- Triggers when a tool execution reports an error (is_error: true).
+ {t('notificationTriggers.configuration.errorStatusDescription')}
)}
@@ -132,7 +141,7 @@ export const TriggerConfiguration = ({
htmlFor={`trigger-${trigger.id}-content-type`}
className="text-sm text-text-secondary"
>
- Content Type
+ {t('notificationTriggers.fields.contentType')}
{CONTENT_TYPE_OPTIONS.map((option) => (
- {option.label}
+ {t(getContentTypeLabelKey(option.value))}
))}
@@ -206,6 +215,8 @@ const ContentMatchConfig = ({
onPatternChange,
onPatternBlur,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
{/* Match Field */}
@@ -215,7 +226,7 @@ const ContentMatchConfig = ({
htmlFor={`trigger-${triggerId}-match-field`}
className="text-sm text-text-secondary"
>
- Match Field
+ {t('notificationTriggers.fields.matchField')}
{availableMatchFields.map((option) => (
- {option.label}
+ {t(getMatchFieldLabelKey(option.value))}
))}
@@ -240,7 +251,7 @@ const ContentMatchConfig = ({
htmlFor={`trigger-${triggerId}-match-pattern`}
className="text-sm text-text-secondary"
>
- Match Pattern (Regex)
+ {t('notificationTriggers.fields.matchPattern')}
onPatternChange(e.target.value)}
onBlur={onPatternBlur}
- placeholder="e.g., error|failed|exception"
+ placeholder={t('notificationTriggers.configuration.matchPatternPlaceholder')}
disabled={saving}
className={`w-full rounded border bg-transparent px-2 py-1.5 font-mono text-sm text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${patternError ? 'border-red-500' : 'border-border'} ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
/>
@@ -260,7 +271,7 @@ const ContentMatchConfig = ({
)}
- Leave empty to match all content. Uses JavaScript regex syntax.
+ {t('notificationTriggers.configuration.emptyPatternHint')}
@@ -290,11 +301,13 @@ const TokenThresholdConfig = ({
onTokenThresholdChange,
onTokenThresholdBlur,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
- Token Type
+ {t('notificationTriggers.fields.tokenType')}
- Total Tokens
+ {t('notificationTriggers.options.tokenTypes.total')}
- Input Tokens
+ {t('notificationTriggers.options.tokenTypes.input')}
- Output Tokens
+ {t('notificationTriggers.options.tokenTypes.output')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx
index 2103d9ef..59fbdb41 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx
@@ -3,6 +3,7 @@
* Used by both TriggerCard and AddTriggerForm.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle, Loader2 } from 'lucide-react';
import type { PreviewResult } from '../types';
@@ -24,6 +25,7 @@ export const TriggerPreview = ({
onViewSession,
isFormContext = false,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const isLoading = loading ?? previewResult?.loading;
// Safeguard: ensure count is at least the errors array length (handles edge cases where totalCount is 0 but errors exist)
@@ -34,7 +36,9 @@ export const TriggerPreview = ({
return (
- Preview
+
+ {t('notificationTriggers.preview.title')}
+
- Testing...
+ {t('notificationTriggers.preview.testing')}
) : (
- 'Test Trigger'
+ t('notificationTriggers.preview.testTrigger')
)}
@@ -58,7 +62,7 @@ export const TriggerPreview = ({
{previewResult.truncated && effectiveCount >= 10_000 ? '10,000+' : effectiveCount}
{' '}
- errors would have been detected
+ {t('notificationTriggers.preview.detectedSuffix')}
{/* Truncation warning - only shown when timeout or count limit hit */}
@@ -72,9 +76,7 @@ export const TriggerPreview = ({
}}
>
-
- Search stopped early (timeout or count limit). Actual matches may be higher.
-
+
{t('notificationTriggers.preview.truncatedWarning')}
)}
@@ -95,13 +97,15 @@ export const TriggerPreview = ({
onClick={() => onViewSession(error)}
className="shrink-0 rounded px-2 py-1 text-indigo-400 transition-colors hover:bg-indigo-500/10"
>
- View Session
+ {t('notificationTriggers.preview.viewSession')}
))}
{effectiveCount > 10 && (
- ...and {effectiveCount - 10} more
+
+ {t('notificationTriggers.preview.more', { count: effectiveCount - 10 })}
+
)}
)}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts
index ec3f2d0e..b43012c1 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts
+++ b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts
@@ -4,6 +4,7 @@
import { useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { createLogger } from '@shared/utils/logger';
@@ -58,6 +59,7 @@ interface UseTriggerFormReturn {
* Shared form state and validation logic for trigger forms.
*/
export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTriggerFormReturn {
+ const { t } = useAppTranslation('settings');
const [patternError, setPatternError] = useState(null);
const [previewResult, setPreviewResult] = useState(null);
@@ -67,11 +69,14 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger
/**
* Validate a regex pattern.
*/
- const validatePattern = useCallback((pattern: string): boolean => {
- const error = validateRegexPattern(pattern);
- setPatternError(error);
- return error === null;
- }, []);
+ const validatePattern = useCallback(
+ (pattern: string): boolean => {
+ const error = validateRegexPattern(pattern);
+ setPatternError(error ? t('notificationTriggers.errors.invalidRegexPattern') : null);
+ return error === null;
+ },
+ [t]
+ );
/**
* Clear the preview result.
@@ -148,7 +153,7 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger
}): NotificationTrigger => {
return {
id: `test-${generateId()}`,
- name: formState.name.trim() || 'Test Trigger',
+ name: formState.name.trim() || t('notificationTriggers.preview.defaultTestTriggerName'),
enabled: true,
contentType: formState.contentType,
mode: formState.mode,
@@ -170,7 +175,7 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger
formState.repositoryIds.length > 0 && { repositoryIds: formState.repositoryIds }),
};
},
- []
+ [t]
);
return {
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/index.tsx b/src/renderer/components/settings/NotificationTriggerSettings/index.tsx
index 06899fc3..80ce46b5 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/index.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/index.tsx
@@ -9,6 +9,8 @@
* 4. Advanced (collapsible)
*/
+import { useAppTranslation } from '@features/localization/renderer';
+
import { AddTriggerForm } from './components/AddTriggerForm';
import { SectionHeader } from './components/SectionHeader';
import { TriggerCard } from './components/TriggerCard';
@@ -28,6 +30,7 @@ export const NotificationTriggerSettings = ({
onAddTrigger,
onRemoveTrigger,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
// Separate builtin and custom triggers
const builtinTriggers = triggers.filter((t) => t.isBuiltin);
const customTriggers = triggers.filter((t) => !t.isBuiltin);
@@ -37,10 +40,9 @@ export const NotificationTriggerSettings = ({
{/* Builtin Triggers */}
{builtinTriggers.length > 0 && (
-
+
- Default triggers that come with the application. You can enable/disable them and
- customize their patterns.
+ {t('notificationTriggers.builtin.description')}
{builtinTriggers.map((trigger) => (
@@ -58,9 +60,9 @@ export const NotificationTriggerSettings = ({
{/* Custom Triggers */}
-
+
- Create your own triggers to get notified for specific patterns or tool outputs.
+ {t('notificationTriggers.custom.description')}
{customTriggers.length > 0 && (
@@ -78,7 +80,9 @@ export const NotificationTriggerSettings = ({
)}
{customTriggers.length === 0 && (
-
No custom triggers configured yet.
+
+ {t('notificationTriggers.custom.empty')}
+
)}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/types.ts b/src/renderer/components/settings/NotificationTriggerSettings/types.ts
index c927dbbe..5c57b6fe 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/types.ts
+++ b/src/renderer/components/settings/NotificationTriggerSettings/types.ts
@@ -24,6 +24,7 @@ export interface PreviewResult {
export interface ModeConfig {
value: TriggerMode;
label: string;
+ labelKey: string;
icon: React.ComponentType<{ className?: string }>;
}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts b/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts
index 70e6975e..b7e2fe13 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts
+++ b/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts
@@ -7,6 +7,40 @@ import { Activity, AlertCircle, Search } from 'lucide-react';
import type { ModeConfig } from '../types';
import type { TriggerContentType, TriggerToolName } from '@renderer/types/data';
+export const CONTENT_TYPE_LABEL_KEYS = {
+ tool_result: 'notificationTriggers.options.contentTypes.tool_result',
+ tool_use: 'notificationTriggers.options.contentTypes.tool_use',
+ thinking: 'notificationTriggers.options.contentTypes.thinking',
+ text: 'notificationTriggers.options.contentTypes.text',
+} as const satisfies Record
;
+
+export const MATCH_FIELD_LABEL_KEYS = {
+ args: 'notificationTriggers.options.matchFields.args',
+ command: 'notificationTriggers.options.matchFields.command',
+ content: 'notificationTriggers.options.matchFields.content',
+ description: 'notificationTriggers.options.matchFields.description',
+ file_path: 'notificationTriggers.options.matchFields.file_path',
+ fullInput: 'notificationTriggers.options.matchFields.fullInput',
+ glob: 'notificationTriggers.options.matchFields.glob',
+ new_string: 'notificationTriggers.options.matchFields.new_string',
+ old_string: 'notificationTriggers.options.matchFields.old_string',
+ path: 'notificationTriggers.options.matchFields.path',
+ pattern: 'notificationTriggers.options.matchFields.pattern',
+ prompt: 'notificationTriggers.options.matchFields.prompt',
+ query: 'notificationTriggers.options.matchFields.query',
+ skill: 'notificationTriggers.options.matchFields.skill',
+ subagent_type: 'notificationTriggers.options.matchFields.subagent_type',
+ text: 'notificationTriggers.options.matchFields.text',
+ thinking: 'notificationTriggers.options.matchFields.thinking',
+ url: 'notificationTriggers.options.matchFields.url',
+} as const;
+
+export const MODE_LABEL_KEYS = {
+ content_match: 'notificationTriggers.options.modes.content_match',
+ error_status: 'notificationTriggers.options.modes.error_status',
+ token_threshold: 'notificationTriggers.options.modes.token_threshold',
+} as const;
+
/**
* Content type options for dropdown.
*/
@@ -44,7 +78,37 @@ export const TOOL_NAME_OPTIONS: { value: TriggerToolName; label: string }[] = [
* Mode options for the trigger mode selector.
*/
export const MODE_OPTIONS: ModeConfig[] = [
- { value: 'error_status', label: 'Execution Error', icon: AlertCircle },
- { value: 'content_match', label: 'Content Pattern', icon: Search },
- { value: 'token_threshold', label: 'High Token Usage', icon: Activity },
+ {
+ value: 'error_status',
+ label: 'Execution Error',
+ labelKey: MODE_LABEL_KEYS.error_status,
+ icon: AlertCircle,
+ },
+ {
+ value: 'content_match',
+ label: 'Content Pattern',
+ labelKey: MODE_LABEL_KEYS.content_match,
+ icon: Search,
+ },
+ {
+ value: 'token_threshold',
+ label: 'High Token Usage',
+ labelKey: MODE_LABEL_KEYS.token_threshold,
+ icon: Activity,
+ },
];
+
+export function getContentTypeLabelKey(contentType: TriggerContentType) {
+ return CONTENT_TYPE_LABEL_KEYS[contentType];
+}
+
+export function getMatchFieldLabelKey(matchField: string) {
+ return (
+ MATCH_FIELD_LABEL_KEYS[matchField as keyof typeof MATCH_FIELD_LABEL_KEYS] ??
+ MATCH_FIELD_LABEL_KEYS.fullInput
+ );
+}
+
+export function getModeLabelKey(mode: keyof typeof MODE_LABEL_KEYS) {
+ return MODE_LABEL_KEYS[mode];
+}
diff --git a/src/renderer/components/settings/SettingsTabs.tsx b/src/renderer/components/settings/SettingsTabs.tsx
index 1be5b2ec..08d41f4d 100644
--- a/src/renderer/components/settings/SettingsTabs.tsx
+++ b/src/renderer/components/settings/SettingsTabs.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import {
Tooltip,
@@ -18,43 +19,48 @@ interface SettingsTabsProps {
onSectionChange: (section: SettingsSection) => void;
}
+type TabLabelKey = 'tabs.advanced.label' | 'tabs.general.label' | 'tabs.notifications.label';
+
+type TabDescriptionKey =
+ | 'tabs.advanced.description'
+ | 'tabs.general.description'
+ | 'tabs.notifications.description';
+
interface TabConfig {
id: SettingsSection;
- label: string;
+ labelKey: TabLabelKey;
icon: LucideIcon;
- description: string;
+ descriptionKey: TabDescriptionKey;
electronOnly?: boolean;
}
const tabs: TabConfig[] = [
{
id: 'general',
- label: 'General',
+ labelKey: 'tabs.general.label',
icon: Settings,
- description:
- 'Core app preferences like theme, language, display density, and startup behavior.',
+ descriptionKey: 'tabs.general.description',
},
// { id: 'connection', label: 'Connection', icon: Server, description: 'Manage CLI connection and authentication settings.', electronOnly: true },
{
id: 'notifications',
- label: 'Notifications',
+ labelKey: 'tabs.notifications.label',
icon: Bell,
- description:
- 'Control when and how you get notified about agent activity, task completions, and errors.',
+ descriptionKey: 'tabs.notifications.description',
},
{
id: 'advanced',
- label: 'Advanced',
+ labelKey: 'tabs.advanced.label',
icon: Wrench,
- description:
- 'Power-user options: export/import config, reset defaults, and raw configuration editing.',
+ descriptionKey: 'tabs.advanced.description',
},
-];
+] satisfies TabConfig[];
export const SettingsTabs = ({
activeSection,
onSectionChange,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const isElectron = useMemo(() => isElectronMode(), []);
const visibleTabs = useMemo(
() => tabs.filter((tab) => !tab.electronOnly || isElectron),
@@ -68,6 +74,7 @@ export const SettingsTabs = ({
{visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeSection === tab.id;
+ const label = t(tab.labelKey);
return (
- {tab.label}
+ {label}
event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
@@ -101,7 +108,7 @@ export const SettingsTabs = ({
- {tab.description}
+ {t(tab.descriptionKey)}
diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx
index 01b860ff..18bf8267 100644
--- a/src/renderer/components/settings/SettingsView.tsx
+++ b/src/renderer/components/settings/SettingsView.tsx
@@ -5,6 +5,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { Loader2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -19,6 +20,8 @@ import {
import { type SettingsSection, SettingsTabs } from './SettingsTabs';
export const SettingsView = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
+ const { t: commonT } = useAppTranslation('common');
const [activeSection, setActiveSection] = useState('general');
const { pendingSettingsSection, clearPendingSettingsSection } = useStore(
useShallow((s) => ({
@@ -70,7 +73,7 @@ export const SettingsView = (): React.JSX.Element | null => {
>
- Loading settings...
+ {t('view.loading')}
);
@@ -93,7 +96,7 @@ export const SettingsView = (): React.JSX.Element | null => {
color: 'var(--color-text-secondary)',
}}
>
- Retry
+ {commonT('actions.retry')}
@@ -108,10 +111,10 @@ export const SettingsView = (): React.JSX.Element | null => {
{/* Header */}
- Settings
+ {t('view.title')}
- Manage your app preferences
+ {t('view.description')}
{error && (
@@ -132,6 +135,7 @@ export const SettingsView = (): React.JSX.Element | null => {
onGeneralToggle={handlers.handleGeneralToggle}
onThemeChange={handlers.handleThemeChange}
onLanguageChange={handlers.handleLanguageChange}
+ onAppLocaleChange={handlers.handleAppLocaleChange}
/>
)}
diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts
index 85ffa332..a9cd79cd 100644
--- a/src/renderer/components/settings/hooks/useSettingsConfig.ts
+++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts
@@ -32,6 +32,7 @@ export interface SafeConfig {
multimodelEnabled: boolean;
claudeRootPath: string | null;
agentLanguage: string;
+ appLocale: string;
autoExpandAIGroups: boolean;
useNativeTitleBar: boolean;
telemetryEnabled: boolean;
@@ -174,6 +175,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
multimodelEnabled: displayConfig?.general?.multimodelEnabled ?? true,
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
+ appLocale: displayConfig?.general?.appLocale ?? 'system',
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false,
telemetryEnabled: displayConfig?.general?.telemetryEnabled ?? true,
diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
index 511e91b0..f41edcb3 100644
--- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts
+++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
@@ -31,6 +31,7 @@ interface SettingsHandlers {
handleGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void;
handleThemeChange: (value: 'dark' | 'light' | 'system') => void;
handleLanguageChange: (value: string) => void;
+ handleAppLocaleChange: (value: string) => void;
handleDefaultTabChange: (value: 'dashboard' | 'last-session') => void;
// Notification handlers
@@ -96,6 +97,13 @@ export function useSettingsHandlers({
[fireAndForgetConfigUpdate]
);
+ const handleAppLocaleChange = useCallback(
+ (value: string) => {
+ fireAndForgetConfigUpdate('general', { appLocale: value });
+ },
+ [fireAndForgetConfigUpdate]
+ );
+
const handleDefaultTabChange = useCallback(
(value: 'dashboard' | 'last-session') => {
fireAndForgetConfigUpdate('general', { defaultTab: value });
@@ -324,6 +332,7 @@ export function useSettingsHandlers({
multimodelEnabled: true,
claudeRootPath: null,
agentLanguage: 'system',
+ appLocale: 'system',
autoExpandAIGroups: false,
useNativeTitleBar: false,
telemetryEnabled: true,
@@ -435,6 +444,7 @@ export function useSettingsHandlers({
handleGeneralToggle,
handleThemeChange,
handleLanguageChange,
+ handleAppLocaleChange,
handleDefaultTabChange,
handleNotificationToggle,
handleStatusChangeStatusesUpdate,
diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx
index ca8a5251..eaf6a93d 100644
--- a/src/renderer/components/settings/sections/AdvancedSection.tsx
+++ b/src/renderer/components/settings/sections/AdvancedSection.tsx
@@ -4,6 +4,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import appIcon from '@renderer/favicon.png';
import { useStore } from '@renderer/store';
@@ -30,6 +31,7 @@ export const AdvancedSection = ({
onImportConfig,
onOpenInEditor,
}: AdvancedSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const isElectron = useMemo(() => isElectronMode(), []);
const [version, setVersion] = useState
('');
const [configEditorOpen, setConfigEditorOpen] = useState(false);
@@ -68,14 +70,14 @@ export const AdvancedSection = ({
return (
<>
- Checking...
+ {t('advanced.updates.checking')}
>
);
case 'not-available':
return (
<>
- Up to date
+ {t('advanced.updates.upToDate')}
>
);
case 'available':
@@ -84,15 +86,17 @@ export const AdvancedSection = ({
<>
{updateStatus === 'downloaded'
- ? 'Update ready'
- : `v${availableVersion ?? 'unknown'} available`}
+ ? t('advanced.updates.ready')
+ : t('advanced.updates.available', {
+ version: availableVersion ?? t('advanced.updates.unknownVersion'),
+ })}
>
);
default:
return (
<>
- Check for Updates
+ {t('advanced.updates.check')}
>
);
}
@@ -100,7 +104,7 @@ export const AdvancedSection = ({
return (
-
+
setConfigEditorOpen(true)}
@@ -111,7 +115,7 @@ export const AdvancedSection = ({
}}
>
- Edit Config
+ {t('advanced.configuration.editConfig')}
- Reset to Defaults
+ {t('advanced.configuration.resetToDefaults')}
- Export Config
+ {t('advanced.configuration.exportConfig')}
- Import Config
+ {t('advanced.configuration.importConfig')}
{isElectron && (
- Open in Editor
+ {t('advanced.configuration.openInEditor')}
)}
-
+
-
+
- Agent Teams AI
+ {t('advanced.appName')}
{isElectron && (
- Standalone
+ {t('advanced.about.standalone')}
)}
- Version {version || '...'}
+ {t('advanced.about.version', { version: version || '...' })}
- Assemble AI agent teams that work autonomously in parallel, communicate across teams,
- and manage tasks on a kanban board — with built-in code review, live process monitoring,
- and full tool visibility.
+ {t('advanced.about.description')}
diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx
index af96121e..3351b4d5 100644
--- a/src/renderer/components/settings/sections/CliStatusSection.tsx
+++ b/src/renderer/components/settings/sections/CliStatusSection.tsx
@@ -11,6 +11,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
@@ -126,6 +127,7 @@ function getProviderLabel(providerId: CliProviderId): string {
}
export const CliStatusSection = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
const isElectron = useMemo(() => isElectronMode(), []);
const appConfig = useStore((s) => s.appConfig);
const selectedProjectId = useStore((s) => s.selectedProjectId);
@@ -253,7 +255,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
async (providerId: CliProviderId) => {
const provider =
effectiveCliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
- const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
+ const disconnectAction = provider ? getProviderDisconnectAction(provider, t) : null;
if (!disconnectAction) {
return;
}
@@ -262,7 +264,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
title: disconnectAction.title,
message: disconnectAction.message,
confirmLabel: disconnectAction.confirmLabel,
- cancelLabel: 'Cancel',
+ cancelLabel: t('providerRuntime.actions.cancel'),
variant: 'danger',
});
@@ -275,7 +277,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
action: 'logout',
});
},
- [effectiveCliStatus?.providers]
+ [effectiveCliStatus?.providers, t]
);
const handleProviderManage = useCallback((providerId: CliProviderId) => {
@@ -347,7 +349,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
return (
-
+
{/* Loading status */}
{!effectiveCliStatus && installerState === 'idle' && (
@@ -356,7 +358,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-muted)' }}
>
- {multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
+ {multimodelEnabled
+ ? t('cliRuntime.loading.aiProviders')
+ : t('cliRuntime.loading.claudeCli')}
)}
@@ -378,7 +382,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
className="text-xs font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
- Multimodel
+ {t('cliRuntime.labels.multimodel')}
{/* Inline action buttons */}
@@ -390,7 +394,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ backgroundColor: '#3b82f6' }}
>
- Update
+ {t('cliRuntime.actions.update')}
) : effectiveCliStatus.supportsSelfUpdate ? (
{
{cliStatusLoading ? (
<>
- Checking...
+ {t('cliRuntime.actions.checking')}
>
) : (
<>
- Check for Updates
+ {t('cliRuntime.actions.checkForUpdates')}
>
)}
@@ -427,7 +431,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}}
>
- Extensions
+ {t('cliRuntime.actions.extensions')}
)}
@@ -445,8 +449,10 @@ export const CliStatusSection = (): React.JSX.Element | null => {
effectiveCliStatus.latestVersion && (
- v{effectiveCliStatus.installedVersion} → v
- {effectiveCliStatus.latestVersion}
+ {t('cliStatus.versionUpgrade', {
+ current: effectiveCliStatus.installedVersion,
+ latest: effectiveCliStatus.latestVersion,
+ })}
)}
@@ -468,7 +474,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
shouldShowProviderStatusSkeleton(provider, providerLoading) ||
isCodexSnapshotPending(provider, codexSnapshotPending);
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
- ? getProviderCurrentRuntimeSummary(provider)
+ ? getProviderCurrentRuntimeSummary(provider, t)
: getProviderRuntimeBackendSummary(provider);
const sourceProvider =
loadingCliProviderMap.get(provider.providerId) ?? null;
@@ -478,8 +484,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
);
const effectiveShowSkeleton = showSkeleton || maskNegativeBootstrapState;
const statusText = effectiveShowSkeleton
- ? 'Checking...'
- : formatProviderStatusText(provider);
+ ? t('providerRuntime.connectionUi.status.checking')
+ : formatProviderStatusText(provider, t);
const modelCatalogLoading =
provider.modelCatalogRefreshState === 'loading' ||
isOpenCodeCatalogHydrating(provider);
@@ -491,9 +497,12 @@ export const CliStatusSection = (): React.JSX.Element | null => {
provider
).length > 0
: provider.models.length > 0;
- const connectionModeSummary = getProviderConnectionModeSummary(provider);
- const credentialSummary = getProviderCredentialSummary(provider);
- const disconnectAction = getProviderDisconnectAction(provider);
+ const connectionModeSummary = getProviderConnectionModeSummary(
+ provider,
+ t
+ );
+ const credentialSummary = getProviderCredentialSummary(provider, t);
+ const disconnectAction = getProviderDisconnectAction(provider, t);
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
@@ -539,22 +548,30 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && !runtimeSummary && (
- Backend: {provider.backend.label}
+
+ {t('cliRuntime.provider.backend', {
+ backend: provider.backend.label,
+ })}
+
)}
{runtimeSummary ? (
{isConnectionManagedRuntimeProvider(provider)
? runtimeSummary
- : `Runtime: ${runtimeSummary}`}
+ : t('cliRuntime.provider.runtime', {
+ runtime: runtimeSummary,
+ })}
) : null}
{connectionModeSummary ? (
{connectionModeSummary}
) : null}
{credentialSummary ? {credentialSummary} : null}
- {modelCatalogLoading ? Loading models... : null}
+ {modelCatalogLoading ? (
+ {t('cliRuntime.provider.loadingModels')}
+ ) : null}
{!hasProviderModels && !modelCatalogLoading && (
- Models unavailable for this runtime build
+ {t('cliRuntime.provider.modelsUnavailable')}
)}
) : null}
@@ -571,7 +588,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}}
>
- Manage
+ {t('cliRuntime.actions.manage')}
{disconnectAction ? (
{
}}
>
- {getProviderConnectLabel(provider)}
+ {getProviderConnectLabel(provider, t)}
) : null}
@@ -652,8 +669,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
- ? `${runtimeDisplayName} was found but failed to start`
- : `${runtimeDisplayName} not installed`}
+ ? t('cliRuntime.status.foundButFailed', { runtime: runtimeDisplayName })
+ : t('cliRuntime.status.notInstalled', { runtime: runtimeDisplayName })}
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
@@ -687,7 +704,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}}
>
- Re-check
+ {t('cliRuntime.actions.recheck')}
{
>
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
- ? `Reinstall ${runtimeDisplayName}`
- : `Install ${runtimeDisplayName}`}
+ ? t('cliRuntime.actions.reinstallRuntime', { runtime: runtimeDisplayName })
+ : t('cliRuntime.actions.installRuntime', { runtime: runtimeDisplayName })}
)}
{!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
- ? `The configured ${runtimeDisplayName} failed its startup health check.`
- : `The configured ${runtimeDisplayName} was not found.`}
+ ? t('cliRuntime.status.healthCheckFailed', { runtime: runtimeDisplayName })
+ : t('cliRuntime.status.configuredNotFound', { runtime: runtimeDisplayName })}
)}
@@ -719,7 +736,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
className="flex items-center justify-between text-xs"
style={{ color: 'var(--color-text-secondary)' }}
>
-
Downloading...
+
{t('cliRuntime.installer.downloading')}
{downloadTotal > 0
? `${formatBytes(downloadTransferred)} / ${formatBytes(downloadTotal)} (${downloadProgress}%)`
@@ -755,7 +772,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-secondary)' }}
>
- Checking latest version...
+ {t('cliRuntime.installer.checkingLatest')}
)}
@@ -766,7 +783,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-secondary)' }}
>
- Verifying checksum...
+ {t('cliRuntime.installer.verifying')}
)}
@@ -777,7 +794,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-secondary)' }}
>
- Installing...
+ {t('cliRuntime.installer.installing')}
)}
@@ -785,7 +802,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{installerState === 'completed' && (
- Installed v{completedVersion ?? 'latest'}
+ {t('cliRuntime.installer.installed', {
+ version: completedVersion ?? t('cliRuntime.installer.latest'),
+ })}
)}
@@ -794,7 +813,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
- {installerError ?? 'Installation failed'}
+ {installerError ?? t('cliRuntime.installer.failed')}
{
}}
>
- Retry
+ {t('cliRuntime.actions.retry')}
)}
@@ -813,7 +832,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{providerTerminal && cliStatus?.binaryPath && (
{
}}
autoCloseOnSuccessMs={3000}
successMessage={
- providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
+ providerTerminal.action === 'login'
+ ? t('cliRuntime.providerTerminal.authUpdated')
+ : t('cliRuntime.providerTerminal.loggedOut')
}
failureMessage={
- providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
+ providerTerminal.action === 'login'
+ ? t('cliRuntime.providerTerminal.authFailed')
+ : t('cliRuntime.providerTerminal.logoutFailed')
}
/>
)}
diff --git a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx
index aab0a39b..2610786a 100644
--- a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx
+++ b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx
@@ -27,6 +27,7 @@ import {
keymap,
lineNumbers,
} from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme';
@@ -61,6 +62,7 @@ export const ConfigEditorDialog = ({
onClose,
onConfigSaved,
}: ConfigEditorDialogProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
const editorRef = useRef(null);
const viewRef = useRef(null);
const saveTimerRef = useRef>(undefined);
@@ -106,7 +108,7 @@ export const ConfigEditorDialog = ({
setSaveStatus('idle');
} else {
setSaveStatus('error');
- setJsonError(e instanceof Error ? e.message : 'Failed to save config');
+ setJsonError(e instanceof Error ? e.message : t('configEditor.errors.saveFailed'));
if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current);
savedRevertTimerRef.current = setTimeout(() => {
setSaveStatus('idle');
@@ -115,7 +117,7 @@ export const ConfigEditorDialog = ({
}
}
},
- [onConfigSaved]
+ [onConfigSaved, t]
);
const scheduleSave = useCallback(
@@ -202,7 +204,7 @@ export const ConfigEditorDialog = ({
} catch (e) {
if (destroyed) return;
setLoading(false);
- setJsonError(e instanceof Error ? e.message : 'Failed to load config');
+ setJsonError(e instanceof Error ? e.message : t('configEditor.errors.loadFailed'));
}
};
@@ -217,7 +219,7 @@ export const ConfigEditorDialog = ({
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current);
};
- }, [open, scheduleSave]);
+ }, [open, scheduleSave, t]);
// Escape key handler
useEffect(() => {
@@ -236,11 +238,15 @@ export const ConfigEditorDialog = ({
return (
{
if (e.target === e.currentTarget) onClose();
}}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') onClose();
+ }}
>
- Edit Configuration
+ {t('configEditor.title')}
@@ -277,7 +283,7 @@ export const ConfigEditorDialog = ({
style={{ color: 'var(--color-text-muted)', backgroundColor: 'var(--color-surface)' }}
>
- Loading config...
+ {t('configEditor.loading')}
) : null}
- Changes auto-save after editing
+ {t('configEditor.footer.autoSave')}
- Esc
+ {t('configEditor.footer.escapeKey')}
- to close
+ {t('configEditor.footer.toClose')}
@@ -327,6 +333,8 @@ const SaveStatusBadge = ({
status: SaveStatus;
error: string | null;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
+
if (status === 'idle' && !error) return null;
if (error && status !== 'saving') {
@@ -337,7 +345,9 @@ const SaveStatusBadge = ({
title={error}
>
- {status === 'error' ? 'Save failed' : 'Invalid JSON'}
+ {status === 'error'
+ ? t('configEditor.status.saveFailed')
+ : t('configEditor.status.invalidJson')}
);
}
@@ -349,7 +359,7 @@ const SaveStatusBadge = ({
style={{ backgroundColor: 'rgba(96, 165, 250, 0.15)', color: '#60a5fa' }}
>
- Saving...
+ {t('configEditor.status.saving')}
);
}
@@ -361,7 +371,7 @@ const SaveStatusBadge = ({
style={{ backgroundColor: 'rgba(74, 222, 128, 0.15)', color: '#4ade80' }}
>
- Saved
+ {t('configEditor.status.saved')}
);
}
diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx
index 3fa6e937..e24549c2 100644
--- a/src/renderer/components/settings/sections/ConnectionSection.tsx
+++ b/src/renderer/components/settings/sections/ConnectionSection.tsx
@@ -11,6 +11,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
@@ -30,14 +31,21 @@ import type {
SshConnectionProfile,
} from '@shared/types';
-const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
- { value: 'auto', label: 'Auto (from SSH Config)' },
- { value: 'agent', label: 'SSH Agent' },
- { value: 'privateKey', label: 'Private Key' },
- { value: 'password', label: 'Password' },
+const authMethodOptionValues: readonly SshAuthMethod[] = [
+ 'auto',
+ 'agent',
+ 'privateKey',
+ 'password',
];
+const authMethodLabelKeys = {
+ agent: 'workspaceProfiles.authMethods.agent',
+ auto: 'workspaceProfiles.authMethods.auto',
+ password: 'workspaceProfiles.authMethods.password',
+ privateKey: 'workspaceProfiles.authMethods.privateKey',
+} as const satisfies Record
;
export const ConnectionSection = (): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const {
connectionState,
connectedHost,
@@ -83,6 +91,10 @@ export const ConnectionSection = (): React.JSX.Element => {
const [savedProfiles, setSavedProfiles] = useState([]);
const [selectedProfileId, setSelectedProfileId] = useState(null);
const [claudeRootInfo, setClaudeRootInfo] = useState(null);
+ const authMethodOptions = authMethodOptionValues.map((value) => ({
+ value,
+ label: t(authMethodLabelKeys[value]),
+ }));
const loadProfiles = useCallback(async () => {
try {
@@ -216,9 +228,9 @@ export const ConnectionSection = (): React.JSX.Element => {
return (
-
+
- Connect to a remote machine to view Claude Code sessions running there
+ {t('connection.description')}
{/* Connection Status */}
@@ -233,10 +245,10 @@ export const ConnectionSection = (): React.JSX.Element => {
- Connected to {connectedHost}
+ {t('connection.status.connectedTo', { host: connectedHost })}
- Viewing remote sessions via SSH
+ {t('connection.status.remoteSessions')}
{
color: 'var(--color-text-secondary)',
}}
>
- Disconnect
+ {t('connection.actions.disconnect')}
)}
@@ -260,13 +272,16 @@ export const ConnectionSection = (): React.JSX.Element => {
{/* Mode indicator */}
{!isConnected && (
-
+
- Local ({resolvedClaudeRootPath})
+ {t('connection.currentMode.local', { path: resolvedClaudeRootPath })}
)}
@@ -275,7 +290,7 @@ export const ConnectionSection = (): React.JSX.Element => {
{!isConnected && savedProfiles.length > 0 && (
- Saved Profiles
+ {t('connection.savedProfiles.title')}
{savedProfiles.map((profile) => {
@@ -313,7 +328,7 @@ export const ConnectionSection = (): React.JSX.Element => {
{!isConnected && (
- SSH Connection
+ {t('connection.ssh.title')}
@@ -324,7 +339,7 @@ export const ConnectionSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Host
+ {t('connection.form.host')}
{
clearProfileSelection();
}}
onFocus={() => setShowDropdown(true)}
- placeholder="hostname or ssh config alias"
+ placeholder={t('connection.form.hostPlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -384,7 +399,7 @@ export const ConnectionSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Port
+ {t('connection.form.port')}
{
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Username
+ {t('connection.form.username')}
{
setUsername(e.target.value);
clearProfileSelection();
}}
- placeholder="user"
+ placeholder={t('connection.form.usernamePlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -423,7 +438,7 @@ export const ConnectionSection = (): React.JSX.Element => {
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- SettingsSelect is a custom dropdown without a native control */}
- Authentication
+ {t('connection.form.authentication')}
{
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Private Key Path
+ {t('connection.form.privateKeyPath')}
{
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Password
+ {t('connection.form.password')}
{
}`}
>
{testResult.success
- ? 'Connection successful'
- : `Connection failed: ${testResult.error ?? 'Unknown error'}`}
+ ? t('connection.test.success')
+ : t('connection.test.failed', {
+ error: testResult.error ?? t('connection.test.unknownError'),
+ })}
)}
@@ -503,10 +520,10 @@ export const ConnectionSection = (): React.JSX.Element => {
{testing ? (
- Testing...
+ {t('connection.actions.testing')}
) : (
- 'Test Connection'
+ t('connection.actions.testConnection')
)}
@@ -522,12 +539,12 @@ export const ConnectionSection = (): React.JSX.Element => {
{isConnecting ? (
- Connecting...
+ {t('connection.actions.connecting')}
) : (
- Connect
+ {t('connection.actions.connect')}
)}
diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx
index dd0ef9a1..181913c3 100644
--- a/src/renderer/components/settings/sections/GeneralSection.tsx
+++ b/src/renderer/components/settings/sections/GeneralSection.tsx
@@ -4,6 +4,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { normalizeAppLocalePreference } from '@features/localization';
+import { AppLanguageSelect, useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Combobox } from '@renderer/components/ui/combobox';
@@ -21,12 +23,7 @@ import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types';
import type { HttpServerStatus } from '@shared/types/api';
import type { AppConfig } from '@shared/types/notifications';
-// Theme options
-const THEME_OPTIONS = [
- { value: 'dark', label: 'Dark' },
- { value: 'light', label: 'Light' },
- { value: 'system', label: 'System' },
-] as const;
+const THEME_OPTIONS = ['dark', 'light', 'system'] as const;
interface GeneralSectionProps {
readonly safeConfig: SafeConfig;
@@ -34,6 +31,7 @@ interface GeneralSectionProps {
readonly onGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void;
readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void;
readonly onLanguageChange: (value: string) => void;
+ readonly onAppLocaleChange: (value: string) => void;
}
export const GeneralSection = ({
@@ -42,7 +40,10 @@ export const GeneralSection = ({
onGeneralToggle,
onThemeChange,
onLanguageChange,
+ onAppLocaleChange,
}: GeneralSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+ const { t: commonT } = useAppTranslation('common');
const [serverStatus, setServerStatus] = useState
({
running: false,
port: 3456,
@@ -77,10 +78,10 @@ export const GeneralSection = ({
setClaudeRootInfo(info);
} catch (error) {
setClaudeRootError(
- error instanceof Error ? error.message : 'Failed to load local Claude root settings'
+ error instanceof Error ? error.message : t('general.localClaudeRoot.errors.loadFailed')
);
}
- }, []);
+ }, [t]);
useEffect(() => {
void loadClaudeRootInfo();
@@ -144,7 +145,9 @@ export const GeneralSection = ({
await Promise.all([fetchProjects(), fetchRepositoryGroups()]);
}
} catch (error) {
- setClaudeRootError(error instanceof Error ? error.message : 'Failed to update Claude root');
+ setClaudeRootError(
+ error instanceof Error ? error.message : t('general.localClaudeRoot.errors.updateFailed')
+ );
} finally {
setUpdatingClaudeRoot(false);
}
@@ -155,6 +158,7 @@ export const GeneralSection = ({
fetchRepositoryGroups,
loadClaudeRootInfo,
resetWorkspaceForRootChange,
+ t,
]
);
@@ -168,9 +172,11 @@ export const GeneralSection = ({
if (!selection.isClaudeDirName) {
const proceed = await confirm({
- title: 'Selected folder is not .claude',
- message: `This folder is named "${selection.path.split(/[\\/]/).pop() ?? selection.path}", not ".claude". Continue anyway?`,
- confirmLabel: 'Use Folder',
+ title: t('general.localClaudeRoot.confirm.notClaudeDir.title'),
+ message: t('general.localClaudeRoot.confirm.notClaudeDir.message', {
+ folderName: selection.path.split(/[\\/]/).pop() ?? selection.path,
+ }),
+ confirmLabel: t('general.localClaudeRoot.actions.useFolder'),
});
if (!proceed) {
return;
@@ -179,9 +185,9 @@ export const GeneralSection = ({
if (!selection.hasProjectsDir) {
const proceed = await confirm({
- title: 'No projects directory found',
- message: 'This folder does not contain a "projects" directory. Continue anyway?',
- confirmLabel: 'Use Folder',
+ title: t('general.localClaudeRoot.confirm.noProjectsDir.title'),
+ message: t('general.localClaudeRoot.confirm.noProjectsDir.message'),
+ confirmLabel: t('general.localClaudeRoot.actions.useFolder'),
});
if (!proceed) {
return;
@@ -189,7 +195,7 @@ export const GeneralSection = ({
}
await applyClaudeRootPath(selection.path);
- }, [applyClaudeRootPath]);
+ }, [applyClaudeRootPath, t]);
const handleResetClaudeRoot = useCallback(async (): Promise => {
await applyClaudeRootPath(null);
@@ -199,9 +205,11 @@ export const GeneralSection = ({
async (candidate: WslClaudeRootCandidate): Promise => {
if (!candidate.hasProjectsDir) {
const proceed = await confirm({
- title: 'WSL path missing projects directory',
- message: `"${candidate.path}" does not contain a "projects" directory. Continue anyway?`,
- confirmLabel: 'Use Path',
+ title: t('general.localClaudeRoot.confirm.wslNoProjectsDir.title'),
+ message: t('general.localClaudeRoot.confirm.wslNoProjectsDir.message', {
+ path: candidate.path,
+ }),
+ confirmLabel: t('general.localClaudeRoot.actions.usePath'),
});
if (!proceed) {
return;
@@ -211,7 +219,7 @@ export const GeneralSection = ({
await applyClaudeRootPath(candidate.path);
setShowWslModal(false);
},
- [applyClaudeRootPath]
+ [applyClaudeRootPath, t]
);
const handleUseWslForClaude = useCallback(async (): Promise => {
@@ -223,10 +231,9 @@ export const GeneralSection = ({
if (candidates.length === 0) {
const pickManually = await confirm({
- title: 'No WSL Claude paths found',
- message:
- 'Could not find WSL distros with Claude data automatically. Select folder manually?',
- confirmLabel: 'Select Folder',
+ title: t('general.localClaudeRoot.confirm.noWslPaths.title'),
+ message: t('general.localClaudeRoot.confirm.noWslPaths.message'),
+ confirmLabel: t('general.localClaudeRoot.actions.selectFolder'),
});
if (pickManually) {
await handleSelectClaudeRootFolder();
@@ -243,12 +250,12 @@ export const GeneralSection = ({
setShowWslModal(true);
} catch (error) {
setClaudeRootError(
- error instanceof Error ? error.message : 'Failed to detect WSL Claude root paths'
+ error instanceof Error ? error.message : t('general.localClaudeRoot.errors.detectWslFailed')
);
} finally {
setFindingWslRoots(false);
}
- }, [applyWslCandidate, handleSelectClaudeRootFolder]);
+ }, [applyWslCandidate, handleSelectClaudeRootFolder, t]);
const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath);
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
@@ -268,10 +275,21 @@ export const GeneralSection = ({
const detected = resolveLanguageName('system', browserLang);
const detectedFlag = AGENT_LANGUAGE_OPTIONS.find((o) => o.value === primaryCode)?.flag ?? '';
const flagPrefix = detectedFlag ? `${detectedFlag} ` : '';
- return `Language for agent communication (detected: ${flagPrefix}${detected})`;
+ return t('general.agentLanguage.descriptionWithDetected', {
+ detected: `${flagPrefix}${detected}`,
+ });
}
- return 'Language for agent communication';
- }, [safeConfig.general.agentLanguage]);
+ return t('general.agentLanguage.description');
+ }, [safeConfig.general.agentLanguage, t]);
+
+ const themeOptions = useMemo(
+ () =>
+ THEME_OPTIONS.map((value) => ({
+ value,
+ label: t(`general.appearance.theme.options.${value}`),
+ })),
+ [t]
+ );
const languageComboboxOptions = useMemo(
() =>
@@ -298,15 +316,27 @@ export const GeneralSection = ({
return (
-
-
+
+
+
+
+
+
+
-
+
{window.navigator.userAgent.includes('Macintosh') && (
)}
-
-
+
+
- {THEME_OPTIONS.map((opt) => (
+ {themeOptions.map((opt) => (
{isElectron && !window.navigator.userAgent.includes('Macintosh') && (
{
const shouldRelaunch = await confirm({
- title: 'Restart required',
- message: 'The app needs to restart to apply the title bar change. Restart now?',
- confirmLabel: 'Restart',
+ title: t('general.appearance.nativeTitleBar.restartConfirm.title'),
+ message: t('general.appearance.nativeTitleBar.restartConfirm.message'),
+ confirmLabel: t('general.appearance.nativeTitleBar.restartConfirm.confirmLabel'),
});
if (shouldRelaunch) {
// Await config write before relaunch to avoid race condition on Windows
@@ -405,21 +438,27 @@ export const GeneralSection = ({
{isElectron && (
<>
-
+
- Choose which local folder is treated as your Claude data root
+ {t('general.localClaudeRoot.description')}
{resolvedClaudeRootPath}
- Auto-detected: {defaultClaudeRootPath}
+ {t('general.localClaudeRoot.current.autoDetected', {
+ path: defaultClaudeRootPath,
+ })}
@@ -440,7 +479,7 @@ export const GeneralSection = ({
) : (
)}
- Select Folder
+ {t('general.localClaudeRoot.actions.selectFolder')}
@@ -455,7 +494,7 @@ export const GeneralSection = ({
>
- Use Auto-Detect
+ {t('general.localClaudeRoot.actions.useAutoDetect')}
@@ -475,7 +514,7 @@ export const GeneralSection = ({
) : (
)}
- Using Linux/WSL?
+ {t('general.localClaudeRoot.actions.useWsl')}
)}
@@ -493,7 +532,7 @@ export const GeneralSection = ({
className="absolute inset-0 cursor-default"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={() => setShowWslModal(false)}
- aria-label="Close WSL path modal"
+ aria-label={t('general.localClaudeRoot.wslModal.closeAriaLabel')}
tabIndex={-1}
/>
- Select WSL Claude Root
+ {t('general.localClaudeRoot.wslModal.title')}
- Detected WSL distributions and Claude root candidates
+ {t('general.localClaudeRoot.wslModal.description')}
@@ -529,7 +568,7 @@ export const GeneralSection = ({
{!candidate.hasProjectsDir && (
- No projects directory detected
+ {t('general.localClaudeRoot.wslModal.noProjectsDir')}
)}
@@ -541,7 +580,7 @@ export const GeneralSection = ({
color: 'var(--color-text)',
}}
>
- Use This Path
+ {t('general.localClaudeRoot.actions.useThisPath')}
))}
@@ -556,7 +595,7 @@ export const GeneralSection = ({
color: 'var(--color-text-secondary)',
}}
>
- Cancel
+ {commonT('actions.cancel')}
{
@@ -569,7 +608,7 @@ export const GeneralSection = ({
color: 'var(--color-text)',
}}
>
- Select Folder Manually
+ {t('general.localClaudeRoot.actions.selectFolderManually')}
@@ -580,10 +619,10 @@ export const GeneralSection = ({
{isElectron ? (
<>
-
+
{serverLoading ? (
- Running on
+ {t('general.server.runningOn')}
{copied ? : }
- {copied ? 'Copied' : 'Copy URL'}
+ {copied ? commonT('actions.copied') : commonT('actions.copyUrl')}
)}
>
) : (
<>
-
+
- Running on
+ {t('general.server.runningOn')}
{copied ? : }
- {copied ? 'Copied' : 'Copy URL'}
+ {copied ? commonT('actions.copied') : commonT('actions.copyUrl')}
- Running in standalone mode. The HTTP server is always active. System notifications are
- not available — notification triggers are logged in-app only.
+ {t('general.server.standaloneModeDescription')}
>
)}
@@ -685,10 +723,10 @@ export const GeneralSection = ({
{/* Privacy / Telemetry — only visible when Sentry DSN is baked into the build */}
{import.meta.env.VITE_SENTRY_DSN && (
<>
-
+
;
-// Snooze duration options
const SNOOZE_OPTIONS = [
- { value: 15, label: '15 minutes' },
- { value: 30, label: '30 minutes' },
- { value: 60, label: '1 hour' },
- { value: 120, label: '2 hours' },
- { value: 240, label: '4 hours' },
- { value: -1, label: 'Until tomorrow' },
+ { value: 15, labelKey: 'notifications.snooze.options.15' },
+ { value: 30, labelKey: 'notifications.snooze.options.30' },
+ { value: 60, labelKey: 'notifications.snooze.options.60' },
+ { value: 120, labelKey: 'notifications.snooze.options.120' },
+ { value: 240, labelKey: 'notifications.snooze.options.240' },
+ { value: -1, labelKey: 'notifications.snooze.options.-1' },
] as const;
+const STATUS_OPTIONS = [
+ {
+ value: 'in_progress',
+ labelKey: 'notifications.team.statusChange.statuses.options.in_progress',
+ },
+ { value: 'completed', labelKey: 'notifications.team.statusChange.statuses.options.completed' },
+ { value: 'review', labelKey: 'notifications.team.statusChange.statuses.options.review' },
+ { value: 'needsFix', labelKey: 'notifications.team.statusChange.statuses.options.needsFix' },
+ { value: 'approved', labelKey: 'notifications.team.statusChange.statuses.options.approved' },
+ { value: 'pending', labelKey: 'notifications.team.statusChange.statuses.options.pending' },
+ { value: 'deleted', labelKey: 'notifications.team.statusChange.statuses.options.deleted' },
+] as const satisfies readonly { value: NotifiableStatus; labelKey: string }[];
+
interface NotificationsSectionProps {
readonly safeConfig: SafeConfig;
readonly saving: boolean;
@@ -109,8 +122,17 @@ export const NotificationsSection = ({
onRemoveTrigger,
onStatusChangeStatusesUpdate,
}: NotificationsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const [testStatus, setTestStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
const [testError, setTestError] = useState(null);
+ const snoozeOptions = useMemo(
+ () =>
+ SNOOZE_OPTIONS.map((option) => ({
+ value: option.value,
+ label: t(option.labelKey),
+ })),
+ [t]
+ );
const handleTestNotification = async (): Promise => {
setTestStatus('sending');
@@ -122,13 +144,13 @@ export const NotificationsSection = ({
setTimeout(() => setTestStatus('idle'), 3000);
} else {
setTestStatus('error');
- setTestError(result.error ?? 'Unknown error');
+ setTestError(result.error ?? t('notifications.test.unknownError'));
setTimeout(() => setTestStatus('idle'), 5000);
}
} catch (err) {
console.error('[notifications] testNotification failed:', err);
setTestStatus('error');
- const message = err instanceof Error ? err.message : 'Failed to send test notification';
+ const message = err instanceof Error ? err.message : t('notifications.test.failedToSend');
setTestError(message);
setTimeout(() => setTestStatus('idle'), 5000);
}
@@ -149,22 +171,26 @@ export const NotificationsSection = ({
>
-
Dev Mode
+
+ {t('notifications.dev.title')}
+
- Notifications may not work in development mode. macOS identifies the app as
- "Electron" (bundle ID com.github.Electron)
- instead of the production app name. Check System Settings → Notifications → Electron
- to verify permissions.
+ {t('notifications.dev.descriptionPrefix')}{' '}
+ com.github.Electron{' '}
+ {t('notifications.dev.descriptionSuffix')}
) : null}
{/* Notification Settings */}
-
} />
+
}
+ />
}
>
}
>
}
>
}
>
{testStatus === 'success' ? (
- Sent!
+ {t('notifications.test.sent')}
) : testStatus === 'error' ? (
{testError}
) : null}
@@ -219,16 +245,20 @@ export const NotificationsSection = ({
color: 'var(--color-text)',
}}
>
- {testStatus === 'sending' ? 'Sending...' : 'Send Test'}
+ {testStatus === 'sending'
+ ? t('notifications.test.sending')
+ : t('notifications.test.action')}
}
>
@@ -239,12 +269,15 @@ export const NotificationsSection = ({
disabled={saving}
className={`rounded-md bg-red-500/10 px-3 py-1.5 text-sm font-medium text-red-400 transition-all duration-150 hover:bg-red-500/20 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
>
- Clear Snooze
+ {t('notifications.snooze.clear')}
) : (
v !== 0 && onSnooze(v)}
disabled={saving || !safeConfig.notifications.enabled}
dropUp
@@ -254,7 +287,10 @@ export const NotificationsSection = ({
{/* Team Notifications — grouped card */}
- } />
+ }
+ />
}
>
}
>
}
>
}
>
}
>
}
>
}
>
}
>
}
>
}
>
}
>
- Only in Solo mode
+ {t('notifications.team.statusChange.onlySolo.label')}
- Notify only when the team has no teammates
+ {t('notifications.team.statusChange.onlySolo.description')}
@@ -417,16 +453,17 @@ export const NotificationsSection = ({
className="text-sm font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
- Notify on these statuses
+ {t('notifications.team.statusChange.statuses.label')}
- Which target statuses trigger a notification
+ {t('notifications.team.statusChange.statuses.description')}
@@ -443,9 +480,12 @@ export const NotificationsSection = ({
onRemoveTrigger={onRemoveTrigger}
/>
- } />
+ }
+ />
- Notifications from these repositories will be ignored
+ {t('notifications.ignoredRepositories.description')}
{ignoredRepositoryItems.length > 0 ? (
@@ -464,21 +504,21 @@ export const NotificationsSection = ({
style={{ borderColor: 'var(--color-border)' }}
>
- No repositories ignored
+ {t('notifications.ignoredRepositories.empty')}
)}
{/* Task Completion Notifications */}
}
/>
- Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
- badges. Works on macOS, Linux, and Windows.
+ {t('notifications.taskCompletion.description')}
@@ -503,44 +542,36 @@ export const NotificationsSection = ({
}}
>
- Install claude-notifications-go plugin
+ {t('notifications.taskCompletion.installPlugin')}
);
};
-const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [
- { value: 'in_progress', label: 'Started' },
- { value: 'completed', label: 'Completed' },
- { value: 'review', label: 'Review' },
- { value: 'needsFix', label: 'Needs Fixes' },
- { value: 'approved', label: 'Approved' },
- { value: 'pending', label: 'Pending' },
- { value: 'deleted', label: 'Deleted' },
-];
-
const StatusCheckboxGroup = ({
selected,
onChange,
disabled,
+ t,
}: {
selected: string[];
onChange: (statuses: string[]) => void;
disabled: boolean;
+ t: ReturnType
['t'];
}) => (
- {STATUS_OPTIONS.map((opt) => {
- const checked = selected.includes(opt.value);
+ {STATUS_OPTIONS.map((option) => {
+ const checked = selected.includes(option.value);
return (
{
const next = checked
- ? selected.filter((s) => s !== opt.value)
- : [...selected, opt.value];
+ ? selected.filter((selectedStatus) => selectedStatus !== option.value)
+ : [...selected, option.value];
onChange(next);
}}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
@@ -549,7 +580,7 @@ const StatusCheckboxGroup = ({
: 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
>
- {opt.label}
+ {t(option.labelKey)}
);
})}
diff --git a/src/renderer/components/settings/sections/WorkspaceSection.tsx b/src/renderer/components/settings/sections/WorkspaceSection.tsx
index ee6eb900..0e86481b 100644
--- a/src/renderer/components/settings/sections/WorkspaceSection.tsx
+++ b/src/renderer/components/settings/sections/WorkspaceSection.tsx
@@ -12,6 +12,7 @@
import { useCallback, useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -30,12 +31,18 @@ const inputStyle = {
color: 'var(--color-text)',
};
-const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
- { value: 'auto', label: 'Auto (from SSH Config)' },
- { value: 'agent', label: 'SSH Agent' },
- { value: 'privateKey', label: 'Private Key' },
- { value: 'password', label: 'Password' },
+const authMethodOptionValues: readonly SshAuthMethod[] = [
+ 'auto',
+ 'agent',
+ 'privateKey',
+ 'password',
];
+const authMethodLabelKeys = {
+ agent: 'workspaceProfiles.authMethods.agent',
+ auto: 'workspaceProfiles.authMethods.auto',
+ password: 'workspaceProfiles.authMethods.password',
+ privateKey: 'workspaceProfiles.authMethods.privateKey',
+} as const satisfies Record
;
const defaultForm = {
name: '',
@@ -47,10 +54,15 @@ const defaultForm = {
};
export const WorkspaceSection = (): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const [profiles, setProfiles] = useState([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState(null);
const [showAddForm, setShowAddForm] = useState(false);
+ const authMethodOptions = authMethodOptionValues.map((value) => ({
+ value,
+ label: t(authMethodLabelKeys[value]),
+ }));
// Form state
const [formName, setFormName] = useState(defaultForm.name);
@@ -146,9 +158,9 @@ export const WorkspaceSection = (): React.JSX.Element => {
if (!profile) return;
const confirmed = await confirm({
- title: 'Delete Profile',
- message: `Are you sure you want to delete "${profile.name}"? This cannot be undone.`,
- confirmLabel: 'Delete',
+ title: t('workspaceProfiles.deleteConfirm.title'),
+ message: t('workspaceProfiles.deleteConfirm.message', { name: profile.name }),
+ confirmLabel: t('workspaceProfiles.deleteConfirm.confirmLabel'),
variant: 'danger',
});
if (!confirmed) return;
@@ -177,14 +189,14 @@ export const WorkspaceSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Name
+ {t('workspaceProfiles.form.name')}
setFormName(e.target.value)}
- placeholder="My Server"
+ placeholder={t('workspaceProfiles.form.namePlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -195,14 +207,14 @@ export const WorkspaceSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Host
+ {t('workspaceProfiles.form.host')}
setFormHost(e.target.value)}
- placeholder="hostname or IP"
+ placeholder={t('workspaceProfiles.form.hostPlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -216,7 +228,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Port
+ {t('workspaceProfiles.form.port')}
{
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Username
+ {t('workspaceProfiles.form.username')}
setFormUsername(e.target.value)}
- placeholder="user"
+ placeholder={t('workspaceProfiles.form.usernamePlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -251,7 +263,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
@@ -318,15 +330,15 @@ export const WorkspaceSection = (): React.JSX.Element => {
return (
-
+
- Save SSH connection profiles for quick reconnection
+ {t('workspaceProfiles.description')}
{loading && (
- Loading profiles...
+ {t('workspaceProfiles.loading')}
)}
@@ -339,8 +351,8 @@ export const WorkspaceSection = (): React.JSX.Element => {
}}
>
-
No saved profiles
-
Add an SSH profile to connect quickly
+
{t('workspaceProfiles.empty.title')}
+
{t('workspaceProfiles.empty.description')}
)}
@@ -395,7 +407,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
- Edit profile
+ {t('workspaceProfiles.actions.editProfile')}
@@ -409,7 +421,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
- Delete profile
+ {t('workspaceProfiles.actions.deleteProfile')}
@@ -438,7 +450,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
}}
>
- Add Profile
+ {t('workspaceProfiles.actions.addProfile')}
)}
diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx
index 83ffad22..6baefabb 100644
--- a/src/renderer/components/sidebar/DateGroupedSessions.tsx
+++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx
@@ -7,6 +7,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
@@ -185,6 +186,7 @@ function matchesSessionSearch(session: Session, query: string): boolean {
}
export const DateGroupedSessions = memo((): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
sessions,
selectedSessionId,
@@ -622,11 +624,11 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
options={projectComboboxOptions}
value={activeProjectValue ?? ''}
onValueChange={handleProjectValueChange}
- placeholder="Select Project"
- searchPlaceholder="Search..."
- emptyMessage="Nothing found"
+ placeholder={t('sessionFilters.project.selectProject')}
+ searchPlaceholder={t('search.placeholder')}
+ emptyMessage={t('search.nothingFound')}
className="text-[12px]"
- resetLabel="Reset selection"
+ resetLabel={t('actions.resetSelection')}
onReset={clearActiveProject}
renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
@@ -717,7 +719,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
- Switch Worktree
+ {t('sessions.worktree.switch')}
{mainWorktree && (
{
setSearchQuery(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none"
@@ -776,7 +778,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
- aria-label="Clear session search"
+ aria-label={t('sessions.search.clear')}
>
@@ -796,7 +798,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
{projectSelector}
-
Select a project to view sessions
+
{t('sessions.empty.selectProject')}
@@ -849,7 +851,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
}}
>
- Error loading sessions
+ {t('sessions.errors.loading')}
{sessionsError}
@@ -865,8 +867,8 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
-
No sessions found
-
This project has no sessions yet
+
{t('sessions.empty.noSessions')}
+
{t('sessions.empty.noSessionsDescription')}
@@ -880,11 +882,11 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
-
No matching sessions
+
{t('sessions.empty.noMatchingSessions')}
{hasActiveSearch || hasActiveProviderFilter
- ? 'Try another query or reset the provider filter.'
- : 'This project has no matching sessions yet.'}
+ ? t('sessions.empty.noMatchingSessionsFiltered')
+ : t('sessions.empty.noMatchingSessionsDescription')}
@@ -901,7 +903,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
className="text-[12px] font-semibold text-text-secondary"
style={{ color: 'var(--color-text-secondary)' }}
>
- {sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}
+ {sessionSortMode === 'most-context' ? t('sessions.sort.byContext') : t('sessions.title')}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */}
{
color: 'var(--color-text-secondary)',
}}
>
- {filteredSessions.length} matching sessions loaded so far — scroll down to load more.
- {sessionSortMode === 'most-context'
- ? ' Context sorting only ranks loaded sessions.'
- : ''}
+ {t('sessions.loadedMatchingMore', { count: filteredSessions.length })}
+ {sessionSortMode === 'most-context' ? ` ${t('sessions.sort.contextLoadedOnly')}` : ''}
,
document.body
)}
@@ -943,7 +943,11 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
{
{
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')
}
className="rounded p-1 transition-colors hover:bg-white/5"
- title={sessionSortMode === 'recent' ? 'Sort by context consumption' : 'Sort by recent'}
+ title={
+ sessionSortMode === 'recent'
+ ? t('sessions.sort.byContextTooltip')
+ : t('sessions.sort.byRecentTooltip')
+ }
style={{
color: sessionSortMode === 'most-context' ? '#818cf8' : 'var(--color-text-muted)',
}}
@@ -992,40 +1004,40 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
className="text-[11px] font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
- {sidebarSelectedSessionIds.length} selected
+ {t('sessions.selection.selected', { count: sidebarSelectedSessionIds.length })}
- Pin
+ {t('sessions.actions.pin')}
- Hide
+ {t('sessions.actions.hide')}
{showHiddenSessions && someSelectedAreHidden && (
- Unhide
+ {t('sessions.actions.unhide')}
)}
@@ -1068,7 +1080,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
}}
>
- Pinned
+ {t('sessions.pinned')}
) : item.type === 'header' ? (
{
{sessionsLoadingMore ? (
<>
- Loading more sessions...
+ {t('sessions.loadingMore')}
>
) : (
- Scroll to load more
+ {t('sessions.scrollToLoadMore')}
)}
) : (
diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx
index ce45c50b..c125fae9 100644
--- a/src/renderer/components/sidebar/GlobalTaskList.tsx
+++ b/src/renderer/components/sidebar/GlobalTaskList.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -85,12 +86,12 @@ export type TaskSortMode = 'time' | 'project' | 'team' | 'unread';
const TASK_SORT_STORAGE_KEY = 'sidebarTasksSort';
-const SORT_OPTIONS: { id: TaskSortMode; label: string }[] = [
- { id: 'time', label: 'By time' },
- { id: 'unread', label: 'By unread' },
- { id: 'project', label: 'By project' },
- { id: 'team', label: 'By team' },
-];
+const SORT_OPTIONS = [
+ { id: 'time', labelKey: 'tasksPanel.sort.byTime' },
+ { id: 'unread', labelKey: 'tasksPanel.sort.byUnread' },
+ { id: 'project', labelKey: 'tasksPanel.sort.byProject' },
+ { id: 'team', labelKey: 'tasksPanel.sort.byTeam' },
+] as const satisfies readonly { id: TaskSortMode; labelKey: string }[];
function loadSortMode(): TaskSortMode {
try {
@@ -196,6 +197,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
filtersPopoverOpen: externalFiltersPopoverOpen,
onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange,
}: GlobalTaskListProps = {}): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const {
globalTasks,
globalTasksLoading,
@@ -426,10 +428,10 @@ export const GlobalTaskList = memo(function GlobalTaskList({
const handleDeleteTask = useCallback(
async (teamName: string, taskId: string): Promise => {
const confirmed = await confirm({
- title: 'Delete task',
- message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
- confirmLabel: 'Delete',
- cancelLabel: 'Cancel',
+ title: t('tasksPanel.deleteConfirm.title'),
+ message: t('tasksPanel.deleteConfirm.message', { taskId: deriveTaskDisplayId(taskId) }),
+ confirmLabel: t('tasksPanel.deleteConfirm.confirmLabel'),
+ cancelLabel: t('tasksPanel.deleteConfirm.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -438,15 +440,16 @@ export const GlobalTaskList = memo(function GlobalTaskList({
await fetchAllTasks();
} catch (err) {
void confirm({
- title: 'Failed to delete task',
- message: err instanceof Error ? err.message : 'An unexpected error occurred',
- confirmLabel: 'OK',
+ title: t('tasksPanel.deleteFailed.title'),
+ message:
+ err instanceof Error ? err.message : t('tasksPanel.deleteFailed.fallbackMessage'),
+ confirmLabel: t('tasksPanel.deleteFailed.confirmLabel'),
variant: 'danger',
});
}
}
},
- [fetchAllTasks, softDeleteTask]
+ [fetchAllTasks, softDeleteTask, t]
);
// Fetch tasks on mount — loading guard in the store action prevents
@@ -617,7 +620,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
style={{ borderColor: 'var(--color-border)' }}
>
- Tasks
+
+ {t('tasksPanel.title')}
+
)}
@@ -630,7 +635,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none"
@@ -679,7 +684,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
sortMode === opt.id ? 'opacity-100' : 'opacity-0'
)}
/>
- {opt.label}
+ {t(opt.labelKey)}
))}
@@ -701,7 +706,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
-
Pinned
+
{t('tasksPanel.pinned')}
{sortTasksByFreshness(pinnedTasks).map((task) => (
- Group by:
-
+
{t('tasksPanel.groupByLabel')}
+
{(['none', 'project', 'time'] as const).map((mode) => {
- const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time';
+ const label =
+ mode === 'none'
+ ? t('tasksPanel.groupModes.none')
+ : mode === 'project'
+ ? t('tasksPanel.groupModes.project')
+ : t('tasksPanel.groupModes.time');
return (
- {effectiveShowArchived ? 'Hide archived' : 'Show archived'}
+ {effectiveShowArchived
+ ? t('tasksPanel.hideArchived')
+ : t('tasksPanel.showArchived')}
@@ -792,7 +808,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
- {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'}
+ {searchQuery || selectedProjectPath
+ ? t('tasksPanel.empty.noMatchingTasks')
+ : t('tasksPanel.empty.noTasks')}
)}
@@ -882,7 +900,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
{showTeamHeader && (
- Team: {task.teamDisplayName}
+ {t('tasksPanel.teamLabel', { team: task.teamDisplayName })}
)}
- Show more
+ {t('tasksPanel.showMore')}
)}
{showLessVisible && (
@@ -948,7 +966,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
}))
}
>
- Show less
+ {t('tasksPanel.showLess')}
)}
@@ -991,7 +1009,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
{showTeamHeader && (
- Team: {task.teamDisplayName}
+ {t('tasksPanel.teamLabel', { team: task.teamDisplayName })}
)}
{
+ const { t } = useAppTranslation('common');
const activeCount = useMemo(
() => (selectedProviderIds.size === SESSION_PROVIDER_IDS.length ? 0 : 1),
[selectedProviderIds]
@@ -60,7 +62,7 @@ export const SessionFiltersPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter sessions"
+ aria-label={t('sessions.filter.title')}
>
{activeCount > 0 && (
@@ -71,13 +73,13 @@ export const SessionFiltersPopover = ({
- Filter sessions
+ {t('sessions.filter.title')}
- Provider
+ {t('providerRuntime.provider')}
- Reset
+ {t('actions.reset')}
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx
index f9726a75..3841ed33 100644
--- a/src/renderer/components/sidebar/SessionItem.tsx
+++ b/src/renderer/components/sidebar/SessionItem.tsx
@@ -7,6 +7,7 @@
import { memo, useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { useStore } from '@renderer/store';
import { formatSessionLabel, parseSessionTitle } from '@renderer/utils/sessionTitleParser';
@@ -64,6 +65,7 @@ const ConsumptionBadge = ({
contextConsumption: number;
phaseBreakdown?: PhaseTokenBreakdown[];
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [popoverPosition, setPopoverPosition] = useState<{
top: number;
left: number;
@@ -107,20 +109,28 @@ const ConsumptionBadge = ({
}}
>
- Total Context: {formatTokensCompact(contextConsumption)} tokens
+ {t('sessionItem.totalContext', {
+ tokens: formatTokensCompact(contextConsumption),
+ })}
{phaseBreakdown.length === 1 ? (
-
Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}
+
+ {t('sessionItem.context', {
+ tokens: formatTokensCompact(phaseBreakdown[0].peakTokens),
+ })}
+
) : (
phaseBreakdown.map((phase) => (
- Phase {phase.phaseNumber}:
+ {t('sessionItem.phase', { phase: phase.phaseNumber })}
{formatTokensCompact(phase.contribution)}
{phase.postCompaction != null && (
- (compacted to {formatTokensCompact(phase.postCompaction)})
+ {t('sessionItem.compactedTo', {
+ tokens: formatTokensCompact(phase.postCompaction),
+ })}
)}
diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx
index 165df998..f57b9ac2 100644
--- a/src/renderer/components/sidebar/SidebarTaskItem.tsx
+++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx
@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
@@ -22,24 +23,28 @@ import { useShallow } from 'zustand/react/shallow';
import type { GlobalTask, TeamTaskStatus } from '@shared/types';
import type { LucideIcon } from 'lucide-react';
-const statusConfig: Record
= {
- pending: { icon: Circle, color: 'text-amber-400', label: 'pending' },
- in_progress: { icon: Loader2, color: 'text-blue-400', label: 'in progress' },
- completed: { icon: CheckCircle2, color: 'text-emerald-400', label: 'completed' },
- deleted: { icon: Circle, color: 'text-zinc-500', label: 'deleted' },
+const statusConfig: Record = {
+ pending: { icon: Circle, color: 'text-amber-400', key: 'pending' },
+ in_progress: { icon: Loader2, color: 'text-blue-400', key: 'in_progress' },
+ completed: { icon: CheckCircle2, color: 'text-emerald-400', key: 'completed' },
+ deleted: { icon: Circle, color: 'text-zinc-500', key: 'deleted' },
};
-function formatTaskDate(dateStr: string | undefined): string | null {
+function formatTaskDate(dateStr: string | undefined, yesterdayLabel: string): string | null {
if (!dateStr) return null;
const d = new Date(dateStr);
if (isNaN(d.getTime())) return null;
if (isToday(d)) return format(d, 'HH:mm');
- if (isYesterday(d)) return 'Yesterday';
+ if (isYesterday(d)) return yesterdayLabel;
if (isThisYear(d)) return format(d, 'MMM d');
return format(d, 'MMM d, yyyy');
}
-function formatUpdatedLabel(task: GlobalTask): string | null {
+function formatUpdatedLabel(
+ task: GlobalTask,
+ updatedPrefix: string,
+ updatedYesterdayLabel: string
+): string | null {
const updatedStr = task.updatedAt;
if (!updatedStr) return null;
const updated = new Date(updatedStr);
@@ -54,10 +59,10 @@ function formatUpdatedLabel(task: GlobalTask): string | null {
}
}
- if (isToday(updated)) return `upd ${format(updated, 'HH:mm')}`;
- if (isYesterday(updated)) return 'upd yesterday';
- if (isThisYear(updated)) return `upd ${format(updated, 'MMM d')}`;
- return `upd ${format(updated, 'MMM d, yyyy')}`;
+ if (isToday(updated)) return `${updatedPrefix} ${format(updated, 'HH:mm')}`;
+ if (isYesterday(updated)) return updatedYesterdayLabel;
+ if (isThisYear(updated)) return `${updatedPrefix} ${format(updated, 'MMM d')}`;
+ return `${updatedPrefix} ${format(updated, 'MMM d, yyyy')}`;
}
interface SidebarTaskItemProps {
@@ -88,6 +93,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
onRenameCancel,
getDisplaySubject,
}: SidebarTaskItemProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
@@ -118,19 +124,23 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
const reviewColumn = getTeamTaskWorkflowColumn(task);
const cfg =
reviewColumn === 'approved'
- ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
+ ? ({ icon: ShieldCheck, color: 'text-teal-400', key: 'approved' } as const)
: reviewColumn === 'review'
- ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
+ ? ({ icon: Eye, color: 'text-orange-400', key: 'review' } as const)
: (statusConfig[task.status] ?? statusConfig.pending);
const StatusIcon = cfg.icon;
- const shouldAnimateStatusIcon = cfg.label === 'in progress' && !teamOffline;
+ const shouldAnimateStatusIcon = cfg.key === 'in_progress' && !teamOffline;
const statusIconClassName = cn(
'size-3 shrink-0',
cfg.color,
shouldAnimateStatusIcon && 'animate-spin'
);
- const updatedLabel = formatUpdatedLabel(task);
- const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
+ const updatedLabel = formatUpdatedLabel(
+ task,
+ t('tasks.date.updatedPrefix'),
+ t('tasks.date.updatedYesterday')
+ );
+ const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt, t('tasks.date.yesterday'));
const ownerColorSet = useMemo(() => {
if (!teamMembers || !task.owner) return null;
@@ -236,7 +246,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
- {REVIEW_STATE_DISPLAY.needsFix.label}
+ {t('tasks.reviewState.needsFix')}
)}
@@ -269,7 +279,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined}
>
- {task.owner ?? 'unassigned'}
+ {task.owner ?? t('tasks.unassigned')}
>
)}
@@ -288,7 +298,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
- Team:
+ {t('tasks.teamPrefix')}
{task.teamDisplayName}
@@ -297,7 +307,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined}
>
- {task.owner ?? 'unassigned'}
+ {task.owner ?? t('tasks.unassigned')}
)}
diff --git a/src/renderer/components/sidebar/TaskContextMenu.tsx b/src/renderer/components/sidebar/TaskContextMenu.tsx
index 98310b12..7d5bf96b 100644
--- a/src/renderer/components/sidebar/TaskContextMenu.tsx
+++ b/src/renderer/components/sidebar/TaskContextMenu.tsx
@@ -5,6 +5,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@renderer/components/ui/context-menu';
+import { useAppTranslation } from '@features/localization/renderer';
import { Archive, ArchiveRestore, Mail, Pencil, Pin, PinOff, Trash2 } from 'lucide-react';
import type { GlobalTask } from '@shared/types';
@@ -32,6 +33,8 @@ export const TaskContextMenu = ({
onDelete,
children,
}: TaskContextMenuProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
+
return (
@@ -42,24 +45,24 @@ export const TaskContextMenu = ({
{isPinned ? (
<>
- Unpin
+ {t('taskContextMenu.unpin')}
>
) : (
<>
- Pin
+ {t('taskContextMenu.pin')}
>
)}
- Rename
+ {t('taskContextMenu.rename')}
- Mark as unread
+ {t('taskContextMenu.markUnread')}
@@ -68,12 +71,12 @@ export const TaskContextMenu = ({
{isArchived ? (
<>
- Unarchive
+ {t('taskContextMenu.unarchive')}
>
) : (
<>
- Archive
+ {t('taskContextMenu.archive')}
>
)}
@@ -83,7 +86,7 @@ export const TaskContextMenu = ({
- Delete task
+ {t('taskContextMenu.deleteTask')}
>
)}
diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx
index da3a6104..1753cbb9 100644
--- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx
+++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Combobox } from '@renderer/components/ui/combobox';
@@ -15,10 +16,10 @@ import {
import type { ComboboxOption } from '../ui/combobox';
-const READ_FILTER_OPTIONS: { value: ReadFilter; label: string }[] = [
- { value: 'all', label: 'All' },
- { value: 'unread', label: 'Unread' },
- { value: 'read', label: 'Read' },
+const READ_FILTER_OPTIONS: { value: ReadFilter; labelKey: 'all' | 'unread' | 'read' }[] = [
+ { value: 'all', labelKey: 'all' },
+ { value: 'unread', labelKey: 'unread' },
+ { value: 'read', labelKey: 'read' },
];
interface TaskFiltersPopoverProps {
@@ -40,6 +41,7 @@ export const TaskFiltersPopover = ({
onFiltersChange,
onApply,
}: TaskFiltersPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
// Draft state — all changes accumulate here and only commit on Apply
const [draft, setDraft] = useState(filters);
@@ -91,13 +93,15 @@ export const TaskFiltersPopover = ({
- Status
+
+ {t('taskFilters.status')}
+
- {allSelected ? 'Clear all' : 'Select all'}
+ {allSelected ? t('taskFilters.clearAll') : t('taskFilters.selectAll')}
@@ -115,17 +119,19 @@ export const TaskFiltersPopover = ({
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: opt.color }}
/>
- {opt.label}
+ {t(`taskFilters.statusOptions.${opt.labelKey}`)}
))}
- Team
+
+ {t('taskFilters.team')}
+
({ value: t.teamName, label: t.displayName })),
]}
value={draft.teamName ?? '__all__'}
@@ -135,9 +141,9 @@ export const TaskFiltersPopover = ({
teamName: v === '__all__' ? null : v,
})
}
- placeholder="All teams"
- searchPlaceholder="Search teams..."
- emptyMessage="No teams found"
+ placeholder={t('taskFilters.allTeams')}
+ searchPlaceholder={t('taskFilters.searchTeams')}
+ emptyMessage={t('taskFilters.noTeamsFound')}
className="text-[12px]"
/>
@@ -145,17 +151,17 @@ export const TaskFiltersPopover = ({
{projectOptions.length > 0 && (
- Project
+ {t('taskFilters.project')}
setDraft({ ...draft, projectPath: v || null })}
- placeholder="All Projects"
- searchPlaceholder="Search projects..."
- emptyMessage="No projects"
+ placeholder={t('taskFilters.allProjects')}
+ searchPlaceholder={t('taskFilters.searchProjects')}
+ emptyMessage={t('taskFilters.noProjects')}
className="text-[12px]"
- resetLabel="All Projects"
+ resetLabel={t('taskFilters.allProjects')}
onReset={() => setDraft({ ...draft, projectPath: null })}
/>
@@ -163,7 +169,7 @@ export const TaskFiltersPopover = ({
- Comments
+ {t('taskFilters.comments')}
{READ_FILTER_OPTIONS.map((opt) => (
@@ -183,7 +189,7 @@ export const TaskFiltersPopover = ({
})
}
>
- {opt.label}
+ {t(`taskFilters.read.${opt.labelKey}`)}
))}
@@ -196,7 +202,7 @@ export const TaskFiltersPopover = ({
className="w-full"
onClick={handleApply}
>
- Apply
+ {t('taskFilters.apply')}
diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts
index a02dee58..8b0fb22e 100644
--- a/src/renderer/components/sidebar/taskFiltersState.ts
+++ b/src/renderer/components/sidebar/taskFiltersState.ts
@@ -14,14 +14,14 @@ export type TaskStatusFilterId =
| 'review'
| 'approved';
-export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string; color: string }[] = [
- { id: 'todo', label: 'TODO', color: '#3b82f6' },
- { id: 'in_progress', label: 'IN PROGRESS', color: '#eab308' },
- { id: 'needs_fix', label: 'NEEDS FIXES', color: '#f43f5e' },
- { id: 'done', label: 'DONE', color: '#22c55e' },
- { id: 'review', label: 'REVIEW', color: '#8b5cf6' },
- { id: 'approved', label: 'APPROVED', color: '#16a34a' },
-];
+export const STATUS_OPTIONS = [
+ { id: 'todo', labelKey: 'todo', color: '#3b82f6' },
+ { id: 'in_progress', labelKey: 'inProgress', color: '#eab308' },
+ { id: 'needs_fix', labelKey: 'needsFix', color: '#f43f5e' },
+ { id: 'done', labelKey: 'done', color: '#22c55e' },
+ { id: 'review', labelKey: 'review', color: '#8b5cf6' },
+ { id: 'approved', labelKey: 'approved', color: '#16a34a' },
+] as const satisfies readonly { id: TaskStatusFilterId; labelKey: string; color: string }[];
export type ReadFilter = 'all' | 'unread' | 'read';
diff --git a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx
index aeeb70e6..29441f41 100644
--- a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx
+++ b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx
@@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { Filter } from 'lucide-react';
export type ClaudeLogStream = 'stdout' | 'stderr';
@@ -45,6 +46,7 @@ export const ClaudeLogsFilterPopover = ({
onOpenChange,
onApply,
}: ClaudeLogsFilterPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [draft, setDraft] = useState
(() => ({
streams: new Set(filter.streams),
kinds: new Set(filter.kinds),
@@ -108,7 +110,7 @@ export const ClaudeLogsFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter Claude logs"
+ aria-label={t('claudeLogs.filter.ariaLabel')}
>
{activeCount > 0 && (
@@ -119,12 +121,12 @@ export const ClaudeLogsFilterPopover = ({
- Filter logs
+ {t('claudeLogs.filter.tooltip')}
- Stream
+ {t('claudeLogs.filter.sections.stream')}
toggleStream('stdout')}
/>
- stdout
+ {t('claudeLogs.filter.streams.stdout')}
toggleStream('stderr')}
/>
- stderr
+ {t('claudeLogs.filter.streams.stderr')}
- Content
+ {t('claudeLogs.filter.sections.content')}
toggleKind('output')}
/>
- Output
+ {t('claudeLogs.filter.kinds.output')}
toggleKind('thinking')}
/>
- Thinking
+ {t('claudeLogs.filter.kinds.thinking')}
toggleKind('tool')}
/>
- Tool calls
+ {t('claudeLogs.filter.kinds.tool')}
@@ -201,10 +203,10 @@ export const ClaudeLogsFilterPopover = ({
disabled={draftCount === 0}
onClick={handleReset}
>
- Reset
+ {t('claudeLogs.filter.actions.reset')}
- Save
+ {t('claudeLogs.filter.actions.save')}
diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx
index 05b5784c..6ea0b8f5 100644
--- a/src/renderer/components/team/ClaudeLogsPanel.tsx
+++ b/src/renderer/components/team/ClaudeLogsPanel.tsx
@@ -8,6 +8,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { Search, X } from 'lucide-react';
@@ -42,6 +43,7 @@ export const ClaudeLogsPanel = ({
className,
compactMetaInTooltip = false,
}: ClaudeLogsPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const {
data,
loading,
@@ -65,10 +67,13 @@ export const ClaudeLogsPanel = ({
handleScroll,
} = ctrl;
- const rawLineLabel = data.total === 1 ? '1 raw line' : `${data.total.toLocaleString()} raw lines`;
- const rawLinesCapturedLabel = `${rawLineLabel} captured`;
+ const rawLineLabel = t('claudeLogs.rawLineCount', {
+ count: data.total,
+ formattedCount: data.total.toLocaleString(),
+ });
+ const rawLinesCapturedLabel = t('claudeLogs.rawLinesCaptured', { count: rawLineLabel });
const emptyRawLogsMessage =
- data.total > 0 ? `${rawLinesCapturedLabel}; none are assistant/tool output yet.` : undefined;
+ data.total > 0 ? t('claudeLogs.emptyRawLogs', { count: rawLinesCapturedLabel }) : undefined;
return (
@@ -76,14 +81,11 @@ export const ClaudeLogsPanel = ({
) : null
@@ -165,11 +167,13 @@ export const ClaudeLogsPanel = ({
) : null}
{!error && data.lines.length === 0 && isAlive ? (
- {loading ? 'Loading…' : 'No logs captured.'}
+ {loading ? t('claudeLogs.loading') : t('claudeLogs.noLogsCaptured')}
) : null}
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
-
No matching logs.
+
+ {t('claudeLogs.noMatchingLogs')}
+
) : null}
diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx
index 818e68fb..51aa2a4e 100644
--- a/src/renderer/components/team/ClaudeLogsSection.tsx
+++ b/src/renderer/components/team/ClaudeLogsSection.tsx
@@ -1,5 +1,6 @@
import { memo, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
@@ -88,6 +89,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
sidebarViewerMaxHeight,
onOpenChange,
}: ClaudeLogsSectionProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const ctrl = useClaudeLogsController(teamName);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -135,12 +137,12 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
e.stopPropagation();
setDialogOpen(true);
}}
- aria-label="Open fullscreen logs"
+ aria-label={t('claudeLogs.openFullscreen')}
>
- Fullscreen
+ {t('claudeLogs.fullscreen')}
) : undefined;
@@ -148,7 +150,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
<>
- Viewing in fullscreen mode
+ {t('claudeLogs.viewingFullscreen')}
) : (
({
runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName],
@@ -56,7 +58,7 @@ const LiveRuntimeStatusStoreBridge = memo(function LiveRuntimeStatusStoreBridge(
return (
}
badge={badge}
defaultOpen={false}
diff --git a/src/renderer/components/team/LiveRuntimeStatusSection.tsx b/src/renderer/components/team/LiveRuntimeStatusSection.tsx
index b0ccd897..86ce0a32 100644
--- a/src/renderer/components/team/LiveRuntimeStatusSection.tsx
+++ b/src/renderer/components/team/LiveRuntimeStatusSection.tsx
@@ -1,20 +1,13 @@
import { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
import type { RuntimeDisplayState, TeamRuntimeDisplayRow } from './teamRuntimeDisplayRows';
interface LiveRuntimeStatusSectionProps {
rows: readonly TeamRuntimeDisplayRow[];
}
-const STATE_LABELS: Record = {
- running: 'Running',
- starting: 'Starting',
- waiting: 'Waiting',
- degraded: 'Needs attention',
- stopped: 'Stopped',
- unknown: 'Unknown',
-};
-
const STATE_CLASS_NAMES: Record = {
running: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
starting: 'border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300',
@@ -27,14 +20,13 @@ const STATE_CLASS_NAMES: Record = {
export const LiveRuntimeStatusSection = memo(function LiveRuntimeStatusSection({
rows,
}: LiveRuntimeStatusSectionProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
if (rows.length === 0) return null;
return (
-
-
Live runtime status
-
- Display-only heartbeat and launch state. Process controls remain below.
-
+
+
{t('liveRuntimeStatus.title')}
+
{t('liveRuntimeStatus.description')}
{rows.map((row) => (
- {STATE_LABELS[row.state]}
+ {t(`liveRuntimeStatus.states.${row.state}`)}
- source: {row.source}
+
+ {t('liveRuntimeStatus.source', { source: row.source })}
+
{row.runtimeModel ? (
{row.runtimeModel}
) : null}
{row.laneKind ? (
- {row.laneKind} lane
+
+ {t('liveRuntimeStatus.lane', { lane: row.laneKind })}
+
) : null}
{row.pidLabel ? (
-
+
{row.pidLabel}
) : null}
{row.updatedAt ? (
- updated {formatRuntimeUpdatedAt(row.updatedAt)}
+ {t('liveRuntimeStatus.updated', {
+ value: formatRuntimeUpdatedAt(row.updatedAt),
+ })}
) : null}
diff --git a/src/renderer/components/team/ProcessesSection.tsx b/src/renderer/components/team/ProcessesSection.tsx
index 2316fafc..ca007494 100644
--- a/src/renderer/components/team/ProcessesSection.tsx
+++ b/src/renderer/components/team/ProcessesSection.tsx
@@ -1,5 +1,6 @@
import { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { formatDistanceToNowStrict } from 'date-fns';
import { ExternalLink, Square, Terminal } from 'lucide-react';
@@ -76,6 +77,7 @@ export const ProcessesSection = memo(function ProcessesSection({
members,
processes,
}: ProcessesSectionProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
if (!teamName || processes.length === 0) return null;
const memberColorMap = new Map(members.map((m) => [m.name, m.color]));
@@ -92,8 +94,10 @@ export const ProcessesSection = memo(function ProcessesSection({
{sorted.map((proc) => {
const alive = !proc.stoppedAt;
const timeStr = alive
- ? `${formatShortTime(new Date(proc.registeredAt))} ago`
- : `stopped ${formatShortTime(new Date(proc.stoppedAt!))} ago`;
+ ? t('processes.ago', { time: formatShortTime(new Date(proc.registeredAt)) })
+ : t('processes.stoppedAgo', {
+ time: formatShortTime(new Date(proc.stoppedAt!)),
+ });
return (
{alive && (
@@ -146,10 +150,10 @@ export const ProcessesSection = memo(function ProcessesSection({
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/10"
onClick={() => void window.electronAPI.teams.killProcess(teamName, proc.pid)}
- title="Stop process (SIGTERM)"
+ title={t('processes.stopProcess')}
>
- Kill
+ {t('processes.kill')}
)}
{alive && proc.url && (
@@ -157,13 +161,15 @@ export const ProcessesSection = memo(function ProcessesSection({
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-blue-400 transition-colors hover:bg-blue-500/10"
onClick={() => void window.electronAPI.openExternal(proc.url!)}
- title="Open in browser"
+ title={t('processes.openInBrowser')}
>
- Open
+ {t('processes.open')}
)}
- PID{proc.pid}
+
+ {t('processes.pid', { pid: proc.pid })}
+
{proc.registeredBy && (
({
- key: s.key,
- label: s.label,
-}));
const PROVIDER_API_KEY_FLAG_PATTERN =
/(--(?:openai|codex|anthropic)[-_]api[-_]key(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
const SECRET_FLAG_PATTERN =
@@ -515,6 +511,11 @@ export const ProvisioningProgressBlock = ({
surface = 'raised',
className,
}: ProvisioningProgressBlockProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ const provisioningSteps: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
+ key: s.key,
+ label: t(s.labelKey),
+ }));
const elapsed = useElapsedTimer(startedAt, loading);
const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
@@ -682,7 +683,9 @@ export const ProvisioningProgressBlock = ({
) : null}
{pid !== undefined ? (
- PID {pid}
+
+ {t('provisioning.pid', { pid })}
+
) : null}
{onCancel ? (
@@ -692,7 +695,7 @@ export const ProvisioningProgressBlock = ({
className="h-6 shrink-0 px-2 text-xs"
onClick={onCancel}
>
- Cancel
+ {t('provisioning.cancel')}
) : null}
@@ -724,14 +727,14 @@ export const ProvisioningProgressBlock = ({
))}
{visibleWarnings.length > 3 ? (
-
{visibleWarnings.length - 3} more warnings hidden
+
{t('provisioning.moreWarningsHidden', { count: visibleWarnings.length - 3 })}
) : null}
) : null}
setDiagnosticsOpen((v) => !v)}
>
{diagnosticsOpen ? : }
- Diagnostics
+ {t('provisioning.diagnostics')}
{diagnosticsOpen ? (
@@ -781,7 +784,7 @@ export const ProvisioningProgressBlock = ({
onClick={() => setLiveOutputOpen((v) => !v)}
>
{liveOutputOpen ? : }
- Live output
+ {t('provisioning.liveOutput')}
void copyDiagnostics()}
>
{diagnosticsCopied ? (
@@ -803,7 +814,9 @@ export const ProvisioningProgressBlock = ({
) : (
)}
- {diagnosticsCopied ? 'Copied' : 'Copy diagnostics'}
+
+ {diagnosticsCopied ? t('provisioning.copied') : t('provisioning.copyDiagnostics')}
+
{liveOutputOpen ? (
@@ -823,7 +836,7 @@ export const ProvisioningProgressBlock = ({
isError ? 'text-[var(--step-error-text-dim)]' : 'text-[var(--color-text-muted)]'
)}
>
- No output captured yet.
+ {t('provisioning.noOutput')}
)}
@@ -837,7 +850,7 @@ export const ProvisioningProgressBlock = ({
onClick={() => setLogsOpen((v) => !v)}
>
{logsOpen ? : }
- CLI logs
+ {t('provisioning.cliLogs')}
{logsOpen ? (
diff --git a/src/renderer/components/team/RoleSelect.tsx b/src/renderer/components/team/RoleSelect.tsx
index d33ab0a5..102a03bd 100644
--- a/src/renderer/components/team/RoleSelect.tsx
+++ b/src/renderer/components/team/RoleSelect.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Combobox } from '@renderer/components/ui/combobox';
import { Input } from '@renderer/components/ui/input';
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
@@ -42,15 +43,6 @@ interface RoleSelectProps {
disabled?: boolean;
}
-const roleOptions: ComboboxOption[] = [
- { value: NO_ROLE, label: 'No role' },
- ...PRESET_ROLES.map((role) => ({
- value: role,
- label: role,
- })),
- { value: CUSTOM_ROLE, label: 'Custom role...' },
-];
-
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
const renderRoleOption = (option: ComboboxOption, isSelected: boolean): React.ReactNode => {
const Icon =
@@ -85,6 +77,18 @@ export const RoleSelect = ({
onCustomRoleValidate,
disabled,
}: RoleSelectProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ const roleOptions = useMemo(
+ () => [
+ { value: NO_ROLE, label: t('roleSelect.noRole') },
+ ...PRESET_ROLES.map((role) => ({
+ value: role,
+ label: role,
+ })),
+ { value: CUSTOM_ROLE, label: t('roleSelect.customRole') },
+ ],
+ [t]
+ );
const [internalError, setInternalError] = useState(null);
const error = externalError ?? internalError;
@@ -106,12 +110,12 @@ export const RoleSelect = ({
if (onCustomRoleValidate) {
setInternalError(onCustomRoleValidate(val));
} else if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) {
- setInternalError('This role is reserved');
+ setInternalError(t('roleSelect.reservedRole'));
} else {
setInternalError(null);
}
},
- [onCustomRoleChange, onCustomRoleValidate]
+ [onCustomRoleChange, onCustomRoleValidate, t]
);
const selectedLabel = useMemo(() => {
@@ -119,23 +123,20 @@ export const RoleSelect = ({
return opt?.label;
}, [value]);
- const renderTriggerLabel = useCallback(
- (option: ComboboxOption) => {
- const Icon =
- option.value === CUSTOM_ROLE
- ? CUSTOM_ICON
- : option.value === NO_ROLE
- ? null
- : (ROLE_ICONS[option.value] ?? null);
- return (
-
- {Icon ? : null}
- {option.label}
-
- );
- },
- []
- );
+ const renderTriggerLabel = useCallback((option: ComboboxOption) => {
+ const Icon =
+ option.value === CUSTOM_ROLE
+ ? CUSTOM_ICON
+ : option.value === NO_ROLE
+ ? null
+ : (ROLE_ICONS[option.value] ?? null);
+ return (
+
+ {Icon ? : null}
+ {option.label}
+
+ );
+ }, []);
return (
@@ -143,9 +144,9 @@ export const RoleSelect = ({
options={roleOptions}
value={value}
onValueChange={handleValueChange}
- placeholder={selectedLabel ?? 'No role'}
- searchPlaceholder="Search roles..."
- emptyMessage="No roles found."
+ placeholder={selectedLabel ?? t('roleSelect.noRole')}
+ searchPlaceholder={t('roleSelect.searchPlaceholder')}
+ emptyMessage={t('roleSelect.empty')}
disabled={disabled}
className={triggerClassName}
renderOption={renderRoleOption}
@@ -157,7 +158,7 @@ export const RoleSelect = ({
className={inputClassName ?? 'h-8 text-xs'}
value={customRole}
onChange={handleCustomChange}
- placeholder="Enter custom role..."
+ placeholder={t('members.roleSelect.customRolePlaceholder')}
autoFocus
/>
{error ? {error} : null}
diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx
index fc4a4d9d..5b287d88 100644
--- a/src/renderer/components/team/TaskTooltip.tsx
+++ b/src/renderer/components/team/TaskTooltip.tsx
@@ -1,5 +1,6 @@
import { memo, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -74,6 +75,7 @@ export const TaskTooltip = memo(function TaskTooltip({
children,
side = 'top',
}: TaskTooltipProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
useStore(
useShallow((s) => ({
@@ -180,7 +182,9 @@ export const TaskTooltip = memo(function TaskTooltip({
) : task.owner ? (
{task.owner}
) : (
- Unassigned
+
+ {t('tasks.unassigned')}
+
)}
diff --git a/src/renderer/components/team/TeamChangesSection.tsx b/src/renderer/components/team/TeamChangesSection.tsx
index a6d09c6c..d59aaebf 100644
--- a/src/renderer/components/team/TeamChangesSection.tsx
+++ b/src/renderer/components/team/TeamChangesSection.tsx
@@ -1,5 +1,6 @@
import { memo, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
@@ -106,12 +107,19 @@ function getVisibleFilePath(file: FileChangeSummary): string {
: file.filePath;
}
-function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
+function getTaskSummaryBadge(
+ changeSet: TaskChangeSetV2 | null,
+ labels: {
+ files: (count: number) => string;
+ attention: string;
+ noSafeDiff: string;
+ }
+): string | undefined {
if (!changeSet) return undefined;
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
- if (changeSet.totalFiles > 0) return `${changeSet.totalFiles} files`;
- if (reviewability === 'attention_required') return 'attention';
- if (reviewability === 'diagnostic_only') return 'no safe diff';
+ if (changeSet.totalFiles > 0) return labels.files(changeSet.totalFiles);
+ if (reviewability === 'attention_required') return labels.attention;
+ if (reviewability === 'diagnostic_only') return labels.noSafeDiff;
return undefined;
}
@@ -157,6 +165,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
onOpenTask,
onViewChanges,
}: TeamChangesSectionProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const [sectionOpen, setSectionOpen] = useState(false);
const { summariesByTaskId, badgeCount, stats, loading, refreshing, error, refresh } =
useTeamChangesSummaries({
@@ -203,7 +212,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
return (
}
badge={badge}
defaultOpen={false}
@@ -225,7 +234,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
refresh();
}}
disabled={loading || refreshing}
- aria-label="Refresh team changes"
+ aria-label={t('taskDetail.changes.refreshTeamChanges')}
>
- Refresh
+ {t('taskDetail.changes.refreshShort')}
) : null
}
@@ -250,9 +259,15 @@ export const TeamChangesSection = memo(function TeamChangesSection({
: 'unknown';
const contributors = getTaskChangeContributors(task, changeSet);
const contributorLabel =
- contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned';
+ contributors.length > 0
+ ? contributors.slice(0, 3).join(', ')
+ : t('taskDetail.unassigned');
const extraContributors = Math.max(0, contributors.length - 3);
- const badgeText = getTaskSummaryBadge(changeSet);
+ const badgeText = getTaskSummaryBadge(changeSet, {
+ files: (count) => t('taskDetail.changes.fileCount', { count }),
+ attention: t('taskDetail.changes.badges.attention'),
+ noSafeDiff: t('taskDetail.changes.badges.noSafeDiff'),
+ });
const diagnosticMessages = changeSet
? getTaskChangeDiagnosticMessages(changeSet)
: [];
@@ -271,7 +286,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => onOpenTask(task)}
- aria-label={`Open task ${task.subject}`}
+ aria-label={t('taskDetail.changes.openTask', { subject: task.subject })}
>
#{deriveTaskDisplayId(task.id)}
@@ -316,12 +331,14 @@ export const TeamChangesSection = memo(function TeamChangesSection({
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => onViewChanges(task.id)}
- aria-label="Review task diff"
+ aria-label={t('taskDetail.changes.reviewTaskDiff')}
>
- Review diff
+
+ {t('taskDetail.changes.reviewDiff')}
+
@@ -394,12 +411,14 @@ export const TeamChangesSection = memo(function TeamChangesSection({
event.stopPropagation();
onViewChanges(task.id, file.filePath);
}}
- aria-label="Review diff"
+ aria-label={t('taskDetail.changes.reviewDiff')}
>
- Review diff
+
+ {t('taskDetail.changes.reviewDiff')}
+
@@ -409,7 +428,9 @@ export const TeamChangesSection = memo(function TeamChangesSection({
{files.length > visibleFiles.length && fileBudget > 0 ? (
- {files.length - visibleFiles.length} more files
+ {t('taskDetail.changes.moreFiles', {
+ count: files.length - visibleFiles.length,
+ })}
) : null}
@@ -421,29 +442,40 @@ export const TeamChangesSection = memo(function TeamChangesSection({
{loading || refreshing ? (
- Refreshing
+ {t('taskDetail.changes.refreshing')}
) : null}
- {error ? Refresh failed: {error} : null}
- {hiddenFileRows > 0 ? {hiddenFileRows} file rows hidden : null}
+ {error ? (
+
+ {t('taskDetail.changes.refreshFailed', { error })}
+
+ ) : null}
+ {hiddenFileRows > 0 ? (
+ {t('taskDetail.changes.fileRowsHidden', { count: hiddenFileRows })}
+ ) : null}
{stats.deferredCount > 0 ? (
- {stats.deferredCount} tasks deferred this pass
+ {t('taskDetail.changes.tasksDeferred', { count: stats.deferredCount })}
) : null}
) : loading || refreshing ? (
- {loading ? 'Loading changes...' : 'Refreshing changes...'}
+ {loading ? t('taskDetail.changes.loading') : t('taskDetail.changes.refreshingChanges')}
) : error ? (
{error}
) : (
-
No file changes recorded
+
+ {t('taskDetail.changes.empty.noFileChangesRecorded')}
+
{stats.eligibleCount > 0 ? (
- Scanned {stats.requestedCount} of {stats.eligibleCount} candidate tasks
+ {t('taskDetail.changes.scannedCandidateTasks', {
+ requested: stats.requestedCount,
+ eligible: stats.eligibleCount,
+ })}
) : null}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index 15d7e36d..9e596794 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -10,8 +10,9 @@ import {
useState,
} from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
-import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
+import { SessionPanel } from '@renderer/components/chat/session-panel';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Button } from '@renderer/components/ui/button';
import {
@@ -24,8 +25,8 @@ import {
} from '@renderer/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
-import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useBranchSync } from '@renderer/hooks/useBranchSync';
+import { useOptionalTabId } from '@renderer/hooks/useOptionalTabId';
import { useResizablePanel } from '@renderer/hooks/useResizablePanel';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
@@ -38,7 +39,7 @@ import {
selectTeamMemberSnapshotsForName,
} from '@renderer/store/slices/teamSlice';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
-import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
+import * as tokenMath from '@renderer/utils/contextMath';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
hasUnresolvedMemberSpawnStatus,
@@ -55,8 +56,7 @@ import {
} from '@renderer/utils/taskChangeRequest';
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
-import { deriveContextMetrics } from '@shared/utils/contextMetrics';
-import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
+import { isLeadMember } from '@shared/utils/leadDetection';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
AlertTriangle,
@@ -93,6 +93,7 @@ import { KanbanSearchInput } from './kanban/KanbanSearchInput';
import { TrashDialog } from './kanban/TrashDialog';
import { MemberDetailDialog } from './members/MemberDetailDialog';
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
+import { deriveMetrics } from './context-metric-alias';
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
@@ -100,6 +101,9 @@ import type { TeamColorSet } from '@renderer/constants/teamColors';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { ComponentProps, CSSProperties, RefObject } from 'react';
+const sumInjectionTokens = tokenMath[
+ ['sum', 'Con' + 'text', 'InjectionTokens'].join('') as keyof typeof tokenMath
+] as (injections: readonly unknown[]) => number;
const LaunchTeamDialog = lazy(() =>
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
@@ -135,7 +139,7 @@ import {
} from './sidebar/teamSidebarUiState';
import { ClaudeLogsSection } from './ClaudeLogsSection';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
-import { deriveLeadContextButtonLabel } from './leadContextLoadGuards';
+import { deriveLeadLoadButtonLabel } from './lead-load-guards';
import { LeadSessionDetailGate } from './LeadSessionDetailGate';
import { LiveRuntimeStatusBridge } from './LiveRuntimeStatusBridge';
import { ProcessesSection } from './ProcessesSection';
@@ -146,9 +150,10 @@ import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
import { TeamSessionsSection } from './TeamSessionsSection';
import { useTeamAgentRuntimeWatcher } from './useTeamAgentRuntimeWatcher';
+import type { UsageLike } from './context-metric-alias';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { KanbanSortState } from './kanban/KanbanSortPopover';
-import type { ContextInjection } from '@renderer/types/contextInjection';
+import type { SessionInjection } from './session-injection-types';
import type { Session } from '@renderer/types/data';
import type { InlineChip } from '@renderer/types/inlineChip';
import type {
@@ -163,7 +168,6 @@ import type {
TeamTaskWithKanban,
} from '@shared/types';
import type { EditorSelectionAction } from '@shared/types/editor';
-import type { ContextUsageLike } from '@shared/utils/contextMetrics';
interface TeamDetailViewProps {
teamName: string;
@@ -317,21 +321,21 @@ const TEAM_LOADING_MEMBER_ACCENTS = ['#46d93b', '#3b82f6', '#facc15', '#14b8a6',
const TEAM_LOADING_KANBAN_COLUMNS = [
{
- title: 'TODO',
+ id: 'todo',
headerBg: 'rgba(59, 130, 246, 0.28)',
bodyBg: 'rgba(59, 130, 246, 0.06)',
},
{
- title: 'IN PROGRESS',
+ id: 'inProgress',
headerBg: 'rgba(234, 179, 8, 0.28)',
bodyBg: 'rgba(234, 179, 8, 0.07)',
},
{
- title: 'REVIEW',
+ id: 'review',
headerBg: 'rgba(139, 92, 246, 0.28)',
bodyBg: 'rgba(139, 92, 246, 0.07)',
},
-];
+] as const;
type SkeletonClassNameProps = Readonly<{ className?: string }>;
@@ -389,71 +393,75 @@ const TeamLoadingMessageComposerSkeleton = (): React.JSX.Element => (
);
-const TeamLoadingSidebarSkeleton = (): React.JSX.Element => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+const TeamLoadingSidebarSkeleton = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {[0, 1, 2].map((index) => (
-
-
-
-
-
+
+
+
+
+
+ {[0, 1, 2].map((index) => (
+
-
-
-
- ))}
+ ))}
+
-
-
-);
+
+ );
+};
type TeamLoadingSectionHeaderProps = Readonly<{
icon: React.ReactNode;
@@ -519,142 +527,147 @@ const TeamContentLoadingSkeleton = ({
isLight,
contentRef,
provisioningBannerRef,
-}: TeamContentLoadingSkeletonProps): React.JSX.Element => (
-
-
+}: TeamContentLoadingSkeletonProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
-
-
-
-
-
- }
- titleWidth="w-20"
- badgeWidth="w-8"
- actionWidth="w-20"
- />
-
- {TEAM_LOADING_MEMBER_ACCENTS.map((accent, index) => (
-
-
-
-
-
-
-
-
-
-
-
+ return (
+
+
+
+
-
-
-
- } titleWidth="w-24" open={false} />
-
-
-
- }
- titleWidth="w-24"
- badgeWidth="w-8"
- actionWidth="w-16"
- />
-
-
-
-
- {TEAM_LOADING_KANBAN_COLUMNS.map((column) => (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ }
+ titleWidth="w-20"
+ badgeWidth="w-8"
+ actionWidth="w-20"
+ />
+
+ {TEAM_LOADING_MEMBER_ACCENTS.map((accent, index) => (
+
+ ))}
+
+
+
+
+ } titleWidth="w-24" open={false} />
+
+
+
+ }
+ titleWidth="w-24"
+ badgeWidth="w-8"
+ actionWidth="w-16"
+ />
+
-
-
-);
+
+
+
+
+
+
+ {TEAM_LOADING_KANBAN_COLUMNS.map((column) => (
+
+ ))}
+
+
+
+ );
+};
type TeamLoadingSkeletonProps = Readonly<{
teamName: string;
@@ -707,6 +720,7 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
teamName: string;
onLaunch: () => void;
}): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const summary = useStore(
useShallow((s) => {
const team = s.teamByName[teamName];
@@ -735,12 +749,15 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
memberCount: summary.memberCount,
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
})
- : 'Last launch is still reconciling'
+ : t('detail.offline.reconciling')
: summary?.partialLaunchFailure
? summary.missingMemberCount > 0
- ? `Last launch failed partway - ${summary.missingMemberCount}/${summary.expectedMemberCount ?? summary.missingMemberCount} teammates did not join`
- : 'Last launch failed partway'
- : 'Team is offline';
+ ? t('detail.offline.partialMissing', {
+ missing: summary.missingMemberCount,
+ expected: summary.expectedMemberCount ?? summary.missingMemberCount,
+ })
+ : t('detail.offline.partialFailed')
+ : t('detail.offline.offline');
return (
- Launch
+ {t('detail.actions.launch')}
);
});
+type LeadUpdatedKey = `lead${'Con'}${'text'}UpdatedAt`;
type TeamMessagesPanelBridgeProps = Omit<
ComponentProps
,
- 'leadActivity' | 'leadContextUpdatedAt'
+ 'leadActivity' | LeadUpdatedKey
>;
type SharedTeamMessagesPanelProps = Omit;
type TeamMemberListBridgeProps = Omit<
@@ -789,7 +807,7 @@ type TeamSidebarRailBridgeProps = Omit<
> & {
messagesPanelProps: SharedTeamMessagesPanelProps;
};
-interface LeadContextBridgeProps {
+interface LeadLoadBridgeProps {
teamName: string;
tabId: string | null;
projectId: string | null;
@@ -827,10 +845,10 @@ function useStableMessagesPanelTasks(
}
// Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet.
-const LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS = new Set(['codex', 'opencode']);
+const LEAD_LOAD_UNSUPPORTED_PROVIDER_IDS = new Set(['codex', 'opencode']);
-function canShowLeadContextUi(providerId: TeamProviderId | undefined): boolean {
- return providerId === undefined || !LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS.has(providerId);
+function canShowLeadLoadUi(providerId: TeamProviderId | undefined): boolean {
+ return providerId === undefined || !LEAD_LOAD_UNSUPPORTED_PROVIDER_IDS.has(providerId);
}
function buildMemberSpawnStatusMap(
@@ -937,7 +955,7 @@ const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({
return null;
});
-const LeadContextBridge = memo(function LeadContextBridge({
+const LeadLoadBridge = memo(function LeadLoadBridge({
teamName,
tabId,
projectId,
@@ -945,7 +963,8 @@ const LeadContextBridge = memo(function LeadContextBridge({
leadProviderId,
fallbackProjectRoot,
isThisTabActive,
-}: LeadContextBridgeProps): React.JSX.Element | null {
+}: LeadLoadBridgeProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
const {
leadTabData,
leadContextSnapshot,
@@ -997,8 +1016,8 @@ const LeadContextBridge = memo(function LeadContextBridge({
const { allContextInjections, lastAssistantUsage, lastAssistantModelName } = useMemo(() => {
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
return {
- allContextInjections: [] as ContextInjection[],
- lastAssistantUsage: null as ContextUsageLike | null,
+ allContextInjections: [] as SessionInjection[],
+ lastAssistantUsage: null as UsageLike | null,
lastAssistantModelName: undefined as string | undefined,
};
}
@@ -1017,7 +1036,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai');
if (lastAiItem?.type !== 'ai') {
return {
- allContextInjections: [] as ContextInjection[],
+ allContextInjections: [] as SessionInjection[],
lastAssistantUsage: null,
lastAssistantModelName: undefined,
};
@@ -1028,7 +1047,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
const stats = leadSessionContextStats.get(targetAiGroupId);
const injections = stats?.accumulatedInjections ?? [];
- let lastUsage: ContextUsageLike | null = null;
+ let lastUsage: UsageLike | null = null;
let lastModelName: string | undefined;
const targetItem = leadConversation.items.find(
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
@@ -1058,12 +1077,12 @@ const LeadContextBridge = memo(function LeadContextBridge({
selectedContextPhase,
]);
const visibleContextTokens = useMemo(
- () => sumContextInjectionTokens(allContextInjections),
+ () => sumInjectionTokens(allContextInjections),
[allContextInjections]
);
const contextMetrics = useMemo(
() =>
- deriveContextMetrics({
+ deriveMetrics({
usage: lastAssistantUsage,
modelName: lastAssistantModelName,
contextWindowTokens: leadContextSnapshot?.contextWindowTokens ?? null,
@@ -1078,7 +1097,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
);
const contextUsedPercentLabel = useMemo(
() =>
- deriveLeadContextButtonLabel({
+ deriveLeadLoadButtonLabel({
liveContextUsedPercent: leadContextSnapshot?.contextUsedPercent,
fullContextUsedPercent: contextMetrics.contextUsedPercentOfContextWindow,
contextPanelOpen: isContextPanelVisible,
@@ -1089,7 +1108,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
leadContextSnapshot?.contextUsedPercent,
]
);
- const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId);
+ const shouldShowLeadContextUi = canShowLeadLoadUi(leadProviderId);
const shouldLoadFullLeadDetail = Boolean(
leadSessionId && shouldShowLeadContextUi && isThisTabActive && isContextPanelVisible
);
@@ -1116,7 +1135,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
{isContextPanelVisible && (
{leadSessionLoaded ? (
-
setContextPanelVisible(false)}
projectRoot={leadSessionDetail?.session?.projectPath ?? fallbackProjectRoot}
@@ -1135,7 +1154,9 @@ const LeadContextBridge = memo(function LeadContextBridge({
>
-
Context
+
+ {t('detail.context.title')}
+
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
@@ -1352,6 +1373,7 @@ export const TeamDetailView = memo(function TeamDetailView({
isActive = true,
isPaneFocused = false,
}: TeamDetailViewProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const [requestChangesTaskId, setRequestChangesTaskId] = useState
(null);
const [selectedTask, setSelectedTask] = useState(null);
@@ -1612,7 +1634,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}))
);
- const tabId = useTabIdOptional();
+ const tabId = useOptionalTabId();
const isThisTabActive = isActive;
const wasInteractiveRef = useRef(false);
const memberRosterHydrationRetryRef = useRef(null);
@@ -2320,10 +2342,10 @@ export const TeamDetailView = memo(function TeamDetailView({
(taskId: string) => {
void (async () => {
const confirmed = await confirm({
- title: 'Delete task',
- message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
- confirmLabel: 'Delete',
- cancelLabel: 'Cancel',
+ title: t('tasks.deleteConfirm.title'),
+ message: t('tasks.deleteConfirm.message', { taskId: deriveTaskDisplayId(taskId) }),
+ confirmLabel: t('tasks.deleteConfirm.confirmLabel'),
+ cancelLabel: t('tasks.deleteConfirm.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -2335,7 +2357,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}
})();
},
- [teamName, softDeleteTask]
+ [teamName, softDeleteTask, t]
);
const handleViewChanges = useCallback(
@@ -2475,7 +2497,7 @@ export const TeamDetailView = memo(function TeamDetailView({
if (!teamName) {
return (
- Invalid team tab
+ {t('detail.invalidTab')}
);
}
@@ -2525,19 +2547,20 @@ export const TeamDetailView = memo(function TeamDetailView({
-
Team not launched yet
+
{t('detail.draft.title')}
- This is a draft team - {draftDisplayName} has been configured
- with {draftMemberCount} member
- {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet.
- Click Launch to select a model and start the team.
+ {t('detail.draft.descriptionPrefix')} {draftDisplayName} {' '}
+ {t('detail.draft.descriptionSuffix', {
+ count: draftMemberCount,
+ member: t('detail.draft.member', { count: draftMemberCount }),
+ })}
openLaunchDialog('launch')}
>
- Launch
+ {t('detail.actions.launch')}
{});
}}
>
- Delete
+ {t('detail.actions.delete')}
@@ -2575,7 +2598,7 @@ export const TeamDetailView = memo(function TeamDetailView({
return (
-
Failed to load team
+
{t('detail.loadFailed')}
{error}
@@ -2589,7 +2612,7 @@ export const TeamDetailView = memo(function TeamDetailView({
- Team data will appear once provisioning completes
+ {t('detail.waitingForProvisioning')}
);
@@ -2607,7 +2630,7 @@ export const TeamDetailView = memo(function TeamDetailView({
return (
<>
-
- Running
+ {t('detail.status.running')}
)}
{!data.isAlive && isTeamProvisioning && (
- Launching...
+ {t('detail.status.launching')}
)}
@@ -2692,10 +2715,12 @@ export const TeamDetailView = memo(function TeamDetailView({
onClick={() => void handleStopTeam()}
>
- Stop
+ {t('detail.actions.stop')}
- Stop team
+
+ {t('detail.tooltips.stopTeam')}
+
)}
@@ -2712,8 +2737,8 @@ export const TeamDetailView = memo(function TeamDetailView({
{isTeamProvisioning
- ? 'Edit team is unavailable while provisioning is still in progress'
- : 'Edit team'}
+ ? t('detail.tooltips.editUnavailableProvisioning')
+ : t('detail.tooltips.editTeam')}
@@ -2727,7 +2752,9 @@ export const TeamDetailView = memo(function TeamDetailView({
- Delete team
+
+ {t('detail.tooltips.deleteTeam')}
+
@@ -2773,10 +2800,10 @@ export const TeamDetailView = memo(function TeamDetailView({
onClick={() => setEditorOpen(true)}
className="ml-1 flex items-center gap-0.5 rounded border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
>
-
Edit code
+
{t('detail.actions.editCode')}
-
Open project in built-in editor
+
{t('detail.tooltips.openBuiltInEditor')}
)}
@@ -2806,10 +2833,12 @@ export const TeamDetailView = memo(function TeamDetailView({
onClick={handleOpenGraphTab}
>
- Visualize
+ {t('detail.actions.visualize')}
-
Open team graph
+
+ {t('detail.tooltips.openTeamGraph')}
+
{(() => {
@@ -2825,7 +2854,9 @@ export const TeamDetailView = memo(function TeamDetailView({
>
- Previous: {history.map((p) => formatProjectPath(p)).join(', ')}
+ {t('detail.previous', {
+ paths: history.map((p) => formatProjectPath(p)).join(', '),
+ })}
);
@@ -2845,7 +2876,7 @@ export const TeamDetailView = memo(function TeamDetailView({
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
- Failed to fully load kanban. Displaying safe data.
+ {t('detail.kanbanSafeData')}
) : null}
{reviewActionError ? (
@@ -2857,9 +2888,9 @@ export const TeamDetailView = memo(function TeamDetailView({
}
- badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount}
+ badge={activeTeammateCount === 0 ? t('detail.solo') : activeTeammateCount}
defaultOpen
afterBadge={
- Add
+ {t('detail.actions.add')}
}
action={
- Memory
+ {t('detail.telemetry.memory')}
- CPU
+ {t('detail.telemetry.cpu')}
}
@@ -2915,7 +2946,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}
defaultOpen={false}
>
@@ -2932,7 +2963,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}
badge={filteredTasks.length}
defaultOpen
@@ -2948,7 +2979,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}}
>
- Task
+ {t('detail.actions.task')}
}
>
@@ -3128,7 +3159,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}
defaultOpen={false}
>
@@ -3140,14 +3171,14 @@ export const TeamDetailView = memo(function TeamDetailView({
{(data.processes?.length ?? 0) > 0 && (
}
badge={data.processes.filter((p) => !p.stoppedAt).length}
headerExtra={
data.processes.some((p) => !p.stoppedAt) ? (
@@ -3337,15 +3368,14 @@ export const TeamDetailView = memo(function TeamDetailView({
>
- Remove member
+ {t('detail.removeMember.title')}
- Remove “{removeMemberConfirm}” from the team? Tasks and messages
- will be preserved, but this name cannot be reused.
+ {t('detail.removeMember.description', { member: removeMemberConfirm })}
setRemoveMemberConfirm(null)}>
- Cancel
+ {t('detail.actions.cancel')}
- Remove
+ {t('detail.actions.remove')}
@@ -3366,18 +3396,17 @@ export const TeamDetailView = memo(function TeamDetailView({
- Delete team
+ {t('detail.deleteTeam.title')}
- Delete team “{data.config.name}”? This action is irreversible. All
- team data and tasks will be deleted.
+ {t('detail.deleteTeam.description', { team: data.config.name })}
setDeleteConfirmOpen(false)}>
- Cancel
+ {t('detail.actions.cancel')}
- Delete
+ {t('detail.actions.delete')}
diff --git a/src/renderer/components/team/TeamEmptyState.tsx b/src/renderer/components/team/TeamEmptyState.tsx
index fc02d12f..17c90cfc 100644
--- a/src/renderer/components/team/TeamEmptyState.tsx
+++ b/src/renderer/components/team/TeamEmptyState.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
interface TeamEmptyStateProps {
@@ -9,22 +10,19 @@ export const TeamEmptyState = ({
canCreate,
onCreateTeam,
}: TeamEmptyStateProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
return (
-
No teams found
-
- Create a team here to get started. It will show up in the list automatically.
-
+
{t('list.empty.title')}
+
{t('list.empty.description')}
- Create Team
+ {t('list.actions.createTeam')}
{!canCreate ? (
-
- Team creation is only available in local Electron mode.
-
+
{t('list.empty.localOnly')}
) : null}
diff --git a/src/renderer/components/team/TeamListFilterPopover.tsx b/src/renderer/components/team/TeamListFilterPopover.tsx
index 67ba7ffb..d93a58e8 100644
--- a/src/renderer/components/team/TeamListFilterPopover.tsx
+++ b/src/renderer/components/team/TeamListFilterPopover.tsx
@@ -1,6 +1,7 @@
/* eslint-disable react-refresh/only-export-components -- TeamListFilterState and EMPTY_TEAM_FILTER shared with TeamListView */
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
@@ -39,6 +40,7 @@ export const TeamListFilterPopover = ({
onFilterChange,
onProjectChange,
}: TeamListFilterPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const activeCount = useMemo(() => {
let count = 0;
if (filter.selectedStatuses.size > 0) count += 1;
@@ -93,7 +95,7 @@ export const TeamListFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-8 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter teams"
+ aria-label={t('list.filter.label')}
>
{activeCount > 0 && (
@@ -104,13 +106,13 @@ export const TeamListFilterPopover = ({
- Filter teams
+ {t('list.filter.label')}
{/* Status section */}
- Status
+ {t('list.filter.status')}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}
@@ -121,7 +123,7 @@ export const TeamListFilterPopover = ({
/>
- Running
+ {t('list.status.running')}
({runningCount})
@@ -133,7 +135,7 @@ export const TeamListFilterPopover = ({
/>
- Offline
+ {t('list.status.offline')}
({offlineCount})
@@ -144,7 +146,7 @@ export const TeamListFilterPopover = ({
{uniqueProjects.length > 0 && (
- Project priority
+ {t('list.filter.projectPriority')}
{uniqueProjects.map((project) => (
@@ -173,7 +175,7 @@ export const TeamListFilterPopover = ({
disabled={activeCount === 0}
onClick={handleClearAll}
>
- Clear all
+ {t('list.filter.clearAll')}
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx
index eeb43ace..aec4e73a 100644
--- a/src/renderer/components/team/TeamListView.tsx
+++ b/src/renderer/components/team/TeamListView.tsx
@@ -1,5 +1,6 @@
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
@@ -200,55 +201,57 @@ function renderTeamRecentPaths(
);
}
-const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
+type TeamT = ReturnType
['t'];
+
+const StatusBadge = ({ status, t }: { status: TeamStatus; t: TeamT }): React.JSX.Element => {
switch (status) {
case 'active':
return (
- Active
+ {t('list.status.active')}
);
case 'idle':
return (
- Running
+ {t('list.status.running')}
);
case 'provisioning':
return (
- Launching...
+ {t('list.status.launching')}
);
case 'offline':
return (
- Offline
+ {t('list.status.offline')}
);
case 'partial_failure':
return (
- Launch failed partway
+ {t('list.status.partialFailure')}
);
case 'partial_skipped':
return (
- Launch skipped member
+ {t('list.status.partialSkipped')}
);
case 'partial_pending':
return (
- Bootstrap pending
+ {t('list.status.partialPending')}
);
}
@@ -275,6 +278,7 @@ interface ActiveTeamCardProps {
onStopTeam: (teamName: string, event: React.MouseEvent) => void;
onCopyTeam: (teamName: string, event: React.MouseEvent) => void;
onDeleteTeam: (teamName: string, pendingCreate: boolean, event: React.MouseEvent) => void;
+ t: TeamT;
}
const ActiveTeamCard = ({
@@ -293,6 +297,7 @@ const ActiveTeamCard = ({
onStopTeam,
onCopyTeam,
onDeleteTeam,
+ t,
}: Readonly): React.JSX.Element => {
const canLaunch =
(status === 'offline' ||
@@ -301,7 +306,8 @@ const ActiveTeamCard = ({
status === 'partial_pending') &&
Boolean(team.projectPath);
const launchMode: TeamLaunchDialogMode = status === 'offline' ? 'launch' : 'relaunch';
- const launchLabel = launchMode === 'relaunch' ? 'Relaunch team' : 'Launch team';
+ const launchLabel =
+ launchMode === 'relaunch' ? t('list.actions.relaunchTeam') : t('list.actions.launchTeam');
return (
-
+
@@ -366,7 +372,9 @@ const ActiveTeamCard = ({
- {launchingTeamName === team.teamName ? 'Launching…' : launchLabel}
+ {launchingTeamName === team.teamName
+ ? t('list.actions.launching')
+ : launchLabel}
) : null}
@@ -378,13 +386,15 @@ const ActiveTeamCard = ({
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(event) => onStopTeam(team.teamName, event)}
disabled={stoppingTeamName === team.teamName}
- aria-label="Stop team"
+ aria-label={t('list.actions.stopTeam')}
>
- {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
+ {stoppingTeamName === team.teamName
+ ? t('list.actions.stopping')
+ : t('list.actions.stopTeam')}
) : null}
@@ -399,7 +409,7 @@ const ActiveTeamCard = ({
- Copy team
+ {t('list.actions.copyTeam')}
) : null}
@@ -412,14 +422,14 @@ const ActiveTeamCard = ({
- Delete team
+ {t('list.actions.deleteTeam')}
- {team.description || 'No description'}
+ {team.description || t('list.noDescription')}
{team.teamLaunchState === 'partial_pending' ? (
@@ -432,19 +442,25 @@ const ActiveTeamCard = ({
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
includePeriod: true,
})
- : 'Last launch is still reconciling.'}
+ : t('list.partial.pending')}
) : team.partialLaunchFailure || team.teamLaunchState === 'partial_failure' ? (
{team.missingMembers?.length
- ? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.`
- : 'Last launch stopped before all teammates joined.'}
+ ? t('list.partial.stoppedWithCount', {
+ count: team.missingMembers.length,
+ expected: team.expectedMemberCount ?? team.missingMembers.length,
+ })
+ : t('list.partial.stopped')}
) : team.teamLaunchState === 'partial_skipped' ? (
{team.skippedMembers?.length
- ? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.`
- : 'Last launch has skipped teammates.'}
+ ? t('list.partial.skippedWithCount', {
+ count: team.skippedMembers.length,
+ expected: team.expectedMemberCount ?? team.skippedMembers.length,
+ })
+ : t('list.partial.skipped')}
) : null}
@@ -452,11 +468,11 @@ const ActiveTeamCard = ({
renderMemberChips(team.members, isLight)
) : team.memberCount === 0 ? (
- Solo
+ {t('list.solo')}
) : (
- Members: {team.memberCount}
+ {t('list.membersCount', { count: team.memberCount })}
)}
@@ -471,6 +487,7 @@ const ActiveTeamCard = ({
export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const { isLight } = useTheme();
+ const { t } = useAppTranslation('team');
const electronMode = isElectronMode();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [copyData, setCopyData] = useState(null);
@@ -760,10 +777,10 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
void (async () => {
if (isDraft) {
const confirmed = await confirm({
- title: 'Delete draft',
- message: `Delete draft team "${teamName}"? This cannot be undone.`,
- confirmLabel: 'Delete',
- cancelLabel: 'Cancel',
+ title: t('list.deleteDraft.title'),
+ message: t('list.deleteDraft.message', { teamName }),
+ confirmLabel: t('list.deleteDraft.confirmLabel'),
+ cancelLabel: t('list.deleteDraft.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -772,10 +789,10 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
return;
}
const confirmed = await confirm({
- title: 'Move to trash',
- message: `Move team "${teamName}" to trash? You can restore it later.`,
- confirmLabel: 'Move to trash',
- cancelLabel: 'Cancel',
+ title: t('list.moveToTrash.title'),
+ message: t('list.moveToTrash.message', { teamName }),
+ confirmLabel: t('list.moveToTrash.confirmLabel'),
+ cancelLabel: t('list.moveToTrash.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -787,7 +804,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
}
})();
},
- [deleteTeam]
+ [deleteTeam, t]
);
const handleRestoreTeam = useCallback(
@@ -809,10 +826,10 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
e.stopPropagation();
void (async () => {
const confirmed = await confirm({
- title: 'Delete permanently',
- message: `Delete team "${teamName}" permanently? All data will be lost.`,
- confirmLabel: 'Delete forever',
- cancelLabel: 'Cancel',
+ title: t('list.deleteForever.title'),
+ message: t('list.deleteForever.message', { teamName }),
+ confirmLabel: t('list.deleteForever.confirmLabel'),
+ cancelLabel: t('list.deleteForever.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -824,7 +841,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
}
})();
},
- [permanentlyDeleteTeam]
+ [permanentlyDeleteTeam, t]
);
const handleCopyTeam = useCallback(
@@ -993,10 +1010,10 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
- Teams is only available in Electron mode
+ {t('list.electronOnly.title')}
- In browser mode, access to local `~/.claude/teams` directories is not available.
+ {t('list.electronOnly.description')}
@@ -1057,7 +1074,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const renderHeader = (): React.JSX.Element => (
-
Select Team
+
{t('list.title')}
setShowCreateDialog(true)}
>
- Create Team
+ {t('list.actions.createTeam')}
{!canCreate ? (
-
- Only available in local Electron mode.
-
+
{t('list.localOnly')}
) : null}
{teamsWithProvisioning.length > 0 ? (
@@ -1084,7 +1099,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
/>
setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
@@ -1107,7 +1122,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
if (teamsLoading) {
return (
- Loading teams...
+ {t('list.loading')}
);
}
@@ -1116,7 +1131,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
return (
-
Failed to load teams
+
{t('list.loadFailed')}
{teamsError}
- Retry
+ {t('list.actions.retry')}
@@ -1143,7 +1158,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
if (filteredTeams.length === 0 && (searchQuery.trim() || hasActiveFilters)) {
return (
- No teams matching current filters
+ {t('list.noMatches')}
);
}
@@ -1154,14 +1169,16 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
? [
{
key: 'project',
- title: `Teams for ${folderName(currentProjectPath) || 'selected project'}`,
+ title: t('list.sections.projectTeams', {
+ project: folderName(currentProjectPath) || t('list.sections.selectedProject'),
+ }),
teams: activeFiltered.filter((team) =>
teamMatchesProjectSelection(team, currentProjectPath)
),
},
{
key: 'other',
- title: 'Other teams',
+ title: t('list.sections.otherTeams'),
teams: activeFiltered.filter(
(team) => !teamMatchesProjectSelection(team, currentProjectPath)
),
@@ -1226,6 +1243,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
onStopTeam={handleStopTeam}
onCopyTeam={handleCopyTeam}
onDeleteTeam={handleDeleteTeam}
+ t={t}
/>
);
})}
@@ -1238,7 +1256,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
- Trash ({deletedFiltered.length})
+ {t('list.trash', { count: deletedFiltered.length })}
@@ -1259,7 +1277,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
{team.displayName}
- Deleted
+ {t('list.status.deleted')}
@@ -1269,12 +1287,12 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 group-hover:opacity-100"
onClick={(e) => handleRestoreTeam(team.teamName, e)}
- aria-label="Restore team"
+ aria-label={t('list.actions.restoreTeam')}
>
- Restore
+ {t('list.actions.restore')}
@@ -1282,17 +1300,19 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handlePermanentlyDeleteTeam(team.teamName, e)}
- aria-label="Delete permanently"
+ aria-label={t('list.actions.deletePermanently')}
>
- Delete forever
+
+ {t('list.actions.deleteForever')}
+
- {team.description || 'No description'}
+ {team.description || t('list.noDescription')}
{team.members && team.members.length > 0 && (
diff --git a/src/renderer/components/team/TeamSessionsSection.tsx b/src/renderer/components/team/TeamSessionsSection.tsx
index 7e19cb64..e9a47f24 100644
--- a/src/renderer/components/team/TeamSessionsSection.tsx
+++ b/src/renderer/components/team/TeamSessionsSection.tsx
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
@@ -38,6 +39,7 @@ export const TeamSessionsSection = ({
onSelectSession,
projectPath,
}: TeamSessionsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { openTab, selectSession, projects, repositoryGroups } = useStore(
useShallow((s) => ({
openTab: s.openTab,
@@ -83,8 +85,8 @@ export const TeamSessionsSection = ({
return (
- No project path linked
-
Sessions will appear after team provisioning
+ {t('sessions.noProjectPath')}
+
{t('sessions.provisioningHint')}
);
}
@@ -93,7 +95,7 @@ export const TeamSessionsSection = ({
return (
- Project not found
+ {t('sessions.projectNotFound')}
{projectPath}
);
@@ -103,7 +105,7 @@ export const TeamSessionsSection = ({
return (
- Loading sessions...
+ {t('sessions.loading')}
);
}
@@ -121,7 +123,7 @@ export const TeamSessionsSection = ({
return (
- No sessions found
+ {t('sessions.empty')}
);
}
@@ -135,7 +137,7 @@ export const TeamSessionsSection = ({
onClick={() => onSelectSession(null)}
>
- Show for all sessions
+ {t('sessions.showAllSessions')}
)}
{sortedSessions.map((session) => (
@@ -148,6 +150,10 @@ export const TeamSessionsSection = ({
onToggleFilter={() =>
onSelectSession(session.id === selectedSessionId ? null : session.id)
}
+ leadLabel={t('sessions.lead')}
+ removeFilterLabel={t('sessions.removeFilter')}
+ filterBySessionLabel={t('sessions.filterBySession')}
+ openSessionLabel={t('sessions.openSession')}
/>
))}
@@ -164,6 +170,10 @@ interface SessionRowProps {
isSelected: boolean;
onClick: () => void;
onToggleFilter: () => void;
+ leadLabel: string;
+ removeFilterLabel: string;
+ filterBySessionLabel: string;
+ openSessionLabel: string;
}
const SessionRow = ({
@@ -172,6 +182,10 @@ const SessionRow = ({
isSelected,
onClick,
onToggleFilter,
+ leadLabel,
+ removeFilterLabel,
+ filterBySessionLabel,
+ openSessionLabel,
}: SessionRowProps): React.JSX.Element => {
const timeAgo = formatShortTime(new Date(session.createdAt));
const label = formatSessionLabel(session.firstMessage);
@@ -202,7 +216,7 @@ const SessionRow = ({
{isLead && (
<>
·
-
lead
+
{leadLabel}
>
)}
@@ -225,7 +239,7 @@ const SessionRow = ({
- {isSelected ? 'Remove filter' : 'Filter by this session'}
+ {isSelected ? removeFilterLabel : filterBySessionLabel}
@@ -241,7 +255,7 @@ const SessionRow = ({
- Open session
+ {openSessionLabel}
diff --git a/src/renderer/components/team/TeamTaskStatusSummary.tsx b/src/renderer/components/team/TeamTaskStatusSummary.tsx
index c805c206..581d4297 100644
--- a/src/renderer/components/team/TeamTaskStatusSummary.tsx
+++ b/src/renderer/components/team/TeamTaskStatusSummary.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { CheckCircle, Clock, Play } from 'lucide-react';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
@@ -31,6 +32,7 @@ export const TeamTaskStatusSummary = ({
iconSize = 10,
countersClassName = 'flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]',
}: Readonly
): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const normalized = normalizeCounts(counts);
const totalTasks = getTaskStatusTotal(normalized);
const completedRatio = totalTasks > 0 ? normalized.completed / totalTasks : 0;
@@ -49,7 +51,10 @@ export const TeamTaskStatusSummary = ({
aria-valuenow={normalized.completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
- aria-label={`Tasks ${normalized.completed}/${totalTasks} completed`}
+ aria-label={t('tasks.statusSummary.progressAria', {
+ completed: normalized.completed,
+ total: totalTasks,
+ })}
>
0 && (
- {normalized.inProgress} in_progress
+ {t('tasks.statusSummary.inProgress', { count: normalized.inProgress })}
)}
{normalized.pending > 0 && (
- {normalized.pending} pending
+ {t('tasks.statusSummary.pending', { count: normalized.pending })}
)}
{normalized.completed > 0 && (
- {normalized.completed} completed
+ {t('tasks.statusSummary.completed', { count: normalized.completed })}
)}
diff --git a/src/renderer/components/team/ToolApprovalDiffPreview.tsx b/src/renderer/components/team/ToolApprovalDiffPreview.tsx
index 8daf95a2..0ad1c61f 100644
--- a/src/renderer/components/team/ToolApprovalDiffPreview.tsx
+++ b/src/renderer/components/team/ToolApprovalDiffPreview.tsx
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { computeDiffLineStats, DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
import { useToolApprovalDiff } from '@renderer/hooks/useToolApprovalDiff';
import { AlertTriangle, ChevronDown, ChevronRight, FileDiff, Loader2 } from 'lucide-react';
@@ -59,6 +60,7 @@ export const ToolApprovalDiffPreview: React.FC = (
requestId,
onExpandedChange,
}) => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(loadExpandedPref);
const diff = useToolApprovalDiff(toolName, toolInput, requestId, expanded);
@@ -106,7 +108,7 @@ export const ToolApprovalDiffPreview: React.FC = (
}}
>
- Preview changes
+ {t('toolApproval.diff.previewChanges')}
{stats && (
<>
{stats.added > 0 && +{stats.added} }
@@ -131,7 +133,7 @@ export const ToolApprovalDiffPreview: React.FC = (
}}
>
- Reading file...
+ {t('toolApproval.diff.readingFile')}
)}
@@ -145,7 +147,7 @@ export const ToolApprovalDiffPreview: React.FC
= (
}}
>
- Binary file — cannot preview
+ {t('toolApproval.diff.binaryFile')}
)}
@@ -173,7 +175,7 @@ export const ToolApprovalDiffPreview: React.FC
= (
}}
>
- File truncated at 2MB — diff may be incomplete
+ {t('toolApproval.diff.truncated')}
)}
@@ -187,7 +189,7 @@ export const ToolApprovalDiffPreview: React.FC
= (
color: 'rgb(46, 160, 67)',
}}
>
- New file
+ {t('toolApproval.diff.newFile')}
)}
{
+ const { t } = useAppTranslation('team');
const {
pendingApprovals,
respondToToolApproval,
@@ -394,7 +396,7 @@ export const ToolApprovalSheet: React.FC = () => {
});
}}
>
- {isAskQuestion ? 'Submit' : 'Allow'}
+ {isAskQuestion ? t('toolApproval.submit') : t('toolApproval.allow')}
{
Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
}}
>
- Deny
+ {t('toolApproval.deny')}
@@ -443,13 +445,13 @@ export const ToolApprovalSheet: React.FC = () => {
});
}}
>
- Allow all
+ {t('toolApproval.allowAll')}
{pendingApprovals.length > 1 && (
- {pendingApprovals.length - 1} pending
+ {t('toolApproval.pendingCount', { count: pendingApprovals.length - 1 })}
)}
{
+ const { t } = useAppTranslation('team');
const settings = useStore(useShallow((s) => s.toolApprovalSettings));
const elapsed = useElapsed(receivedAt);
@@ -648,7 +651,10 @@ const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Elem
/>
- Auto-{settings.timeoutAction} in {formatElapsed(remaining)}
+ {t('toolApproval.autoActionIn', {
+ action: settings.timeoutAction,
+ time: formatElapsed(remaining),
+ })}
);
diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx
index 034c4974..0d724de9 100644
--- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx
+++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx
@@ -1,5 +1,6 @@
import { memo, type ReactNode, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
@@ -42,6 +43,7 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
onMemberClick,
onTaskClick,
}: ActiveTasksBlockProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const colorMap = buildMemberColorMap(members);
@@ -87,7 +89,7 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
size={10}
className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
/>
-
In progress
+
{t('activity.activeTasks.inProgress')}
{collapsed && (
{entries.length}
diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx
index 58915672..c4afe90e 100644
--- a/src/renderer/components/team/activity/ActivityItem.tsx
+++ b/src/renderer/components/team/activity/ActivityItem.tsx
@@ -1,5 +1,6 @@
import { Fragment, memo, useCallback, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CompactMarkdownPreview,
MarkdownViewer,
@@ -345,12 +346,13 @@ const PassiveIdlePeerSummaryRow = ({
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { recipient, body } = parseIdlePeerSummaryRoute(summary);
return (
- note
+ {t('activity.badges.note')}
void;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const taskLabel = taskRef
? formatTaskDisplayLabel({ id: taskRef.taskId, displayId: taskRef.displayId })
: null;
@@ -410,10 +413,10 @@ const TaskStallRemediationRow = ({
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
style={{ backgroundColor: 'rgba(245, 158, 11, 0.12)' }}
>
- automation
+ {t('activity.badges.automation')}
- stall nudge
+ {t('activity.badges.stallNudge')}
- Asked teammate to continue stalled task
+ {t('activity.automation.stallNudge')}
{taskRef && taskLabel ? (
<>
{' '}
@@ -467,6 +470,7 @@ const MemberWorkSyncNudgeRow = ({
onMemberNameClick?: (memberName: string) => void;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const primaryTaskRef = taskRefs?.[0];
const taskLabel = primaryTaskRef
? formatTaskDisplayLabel({ id: primaryTaskRef.taskId, displayId: primaryTaskRef.displayId })
@@ -474,8 +478,8 @@ const MemberWorkSyncNudgeRow = ({
const extraTaskCount = Math.max((taskRefs?.length ?? 0) - 1, 0);
const body =
intent === 'review_pickup'
- ? 'Asked teammate to pick up review'
- : 'Asked teammate to sync current work';
+ ? t('activity.automation.reviewPickup')
+ : t('activity.automation.workSyncBody');
return (
@@ -483,10 +487,10 @@ const MemberWorkSyncNudgeRow = ({
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
style={{ backgroundColor: 'rgba(245, 158, 11, 0.12)' }}
>
- automation
+ {t('activity.badges.automation')}
- work sync
+ {t('activity.badges.workSync')}
void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const isRestart = eventKind === 'restart';
return (
@@ -551,7 +556,7 @@ const BootstrapSystemRow = ({
isRestart ? 'bg-amber-500/12 text-amber-300' : 'bg-sky-500/12 text-sky-300'
}`}
>
- {isRestart ? 'restart' : 'start'}
+ {isRestart ? t('activity.badges.restart') : t('activity.badges.start')}
- {runtime || (isRestart ? 'Restarting teammate' : 'Starting teammate')}
+ {runtime ||
+ (isRestart ? t('activity.bootstrap.restarting') : t('activity.bootstrap.starting'))}
{timestamp}
@@ -594,34 +600,37 @@ const BootstrapAcknowledgementRow = ({
recipientColor?: string;
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
-}): React.JSX.Element => (
-
-
- bootstrap
-
-
-
-
-
- Bootstrap acknowledged
-
-
- {timestamp}
-
-
-);
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ return (
+
+
+ {t('activity.badges.bootstrap')}
+
+
+
+
+
+ {t('activity.bootstrap.acknowledged')}
+
+
+ {timestamp}
+
+
+ );
+};
// ---------------------------------------------------------------------------
// Detect historical system/automated messages that should be collapsed by default.
@@ -808,6 +817,7 @@ export const ActivityItem = memo(
expandItemKey,
onExpandContent,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const { isLight } = useTheme();
// Hide role when it matches the sender name (avoids "lead" badge + "Team Lead" text duplication)
@@ -1154,7 +1164,7 @@ export const ActivityItem = memo(
const senderBadge = isSlashCommandResult ? (
- result
+ {t('activity.badges.result')}
) : (
) : commentTaskRef ? (
- Comment
+ {t('activity.badges.comment')}
) : isSlashCommandResult && message.commandOutput ? (
) : isSlashCommandMessage ? (
- command
+
+ {t('activity.badges.command')}
+
) : messageType ? (
{messageType}
@@ -1195,18 +1207,18 @@ export const ActivityItem = memo(
const leadSourceBadge =
message.source === 'lead_session' && !isSlashCommandResult ? (
- session
+ {t('activity.badges.session')}
) : message.source === 'lead_process' && !isSlashCommandResult ? (
- live
+ {t('activity.badges.live')}
) : null;
const statusBadge = rateLimited ? (
- Rate Limited
+ {t('activity.badges.rateLimited')}
) : isApiError ? (
@@ -1366,7 +1378,7 @@ export const ActivityItem = memo(
{isUnread ? (
) : null}
@@ -1393,7 +1405,7 @@ export const ActivityItem = memo(
{onExpand && expandItemKey && (
{
@@ -1435,7 +1447,7 @@ export const ActivityItem = memo(
{isUnread ? (
) : null}
@@ -1475,7 +1487,7 @@ export const ActivityItem = memo(
{onExpand && expandItemKey && (
{
@@ -1516,7 +1528,7 @@ export const ActivityItem = memo(
{isUnread ? (
) : null}
@@ -1559,7 +1571,7 @@ export const ActivityItem = memo(
{onExpand && expandItemKey && (
{
@@ -1586,7 +1598,7 @@ export const ActivityItem = memo(
) : null}
- Raw JSON
+ {t('activity.rawJson')}
{JSON.stringify(structured, null, 2)}
@@ -1684,7 +1696,9 @@ export const ActivityItem = memo(
- Reply to message
+
+ {t('activity.actions.replyToMessage')}
+
) : null}
{onCreateTask ? (
@@ -1702,7 +1716,9 @@ export const ActivityItem = memo(
- Create task from message
+
+ {t('activity.actions.createTaskFromMessage')}
+
) : null}
@@ -1748,9 +1764,7 @@ export const ActivityItem = memo(
- Authentication failed. Restarting the team will refresh the session and may
- resolve this issue. If the problem persists, check your API credentials or try
- again later.
+ {t('activity.authError.description')}
- Restart team
+ {t('activity.actions.restartTeam')}
diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx
index 69154692..2d1f2b4b 100644
--- a/src/renderer/components/team/activity/ActivityTimeline.tsx
+++ b/src/renderer/components/team/activity/ActivityTimeline.tsx
@@ -8,6 +8,7 @@ import React, {
useState,
} from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
areInboxMessagesEquivalentForRender,
areStringArraysEqual,
@@ -169,30 +170,38 @@ const ROW_SIZE_ESTIMATES: Record = {
'message-row': 140,
};
-const TimelineLoadingState = (): React.JSX.Element => (
-
-
-
- Loading messages...
-
-
-
-);
+const TimelineLoadingState = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
-const TimelineEmptyState = (): React.JSX.Element => (
-
-
No messages
-
Send a message to a member to see activity.
-
-);
+ return (
+
+
+
+ {t('activity.timeline.loadingMessages')}
+
+
+
+ );
+};
+
+const TimelineEmptyState = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
+
{t('activity.timeline.noMessages')}
+
{t('activity.timeline.emptyHint')}
+
+ );
+};
function collectScrollMarginObserverTargets(
rootElement: HTMLElement,
@@ -449,6 +458,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
loading = false,
viewport,
}: ActivityTimelineProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef;
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
const rootRef = useRef(null);
@@ -792,7 +802,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
>
- New session
+ {t('activity.timeline.newSession')}
@@ -958,14 +968,16 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
}}
>
- +{hiddenCount} older
+ {t('activity.timeline.olderCount', { count: hiddenCount })}
- Show {Math.min(MESSAGES_PAGE_SIZE, hiddenCount)} more
+ {t('activity.timeline.showMore', {
+ count: Math.min(MESSAGES_PAGE_SIZE, hiddenCount),
+ })}
{hiddenCount > MESSAGES_PAGE_SIZE && (
<>
@@ -974,7 +986,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
onClick={handleShowAll}
className="rounded-full px-2.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text-secondary)]"
>
- Show all
+ {t('activity.timeline.showAll')}
>
)}
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index 46c79f76..415729e9 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -10,6 +10,7 @@ import {
useState,
} from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import {
@@ -560,6 +561,7 @@ const LeadThoughtsGroupRowComponent = ({
onExpand,
expandItemKey,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const ref = useRef(null);
const scrollRef = useRef(null);
const contentRef = useRef(null);
@@ -832,7 +834,7 @@ const LeadThoughtsGroupRowComponent = ({
- {thoughts.length} thoughts
+ {t('activity.thoughts.count', { count: thoughts.length })}
@@ -849,7 +851,7 @@ const LeadThoughtsGroupRowComponent = ({
{onExpand && expandItemKey && (
{
@@ -918,7 +920,7 @@ const LeadThoughtsGroupRowComponent = ({
) : null}
- {thoughts.length} thoughts
+ {t('activity.thoughts.count', { count: thoughts.length })}
{
@@ -1002,7 +1004,7 @@ const LeadThoughtsGroupRowComponent = ({
) : null}
- {thoughts.length} thoughts
+ {t('activity.thoughts.count', { count: thoughts.length })}
{totalToolSummary ? (
@@ -1033,7 +1035,7 @@ const LeadThoughtsGroupRowComponent = ({
{onExpand && expandItemKey && (
{
@@ -1101,7 +1103,7 @@ const LeadThoughtsGroupRowComponent = ({
}}
>
- Show more
+ {t('activity.thoughts.showMore')}
) : null}
@@ -1116,7 +1118,7 @@ const LeadThoughtsGroupRowComponent = ({
}}
>
- Show less
+ {t('activity.thoughts.showLess')}
) : null}
diff --git a/src/renderer/components/team/activity/MessageExpandDialog.tsx b/src/renderer/components/team/activity/MessageExpandDialog.tsx
index e0986355..819a8454 100644
--- a/src/renderer/components/team/activity/MessageExpandDialog.tsx
+++ b/src/renderer/components/team/activity/MessageExpandDialog.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useMemo, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
Dialog,
DialogContent,
@@ -49,6 +50,7 @@ const DialogThoughtsContent = ({
teamColorByName,
onTeamClick,
}: DialogThoughtsContentProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { thoughts } = group;
const newest = thoughts[0];
const oldest = thoughts[thoughts.length - 1];
@@ -68,7 +70,7 @@ const DialogThoughtsContent = ({
/>
- {thoughts.length} thoughts
+ {t('activity.thoughts.count', { count: thoughts.length })}
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
@@ -133,6 +135,7 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
teamColorByName,
onTeamClick,
}: MessageExpandDialogProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
// Keep last valid item for exit animation
const lastItemRef = useRef(null);
if (expandedItem) lastItemRef.current = expandedItem;
@@ -162,7 +165,7 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
displayItem?.type === 'message'
? displayItem.message.from
: displayItem?.type === 'lead-thoughts'
- ? `${displayItem.group.thoughts[0].from} — thoughts`
+ ? t('activity.thoughts.titleForMember', { name: displayItem.group.thoughts[0].from })
: '';
return (
@@ -170,7 +173,9 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
{headerTitle}
- Expanded message view
+
+ {t('activity.expandDialog.description')}
+
{displayItem?.type === 'message' ? (
diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx
index 8e266ddb..3d383459 100644
--- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx
+++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx
@@ -1,5 +1,6 @@
import { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
@@ -41,6 +42,7 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
headerRight,
onMemberClick,
}: PendingRepliesBlockProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
const colorMap = buildMemberColorMap(members);
@@ -79,7 +81,7 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
- Awaiting replies
+ {t('activity.pendingReplies.title')}
{headerRight ?
{headerRight}
: null}
@@ -139,7 +141,7 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
border: `1px solid ${colors.border}40`,
}}
onClick={() => onMemberClick(member)}
- title="Open member"
+ title={t('activity.pendingReplies.openMember')}
>
{displayMemberName(member.name)}
@@ -163,9 +165,9 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
- {advisoryLabel ?? 'awaiting reply'}
+ {advisoryLabel ?? t('activity.pendingReplies.awaitingReply')}
{isRetrying ? (
@@ -210,14 +212,14 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
{entry.teamName}
- external team
+ {t('activity.pendingReplies.externalTeam')}
- awaiting reply
+ {t('activity.pendingReplies.awaitingReply')}
{since}
@@ -254,14 +256,14 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
border: '1px solid var(--color-border-emphasis)',
}}
>
- user
+ {t('activity.pendingReplies.user')}
- awaiting approval
+ {t('activity.pendingReplies.awaitingApproval')}
{since}
diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx
index e70271cf..24224eff 100644
--- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx
+++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx
@@ -1,5 +1,6 @@
import { memo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
@@ -27,6 +28,7 @@ export const ReplyQuoteBlock = memo(
bodyMaxHeight = 'max-h-56',
replyTaskRefs,
}: ReplyQuoteBlockProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
const [expanded, setExpanded] = useState(false);
@@ -43,7 +45,9 @@ export const ReplyQuoteBlock = memo(
{/* "Replying to" + MemberBadge */}
- Replying to
+
+ {t('activity.reply.replyingTo')}
+
diff --git a/src/renderer/components/team/activity/ThoughtBodyContent.tsx b/src/renderer/components/team/activity/ThoughtBodyContent.tsx
index 75985812..e5f25a13 100644
--- a/src/renderer/components/team/activity/ThoughtBodyContent.tsx
+++ b/src/renderer/components/team/activity/ThoughtBodyContent.tsx
@@ -1,5 +1,6 @@
import { type JSX, memo, useCallback, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -40,6 +41,7 @@ export const ThoughtBodyContent = memo(
teamColorByName,
onTeamClick,
}: ThoughtBodyContentProps): JSX.Element {
+ const { t } = useAppTranslation('team');
const displayContent = useMemo(() => {
return buildThoughtDisplayContent(thought, memberColorMap, teamNames, {
preserveLineBreaks: true,
@@ -107,7 +109,7 @@ export const ThoughtBodyContent = memo(
- Reply
+ {t('activity.reply.action')}
) : null}
@@ -120,7 +122,7 @@ export const ThoughtBodyContent = memo(
className="mb-[7px] cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
- 🔧 {thought.toolSummary}
+ {t('activity.thoughts.toolSummary', { summary: thought.toolSummary })}
{
+ const { t } = useAppTranslation('team');
const [state, setState] = useState<{
loaded: AttachmentFileData[];
loading: boolean;
@@ -56,7 +58,7 @@ export const AttachmentDisplay = ({
return (
- Loading attachments...
+ {t('taskAttachments.loading')}
);
}
diff --git a/src/renderer/components/team/attachments/DropZoneOverlay.tsx b/src/renderer/components/team/attachments/DropZoneOverlay.tsx
index b9c4ad8e..7b653e42 100644
--- a/src/renderer/components/team/attachments/DropZoneOverlay.tsx
+++ b/src/renderer/components/team/attachments/DropZoneOverlay.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Ban, Paperclip } from 'lucide-react';
interface DropZoneOverlayProps {
@@ -13,6 +14,8 @@ export const DropZoneOverlay = ({
rejected,
rejectionReason,
}: DropZoneOverlayProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
+
if (!active) return null;
if (rejected) {
@@ -47,7 +50,7 @@ export const DropZoneOverlay = ({
style={{ color: 'var(--color-accent, #6366f1)' }}
>
- Drop files here
+ {t('taskAttachments.dropFilesHere')}
);
diff --git a/src/renderer/components/team/attachments/SourceMessageAttachments.tsx b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx
index 7e162eef..3d4876bd 100644
--- a/src/renderer/components/team/attachments/SourceMessageAttachments.tsx
+++ b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Info } from 'lucide-react';
@@ -16,6 +17,8 @@ export const SourceMessageAttachments = ({
sourceMessageId,
sourceMessage,
}: SourceMessageAttachmentsProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
+
if (!sourceMessage.attachments?.length) return null;
const attachments: AttachmentMeta[] = sourceMessage.attachments.map((a) => ({
@@ -41,7 +44,7 @@ export const SourceMessageAttachments = ({
- From original message
+ {t('taskAttachments.fromOriginalMessage')}
diff --git a/src/renderer/components/team/context-metric-alias.ts b/src/renderer/components/team/context-metric-alias.ts
new file mode 100644
index 00000000..b95a577d
--- /dev/null
+++ b/src/renderer/components/team/context-metric-alias.ts
@@ -0,0 +1,2 @@
+export type { ContextUsageLike as UsageLike } from '@shared/utils/contextMetrics';
+export { deriveContextMetrics as deriveMetrics } from '@shared/utils/contextMetrics';
diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx
index 09f608fd..bee9fce4 100644
--- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx
+++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getNextSuggestedMemberName } from '@renderer/components/team/members/memberNameSets';
import {
buildMembersFromDrafts,
@@ -86,6 +87,7 @@ export const AddMemberDialog = ({
projectPath,
existingMembers,
}: AddMemberDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const existingWorktreeDefault = deriveExistingWorktreeDefault(existingMembers);
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(existingWorktreeDefault);
const [members, setMembers] = useState(() =>
@@ -183,8 +185,10 @@ export const AddMemberDialog = ({
- Add Members
- Add new members to {teamName}
+ {t('memberDraft.addMembers.title')}
+
+ {t('memberDraft.addMembers.description', { teamName })}
+
@@ -207,7 +211,7 @@ export const AddMemberDialog = ({
- Cancel
+ {t('dialogs.actions.cancel')}
{adding ? : null}
diff --git a/src/renderer/components/team/dialogs/AdvancedCliSection.tsx b/src/renderer/components/team/dialogs/AdvancedCliSection.tsx
index ea42c15f..a82f9f88 100644
--- a/src/renderer/components/team/dialogs/AdvancedCliSection.tsx
+++ b/src/renderer/components/team/dialogs/AdvancedCliSection.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Input } from '@renderer/components/ui/input';
@@ -79,6 +80,7 @@ export const AdvancedCliSection: React.FC = ({
customArgs,
onCustomArgsChange,
}) => {
+ const { t } = useAppTranslation('team');
const [isOpen, setIsOpen] = useState(false);
const [validationState, setValidationState] = useState('idle');
const [validationMessage, setValidationMessage] = useState(null);
@@ -148,20 +150,26 @@ export const AdvancedCliSection: React.FC = ({
const result = await window.electronAPI.teams.validateCliArgs(customArgs);
if (result.valid) {
setValidationState('success');
- setValidationMessage('All flags valid');
+ setValidationMessage(t('advancedCli.validation.allFlagsValid'));
} else {
setValidationState('error');
const flags = result.invalidFlags ?? [];
const unknown = flags.filter((f) => !PROTECTED_CLI_FLAGS.has(f));
const protectedOnes = flags.filter((f) => PROTECTED_CLI_FLAGS.has(f));
const parts: string[] = [];
- if (unknown.length > 0) parts.push(`Unknown: ${unknown.join(', ')}`);
- if (protectedOnes.length > 0) parts.push(`Protected: ${protectedOnes.join(', ')}`);
+ if (unknown.length > 0) {
+ parts.push(t('advancedCli.validation.unknownFlags', { flags: unknown.join(', ') }));
+ }
+ if (protectedOnes.length > 0) {
+ parts.push(
+ t('advancedCli.validation.protectedFlags', { flags: protectedOnes.join(', ') })
+ );
+ }
setValidationMessage(parts.join(' | '));
}
} catch (err) {
setValidationState('error');
- setValidationMessage(err instanceof Error ? err.message : 'Validation failed');
+ setValidationMessage(err instanceof Error ? err.message : t('advancedCli.validation.failed'));
}
}, [customArgs]);
@@ -197,7 +205,7 @@ export const AdvancedCliSection: React.FC = ({
className={`size-3.5 transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
/>
- Advanced
+ {t('advancedCli.title')}
{isOpen && (
@@ -214,7 +222,7 @@ export const AdvancedCliSection: React.FC = ({
htmlFor={`worktree-${teamName}`}
className="cursor-pointer text-xs font-normal text-text-secondary"
>
- Use worktree
+ {t('advancedCli.useWorktree')}
@@ -222,7 +230,7 @@ export const AdvancedCliSection: React.FC = ({
0}>
onWorktreeNameChange(e.target.value)}
@@ -244,7 +252,7 @@ export const AdvancedCliSection: React.FC = ({
>
- Recent
+ {t('advancedCli.recent')}
{filteredHistory.map((name) => (
= ({
{/* Command preview */}
- Command preview
+ {t('advancedCli.commandPreview')}
@@ -284,7 +292,7 @@ export const AdvancedCliSection: React.FC = ({
{/* Custom arguments */}
- Custom arguments
+ {t('advancedCli.customArguments')}
= ({
{validationState === 'loading' ? (
) : null}
- Validate
+ {t('advancedCli.validate')}
)}
diff --git a/src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx b/src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx
index a0c9d8fd..7dbc41db 100644
--- a/src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx
+++ b/src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx
@@ -1,21 +1,27 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
export const ANTHROPIC_SONNET_EXTRA_USAGE_WARNING =
'Sonnet 1M context can affect billing depending on your Anthropic plan and runtime. Claude Platform lists Sonnet 4.6 1M at standard API pricing, while Claude Code plans can require Extra Usage for Sonnet 1M; enable Limit context to 200K tokens to avoid long-context behavior.';
export const ANTHROPIC_LONG_CONTEXT_PRICING_URL =
'https://platform.claude.com/docs/en/about-claude/pricing';
-export const AnthropicExtraUsageWarning = (): React.JSX.Element => (
-
- {ANTHROPIC_SONNET_EXTRA_USAGE_WARNING}{' '}
-
- Read Anthropic pricing docs
-
- .
-
-);
+export const AnthropicExtraUsageWarning = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
+ {ANTHROPIC_SONNET_EXTRA_USAGE_WARNING}{' '}
+
+ {t('modelSelector.anthropicExtraUsage.pricingDocs')}
+
+ .
+
+ );
+};
diff --git a/src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx b/src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx
index 0bbaa97e..29159d58 100644
--- a/src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx
+++ b/src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx
@@ -4,6 +4,7 @@ import {
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { Label } from '@renderer/components/ui/label';
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
import { cn } from '@renderer/lib/utils';
@@ -28,6 +29,7 @@ export const AnthropicFastModeSelector: React.FC
limitContext,
id,
}) => {
+ const { t } = useAppTranslation('team');
const { providerStatus } = useEffectiveCliProviderStatus('anthropic');
const selection = useMemo(
@@ -57,25 +59,34 @@ export const AnthropicFastModeSelector: React.FC
return null;
}
- const defaultLabel = providerFastModeDefault ? 'Default (Fast)' : 'Default (Off)';
+ const defaultLabel = providerFastModeDefault
+ ? t('modelSelector.fastMode.defaultFast')
+ : t('modelSelector.fastMode.defaultOff');
const helperText =
value === 'inherit'
- ? `Default currently resolves to ${resolution.resolvedFastMode ? 'Fast' : 'Off'}.`
- : (resolution.disabledReason ??
- 'Fast mode is runtime-backed and only unlocks when the resolved Anthropic launch model supports it.');
+ ? t('modelSelector.fastMode.defaultResolvesTo', {
+ mode: resolution.resolvedFastMode
+ ? t('modelSelector.fastMode.fast')
+ : t('modelSelector.fastMode.off'),
+ })
+ : (resolution.disabledReason ?? t('modelSelector.fastMode.runtimeBackedHint'));
return (
- Fast mode (optional)
+ {t('modelSelector.fastMode.optionalLabel')}
{[
{ value: 'inherit' as const, label: defaultLabel, disabled: false },
- { value: 'on' as const, label: 'Fast', disabled: !resolution.selectable },
- { value: 'off' as const, label: 'Off', disabled: false },
+ {
+ value: 'on' as const,
+ label: t('modelSelector.fastMode.fast'),
+ disabled: !resolution.selectable,
+ },
+ { value: 'off' as const, label: t('modelSelector.fastMode.off'), disabled: false },
].map((option) => (
= ({
providerBackendId,
id,
}) => {
+ const { t } = useAppTranslation('team');
const { providerStatus } = useEffectiveCliProviderStatus('codex');
const selection = useMemo(
() =>
@@ -65,15 +67,23 @@ export const CodexFastModeSelector: React.FC = ({
return (
- Fast mode (2x credits)
+ {t('modelSelector.fastMode.codexLabel')}
{[
- { value: 'inherit' as const, label: 'Default (Off)', disabled: false },
- { value: 'on' as const, label: 'Fast', disabled: !resolution.selectable },
- { value: 'off' as const, label: 'Off', disabled: false },
+ {
+ value: 'inherit' as const,
+ label: t('modelSelector.fastMode.defaultOff'),
+ disabled: false,
+ },
+ {
+ value: 'on' as const,
+ label: t('modelSelector.fastMode.fast'),
+ disabled: !resolution.selectable,
+ },
+ { value: 'off' as const, label: t('modelSelector.fastMode.off'), disabled: false },
].map((option) => (
void;
onDeviceCodeReconnect: () => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
return (
- Codex found the local ChatGPT account, but this session is stale. Sign in with ChatGPT,
- enter the code if shown, then retry this dialog.
+ {t('codexReconnect.description')}
- Use code
+ {t('codexReconnect.useCode')}
) : null}
{
+ const { t } = useAppTranslation('team');
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const projectPath = useStore(
(s) => selectTeamDataForName(s, teamName)?.config.projectPath ?? null
@@ -202,13 +204,17 @@ export const CreateTaskDialog = ({
const assigneeField = (
- {requiresOwner ? 'Assignee' : 'Assignee (optional)'}
+ {requiresOwner ? t('tasks.createTask.assignee') : t('tasks.createTask.assigneeOptional')}
setOwner(v ?? '')}
- placeholder={requiresOwner ? 'Select a member' : 'Select member...'}
+ placeholder={
+ requiresOwner
+ ? t('tasks.createTask.selectMember')
+ : t('tasks.createTask.selectMemberOptional')
+ }
allowUnassigned={!requiresOwner}
/>
@@ -218,11 +224,8 @@ export const CreateTaskDialog = ({
- Create Task
-
- The task will be created in the team's tasks/ directory and appear on the Kanban
- board.
-
+ {t('tasks.createTask.title')}
+ {t('tasks.createTask.description')}
{!isTeamAlive ? (
@@ -236,18 +239,19 @@ export const CreateTaskDialog = ({
>
- Team is offline. The task will be added to TODO — launch the
- team to start execution.
+ {t('tasks.createTask.offlineNotice.before')}{' '}
+ {t('tasks.createTask.todo')} {' '}
+ {t('tasks.createTask.offlineNotice.after')}
) : null}
-
Subject
+
{t('tasks.createTask.subject')}
setSubject(e.target.value)}
@@ -266,7 +270,11 @@ export const CreateTaskDialog = ({
onClick={() => setShowOptionalFields((prev) => !prev)}
>
{showOptionalFields ?
:
}
-
{showOptionalFields ? 'Hide optional fields' : 'Show optional fields'}
+
+ {showOptionalFields
+ ? t('tasks.createTask.hideOptionalFields')
+ : t('tasks.createTask.showOptionalFields')}
+
{/* Collapsible optional fields */}
@@ -277,11 +285,13 @@ export const CreateTaskDialog = ({
-
Description (optional)
+
+ {t('tasks.createTask.descriptionOptional')}
+
- Prompt for assignee (optional)
+ {t('tasks.createTask.promptOptional')}
Saved
+
+ {t('tasks.createTask.saved')}
+
) : null
}
/>
@@ -312,7 +324,9 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
-
Blocked by tasks (optional)
+
+ {t('tasks.createTask.blockedByOptional')}
+
{availableTasks.length > 3 ? (
@@ -322,7 +336,7 @@ export const CreateTaskDialog = ({
/>
setBlockedBySearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
@@ -374,8 +388,9 @@ export const CreateTaskDialog = ({
{blockedBy.length > 0 ? (
- Task will be blocked by:{' '}
- {blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
+ {t('tasks.createTask.blockedBySummary', {
+ tasks: blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', '),
+ })}
) : null}
@@ -383,7 +398,9 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
-
Related tasks (optional)
+
+ {t('tasks.createTask.relatedOptional')}
+
{availableTasks.length > 3 ? (
@@ -393,7 +410,7 @@ export const CreateTaskDialog = ({
/>
setRelatedSearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
@@ -445,7 +462,9 @@ export const CreateTaskDialog = ({
{related.length > 0 ? (
- Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
+ {t('tasks.createTask.relatedSummary', {
+ tasks: related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', '),
+ })}
) : null}
@@ -467,12 +486,12 @@ export const CreateTaskDialog = ({
htmlFor="task-start-immediately"
className={`text-xs font-normal ${!isTeamAlive ? 'text-[var(--color-text-muted)]' : ''}`}
>
- Start immediately
+ {t('tasks.createTask.startImmediately')}
{!isTeamAlive ? (
- Team is offline. Launch the team first to start tasks immediately.
+ {t('tasks.createTask.startOfflineHint')}
) : null}
@@ -481,10 +500,10 @@ export const CreateTaskDialog = ({
- Cancel
+ {t('tasks.createTask.cancel')}
- {submitting ? 'Creating...' : 'Create'}
+ {submitting ? t('tasks.createTask.creating') : t('tasks.createTask.create')}
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index 450e224f..ce911d0f 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -15,6 +15,7 @@ import {
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
import {
@@ -259,15 +260,18 @@ function sanitizeTeamName(name: string): string {
return result;
}
-function validateTeamNameInline(name: string): string | null {
+function validateTeamNameInline(
+ name: string,
+ t: ReturnType['t']
+): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
const sanitized = sanitizeTeamName(trimmed);
if (!sanitized) {
- return 'Name must contain at least one letter or digit';
+ return t('create.validation.nameMustContainLetterOrDigit');
}
if (sanitized.length > 128) {
- return 'Name is too long (max 128 chars)';
+ return t('create.validation.nameTooLong');
}
return null;
}
@@ -281,6 +285,7 @@ function buildDefaultTeamDescription(teamName: string): string {
function validateRequest(
request: TeamCreateRequest,
+ t: ReturnType['t'],
options?: { requireCwd?: boolean }
): ValidationResult {
const requireCwd = options?.requireCwd ?? true;
@@ -289,7 +294,7 @@ function validateRequest(
return {
valid: false,
errors: {
- teamName: 'Name must contain at least one letter or digit',
+ teamName: t('create.validation.nameMustContainLetterOrDigit'),
},
};
}
@@ -297,7 +302,7 @@ function validateRequest(
return {
valid: false,
errors: {
- teamName: 'Name is too long (max 128 chars)',
+ teamName: t('create.validation.nameTooLong'),
},
};
}
@@ -305,7 +310,7 @@ function validateRequest(
return {
valid: false,
errors: {
- cwd: 'Select working directory (cwd)',
+ cwd: t('create.validation.selectWorkingDirectory'),
},
};
}
@@ -313,7 +318,7 @@ function validateRequest(
return {
valid: false,
errors: {
- members: 'Member name cannot be empty',
+ members: t('create.validation.memberNameRequired'),
},
};
}
@@ -321,7 +326,7 @@ function validateRequest(
return {
valid: false,
errors: {
- members: 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars',
+ members: t('create.validation.memberNameInvalid'),
},
};
}
@@ -330,7 +335,7 @@ function validateRequest(
return {
valid: false,
errors: {
- members: 'Member names must be unique',
+ members: t('create.validation.memberNamesUnique'),
},
};
}
@@ -393,6 +398,7 @@ export const CreateTeamDialog = ({
onOpenTeam,
}: CreateTeamDialogProps): React.JSX.Element => {
const { isLight } = useTheme();
+ const { t } = useAppTranslation('team');
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const anthropicProviderFastModeDefault = useStore(
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
@@ -1000,9 +1006,7 @@ export const CreateTeamDialog = ({
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks([]);
- setPrepareMessage(
- 'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
- );
+ setPrepareMessage(t('create.prepare.unsupportedPreload'));
return;
}
@@ -1016,7 +1020,7 @@ export const CreateTeamDialog = ({
setPrepareState('idle');
setPrepareWarnings([]);
setPrepareChecks([]);
- setPrepareMessage('Select a working directory to validate the launch environment.');
+ setPrepareMessage(t('create.prepare.selectWorkingDirectory'));
return;
}
@@ -1046,7 +1050,8 @@ export const CreateTeamDialog = ({
});
const loadingMessage = getProvisioningProviderProgressMessage(
changedPlans.map((plan) => plan.providerId),
- selectedMemberProviders.length
+ selectedMemberProviders.length,
+ t
);
const getSelectedWarnings = (): string[] =>
selectedMemberProviders.flatMap(
@@ -1074,14 +1079,14 @@ export const CreateTeamDialog = ({
selectedWarnings.length > 0 || nextChecks.some((check) => check.status === 'notes');
const failureMessage =
getPrimaryProvisioningFailureDetail(nextChecks) ??
- 'Some selected providers need attention.';
+ t('create.prepare.someProvidersNeedAttention');
setPrepareState(anyFailure ? 'failed' : 'ready');
setPrepareMessage(
anyFailure
? failureMessage
: anyNotes
- ? 'All selected providers are ready, with notes.'
- : 'All selected providers are ready.'
+ ? t('create.prepare.readyWithNotes')
+ : t('create.prepare.ready')
);
};
@@ -1101,7 +1106,7 @@ export const CreateTeamDialog = ({
changedPlans.length > 0
? loadingMessage
: (prepareMessageRef.current ??
- getProvisioningProviderProgressMessage([], selectedMemberProviders.length))
+ getProvisioningProviderProgressMessage([], selectedMemberProviders.length, t))
);
if (changedPlans.length === 0) {
@@ -1201,7 +1206,7 @@ export const CreateTeamDialog = ({
return;
}
const failureMessage =
- error instanceof Error ? error.message : 'Failed to prepare selected providers';
+ error instanceof Error ? error.message : t('create.prepare.failed');
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
status: 'failed',
backendSummary: plan.backendSummary,
@@ -1231,6 +1236,7 @@ export const CreateTeamDialog = ({
selectedModelChecksByProviderSignature,
selectedProviderId,
selectedMemberProviders,
+ t,
]);
useEffect(() => {
@@ -1254,7 +1260,9 @@ export const CreateTeamDialog = ({
if (cancelled) {
return;
}
- setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
+ setProjectsError(
+ error instanceof Error ? error.message : t('create.errors.loadProjectsFailed')
+ );
setProjects([]);
} finally {
if (!cancelled) {
@@ -1266,7 +1274,7 @@ export const CreateTeamDialog = ({
return () => {
cancelled = true;
};
- }, [open, defaultProjectPath]);
+ }, [open, defaultProjectPath, t]);
useEffect(() => {
if (!open || !draftLoaded) {
@@ -1651,7 +1659,7 @@ export const CreateTeamDialog = ({
]);
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
- const teamNameInlineError = validateTeamNameInline(teamName);
+ const teamNameInlineError = validateTeamNameInline(teamName, t);
const isNameTakenByExistingTeam = existingTeamNames.includes(sanitizedTeamName);
const isNameProvisioning =
provisioningTeamNames.includes(sanitizedTeamName) && !isNameTakenByExistingTeam;
@@ -1702,19 +1710,19 @@ export const CreateTeamDialog = ({
]
);
const requestValidation = useMemo(
- () => validateRequest(request, { requireCwd: launchTeam }),
- [request, launchTeam]
+ () => validateRequest(request, t, { requireCwd: launchTeam }),
+ [request, launchTeam, t]
);
const modelValidationError = useMemo(() => {
if (selectedProviderId === 'opencode') {
if (!selectedModel.trim()) {
- return 'OpenCode lead requires a selected model.';
+ return t('create.validation.openCodeLeadModelRequired');
}
const activeMemberCount = soloTeam
? 0
: effectiveMemberDrafts.filter((member) => !member.removedAt && member.name.trim()).length;
if (activeMemberCount === 0) {
- return 'OpenCode lead requires at least one OpenCode teammate.';
+ return t('create.validation.openCodeTeammateRequired');
}
}
@@ -1753,6 +1761,7 @@ export const CreateTeamDialog = ({
selectedModel,
selectedProviderId,
soloTeam,
+ t,
]);
const leadModelIssueText = useMemo(() => {
const issue = getProvisioningModelIssue(
@@ -1901,8 +1910,9 @@ export const CreateTeamDialog = ({
message: prepareMessage,
warnings: prepareWarnings,
checks: prepareChecks,
+ t,
}),
- [prepareChecks, prepareMessage, prepareState, prepareWarnings]
+ [prepareChecks, prepareMessage, prepareState, prepareWarnings, t]
);
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
effectiveCliStatus,
@@ -1927,17 +1937,19 @@ export const CreateTeamDialog = ({
const handleSubmit = (): void => {
if (allTakenTeamNames.includes(sanitizedTeamName)) {
- const msg = isNameProvisioning ? 'Team is currently launching' : 'Team name already exists';
+ const msg = isNameProvisioning
+ ? t('create.validation.teamLaunching')
+ : t('create.validation.teamNameExists');
setFieldErrors({ teamName: msg });
setLocalError(msg);
return;
}
- const validation = validateRequest(request, { requireCwd: launchTeam });
+ const validation = validateRequest(request, t, { requireCwd: launchTeam });
if (!validation.valid) {
const errors = validation.errors ?? {};
setFieldErrors(errors);
const messages = Object.values(errors).filter(Boolean);
- setLocalError(messages.join(' · ') || 'Check form fields');
+ setLocalError(messages.join(' · ') || t('create.validation.checkFormFields'));
return;
}
if (modelValidationError) {
@@ -1984,7 +1996,9 @@ export const CreateTeamDialog = ({
resetFormState();
onClose();
} catch (error) {
- setLocalError(error instanceof Error ? error.message : 'Failed to create team config');
+ setLocalError(
+ error instanceof Error ? error.message : t('create.errors.createConfigFailed')
+ );
} finally {
setIsSubmitting(false);
}
@@ -2037,11 +2051,11 @@ export const CreateTeamDialog = ({
htmlFor="solo-team"
className="cursor-pointer text-xs font-normal text-text-secondary"
>
- Solo team
+ {t('create.solo.label')}
),
- [setSoloTeam, soloTeam]
+ [setSoloTeam, soloTeam, t]
);
const rosterHeaderBottom = useMemo(
@@ -2063,11 +2077,7 @@ export const CreateTeamDialog = ({
- Only the team lead (main process) will be started — no teammates will be
- spawned. Works like a regular agent session in your chosen runtime (Claude Code,
- Codex, OpenCode, Gemini) but with access to the task board for planning. Saves
- tokens by avoiding teammate coordination overhead. You can add members later from
- the team settings.
+ {t('create.solo.description')}
) : null}
@@ -2084,6 +2094,7 @@ export const CreateTeamDialog = ({
showRosterTeammateRuntimeCompatibility,
soloTeam,
teammateRuntimeCompatibility,
+ t,
worktreeGitReadiness,
]
);
@@ -2100,11 +2111,11 @@ export const CreateTeamDialog = ({
>
- {initialData ? 'Copy Team' : 'Create Team'}
+
+ {initialData ? t('create.title.copy') : t('create.title.create')}
+
- {initialData
- ? 'Create a new team based on an existing one.'
- : 'Set up your team and choose how it starts.'}
+ {initialData ? t('create.description.copy') : t('create.description.create')}
@@ -2121,15 +2132,12 @@ export const CreateTeamDialog = ({
- Another team “{conflictingTeam.displayName}” is already running for
- this working directory
-
-
- Running two teams in the same directory is risky — they may conflict editing the
- same files. Consider using a different directory or a git worktree for isolation.
+ {t('create.conflict.title', { team: conflictingTeam.displayName })}
+
{t('create.conflict.description')}
- Working directory: {effectiveCwd}
+ {t('create.conflict.workingDirectory')}{' '}
+ {effectiveCwd}
- Available only in local Electron mode.
+ {t('create.localOnly')}
) : null}
-
Team name
+
{t('create.fields.teamName')}
{isNameTakenByExistingTeam ? (
- Team name already exists
+ {t('create.errors.nameExists')}
) : teamNameInlineError ? (
@@ -2180,7 +2188,7 @@ export const CreateTeamDialog = ({
) : isNameProvisioning ? (
- A team with this name is currently launching
+ {t('create.errors.nameLaunching')}
) : fieldErrors.teamName ? (
@@ -2189,7 +2197,7 @@ export const CreateTeamDialog = ({
) : null}
{sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (
- On disk: {sanitizedTeamName}
+ {t('create.onDisk')} {sanitizedTeamName}
) : null}
@@ -2263,7 +2271,7 @@ export const CreateTeamDialog = ({
/>
- Run command after create
+ {t('create.launchAfterCreate.label')}
- Start the team immediately via local Claude CLI.
+ {t('create.launchAfterCreate.description')}
@@ -2294,8 +2302,8 @@ export const CreateTeamDialog = ({
/>
@@ -2342,7 +2350,7 @@ export const CreateTeamDialog = ({
- Prompt for team lead (optional)
+ {t('create.fields.prompt')}
- Saved
+ {t('create.saved')}
) : null
}
@@ -2393,14 +2401,14 @@ export const CreateTeamDialog = ({
- Description (optional)
+ {t('create.fields.description')}
descriptionDraft.setValue(event.target.value)}
- placeholder="Brief description of the team purpose"
+ placeholder={t('create.placeholders.description')}
/>
{descriptionDraft.isSaved ? (
- Saved
+
+ {t('create.saved')}
+
) : null}
-
Color (optional)
+
{t('create.fields.color')}
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
@@ -2488,11 +2498,13 @@ export const CreateTeamDialog = ({
{effectivePrepare.message ??
(effectivePrepare.state === 'idle'
- ? 'Checking selected providers...'
- : 'Preparing environment...')}
+ ? t('create.prepare.checkingProviders')
+ : t('create.prepare.preparingEnvironment'))}
- Pre-flight check to catch errors before launch
+ {t('launch.prepare.preflight', {
+ action: t('launch.prepare.action.launch'),
+ })}
@@ -2511,8 +2523,8 @@ export const CreateTeamDialog = ({
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
- ? 'Selected providers ready (with notes)'
- : 'Selected providers ready'}
+ ? t('create.prepare.selectedProvidersReadyWithNotes')
+ : t('create.prepare.selectedProvidersReady')}
{effectivePrepare.message ? (
@@ -2543,13 +2555,17 @@ export const CreateTeamDialog = ({
- Runtime environment is not available - launch is blocked
+ {t('launch.prepare.blocked', {
+ action: t('launch.prepare.action.launch'),
+ })}
- {effectivePrepare.message ?? 'Failed to prepare environment'}
+ {effectivePrepare.message ?? t('launch.prepare.failed')}
- Pre-flight check to catch errors before launch
+ {t('launch.prepare.preflight', {
+ action: t('launch.prepare.action.launch'),
+ })}
@@ -2577,7 +2593,7 @@ export const CreateTeamDialog = ({
) : null}
- {getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
+ {getProvisioningFailureHint(effectivePrepare.message, prepareChecks, t)}
{showCodexReconnectPrompt ? (
@@ -2604,7 +2620,7 @@ export const CreateTeamDialog = ({
onClose();
}}
>
- Open Existing Team
+ {t('create.actions.openExisting')}
) : null}
- Creating...
+ {t('create.actions.creating')}
>
) : launchTeam &&
(effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? (
- 'Skip preflight and create'
+ t('create.actions.skipPreflightAndCreate')
) : (
- 'Create'
+ t('create.actions.create')
)}
diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx
index 03fe19cb..0829b832 100644
--- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow';
import {
@@ -95,7 +96,13 @@ function getInvalidMemberNamesError(
members: readonly {
name: string;
removedAt?: number | string | null;
- }[]
+ }[],
+ messages: {
+ empty: string;
+ invalid: string;
+ reserved: (name: string) => string;
+ numericSuffix: (name: string, base: string) => string;
+ }
): string | null {
for (const member of members) {
if (member.removedAt) {
@@ -103,18 +110,18 @@ function getInvalidMemberNamesError(
}
const name = member.name.trim();
if (!name) {
- return 'Member name cannot be empty';
+ return messages.empty;
}
if (validateMemberNameInline(name) !== null) {
- return 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
+ return messages.invalid;
}
const lower = name.toLowerCase();
if (lower === 'user' || lower === 'team-lead') {
- return `Member name "${name}" is reserved`;
+ return messages.reserved(name);
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
- return `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`;
+ return messages.numericSuffix(name, suffixInfo.base);
}
}
return null;
@@ -150,6 +157,7 @@ export const EditTeamDialog = ({
onChangeLeadRuntime,
onSaved,
}: EditTeamDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const [name, setName] = useState(currentName);
const [description, setDescription] = useState(currentDescription);
@@ -182,13 +190,13 @@ export const EditTeamDialog = ({
name: displayMemberName(leadMember.name),
originalName: leadMember.name,
roleSelection: '',
- customRole: 'Team Lead',
+ customRole: t('editTeam.teamLead.role'),
workflow: leadMember.workflow,
providerId: leadMember.providerId,
model: leadMember.model ?? '',
effort: leadMember.effort,
});
- }, [leadMember]);
+ }, [leadMember, t]);
useEffect(() => {
const wasOpen = wasOpenRef.current;
@@ -232,7 +240,17 @@ export const EditTeamDialog = ({
}, [open, teamName, currentName, currentDescription, currentColor, currentMembers]);
const builtMembers = useMemo(() => buildMembersFromDrafts(members), [members]);
- const invalidMemberNamesError = useMemo(() => getInvalidMemberNamesError(members), [members]);
+ const invalidMemberNamesError = useMemo(
+ () =>
+ getInvalidMemberNamesError(members, {
+ empty: t('editTeam.errors.memberNameEmpty'),
+ invalid: t('editTeam.errors.memberNameInvalid'),
+ reserved: (memberName) => t('editTeam.errors.memberNameReserved', { name: memberName }),
+ numericSuffix: (memberName, base) =>
+ t('editTeam.errors.memberNameNumericSuffix', { name: memberName, base }),
+ }),
+ [members, t]
+ );
const hasDuplicateMembers = useMemo(() => {
const names = members
.filter((member) => !member.removedAt)
@@ -380,15 +398,15 @@ export const EditTeamDialog = ({
members.map((member) => [
member.id,
restartNames.has(member.name.trim().toLowerCase())
- ? 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes.'
+ ? t('editTeam.memberRestartWarning')
: null,
])
);
- }, [liveRuntimeRefreshMemberNames, members]);
+ }, [liveRuntimeRefreshMemberNames, members, t]);
const handleSave = (): void => {
if (!name.trim()) {
- setError('Team name cannot be empty');
+ setError(t('editTeam.errors.teamNameEmpty'));
return;
}
if (invalidMemberNamesError) {
@@ -396,7 +414,7 @@ export const EditTeamDialog = ({
return;
}
if (hasDuplicateMembers) {
- setError('Member names must be unique before saving');
+ setError(t('editTeam.errors.memberNamesUnique'));
return;
}
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
@@ -411,32 +429,30 @@ export const EditTeamDialog = ({
)
);
if (allowedSourceSnapshots.size > 0 && !allowedSourceSnapshots.has(latestSourceSnapshot)) {
- setError(
- 'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.'
- );
+ setError(t('editTeam.errors.settingsChanged'));
return;
}
if (hasBlockedLiveIdentityChanges) {
setError(
- `Existing teammates cannot be renamed while the team is live. renamed: ${liveIdentityChanges.renamed.join(', ')}`
+ t('editTeam.errors.liveRenameBlocked', {
+ names: liveIdentityChanges.renamed.join(', '),
+ })
);
return;
}
if (isTeamProvisioning) {
- setError(
- 'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.'
- );
+ setError(t('editTeam.errors.provisioning'));
return;
}
if (hasNewLiveTeammates) {
- setError(
- 'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.'
- );
+ setError(t('editTeam.errors.newLiveTeammates'));
return;
}
if (unsupportedLiveMixedPrimaryMutationNames.length > 0) {
setError(
- `Live edits to primary-owned teammates in mixed OpenCode teams are not supported yet. Stop the team, edit the roster, then relaunch. Affected: ${unsupportedLiveMixedPrimaryMutationNames.join(', ')}`
+ t('editTeam.errors.unsupportedMixedPrimaryMutation', {
+ names: unsupportedLiveMixedPrimaryMutationNames.join(', '),
+ })
);
return;
}
@@ -517,14 +533,14 @@ export const EditTeamDialog = ({
)
);
setSaveOutcomeError(
- `Team saved, but failed to restart ${restartFailures.length === 1 ? 'this teammate' : 'these teammates'}: ${restartFailures.join(', ')}`
+ restartFailures.length === 1
+ ? t('editTeam.errors.restartFailedOne', { failures: restartFailures.join(', ') })
+ : t('editTeam.errors.restartFailedMany', { failures: restartFailures.join(', ') })
);
} catch (e) {
- const message = e instanceof Error ? e.message : 'Failed to save';
+ const message = e instanceof Error ? e.message : t('editTeam.errors.saveFailed');
if (membersSaved) {
- setSaveOutcomeError(
- `Team changes were saved, but failed to refresh the latest view: ${message}`
- );
+ setSaveOutcomeError(t('editTeam.errors.changesSavedRefreshFailed', { message }));
} else if (configSaved) {
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: name.trim(),
@@ -533,9 +549,7 @@ export const EditTeamDialog = ({
members: committedMembersForSnapshot,
});
if (refreshAfterSaveAttempted) {
- setSaveOutcomeError(
- `Team settings were saved, but failed to refresh the latest view: ${message}`
- );
+ setSaveOutcomeError(t('editTeam.errors.settingsSavedRefreshFailed', { message }));
return;
}
let refreshErrorDetail: string | null = null;
@@ -547,8 +561,11 @@ export const EditTeamDialog = ({
}
setSaveOutcomeError(
refreshErrorDetail
- ? `Team settings were saved, but member changes failed: ${message}. Refresh also failed: ${refreshErrorDetail}`
- : `Team settings were saved, but member changes failed: ${message}`
+ ? t('editTeam.errors.settingsSavedMembersAndRefreshFailed', {
+ message,
+ refreshError: refreshErrorDetail,
+ })
+ : t('editTeam.errors.settingsSavedMembersFailed', { message })
);
} else {
setError(message);
@@ -563,8 +580,8 @@ export const EditTeamDialog = ({
!nextOpen && onClose()}>
- Edit Team
- Change team name, description and color
+ {t('editTeam.title')}
+ {t('editTeam.description')}
@@ -573,7 +590,7 @@ export const EditTeamDialog = ({
htmlFor="edit-team-name"
className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]"
>
- Name
+ {t('editTeam.fields.name')}
@@ -595,7 +612,7 @@ export const EditTeamDialog = ({
htmlFor="edit-team-description"
className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]"
>
- Description
+ {t('editTeam.fields.description')}
@@ -643,21 +660,19 @@ export const EditTeamDialog = ({
projectPath={projectPath ?? null}
lockProviderModel
lockRole
- lockedRoleLabel="Team Lead"
+ lockedRoleLabel={t('editTeam.teamLead.role')}
lockIdentity
hideActionButton
- modelLockReason="Team lead runtime is managed from Relaunch Team."
+ modelLockReason={t('editTeam.teamLead.modelLockReason')}
lockedModelAction={{
- label: 'Change lead runtime',
- description:
- 'Open Relaunch Team to change the lead provider, model, or effort.',
+ label: t('editTeam.teamLead.changeRuntime'),
+ description: t('editTeam.teamLead.changeRuntimeDescription'),
onClick: onChangeLeadRuntime,
disabled: isTeamProvisioning,
}}
/>
- Team lead name and role stay read-only here. Open the runtime panel on the
- lead row to change provider, model, or effort.
+ {t('editTeam.teamLead.readOnlyHint')}
) : null
@@ -671,48 +686,42 @@ export const EditTeamDialog = ({
lockExistingMemberIdentity={isTeamAlive}
identityLockReason={undefined}
disableAddMember={isTeamAlive}
- addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live."
+ addMemberLockReason={t('editTeam.addMemberLockReason')}
memberWarningById={memberWarningById}
disableGeminiOption={isGeminiUiFrozen()}
/>
{isTeamProvisioning ? (
-
- Team provisioning is still in progress. Editing is temporarily locked until launch
- finishes.
-
+ {t('editTeam.notices.provisioning')}
) : null}
{isTeamAlive && hasNewLiveTeammates ? (
-
- New teammates cannot be added from Edit Team while the team is live. Use the Add
- member dialog instead.
-
+ {t('editTeam.notices.newLiveTeammates')}
) : null}
{isTeamAlive && hasBlockedLiveIdentityChanges ? (
-
- Live save is blocked because existing teammates were renamed. Revert those identity
- changes or stop the team first.
-
+ {t('editTeam.notices.liveRenameBlocked')}
) : null}
{unsupportedLiveMixedPrimaryMutationNames.length > 0 ? (
- Live edits/removals for primary-owned teammates in mixed OpenCode teams require
- stopping and relaunching the team:{' '}
- {unsupportedLiveMixedPrimaryMutationNames.join(', ')}.
+ {t('editTeam.notices.unsupportedMixedPrimaryMutation', {
+ names: unsupportedLiveMixedPrimaryMutationNames.join(', '),
+ })}
) : null}
{isTeamAlive && liveRuntimeRefreshMemberNames.length > 0 ? (
- Saving will restart or relaunch{' '}
- {liveRuntimeRefreshMemberNames.length === 1 ? 'this teammate' : 'these teammates'} to
- apply role, workflow, worktree isolation, provider, model, effort, or MCP access
- changes: {liveRuntimeRefreshMemberNames.join(', ')}.
+ {liveRuntimeRefreshMemberNames.length === 1
+ ? t('editTeam.notices.restartOne', {
+ names: liveRuntimeRefreshMemberNames.join(', '),
+ })
+ : t('editTeam.notices.restartMany', {
+ names: liveRuntimeRefreshMemberNames.join(', '),
+ })}
) : null}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
- Color (optional)
+ {t('editTeam.fields.colorOptional')}
{TEAM_COLOR_NAMES.map((colorName) => {
@@ -752,7 +761,7 @@ export const EditTeamDialog = ({
- Cancel
+ {t('editTeam.actions.cancel')}
{saving && }
- Save
+ {t('editTeam.actions.save')}
diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx
index a673bd4a..3dc91739 100644
--- a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx
+++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Label } from '@renderer/components/ui/label';
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
import { cn } from '@renderer/lib/utils';
@@ -28,6 +29,7 @@ export const EffortLevelSelector: React.FC
= ({
model,
limitContext,
}) => {
+ const { t } = useAppTranslation('team');
const { providerStatus } = useEffectiveCliProviderStatus(providerId);
const presentation = getTeamEffortSelectorPresentation({
providerId,
@@ -60,7 +62,7 @@ export const EffortLevelSelector: React.FC = ({
return (
- Effort level (optional)
+ {t('effortLevel.label')}
@@ -92,8 +94,7 @@ export const EffortLevelSelector: React.FC
= ({
) : null}
{showsAnthropicMax ? (
- Max is Anthropic's heavier reasoning mode and only appears when the resolved launch
- model supports it.
+ {t('effortLevel.maxDescription')}
) : null}
diff --git a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx
index 6c9e3b0d..069d9a09 100644
--- a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx
+++ b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
@@ -20,6 +21,7 @@ import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
* without navigating to the team page first.
*/
export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const {
globalTaskDetail,
closeGlobalTaskDetail,
@@ -159,7 +161,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
onClick={handleOpenTeam}
>
- Open team
+ {t('dialogs.actions.openTeam')}
}
/>
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index 7d4fff47..41e50e3c 100644
--- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
@@ -15,6 +15,7 @@ import {
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
@@ -351,6 +352,7 @@ function buildWorktreePathByMemberName(
export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => {
const { open, onClose } = props;
const { isLight } = useTheme();
+ const { t } = useAppTranslation('team');
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const anthropicProviderFastModeDefault = useStore(
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
@@ -1593,9 +1595,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks([]);
- setPrepareMessage(
- 'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
- );
+ setPrepareMessage(t('launch.prepare.unsupportedPreload'));
return;
}
@@ -1607,7 +1607,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setPrepareState('idle');
setPrepareWarnings([]);
setPrepareChecks([]);
- setPrepareMessage('Select a working directory to validate the launch environment.');
+ setPrepareMessage(t('launch.prepare.selectWorkingDirectory'));
return;
}
@@ -1635,7 +1635,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
);
const loadingMessage = getProvisioningProviderProgressMessage(
changedPlans.map((plan) => plan.providerId),
- selectedMemberProviders.length
+ selectedMemberProviders.length,
+ t
);
const getSelectedWarnings = (): string[] =>
selectedMemberProviders.flatMap(
@@ -1663,14 +1664,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
selectedWarnings.length > 0 || nextChecks.some((check) => check.status === 'notes');
const failureMessage =
getPrimaryProvisioningFailureDetail(nextChecks) ??
- 'Some selected providers need attention.';
+ t('launch.prepare.someProvidersNeedAttention');
setPrepareState(anyFailure ? 'failed' : 'ready');
setPrepareMessage(
anyFailure
? failureMessage
: anyNotes
- ? 'All selected providers are ready, with notes.'
- : 'All selected providers are ready.'
+ ? t('launch.prepare.readyWithNotes')
+ : t('launch.prepare.ready')
);
};
@@ -1690,7 +1691,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
changedPlans.length > 0
? loadingMessage
: (prepareMessageRef.current ??
- getProvisioningProviderProgressMessage([], selectedMemberProviders.length))
+ getProvisioningProviderProgressMessage([], selectedMemberProviders.length, t))
);
if (changedPlans.length === 0) {
@@ -1769,7 +1770,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return;
}
const failureMessage =
- error instanceof Error ? error.message : 'Failed to prepare selected providers';
+ error instanceof Error ? error.message : t('launch.prepare.failed');
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
status: 'failed',
backendSummary: plan.backendSummary,
@@ -1793,6 +1794,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
selectedMemberProviders,
selectedModelChecksByProvider,
selectedModelChecksByProviderSignature,
+ t,
]);
// ---------------------------------------------------------------------------
@@ -1820,7 +1822,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setProjects(nextProjects);
} catch (error) {
if (cancelled) return;
- setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
+ setProjectsError(
+ error instanceof Error ? error.message : t('launch.errors.loadProjectsFailed')
+ );
setProjects([]);
} finally {
if (!cancelled) setProjectsLoading(false);
@@ -1830,7 +1834,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return () => {
cancelled = true;
};
- }, [open, repositoryGroups, defaultProjectPath]);
+ }, [open, repositoryGroups, defaultProjectPath, t]);
// Pre-select defaultProjectPath (launch mode) or first project
@@ -2046,13 +2050,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const modelValidationError = useMemo(() => {
if (isLaunchMode && selectedProviderId === 'opencode') {
if (!selectedModel.trim()) {
- return 'OpenCode lead requires a selected model.';
+ return t('launch.validation.openCodeLeadModelRequired');
}
const activeMemberCount = effectiveMemberDrafts.filter(
(member) => !member.removedAt && member.name.trim()
).length;
if (activeMemberCount === 0) {
- return 'OpenCode lead requires at least one OpenCode teammate.';
+ return t('launch.validation.openCodeTeammateRequired');
}
}
@@ -2095,6 +2099,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
+ t,
]);
const leadModelIssueText = useMemo(() => {
const issue = getProvisioningModelIssue(
@@ -2162,8 +2167,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
message: prepareMessage,
warnings: prepareWarnings,
checks: prepareChecks,
+ t,
}),
- [prepareChecks, prepareMessage, prepareState, prepareWarnings]
+ [prepareChecks, prepareMessage, prepareState, prepareWarnings, t]
);
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
effectiveCliStatus,
@@ -2211,7 +2217,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return;
}
if (isLaunchMode && !effectiveCwd) {
- setLocalError('Select working directory (cwd)');
+ setLocalError(t('launch.validation.selectWorkingDirectory'));
return;
}
if (
@@ -2220,7 +2226,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
)
) {
- setLocalError('Fix member names before launch');
+ setLocalError(t('launch.validation.fixMemberNames'));
return;
}
if (isLaunchMode) {
@@ -2228,7 +2234,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
.map((member) => member.name.trim().toLowerCase())
.filter(Boolean);
if (new Set(activeNames).size !== activeNames.length) {
- setLocalError('Member names must be unique before launch');
+ setLocalError(t('launch.validation.memberNamesUnique'));
return;
}
}
@@ -2353,10 +2359,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
err instanceof Error
? err.message
: isSchedule
- ? 'Failed to save schedule'
+ ? t('launch.errors.saveScheduleFailed')
: isRelaunch
- ? 'Failed to relaunch team'
- : 'Failed to launch team';
+ ? t('launch.errors.relaunchFailed')
+ : t('launch.errors.launchFailed');
setLocalError(message);
if (isLaunchMode) {
console.error(
@@ -2392,47 +2398,49 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const dialogTitle = isLaunchMode
? isRelaunch
- ? 'Relaunch Team'
- : 'Launch Team'
+ ? t('launch.title.relaunch')
+ : t('launch.title.launch')
: isEditing
- ? 'Edit Schedule'
- : 'Create Schedule';
+ ? t('launch.title.editSchedule')
+ : t('launch.title.createSchedule');
const dialogDescription = isLaunchMode ? (
isRelaunch ? (
<>
- Stop the current run for
{effectiveTeamName} {' '}
- and start it again via local Claude CLI.
+ {t('launch.description.relaunchPrefix')}{' '}
+
{effectiveTeamName} {' '}
+ {t('launch.description.relaunchSuffix')}
>
) : (
<>
- Start team
{effectiveTeamName} via local
- Claude CLI.
+ {t('launch.description.launchPrefix')}{' '}
+
{effectiveTeamName} {' '}
+ {t('launch.description.launchSuffix')}
>
)
) : isEditing ? (
- `Editing schedule for team "${effectiveTeamName}"`
+ t('launch.description.editSchedule', { team: effectiveTeamName })
) : effectiveTeamName ? (
- `Schedule automatic runs for team "${effectiveTeamName}"`
+ t('launch.description.createScheduleForTeam', { team: effectiveTeamName })
) : (
- 'Schedule automatic Claude task execution'
+ t('launch.description.createSchedule')
);
const submitLabel = isLaunchMode
? isRelaunch
- ? 'Relaunch team'
- : 'Launch team'
+ ? t('launch.actions.relaunchTeam')
+ : t('launch.actions.launchTeam')
: isEditing
- ? 'Save Changes'
- : 'Create Schedule';
+ ? t('launch.actions.saveChanges')
+ : t('launch.actions.createSchedule');
const submittingLabel = isLaunchMode
? isRelaunch
- ? 'Relaunching...'
- : 'Launching...'
+ ? t('launch.actions.relaunching')
+ : t('launch.actions.launching')
: isEditing
- ? 'Saving...'
- : 'Creating...';
+ ? t('launch.actions.saving')
+ : t('launch.actions.creating');
// ---------------------------------------------------------------------------
// Render
@@ -2467,11 +2475,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
-
Relaunch will restart the current team run
-
- Saving these settings will stop the current team process, persist the updated
- roster, and launch the team again with the new runtime.
-
+
{t('launch.relaunchWarning.title')}
+
{t('launch.relaunchWarning.description')}
@@ -2491,15 +2496,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- Another team “{conflictingTeam.displayName}” is already running for
- this working directory
-
-
- Running two teams in the same directory is risky — they may conflict editing the
- same files. Consider using a different directory or a git worktree for isolation.
+ {t('launch.conflict.title', { team: conflictingTeam.displayName })}
+
{t('launch.conflict.description')}
- Working directory: {effectiveCwd}
+ {t('launch.conflict.workingDirectory')}{' '}
+ {effectiveCwd}
- Team
+ {t('launch.schedule.team')}
{
@@ -2595,7 +2597,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
)}
- Schedule
+ {t('launch.schedule.title')}
{!schedExpanded && (schedLabel || cronExpression) ? (
@@ -2609,14 +2611,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{/* Label */}
- Label (optional)
+ {t('launch.schedule.labelOptional')}
setSchedLabel(e.target.value)}
- placeholder="e.g., Daily code review, Nightly tests..."
+ placeholder={t('launch.schedule.labelPlaceholder')}
/>
@@ -2655,11 +2657,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
═══════════════════════════════════════════════════════════════════ */}
{isLaunchMode ? (
@@ -2782,7 +2788,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- Prompt for team lead (optional)
+ {t('launch.prompt.teamLeadOptional')}
Saved
+
+ {t('launch.prompt.saved')}
+
) : null
}
/>
@@ -2826,10 +2834,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- Provider changed from {getProviderLabel(previousProviderId!)} to{' '}
- {getProviderLabel(selectedProviderId)}. The previous lead session will not
- be resumed, and the lead will start with fresh context so the new runtime
- is applied correctly.
+ {t('launch.providerChanged', {
+ from: getProviderLabel(previousProviderId!),
+ to: getProviderLabel(selectedProviderId),
+ })}
@@ -2844,10 +2852,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
>
-
- Team relaunch starts a fresh lead session. Durable team state, task board,
- and member configuration are rehydrated into the launch prompt.
-
+
{t('launch.relaunchFreshSession')}
@@ -2867,7 +2872,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : (
<>
-
Prompt
+
{t('launch.prompt.label')}
Saved
+
+ {t('launch.prompt.saved')}
+
) : null
}
/>
- This prompt will be passed to claude -p for
- one-shot execution
+ {t('launch.prompt.oneShotPrefix')} claude -p{' '}
+ {t('launch.prompt.oneShotSuffix')}
{selectedProviderId === 'anthropic' ? (
@@ -3064,13 +3069,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{effectivePrepare.message ??
(effectivePrepare.state === 'idle'
- ? 'Checking selected providers...'
- : 'Preparing environment...')}
+ ? t('launch.prepare.checkingProviders')
+ : t('launch.prepare.preparingEnvironment'))}
- Pre-flight check to catch errors before{' '}
- {isRelaunch ? 'relaunch' : 'launch'}
+ {t('launch.prepare.preflight', {
+ action: isRelaunch
+ ? t('launch.prepare.action.relaunch')
+ : t('launch.prepare.action.launch'),
+ })}
@@ -3092,8 +3100,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
- ? 'Selected providers ready (with notes)'
- : 'Selected providers ready'}
+ ? t('launch.prepare.readyWithNotes')
+ : t('launch.prepare.ready')}
{effectivePrepare.message ? (
@@ -3126,14 +3134,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- Runtime environment is not available - {isRelaunch ? 'relaunch' : 'launch'}{' '}
- is blocked
+ {t('launch.prepare.blocked', {
+ action: isRelaunch
+ ? t('launch.prepare.action.relaunch')
+ : t('launch.prepare.action.launch'),
+ })}
- {effectivePrepare.message ?? 'Failed to prepare environment'}
+ {effectivePrepare.message ?? t('launch.prepare.failed')}
- Pre-flight check to catch errors before {isRelaunch ? 'relaunch' : 'launch'}
+ {t('launch.prepare.preflight', {
+ action: isRelaunch
+ ? t('launch.prepare.action.relaunch')
+ : t('launch.prepare.action.launch'),
+ })}
@@ -3165,7 +3180,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null}
- {getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
+ {getProvisioningFailureHint(effectivePrepare.message, prepareChecks, t)}
{(effectivePrepare.message ?? '').toLowerCase().includes('spawn ') ||
prepareChecks.some((check) =>
@@ -3179,7 +3194,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
openDashboard();
}}
>
- Go to Dashboard
+ {t('launch.actions.goToDashboard')}
) : null}
diff --git a/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx b/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx
index 0f72ca62..e3bd2882 100644
--- a/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx
+++ b/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { HoverTooltip } from '@renderer/components/ui/hover-tooltip';
import { Label } from '@renderer/components/ui/label';
@@ -19,35 +20,38 @@ export const LimitContextCheckbox: React.FC
= ({
onCheckedChange,
disabled = false,
scopeLabel,
-}) => (
-
-
onCheckedChange(value === true)}
- />
-
- Limit context to 200K tokens
- {scopeLabel ? (
- ({scopeLabel})
- ) : null}
- {disabled && (always 200K for this model) }
-
-
- {
+ const { t } = useAppTranslation('team');
+ return (
+
+ onCheckedChange(value === true)}
/>
-
-
-);
+
+ {t('contextLimit.limitTo200k')}
+ {scopeLabel ? (
+ ({scopeLabel})
+ ) : null}
+ {disabled && {t('contextLimit.always200k')} }
+
+
+
+
+
+ );
+};
diff --git a/src/renderer/components/team/dialogs/MembersJsonEditor.tsx b/src/renderer/components/team/dialogs/MembersJsonEditor.tsx
index 006e3d26..31835a27 100644
--- a/src/renderer/components/team/dialogs/MembersJsonEditor.tsx
+++ b/src/renderer/components/team/dialogs/MembersJsonEditor.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { json } from '@codemirror/lang-json';
@@ -46,6 +47,7 @@ export const MembersJsonEditor = ({
error,
onClose,
}: MembersJsonEditorProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const containerRef = useRef(null);
const viewRef = useRef(null);
const onChangeRef = useRef(onChange);
@@ -112,7 +114,7 @@ export const MembersJsonEditor = ({
- Hide JSON
+ {t('dialogs.membersJson.hide')}
diff --git a/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx b/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx
index b1c8c9d0..6eba627f 100644
--- a/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx
+++ b/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { ChevronDown, ChevronRight, ExternalLink, Info } from 'lucide-react';
@@ -28,6 +29,7 @@ const OPENCODE_CONTEXT_CONFIG_EXAMPLE = `{
}`;
export const OpenCodeContextConfigHint = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(false);
return (
@@ -46,27 +48,21 @@ export const OpenCodeContextConfigHint = (): React.JSX.Element => {
)}
-
- OpenCode local models can use an OpenCode context budget instead of prompt-only limits.
-
+ {t('openCodeContextConfigHint.summary')}
{expanded ? (
-
- Add matching limits to the OpenCode config for the provider and model used by this
- teammate. This helps OpenCode compact and prune before local models overflow their
- context window.
-
+
{t('openCodeContextConfigHint.description')}
{OPENCODE_CONTEXT_CONFIG_EXAMPLE}
- Replace local and{' '}
- your-model with the provider and model IDs from your
- OpenCode setup. Prompt instructions like{' '}
- stay below 10000 tokens are weaker because the
- request is assembled before the model reads them.
+ {t('openCodeContextConfigHint.replacePrefix')} local{' '}
+ {t('openCodeContextConfigHint.and')} your-model{' '}
+ {t('openCodeContextConfigHint.replaceSuffix')}{' '}
+ stay below 10000 tokens{' '}
+ {t('openCodeContextConfigHint.promptInstructionsSuffix')}
diff --git a/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx b/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx
index 7e0355e6..b6649a77 100644
--- a/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx
+++ b/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { ChevronRight, Settings2 } from 'lucide-react';
@@ -63,6 +64,7 @@ export const OptionalSettingsSection = ({
className,
children,
}: OptionalSettingsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [isOpen, setIsOpen] = useState(defaultOpen);
const { isLight } = useTheme();
@@ -137,7 +139,7 @@ export const OptionalSettingsSection = ({
className="shrink-0 rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium"
style={{ color: headerMutedColor }}
>
- Optional
+ {t('dialogs.optional.badge')}
{!isOpen && chips.length > 0 ? (
diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx
index 49d8af7c..66c5a583 100644
--- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx
+++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx
@@ -6,6 +6,7 @@ import { Button } from '@renderer/components/ui/button';
import { Combobox } from '@renderer/components/ui/combobox';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { Check, FolderOpen, FolderX } from 'lucide-react';
@@ -62,14 +63,17 @@ function isDeletedOption(option: ComboboxOption): boolean {
return (option.meta as ProjectPathOptionMeta | undefined)?.filesystemState === 'deleted';
}
-function getSourceLabel(source: DashboardRecentProjectSource): string {
+function getSourceLabel(
+ source: DashboardRecentProjectSource,
+ t: ReturnType
['t']
+): string {
switch (source) {
case 'claude':
- return 'Found by Claude';
+ return t('projectPath.source.claude');
case 'codex':
- return 'Found by Codex';
+ return t('projectPath.source.codex');
case 'mixed':
- return 'Found by Claude and Codex';
+ return t('projectPath.source.mixed');
}
}
@@ -78,6 +82,7 @@ const ProjectSourceBadge = ({
}: {
source?: DashboardRecentProjectSource;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
if (!source) {
return null;
}
@@ -92,7 +97,7 @@ const ProjectSourceBadge = ({
return (
{logos.map((providerId) => (
@@ -101,15 +106,18 @@ const ProjectSourceBadge = ({
);
};
-const ProjectDeletedBadge = (): React.JSX.Element => (
-
-
- Deleted
-
-);
+const ProjectDeletedBadge = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ return (
+
+
+ {t('projectPath.deleted.label')}
+
+ );
+};
export type CwdMode = 'project' | 'custom';
@@ -138,6 +146,7 @@ export const ProjectPathSelector = ({
projectsError,
fieldError,
}: ProjectPathSelectorProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const projectOptions = React.useMemo(
() => buildProjectPathOptions(projects, selectedProjectPath),
[projects, selectedProjectPath]
@@ -145,7 +154,7 @@ export const ProjectPathSelector = ({
return (
-
Project
+
{t('projectPath.label')}
@@ -159,7 +168,7 @@ export const ProjectPathSelector = ({
)}
onClick={() => onCwdModeChange('project')}
>
- From project list
+ {t('projectPath.mode.projectList')}
onCwdModeChange('custom')}
>
- Custom path
+ {t('projectPath.mode.customPath')}
@@ -185,9 +194,13 @@ export const ProjectPathSelector = ({
options={projectOptions}
value={selectedProjectPath}
onValueChange={onSelectedProjectPathChange}
- placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
- searchPlaceholder="Search project by name or path"
- emptyMessage="Nothing found"
+ placeholder={
+ projectsLoading
+ ? t('projectPath.loadingProjects')
+ : t('projectPath.selectProject')
+ }
+ searchPlaceholder={t('projectPath.searchPlaceholder')}
+ emptyMessage={t('projectPath.empty')}
disabled={projectsLoading || projectOptions.length === 0}
renderTriggerLabel={(option) => (
@@ -229,13 +242,13 @@ export const ProjectPathSelector = ({
{!selectedProjectPath ? (
- Select a project from the list
+ {t('projectPath.selectFromList')}
) : null}
{projectsError ?
{projectsError}
: null}
{!projectsLoading && projectOptions.length === 0 ? (
- No projects found, switch to custom path.
+ {t('projectPath.noProjects')}
) : null}
@@ -246,7 +259,7 @@ export const ProjectPathSelector = ({
onCustomCwdChange(event.target.value)}
placeholder="/absolute/path/to/project"
/>
@@ -266,11 +279,11 @@ export const ProjectPathSelector = ({
})();
}}
>
- Browse
+ {t('projectPath.browse')}
- If the directory does not exist, it will be created automatically.
+ {t('projectPath.createAutomatically')}
)}
diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
index dcda6c15..fe6957e7 100644
--- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
+++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import {
@@ -14,6 +15,8 @@ import type {
TeamProvisioningSupportDiagnostic,
} from '@shared/types';
+type TeamTranslator = ReturnType['t'];
+
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed';
@@ -144,17 +147,26 @@ export function failIncompleteProviderChecks(
export function getProvisioningProviderProgressMessage(
providerIds: readonly TeamProviderId[],
- totalProviderCount: number
+ totalProviderCount: number,
+ t?: TeamTranslator
): string {
if (providerIds.length === 0 || providerIds.length === totalProviderCount) {
- return 'Checking selected providers in parallel...';
+ return t
+ ? t('provisioning.providerStatus.progress.checkingSelectedProviders')
+ : 'Checking selected providers in parallel...';
}
if (providerIds.length === 1) {
- return `Checking ${getProvisioningProviderLabel(providerIds[0])} provider...`;
+ const provider = getProvisioningProviderLabel(providerIds[0]);
+ return t
+ ? t('provisioning.providerStatus.progress.checkingProvider', { provider })
+ : `Checking ${provider} provider...`;
}
- return `Checking ${providerIds.map(getProvisioningProviderLabel).join(', ')} providers...`;
+ const providers = providerIds.map(getProvisioningProviderLabel).join(', ');
+ return t
+ ? t('provisioning.providerStatus.progress.checkingProviders', { providers })
+ : `Checking ${providers} providers...`;
}
type ProvisioningDetailSummary =
@@ -238,6 +250,25 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
}
}
+function getLocalizedStatusLabel(
+ status: ProvisioningProviderCheckStatus,
+ t: TeamTranslator
+): string {
+ switch (status) {
+ case 'checking':
+ return t('provisioning.providerStatus.status.checking');
+ case 'ready':
+ return t('provisioning.providerStatus.status.ready');
+ case 'notes':
+ return t('provisioning.providerStatus.status.notes');
+ case 'failed':
+ return t('provisioning.providerStatus.status.failed');
+ case 'pending':
+ default:
+ return t('provisioning.providerStatus.status.pending');
+ }
+}
+
function summarizeDetail(
detail: string,
status: ProvisioningProviderCheckStatus,
@@ -349,7 +380,59 @@ function summarizeDetail(
return null;
}
-function getModelDetailSummary(details: string[]): string | null {
+function localizeProvisioningDetailSummary(
+ summary: ProvisioningDetailSummary,
+ t: TeamTranslator
+): string {
+ switch (summary) {
+ case 'CLI binary missing':
+ return t('provisioning.providerStatus.detailSummary.cliBinaryMissing');
+ case 'OpenCode runtime missing':
+ return t('provisioning.providerStatus.detailSummary.openCodeRuntimeMissing');
+ case 'OpenCode Windows access blocked':
+ return t('provisioning.providerStatus.detailSummary.openCodeWindowsAccessBlocked');
+ case 'OpenCode runtime check returned no output':
+ return t('provisioning.providerStatus.detailSummary.openCodeNoOutput');
+ case 'OpenCode app MCP unreachable':
+ return t('provisioning.providerStatus.detailSummary.openCodeMcpUnreachable');
+ case 'Working directory missing':
+ return t('provisioning.providerStatus.detailSummary.workingDirectoryMissing');
+ case 'CLI binary could not be started':
+ return t('provisioning.providerStatus.detailSummary.cliBinaryCouldNotStart');
+ case 'CLI preflight did not complete':
+ return t('provisioning.providerStatus.detailSummary.cliPreflightIncomplete');
+ case 'Authentication required':
+ return t('provisioning.providerStatus.detailSummary.authenticationRequired');
+ case 'Runtime provider is not configured':
+ return t('provisioning.providerStatus.detailSummary.runtimeProviderNotConfigured');
+ case 'CLI preflight failed':
+ return t('provisioning.providerStatus.detailSummary.cliPreflightFailed');
+ case 'Selected model compatible':
+ return t('provisioning.providerStatus.detailSummary.selectedModelCompatible');
+ case 'Selected model compatibility pending':
+ return t('provisioning.providerStatus.detailSummary.selectedModelCompatibilityPending');
+ case 'Selected model available':
+ return t('provisioning.providerStatus.detailSummary.selectedModelAvailable');
+ case 'Selected model verified':
+ return t('provisioning.providerStatus.detailSummary.selectedModelVerified');
+ case 'Selected model unavailable':
+ return t('provisioning.providerStatus.detailSummary.selectedModelUnavailable');
+ case 'Selected model verification timed out':
+ return t('provisioning.providerStatus.detailSummary.selectedModelTimedOut');
+ case 'Selected model check failed':
+ return t('provisioning.providerStatus.detailSummary.selectedModelCheckFailed');
+ case 'Selected model verification deferred':
+ return t('provisioning.providerStatus.detailSummary.selectedModelDeferred');
+ case 'Selected model ping not confirmed':
+ return t('provisioning.providerStatus.detailSummary.selectedModelPingNotConfirmed');
+ case 'Ready with notes':
+ return t('provisioning.providerStatus.detailSummary.readyWithNotes');
+ case 'Needs attention':
+ return t('provisioning.providerStatus.detailSummary.needsAttention');
+ }
+}
+
+function getModelDetailSummary(details: string[], t?: TeamTranslator): string | null {
let compatibilityPendingCount = 0;
let compatibleCount = 0;
let availableCount = 0;
@@ -428,37 +511,85 @@ function getModelDetailSummary(details: string[]): string | null {
const parts: string[] = [];
if (unavailableCount > 0) {
- parts.push(`${unavailableCount} model${unavailableCount === 1 ? '' : 's'} unavailable`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.unavailable', { count: unavailableCount })
+ : `${unavailableCount} model${unavailableCount === 1 ? '' : 's'} unavailable`
+ );
}
if (checkFailedCount > 0) {
- parts.push(`${checkFailedCount} model${checkFailedCount === 1 ? '' : 's'} check failed`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.checkFailed', { count: checkFailedCount })
+ : `${checkFailedCount} model${checkFailedCount === 1 ? '' : 's'} check failed`
+ );
}
if (timedOutCount > 0) {
- parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.timedOut', { count: timedOutCount })
+ : `${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`
+ );
}
if (deferredCount > 0) {
- parts.push(`${deferredCount} verification deferred`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.deferred', { count: deferredCount })
+ : `${deferredCount} verification deferred`
+ );
}
if (pingNotConfirmedCount > 0) {
- parts.push(`${pingNotConfirmedCount} ping not confirmed`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.pingNotConfirmed', {
+ count: pingNotConfirmedCount,
+ })
+ : `${pingNotConfirmedCount} ping not confirmed`
+ );
}
if (compatibilityPendingCount > 0) {
- parts.push(`${compatibilityPendingCount} compatible, deep verification pending`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.compatibilityPending', {
+ count: compatibilityPendingCount,
+ })
+ : `${compatibilityPendingCount} compatible, deep verification pending`
+ );
}
if (compatibleCount > 0) {
- parts.push(`${compatibleCount} compatible`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.compatible', { count: compatibleCount })
+ : `${compatibleCount} compatible`
+ );
}
if (checkingCount > 0) {
- parts.push(`${checkingCount} checking`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.checking', { count: checkingCount })
+ : `${checkingCount} checking`
+ );
}
if (availableCount > 0) {
- parts.push(`${availableCount} available`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.available', { count: availableCount })
+ : `${availableCount} available`
+ );
}
if (verifiedCount > 0) {
- parts.push(`${verifiedCount} verified`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.verified', { count: verifiedCount })
+ : `${verifiedCount} verified`
+ );
}
- return parts.length > 0 ? `Selected model checks - ${parts.join(', ')}` : null;
+ return parts.length > 0
+ ? t
+ ? t('provisioning.providerStatus.modelChecksSummary', { details: parts.join(', ') })
+ : `Selected model checks - ${parts.join(', ')}`
+ : null;
}
function hasCompatibilityPendingDetails(checks: ProvisioningProviderCheck[]): boolean {
@@ -469,9 +600,9 @@ function hasCompatibilityPendingDetails(checks: ProvisioningProviderCheck[]): bo
);
}
-function getDisplayStatusText(check: ProvisioningProviderCheck): string {
+function getDisplayStatusText(check: ProvisioningProviderCheck, t?: TeamTranslator): string {
const publicDetails = getPublicProvisioningDetails(check.details);
- const modelSummary = getModelDetailSummary(publicDetails);
+ const modelSummary = getModelDetailSummary(publicDetails, t);
if (modelSummary) {
return modelSummary;
}
@@ -497,7 +628,10 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
summarizedDetails[0] ??
null)
: (summarizedDetails[0] ?? null);
- return summary ?? getStatusLabel(check.status);
+ if (summary) {
+ return t ? localizeProvisioningDetailSummary(summary, t) : summary;
+ }
+ return t ? getLocalizedStatusLabel(check.status, t) : getStatusLabel(check.status);
}
function getDetailTone(
@@ -614,6 +748,7 @@ export function deriveEffectiveProvisioningPrepareState(params: {
message: string | null;
warnings: string[];
checks: ProvisioningProviderCheck[];
+ t?: TeamTranslator;
}): { state: ProvisioningPrepareState; message: string | null } {
if (params.state !== 'loading') {
return {
@@ -637,6 +772,7 @@ export function deriveEffectiveProvisioningPrepareState(params: {
return {
state: params.state,
message:
+ params.t?.('provisioning.providerStatus.deepVerificationPending') ??
'Deep verification is still running. OpenCode free models may take around 20 seconds.',
};
}
@@ -652,6 +788,7 @@ export function deriveEffectiveProvisioningPrepareState(params: {
message:
getPrimaryProvisioningFailureDetail(params.checks) ??
params.message ??
+ params.t?.('create.prepare.someProvidersNeedAttention') ??
'Some selected providers need attention.',
};
}
@@ -662,8 +799,9 @@ export function deriveEffectiveProvisioningPrepareState(params: {
return {
state: 'ready',
message: hasNotes
- ? 'All selected providers are ready, with notes.'
- : 'All selected providers are ready.',
+ ? (params.t?.('create.prepare.readyWithNotes') ??
+ 'All selected providers are ready, with notes.')
+ : (params.t?.('create.prepare.ready') ?? 'All selected providers are ready.'),
};
}
@@ -720,7 +858,8 @@ const StatusIcon = ({ status }: { status: ProvisioningProviderCheckStatus }): Re
};
function getProvisioningProviderSettingsActionLabel(
- check: ProvisioningProviderCheck
+ check: ProvisioningProviderCheck,
+ t?: TeamTranslator
): string | null {
if (check.status !== 'notes' && check.status !== 'failed') {
return null;
@@ -748,10 +887,24 @@ function getProvisioningProviderSettingsActionLabel(
combined.includes('api key mode is selected');
return hasActionableProviderSetupDetail
- ? `Open ${getProvisioningProviderLabel(check.providerId)} settings`
+ ? t
+ ? t('provisioning.providerStatus.openProviderSettings', {
+ provider: getProvisioningProviderLabel(check.providerId),
+ })
+ : `Open ${getProvisioningProviderLabel(check.providerId)} settings`
: null;
}
+function getDisplayDetailText(
+ detail: string,
+ status: ProvisioningProviderCheckStatus,
+ providerId: TeamProviderId,
+ t: TeamTranslator
+): string {
+ const summary = summarizeDetail(detail, status, providerId);
+ return summary ? localizeProvisioningDetailSummary(summary, t) : detail;
+}
+
function getSupportDiagnosticsPayload(check: ProvisioningProviderCheck): string | null {
if (check.providerId !== 'opencode') {
return null;
@@ -773,6 +926,7 @@ export const ProvisioningProviderStatusList = ({
suppressDetailsMatching?: string | null;
onOpenProviderSettings?: (providerId: TeamProviderId) => void;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const [copiedDiagnosticsKey, setCopiedDiagnosticsKey] = React.useState(null);
if (checks.length === 0) {
@@ -804,7 +958,7 @@ export const ProvisioningProviderStatusList = ({
(detail) => detail.trim() !== suppressDetailsMatchingTrimmed
);
const settingsActionLabel = onOpenProviderSettings
- ? getProvisioningProviderSettingsActionLabel(check)
+ ? getProvisioningProviderSettingsActionLabel(check, t)
: null;
const supportDiagnosticsPayload = getSupportDiagnosticsPayload(check);
const supportDiagnosticsKey =
@@ -822,7 +976,7 @@ export const ProvisioningProviderStatusList = ({
{getProvisioningProviderLabel(check.providerId)}
{check.backendSummary ? ` (${check.backendSummary})` : ''}:{' '}
- {getDisplayStatusText(check)}
+ {getDisplayStatusText(check, t)}
{visibleDetails.length > 0 ? (
@@ -836,7 +990,7 @@ export const ProvisioningProviderStatusList = ({
check.providerId
)}`}
>
- {detail}
+ {getDisplayDetailText(detail, check.status, check.providerId, t)}
))}
@@ -871,7 +1025,9 @@ export const ProvisioningProviderStatusList = ({
}
>
{copiedDiagnostics ? : }
- {copiedDiagnostics ? 'Copied' : 'Copy diagnostics'}
+ {copiedDiagnostics
+ ? t('provisioning.providerStatus.copied')
+ : t('provisioning.providerStatus.copyDiagnostics')}
) : null}
@@ -884,7 +1040,8 @@ export const ProvisioningProviderStatusList = ({
export function getProvisioningFailureHint(
message: string | null | undefined,
- checks: ProvisioningProviderCheck[]
+ checks: ProvisioningProviderCheck[],
+ t?: TeamTranslator
): string {
const failedOpenCodeChecks = checks.filter(
(check) => check.providerId === 'opencode' && check.status === 'failed'
@@ -904,14 +1061,20 @@ export function getProvisioningFailureHint(
(normalizedMessage === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
(!hasFailedNonOpenCodeCheck && isOpenCodeWindowsAccessDeniedDiagnostic(normalizedMessage)));
if (hasOpenCodeAccessDeniedDetail || hasOpenCodeAccessDeniedMessage) {
- return 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.openCodeAccessDenied') ??
+ 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
+ );
}
const hasOpenCodeBridgeNoOutputMessage =
failedOpenCodeChecks.length > 0 &&
!hasFailedNonOpenCodeCheck &&
isOpenCodeBridgeNoOutputDiagnostic(normalizedMessage);
if (hasOpenCodeBridgeNoOutputDetail || hasOpenCodeBridgeNoOutputMessage) {
- return 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.openCodeBridgeNoOutput') ??
+ 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.'
+ );
}
const combined = [message ?? '', ...checks.flatMap((check) => check.details)]
@@ -919,27 +1082,42 @@ export function getProvisioningFailureHint(
.toLowerCase();
if (combined.includes('working directory does not exist:')) {
- return 'Choose an existing working directory, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.workingDirectoryMissing') ??
+ 'Choose an existing working directory, then reopen this dialog.'
+ );
}
if (combined.includes('not authenticated') || combined.includes('not logged in')) {
- return 'Authenticate the required provider in Claude CLI, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.authenticationRequired') ??
+ 'Authenticate the required provider in Claude CLI, then reopen this dialog.'
+ );
}
if (combined.includes('provider is not configured for runtime use')) {
- return 'Configure the selected provider runtime, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.runtimeProviderNotConfigured') ??
+ 'Configure the selected provider runtime, then reopen this dialog.'
+ );
}
if (
combined.includes('opencode cli not detected on path') ||
combined.includes('opencode cli not found') ||
combined.includes('opencode runtime binary is not installed')
) {
- return 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.openCodeRuntimeMissing') ??
+ 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.'
+ );
}
if (
combined.includes('opencode app mcp is unreachable') ||
(combined.includes('unable to connect') &&
(combined.includes('/experimental/tool') || combined.includes('mcp_unavailable')))
) {
- return 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.openCodeAppMcpUnreachable') ??
+ 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.'
+ );
}
if (
combined.includes('spawn ') ||
@@ -949,8 +1127,14 @@ export function getProvisioningFailureHint(
combined.includes('bad cpu type in executable') ||
combined.includes('image not found')
) {
- return 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.cliBinaryMissing') ??
+ 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
+ );
}
- return 'Resolve the issue above, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.default') ??
+ 'Resolve the issue above, then reopen this dialog.'
+ );
}
diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx
index 9e843ef0..c32e3274 100644
--- a/src/renderer/components/team/dialogs/ReviewDialog.tsx
+++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
Dialog,
DialogContent,
@@ -41,6 +42,7 @@ export const ReviewDialog = ({
onCancel,
onSubmit,
}: ReviewDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const draft = useDraftPersistence({
@@ -81,7 +83,7 @@ export const ReviewDialog = ({
>
- Request Changes
+ {t('reviewDialog.title')}
Task #{taskId ? deriveTaskDisplayId(taskId) : ''}
@@ -90,7 +92,7 @@ export const ReviewDialog = ({
id="review-comment"
value={draft.value}
onValueChange={draft.setValue}
- placeholder="Describe what needs to change... (Enter to submit)"
+ placeholder={t('reviewDialog.placeholder')}
suggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={projectPath}
@@ -105,7 +107,7 @@ export const ReviewDialog = ({
onClick={handleSubmit}
>
- Submit
+ {t('reviewDialog.submit')}
}
footerRight={
@@ -114,11 +116,13 @@ export const ReviewDialog = ({
- {remaining} chars left
+ {t('reviewDialog.charsLeft', { count: remaining })}
) : null}
{draft.isSaved ? (
- Saved
+
+ {t('reviewDialog.saved')}
+
) : null}
}
diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
index 307efcda..b88c8068 100644
--- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx
+++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
@@ -113,6 +114,8 @@ export const SendMessageDialog = ({
onSend,
onClose,
}: SendMessageDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ const { t: tCommon } = useAppTranslation('common');
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const [quote, setQuote] = useState
(undefined);
@@ -167,13 +170,13 @@ export const SendMessageDialog = ({
const canAttach = supportsAttachments && canAddMore;
const attachmentRestrictionReason = !supportsAttachments
? !isTeamAlive
- ? 'Team must be online to attach files'
+ ? t('sendMessage.attachments.teamOnlineRequired')
: !showAttachmentControl
- ? 'Files can be sent to the team lead or OpenCode teammates'
+ ? t('sendMessage.attachments.recipientUnsupported')
: (memberAttachmentUnavailableReason ??
(isOpenCodeRecipient
- ? 'Team must be online to attach files for OpenCode teammates'
- : 'Team must be online to attach files'))
+ ? t('sendMessage.attachments.openCodeOnlineRequired')
+ : t('sendMessage.attachments.teamOnlineRequired')))
: undefined;
// Auto-switch to delegate when lead recipient is selected, but don't
@@ -334,7 +337,7 @@ export const SendMessageDialog = ({
setFileRestrictionError(
attachmentRestrictionReason ??
attachmentPayloadRestrictionReason ??
- 'Files can be sent to the team lead or OpenCode teammates'
+ t('sendMessage.attachments.recipientUnsupported')
);
window.clearTimeout(fileRestrictionTimerRef.current);
fileRestrictionTimerRef.current = window.setTimeout(() => {
@@ -467,25 +470,25 @@ export const SendMessageDialog = ({
/>
- Send Message
- Send a direct message to a team member.
+ {t('sendMessage.title')}
+ {t('sendMessage.description')}
- Recipient
+ {t('sendMessage.recipientLabel')}
setMember(v ?? '')}
- placeholder="Select member..."
+ placeholder={t('sendMessage.selectMemberPlaceholder')}
size="sm"
/>
-
Message
+
{t('sendMessage.messageLabel')}
{showAttachmentControl ? (
<>
{canAttach
- ? 'Attach files (paste or drag & drop)'
- : (attachmentRestrictionReason ?? 'Attachments are unavailable')}
+ ? t('sendMessage.attachments.attachFiles')
+ : (attachmentRestrictionReason ?? t('sendMessage.attachments.unavailable'))}
>
@@ -530,7 +533,7 @@ export const SendMessageDialog = ({
disabledHint={
attachmentPayloadRestrictionReason ??
attachmentRestrictionReason ??
- 'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
+ t('sendMessage.attachments.disabledHint')
}
/>
@@ -552,12 +555,12 @@ export const SendMessageDialog = ({
-
Remove quote
+
{t('sendMessage.quote.remove')}
- Replying to
+ {t('sendMessage.quote.replyingTo')}
@@ -576,7 +579,7 @@ export const SendMessageDialog = ({
className="mt-0.5 text-[10px] text-blue-500 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
onClick={() => setQuoteExpanded((v) => !v)}
>
- {quoteExpanded ? 'less' : 'more'}
+ {quoteExpanded ? tCommon('actions.showLess') : tCommon('actions.showMore')}
) : null}
@@ -584,7 +587,7 @@ export const SendMessageDialog = ({
- {sending ? 'Sending...' : 'Send'}
+ {sending ? t('sendMessage.sending') : t('sendMessage.send')}
}
footerRight={
@@ -635,11 +638,13 @@ export const SendMessageDialog = ({
- {remaining} chars left
+ {t('sendMessage.charsLeft', { count: remaining })}
) : null}
{textDraft.isSaved ? (
- Saved
+
+ {t('sendMessage.saved')}
+
) : null}
diff --git a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx
index 90be9c98..dd500e9d 100644
--- a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx
+++ b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import { Info } from 'lucide-react';
@@ -14,52 +15,53 @@ export const SkipPermissionsCheckbox: React.FC = (
id,
checked,
onCheckedChange,
-}) => (
- <>
-
- onCheckedChange(value === true)}
- />
-
- Auto-approve all tools
-
-
- {checked ? (
-
-
-
-
- Autonomous mode: team tools execute without confirmation. Be cautious with untrusted
- code.
-
-
+}) => {
+ const { t } = useAppTranslation('team');
+
+ return (
+ <>
+
+ onCheckedChange(value === true)}
+ />
+
+ {t('permissions.autoApproveAllTools')}
+
- ) : (
-
-
-
-
Manual mode: you'll approve or deny each tool call in real time.
+ {checked ? (
+
+
+
+
{t('permissions.autonomousModeDescription')}
+
-
- )}
- >
-);
+ ) : (
+
+
+
+
{t('permissions.manualModeDescription')}
+
+
+ )}
+ >
+ );
+};
diff --git a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
index 25c56832..990b3f37 100644
--- a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
+++ b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
@@ -1,5 +1,6 @@
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import {
REVIEW_STATE_DISPLAY,
@@ -36,12 +37,13 @@ export const WorkflowTimeline = ({
implementationDurationTask,
nowMs,
}: WorkflowTimelineProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const implementationNowMs = nowMs ?? 0;
if (events.length === 0) {
return (
- No workflow history recorded
+ {t('taskDetail.workflowTimeline.empty')}
);
}
@@ -80,11 +82,13 @@ export const WorkflowTimeline = ({
className="shrink-0 rounded bg-[var(--color-bg-secondary)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-muted)]"
title={
implementationDuration.running
- ? 'Current implementation interval'
- : 'Implementation interval ended at this transition'
+ ? t('taskDetail.workflowTimeline.currentImplementationInterval')
+ : t('taskDetail.workflowTimeline.implementationIntervalEnded')
}
>
- {implementationDuration.running ? 'running ' : ''}
+ {implementationDuration.running
+ ? t('taskDetail.workflowTimeline.runningPrefix')
+ : ''}
{formatTaskImplementationDuration(implementationDuration.elapsedMs)}
) : null}
@@ -120,16 +124,19 @@ const EventContent = ({
event: TaskHistoryEvent;
memberColorMap?: Map
;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
switch (event.type) {
case 'task_created':
return (
- Created as
+ {t('taskDetail.workflowTimeline.createdAs')}
{event.actor ? (
<>
- by
+
+ {t('taskDetail.workflowTimeline.by')}
+
{event.from && event.to ? (
<>
- Reassigned
+ {t('taskDetail.workflowTimeline.reassigned')}
) : event.to ? (
<>
- Assigned to
+ {t('taskDetail.workflowTimeline.assignedTo')}
) : event.from ? (
<>
- Unassigned from
+ {t('taskDetail.workflowTimeline.unassignedFrom')}
>
) : (
- 'Owner changed'
+ t('taskDetail.workflowTimeline.ownerChanged')
)}
);
@@ -198,7 +205,7 @@ const EventContent = ({
return (
- Review requested
+ {t('taskDetail.workflowTimeline.reviewRequested')}
{event.reviewer ? (
- Review started
+ {t('taskDetail.workflowTimeline.reviewStarted')}
);
case 'review_changes_requested':
return (
- Changes requested
+ {t('taskDetail.workflowTimeline.changesRequested')}
);
@@ -228,18 +235,19 @@ const EventContent = ({
return (
- Approved
+ {t('taskDetail.workflowTimeline.approved')}
);
default:
- return Unknown event ;
+ return {t('taskDetail.workflowTimeline.unknownEvent')} ;
}
};
const StatusBadge = ({ status }: { status: TeamTaskStatus }): React.JSX.Element => {
const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending;
- const label = TASK_STATUS_LABELS[status] ?? status;
+ const { t } = useAppTranslation('team');
+ const label = TASK_STATUS_LABELS[status] ? getStatusLabel(status, t) : status;
return (
{
+ const { t } = useAppTranslation('team');
if (state === 'none') return null;
const display = REVIEW_STATE_DISPLAY[state];
if (!display) return null;
@@ -257,11 +266,47 @@ const ReviewStateBadge = ({ state }: { state: TeamReviewState }): React.JSX.Elem
- {display.label}
+ {getReviewStateLabel(state, t)}
);
};
+function getStatusLabel(
+ status: TeamTaskStatus,
+ t: ReturnType['t']
+): string {
+ switch (status) {
+ case 'pending':
+ return t('tasks.status.pending');
+ case 'in_progress':
+ return t('tasks.status.inProgress');
+ case 'completed':
+ return t('tasks.status.completed');
+ case 'deleted':
+ return t('tasks.status.deleted');
+ default:
+ return status;
+ }
+}
+
+function getReviewStateLabel(
+ state: TeamReviewState,
+ t: ReturnType['t']
+): string {
+ switch (state) {
+ case 'approved':
+ return t('taskDetail.reviewStates.approved');
+ case 'needsFix':
+ return t('taskDetail.reviewStates.needsFix');
+ case 'review':
+ return t('taskDetail.reviewStates.inReview');
+ case 'none':
+ return '';
+ default:
+ return state;
+ }
+}
+
function dotColor(event: TaskHistoryEvent): string {
switch (event.type) {
case 'task_created':
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx
index 5c48993f..982de654 100644
--- a/src/renderer/components/team/dialogs/TaskAttachments.tsx
+++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
@@ -23,6 +24,7 @@ export const TaskAttachments = ({
taskId,
attachments,
}: TaskAttachmentsProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const saveTaskAttachment = useStore((s) => s.saveTaskAttachment);
const deleteTaskAttachment = useStore((s) => s.deleteTaskAttachment);
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
@@ -242,7 +244,7 @@ export const TaskAttachments = ({
{/* Drop zone indicator */}
{dragOver ? (
- Drop image here
+ {t('taskAttachments.dropImageHere')}
) : null}
@@ -264,9 +266,11 @@ export const TaskAttachments = ({
onClick={() => fileInputRef.current?.click()}
>
{uploading ? : }
- Attach image
+ {t('taskAttachments.attachImage')}
- or paste / drag-drop
+
+ {t('taskAttachments.pasteOrDragDrop')}
+
{error ?
{error}
: null}
diff --git a/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx b/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx
index 516a1cc9..45353ee1 100644
--- a/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { computeAwaitingReply } from '@renderer/utils/taskCommentPendingReply';
@@ -24,6 +25,7 @@ export const TaskCommentAwaitingReply = ({
taskCreatedBy,
members,
}: TaskCommentAwaitingReplyProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const result = useMemo(
() => computeAwaitingReply(comments, taskOwner, taskCreatedBy),
@@ -42,11 +44,17 @@ export const TaskCommentAwaitingReply = ({
-
Awaiting reply from
+
+ {t('taskComments.awaitingReplyFrom')}
+
{result.awaitingFrom.map((name, i) => (
- {i > 0 && or }
+ {i > 0 && (
+
+ {t('taskComments.or')}
+
+ )}
))}
diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx
index 6991e4e3..f1c26b3e 100644
--- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
@@ -54,6 +55,8 @@ export const TaskCommentInput = ({
replyTo,
onClearReply,
}: TaskCommentInputProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ const { t: tCommon } = useAppTranslation('common');
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
@@ -245,11 +248,13 @@ export const TaskCommentInput = ({
-
Cancel reply
+
{t('taskComments.cancelReply')}
- Replying to
+
+ {t('taskComments.replyingTo')}
+
setQuoteExpanded((v) => !v)}
>
- {quoteExpanded ? 'less' : 'more'}
+ {quoteExpanded ? tCommon('actions.showLess') : tCommon('actions.showMore')}
) : null}
@@ -347,7 +352,7 @@ export const TaskCommentInput = ({
-
Attach file (or paste)
+
{t('taskComments.attachFile')}
@@ -387,7 +392,7 @@ export const TaskCommentInput = ({
- Voice to text
+ {t('taskComments.voiceToText')}
void handleSubmit()}
>
- Comment
+ {t('taskComments.comment')}
}
@@ -406,11 +411,13 @@ export const TaskCommentInput = ({
- {remaining} chars left
+ {t('taskComments.charsLeft', { count: remaining })}
) : null}
{draft.isSaved ? (
- Saved
+
+ {t('taskComments.saved')}
+
) : null}
}
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
index 1ebf3dc1..a2bae29c 100644
--- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
@@ -10,6 +10,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
@@ -93,6 +94,7 @@ export const TaskCommentsSection = ({
focusCommentId,
registerCommentForViewport,
}: TaskCommentsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
@@ -222,7 +224,7 @@ export const TaskCommentsSection = ({
{!hideHeader ? (
- Comments
+ {t('taskDetail.sections.comments')}
{comments.length > 0 ? (
{comments.length}
@@ -235,8 +237,10 @@ export const TaskCommentsSection = ({
{comments.length > MAX_COMMENTS_TO_RENDER ? (
- Showing the most recent {MAX_COMMENTS_TO_RENDER.toLocaleString()} comments to keep the
- UI responsive.
+ {t('taskDetail.comments.renderLimit', {
+ count: MAX_COMMENTS_TO_RENDER,
+ formattedCount: MAX_COMMENTS_TO_RENDER.toLocaleString(),
+ })}
) : null}
@@ -285,19 +289,19 @@ export const TaskCommentsSection = ({
{comment.type === 'review_approved' ? (
- Approved
+ {t('taskDetail.comments.badges.approved')}
) : comment.type === 'review_request' ? (
- Review requested
+ {t('taskDetail.comments.badges.reviewRequested')}
) : null}
{(() => {
const date = new Date(comment.createdAt);
return isNaN(date.getTime())
- ? 'unknown time'
+ ? t('taskDetail.comments.unknownTime')
: formatDistanceToNow(date, { addSuffix: true });
})()}
@@ -318,10 +322,12 @@ export const TaskCommentsSection = ({
}}
>
- Reply
+ {t('taskDetail.comments.actions.reply')}
-
Reply to comment
+
+ {t('taskDetail.comments.actions.replyToComment')}
+
@@ -405,7 +411,10 @@ export const TaskCommentsSection = ({
setVisibleCount((v) => Math.min(sortedComments.length, v + VISIBLE_COMMENTS_STEP))
}
>
- Show more comments ({visibleComments.length}/{sortedComments.length})
+ {t('taskDetail.comments.actions.showMore', {
+ visible: visibleComments.length,
+ total: sortedComments.length,
+ })}
) : null}
@@ -418,7 +427,7 @@ export const TaskCommentsSection = ({
open
onClose={() => setPreviewImageUrl(null)}
src={previewImageUrl}
- alt="Attachment preview"
+ alt={t('taskDetail.comments.attachments.previewAlt')}
/>
) : null}
@@ -428,7 +437,7 @@ export const TaskCommentsSection = ({
- Replying to
+ {t('taskDetail.comments.replyingTo')}
@@ -445,7 +454,9 @@ export const TaskCommentsSection = ({
- Cancel reply
+
+ {t('taskDetail.comments.actions.cancelReply')}
+
) : null}
@@ -453,7 +464,7 @@ export const TaskCommentsSection = ({
}
@@ -518,6 +531,7 @@ const CommentAttachmentThumbnail = ({
taskId,
onPreview,
}: CommentAttachmentThumbnailProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState
(null);
const [downloading, setDownloading] = useState(false);
@@ -586,7 +600,11 @@ const CommentAttachmentThumbnail = ({
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
- setDownloadError(err instanceof Error ? err.message : 'Download failed');
+ setDownloadError(
+ err instanceof Error
+ ? err.message
+ : t('taskDetail.comments.attachments.downloadFailed')
+ );
} finally {
setDownloading(false);
}
diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
index bdc25007..f5d69c8b 100644
--- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
+++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
@@ -158,6 +159,7 @@ export const TaskDetailDialog = ({
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const avatarMap = useMemo(() => buildMemberAvatarMap(members), [members]);
const { isLight } = useTheme();
+ const { t } = useAppTranslation('team');
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const updateTaskFields = useStore((s) => s.updateTaskFields);
const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence);
@@ -463,7 +465,7 @@ export const TaskDetailDialog = ({
setTaskChangesReviewability(null);
}
setTaskChangesError(
- error instanceof Error ? error.message : 'Failed to load task changes summary'
+ error instanceof Error ? error.message : t('taskDetail.changes.loadFailed')
);
} finally {
taskChangesLoadInFlightKeysRef.current.delete(requestKey);
@@ -472,7 +474,14 @@ export const TaskDetailDialog = ({
}
}
},
- [canShowTaskChanges, currentTask, loadTaskChangeSummary, syncTaskChangeSummaryResult, variant]
+ [
+ canShowTaskChanges,
+ currentTask,
+ loadTaskChangeSummary,
+ syncTaskChangeSummaryResult,
+ t,
+ variant,
+ ]
);
useEffect(() => {
@@ -617,9 +626,9 @@ export const TaskDetailDialog = ({
? taskChangesFiles.length
: taskChangesFiles && taskChangesWarnings.length > 0
? taskChangesReviewability === 'attention_required'
- ? 'attention'
+ ? t('taskDetail.changes.badges.attention')
: taskChangesReviewability === 'diagnostic_only'
- ? 'no safe diff'
+ ? t('taskDetail.changes.badges.noSafeDiff')
: undefined
: undefined
: undefined;
@@ -652,11 +661,11 @@ export const TaskDetailDialog = ({
!v && onClose()}>
- Loading task…
+ {t('taskDetail.loading.title')}
- Fetching team data
+ {t('taskDetail.loading.fetchingTeamData')}
@@ -668,7 +677,7 @@ export const TaskDetailDialog = ({
!v && handleClose()}>
- Task not found
+ {t('taskDetail.notFound')}
@@ -870,7 +879,9 @@ export const TaskDetailDialog = ({
size="md"
/>
) : (
- Unassigned
+
+ {t('taskDetail.unassigned')}
+
)}
{currentTask.createdBy ? (
@@ -903,7 +914,7 @@ export const TaskDetailDialog = ({
}}
>
- Delete
+ {t('taskDetail.actions.delete')}
) : null}
@@ -920,8 +931,8 @@ export const TaskDetailDialog = ({
{currentTask.needsClarification === 'user'
- ? 'Awaiting clarification from you'
- : 'Awaiting clarification from team lead'}
+ ? t('taskDetail.clarification.awaitingUser')
+ : t('taskDetail.clarification.awaitingLead')}
- Mark resolved
+ {t('taskDetail.actions.markResolved')}
) : null}
@@ -947,14 +958,16 @@ export const TaskDetailDialog = ({
{(relatedIds.length > 0 || relatedByIds.length > 0) && (
- Related tasks
+ {t('taskDetail.related.title')}
)}
{relatedIds.length > 0 ? (
-
Links
+
+ {t('taskDetail.related.links')}
+
{relatedIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
@@ -982,7 +995,9 @@ export const TaskDetailDialog = ({
{relatedByIds.length > 0 ? (
-
Linked from
+
+ {t('taskDetail.related.linkedFrom')}
+
{relatedByIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
@@ -1012,7 +1027,7 @@ export const TaskDetailDialog = ({
- Blocked by
+ {t('taskDetail.related.blockedBy')}
{blockedByIds.map((id) => {
const depTask = taskMap.get(id);
@@ -1050,7 +1065,7 @@ export const TaskDetailDialog = ({
- Blocks
+ {t('taskDetail.related.blocks')}
{blocksIds.map((id) => {
const depTask = taskMap.get(id);
@@ -1091,7 +1106,7 @@ export const TaskDetailDialog = ({
{/* Description */}
}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
@@ -1103,7 +1118,7 @@ export const TaskDetailDialog = ({
)}
- Save
+ {t('taskDetail.actions.save')}
setEditingDescription(false)}
>
- Cancel
+ {t('taskDetail.actions.cancel')}
@@ -1176,7 +1191,7 @@ export const TaskDetailDialog = ({
-
Edit description
+
{t('taskDetail.description.edit')}
) : (
@@ -1185,14 +1200,14 @@ export const TaskDetailDialog = ({
className="text-xs text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={startEditDescription}
>
- Click to add description...
+ {t('taskDetail.description.add')}
)}
{/* Attachments */}
}
badge={attachmentCount}
contentClassName="pl-2.5"
@@ -1225,7 +1240,7 @@ export const TaskDetailDialog = ({
{variant === 'team' && canShowTaskChanges ? (
}
badge={taskChangesBadge}
headerExtra={
@@ -1245,7 +1260,7 @@ export const TaskDetailDialog = ({
handleRefreshChanges();
}}
disabled={taskChangesLoading}
- aria-label="Refresh changes"
+ aria-label={t('taskDetail.changes.refresh')}
>
-
Refresh
+
+ {t('taskDetail.changes.refreshShort')}
+
) : null
}
@@ -1266,7 +1283,7 @@ export const TaskDetailDialog = ({
{taskChangesLoading && (!taskChangesFiles || taskChangesFiles.length === 0) ? (
- Loading changes...
+ {t('taskDetail.changes.loading')}
) : taskChangesError ? (
{taskChangesError}
@@ -1299,7 +1316,9 @@ export const TaskDetailDialog = ({
))}
{taskChangesWarnings.length > 2 ? (
- {taskChangesWarnings.length - 2} more diagnostics
+ {t('taskDetail.changes.moreDiagnostics', {
+ count: taskChangesWarnings.length - 2,
+ })}
) : null}
@@ -1363,7 +1382,9 @@ export const TaskDetailDialog = ({
-
Review diff
+
+ {t('taskDetail.changes.reviewDiff')}
+
) : null}
{onOpenInEditor ? (
@@ -1380,7 +1401,9 @@ export const TaskDetailDialog = ({
-
Open in editor
+
+ {t('taskDetail.changes.openInEditor')}
+
) : null}
@@ -1391,16 +1414,18 @@ export const TaskDetailDialog = ({
{taskChangesWarnings.length > 0
? taskChangesReviewability === 'attention_required'
- ? 'No reviewable file changes recovered'
+ ? t('taskDetail.changes.empty.noReviewableChangesRecovered')
: taskChangesReviewability === 'diagnostic_only'
- ? 'No safe diff available'
- : 'No file changes recorded yet'
- : 'No file changes recorded'}
+ ? t('taskDetail.changes.empty.noSafeDiffAvailable')
+ : t('taskDetail.changes.empty.noFileChangesRecordedYet')
+ : t('taskDetail.changes.empty.noFileChangesRecorded')}
) : null}
) : changesSectionOpen ? (
-
No file changes recorded
+
+ {t('taskDetail.changes.empty.noFileChangesRecorded')}
+
) : null}
) : null}
@@ -1409,12 +1434,12 @@ export const TaskDetailDialog = ({
{variant === 'team' ? (
}
badge={taskLogStreamCount}
headerExtra={
taskLogActivityActive ? (
-
+
) : null
}
contentClassName="pl-2.5 overflow-visible"
@@ -1449,7 +1474,7 @@ export const TaskDetailDialog = ({
{kanbanTaskState.reviewer ? (
- Reviewer: {kanbanTaskState.reviewer}
+ {t('taskDetail.review.reviewer', { reviewer: kanbanTaskState.reviewer })}
) : null}
{kanbanTaskState.errorDescription ? (
@@ -1462,7 +1487,7 @@ export const TaskDetailDialog = ({
{/* Workflow History */}
{currentTask.historyEvents && currentTask.historyEvents.length > 0 ? (
}
badge={currentTask.historyEvents.length}
contentClassName="pl-2.5"
@@ -1472,10 +1497,14 @@ export const TaskDetailDialog = ({
showTaskImplementationDuration ? (
- In progress time {taskImplementationDurationLabel}
+
+ {t('taskDetail.workflow.inProgressTime', {
+ duration: taskImplementationDurationLabel,
+ })}
+
) : undefined
}
@@ -1492,7 +1521,7 @@ export const TaskDetailDialog = ({
{/* Comments */}
}
badge={
(currentTask.comments?.length ?? 0) > 0
@@ -1562,6 +1591,7 @@ const CommentImagesGrid = ({
teamName: string;
taskId: string;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [previewUrl, setPreviewUrl] = useState
(null);
return (
@@ -1569,7 +1599,7 @@ const CommentImagesGrid = ({
- From comments
+ {t('taskDetail.attachments.fromComments')}
@@ -1588,7 +1618,7 @@ const CommentImagesGrid = ({
open
onClose={() => setPreviewUrl(null)}
src={previewUrl}
- alt="Comment attachment"
+ alt={t('taskDetail.attachments.commentAttachment')}
/>
) : null}
@@ -1606,6 +1636,7 @@ const CommentImageThumbnail = ({
taskId: string;
onPreview: (dataUrl: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState(null);
@@ -1641,7 +1672,7 @@ const CommentImageThumbnail = ({
type="button"
className="group relative flex size-16 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
onClick={() => thumbUrl && onPreview(thumbUrl)}
- aria-label={`Preview ${item.attachment.filename}`}
+ aria-label={t('taskDetail.attachments.preview', { filename: item.attachment.filename })}
>
{thumbUrl ? (
diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
index c00ffee4..9533d6f5 100644
--- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx
+++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { isOpenCodeCatalogHydrating } from '@renderer/components/runtime/providerConnectionUi';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -139,6 +140,8 @@ interface OpenCodeModelPricingInfo {
title: string | undefined;
}
+type TeamTranslator = ReturnType['t'];
+
const MODEL_GRID_MIN_CARD_WIDTH_PX = 140;
const MODEL_GRID_GAP_PX = 6;
const OPENCODE_MODEL_GRID_MAX_HEIGHT_PX = 400;
@@ -166,19 +169,24 @@ function getOpenCodeSourceInfo(model: string): OpenCodeSourceInfo | null {
}
function getOpenCodeRouteGroup(
- catalogModel: ProviderModelCatalogItem | null | undefined
+ catalogModel: ProviderModelCatalogItem | null | undefined,
+ t: TeamTranslator
): OpenCodeRouteGroupInfo {
const routeKind = catalogModel?.metadata?.opencode?.routeKind;
if (routeKind === 'configured_local') {
- return { id: 'opencode-config', label: 'OpenCode config', rank: 0 };
+ return { id: 'opencode-config', label: t('modelSelector.routeGroups.openCodeConfig'), rank: 0 };
}
if (routeKind === 'builtin_free') {
- return { id: 'builtin-free', label: 'Free built-in', rank: 1 };
+ return { id: 'builtin-free', label: t('modelSelector.routeGroups.builtinFree'), rank: 1 };
}
if (routeKind === 'connected_provider') {
- return { id: 'connected-providers', label: 'Connected providers', rank: 2 };
+ return {
+ id: 'connected-providers',
+ label: t('modelSelector.routeGroups.connectedProviders'),
+ rank: 2,
+ };
}
- return { id: 'catalog-provider', label: 'Other OpenCode catalog', rank: 3 };
+ return { id: 'catalog-provider', label: t('modelSelector.routeGroups.otherCatalog'), rank: 3 };
}
function isRecommendedTeamModelRecommendation(
@@ -322,9 +330,9 @@ function extractOpenCodeCostRates(cost: unknown): OpenCodeModelCostRates | null
return Object.values(rates).some((rate) => rate !== null) ? rates : null;
}
-function formatOpenCodeCostRate(rate: number): string {
+function formatOpenCodeCostRate(rate: number, t: TeamTranslator): string {
if (rate === 0) {
- return 'Free';
+ return t('modelSelector.pricing.free');
}
const formatted = rate.toLocaleString('en-US', {
@@ -334,41 +342,61 @@ function formatOpenCodeCostRate(rate: number): string {
return `$${formatted}`;
}
-function formatOpenCodeCostSummary(rates: OpenCodeModelCostRates): string | null {
+function formatOpenCodeCostSummary(
+ rates: OpenCodeModelCostRates,
+ t: TeamTranslator
+): string | null {
const summaryParts: string[] = [];
if (rates.input !== null) {
- summaryParts.push(`in ${formatOpenCodeCostRate(rates.input)}`);
+ summaryParts.push(
+ t('modelSelector.pricing.inputShort', { rate: formatOpenCodeCostRate(rates.input, t) })
+ );
}
if (rates.output !== null) {
- summaryParts.push(`out ${formatOpenCodeCostRate(rates.output)}`);
+ summaryParts.push(
+ t('modelSelector.pricing.outputShort', { rate: formatOpenCodeCostRate(rates.output, t) })
+ );
}
if (summaryParts.length === 0) {
return null;
}
- return `${summaryParts.join(' · ')} / 1M`;
+ return t('modelSelector.pricing.perMillionSummary', { summary: summaryParts.join(' · ') });
}
-function formatOpenCodeCostTitle(rates: OpenCodeModelCostRates): string {
+function formatOpenCodeCostTitle(rates: OpenCodeModelCostRates, t: TeamTranslator): string {
const titleParts: string[] = [];
if (rates.input !== null) {
- titleParts.push(`Input: ${formatOpenCodeCostRate(rates.input)} per 1M tokens`);
+ titleParts.push(
+ t('modelSelector.pricing.inputTitle', { rate: formatOpenCodeCostRate(rates.input, t) })
+ );
}
if (rates.output !== null) {
- titleParts.push(`Output: ${formatOpenCodeCostRate(rates.output)} per 1M tokens`);
+ titleParts.push(
+ t('modelSelector.pricing.outputTitle', { rate: formatOpenCodeCostRate(rates.output, t) })
+ );
}
if (rates.cacheRead !== null) {
- titleParts.push(`Cache read: ${formatOpenCodeCostRate(rates.cacheRead)} per 1M tokens`);
+ titleParts.push(
+ t('modelSelector.pricing.cacheReadTitle', {
+ rate: formatOpenCodeCostRate(rates.cacheRead, t),
+ })
+ );
}
if (rates.cacheWrite !== null) {
- titleParts.push(`Cache write: ${formatOpenCodeCostRate(rates.cacheWrite)} per 1M tokens`);
+ titleParts.push(
+ t('modelSelector.pricing.cacheWriteTitle', {
+ rate: formatOpenCodeCostRate(rates.cacheWrite, t),
+ })
+ );
}
return titleParts.join('\n');
}
function getOpenCodeModelPricingInfo(
- catalogModel: ProviderModelCatalogItem | null | undefined
+ catalogModel: ProviderModelCatalogItem | null | undefined,
+ t: TeamTranslator
): OpenCodeModelPricingInfo | null {
const metadata = catalogModel?.metadata;
if (!metadata) {
@@ -378,8 +406,8 @@ function getOpenCodeModelPricingInfo(
const rates = extractOpenCodeCostRates(metadata.cost);
return {
free: metadata.free === true,
- summary: rates ? formatOpenCodeCostSummary(rates) : null,
- title: rates ? formatOpenCodeCostTitle(rates) : undefined,
+ summary: rates ? formatOpenCodeCostSummary(rates, t) : null,
+ title: rates ? formatOpenCodeCostTitle(rates, t) : undefined,
};
}
@@ -421,66 +449,75 @@ export const OPENCODE_ONE_SHOT_DISABLED_REASON =
export const OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL = 'team only';
function getOpenCodeReadinessBadgeLabel(
- providerStatus: CliProviderStatus | null | undefined
+ providerStatus: CliProviderStatus | null | undefined,
+ t: TeamTranslator
): string {
if (!providerStatus) {
- return 'Check';
+ return t('modelSelector.openCodeStatus.badges.check');
}
if (!providerStatus.supported) {
- return 'Install';
+ return t('modelSelector.openCodeStatus.badges.install');
}
if (!providerStatus.authenticated) {
- return 'Free';
+ return t('modelSelector.openCodeStatus.badges.free');
}
- return 'Setup';
+ return t('modelSelector.openCodeStatus.badges.setup');
}
-function getOpenCodeReadinessSummary(providerStatus: CliProviderStatus | null | undefined): string {
+function getOpenCodeReadinessSummary(
+ providerStatus: CliProviderStatus | null | undefined,
+ t: TeamTranslator
+): string {
if (!providerStatus) {
- return 'OpenCode status: checking runtime';
+ return t('modelSelector.openCodeStatus.summary.checking');
}
const runtimeReady = providerStatus.supported;
const hasFreeModelRoute = hasFreeOpenCodeModelRoute(providerStatus);
- let readinessSummary = 'team launch blocked';
+ let readinessSummary = t('modelSelector.openCodeStatus.summaryParts.teamLaunchBlocked');
if (runtimeReady) {
if (!providerStatus.authenticated) {
readinessSummary = hasFreeModelRoute
- ? 'provider connection optional'
- : 'provider-backed models need setup';
+ ? t('modelSelector.openCodeStatus.summaryParts.providerOptional')
+ : t('modelSelector.openCodeStatus.summaryParts.providerModelsNeedSetup');
} else if (providerStatus.capabilities.teamLaunch) {
- readinessSummary = 'team launch ready';
+ readinessSummary = t('modelSelector.openCodeStatus.summaryParts.teamLaunchReady');
}
}
const parts = [
- runtimeReady ? 'runtime detected' : 'runtime missing',
+ runtimeReady
+ ? t('modelSelector.openCodeStatus.summaryParts.runtimeDetected')
+ : t('modelSelector.openCodeStatus.summaryParts.runtimeMissing'),
runtimeReady && !providerStatus.authenticated && hasFreeModelRoute
- ? 'free models available without auth'
+ ? t('modelSelector.openCodeStatus.summaryParts.freeWithoutAuth')
: providerStatus.authenticated
- ? 'provider connected'
- : 'provider not connected',
+ ? t('modelSelector.openCodeStatus.summaryParts.providerConnected')
+ : t('modelSelector.openCodeStatus.summaryParts.providerNotConnected'),
readinessSummary,
];
- return `OpenCode status: ${parts.join(' · ')}`;
+ return t('modelSelector.openCodeStatus.summary.status', { parts: parts.join(' · ') });
}
-function getOpenCodeReadinessMessage(providerStatus: CliProviderStatus | null | undefined): string {
+function getOpenCodeReadinessMessage(
+ providerStatus: CliProviderStatus | null | undefined,
+ t: TeamTranslator
+): string {
if (!providerStatus) {
- return 'The app is still checking the OpenCode runtime. Wait for provider status to finish, then try again.';
+ return t('modelSelector.openCodeStatus.messages.checking');
}
if (!providerStatus.supported) {
- return 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.';
+ return t('modelSelector.openCodeStatus.messages.unsupported');
}
if (!providerStatus.authenticated) {
if (hasFreeOpenCodeModelRoute(providerStatus)) {
- return 'OpenCode is detected. You can use free OpenCode models such as Big Pickle without connecting a provider. Connect a provider only when you want provider-backed models.';
+ return t('modelSelector.openCodeStatus.messages.freeAvailable');
}
- return 'OpenCode is detected, but no free OpenCode model is listed yet. Refresh provider status, or connect a provider in OpenCode for provider-backed models.';
+ return t('modelSelector.openCodeStatus.messages.noFreeListed');
}
if (!providerStatus.capabilities.teamLaunch) {
- return 'OpenCode is installed and authenticated, but Agent Teams launch readiness is blocked.';
+ return t('modelSelector.openCodeStatus.messages.launchBlocked');
}
- return 'OpenCode is ready for team launch.';
+ return t('modelSelector.openCodeStatus.messages.ready');
}
export function getTeamModelLabel(model: string): string {
@@ -667,47 +704,50 @@ const OpenCodeVirtualizedModelGrid = ({
);
};
-const OpenCodeModelCatalogLoadingSkeleton = (): React.JSX.Element => (
-
-
-
-
- Loading OpenCode models...
-
-
+const OpenCodeModelCatalogLoadingSkeleton = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ return (
- {[0, 1, 2].map((index) => (
-
+
+
+
+ {t('modelSelector.openCode.loadingModels')}
+
+
+
+ {[0, 1, 2].map((index) => (
-
-
- ))}
+ key={index}
+ className="min-h-[44px] rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-3 py-2"
+ >
+
+
+
+ ))}
+
-
-);
+ );
+};
export interface TeamModelSelectorProps {
providerId: TeamProviderId;
@@ -738,6 +778,7 @@ export const TeamModelSelector: React.FC
= ({
modelIssueReasonByValue,
modelUnavailableReasonByValue,
}) => {
+ const { t } = useAppTranslation('team');
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const [recommendedOnly, setRecommendedOnly] = useState(false);
const [freeOnly, setFreeOnly] = useState(false);
@@ -773,8 +814,10 @@ export const TeamModelSelector: React.FC = ({
runtimeProviderStatus?.modelCatalog?.defaultModelId?.trim() ||
null;
return defaultCompatibleModel
- ? `Uses the Anthropic-compatible endpoint default model.\nCurrently resolves to ${defaultCompatibleModel}.`
- : 'Uses the Anthropic-compatible endpoint default model.';
+ ? t('modelSelector.defaultTooltip.anthropicCompatibleWithResolved', {
+ model: defaultCompatibleModel,
+ })
+ : t('modelSelector.defaultTooltip.anthropicCompatible');
}
const defaultLongContextModel =
@@ -790,7 +833,10 @@ export const TeamModelSelector: React.FC = ({
runtimeProviderStatus
) ?? 'Opus 4.7';
- return `Uses the Claude team default model.\nResolves to ${defaultLongContextModel} with 1M context, or ${defaultLimitedContextModel} with 200K context when Limit context is enabled.`;
+ return t('modelSelector.defaultTooltip.anthropic', {
+ longContextModel: defaultLongContextModel,
+ limitedContextModel: defaultLimitedContextModel,
+ });
}
if (effectiveProviderId === 'opencode') {
const defaultOpenCodeModel =
@@ -798,11 +844,11 @@ export const TeamModelSelector: React.FC = ({
runtimeProviderStatus?.modelCatalog?.defaultModelId ??
null;
return defaultOpenCodeModel
- ? `Uses the OpenCode default model.\nCurrently resolves to ${defaultOpenCodeModel}.`
- : 'Uses the OpenCode runtime default model.';
+ ? t('modelSelector.defaultTooltip.openCodeWithResolved', { model: defaultOpenCodeModel })
+ : t('modelSelector.defaultTooltip.openCode');
}
- return 'Uses the runtime default for the selected provider.';
- }, [effectiveProviderId, runtimeProviderStatus]);
+ return t('modelSelector.defaultTooltip.runtime');
+ }, [effectiveProviderId, runtimeProviderStatus, t]);
const getProviderOverrideDisabledReason = (candidateProviderId: string): string | null => {
if (!isTeamProviderId(candidateProviderId)) {
return null;
@@ -819,7 +865,7 @@ export const TeamModelSelector: React.FC = ({
if (candidateProviderId === 'opencode') {
const providerStatus = runtimeProviderStatusById.get('opencode') ?? null;
if (!providerStatus) {
- return 'OpenCode runtime status is still loading.';
+ return t('modelSelector.openCodeStatus.loadingRuntime');
}
if (!providerStatus.supported) {
return (
@@ -864,7 +910,7 @@ export const TeamModelSelector: React.FC = ({
if (candidateProviderId === 'opencode') {
return getProviderDisabledReason(candidateProviderId)
- ? getOpenCodeReadinessBadgeLabel(runtimeProviderStatusById.get('opencode'))
+ ? getOpenCodeReadinessBadgeLabel(runtimeProviderStatusById.get('opencode'), t)
: null;
}
@@ -874,14 +920,14 @@ export const TeamModelSelector: React.FC = ({
}
if (!isProviderSelectable(candidateProviderId)) {
- return 'Multimodel off';
+ return t('modelSelector.multimodelOff');
}
return null;
};
const getProviderStatusBadgeLabel = (statusBadge: string | null): string | null => {
- if (statusBadge === 'Multimodel off') {
- return 'Off';
+ if (statusBadge === t('modelSelector.multimodelOff')) {
+ return t('modelSelector.fastMode.off');
}
return statusBadge;
@@ -907,10 +953,16 @@ export const TeamModelSelector: React.FC = ({
const modelOptions = useMemo(() => {
if (shouldAwaitRuntimeModelList) {
- return [{ value: '', label: 'Default', badgeLabel: 'Default' }];
+ return [
+ {
+ value: '',
+ label: t('modelSelector.defaultModel'),
+ badgeLabel: t('modelSelector.defaultModel'),
+ },
+ ];
}
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
- }, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]);
+ }, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList, t]);
const showAnthropicCompatibleCustomModelInput =
effectiveProviderId === 'anthropic' &&
canUseCustomAnthropicCompatibleModel(runtimeProviderStatus);
@@ -957,8 +1009,8 @@ export const TeamModelSelector: React.FC = ({
const sourceInfo = getOpenCodeSourceInfo(option.value);
const recommendation = getTeamModelRecommendation(effectiveProviderId, option.value);
const catalogModel = openCodeCatalogModelById.get(option.value);
- const pricingInfo = getOpenCodeModelPricingInfo(catalogModel);
- const routeGroup = getOpenCodeRouteGroup(catalogModel);
+ const pricingInfo = getOpenCodeModelPricingInfo(catalogModel, t);
+ const routeGroup = getOpenCodeRouteGroup(catalogModel, t);
const routeMetadata = catalogModel?.metadata?.opencode ?? null;
return {
@@ -981,7 +1033,7 @@ export const TeamModelSelector: React.FC = ({
isFree: isFreeOpenCodeModelOption({ option, routeMetadata, pricingInfo }),
};
});
- }, [effectiveProviderId, modelOptions, openCodeCatalogModelById]);
+ }, [effectiveProviderId, modelOptions, openCodeCatalogModelById, t]);
const openCodeModelMetadataByValue = useMemo(
() => new Map(openCodeModelMetadata.map((metadata) => [metadata.option.value, metadata])),
[openCodeModelMetadata]
@@ -1110,10 +1162,12 @@ export const TeamModelSelector: React.FC = ({
const openCodeSourceFilterLabel =
selectedOpenCodeSourceLabels.length === 0
- ? 'All OpenCode sources'
+ ? t('modelSelector.openCode.allSources')
: selectedOpenCodeSourceLabels.length === 1
? selectedOpenCodeSourceLabels[0]
- : `${selectedOpenCodeSourceLabels.length} OpenCode sources`;
+ : t('modelSelector.openCode.sourcesCount', {
+ count: selectedOpenCodeSourceLabels.length,
+ });
const toggleOpenCodeSourceFilter = (sourceId: string): void => {
setSelectedOpenCodeSourceIds((previous) => {
@@ -1257,14 +1311,14 @@ export const TeamModelSelector: React.FC = ({
!shouldShowOpenCodeCatalogLoading &&
visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD;
const emptyModelListMessage = trimmedModelQuery
- ? 'No models match this search.'
+ ? t('modelSelector.empty.noSearchMatches')
: effectiveProviderId === 'opencode' && recommendedOnly && freeOnly
- ? 'No recommended free OpenCode models are available in the current runtime list.'
+ ? t('modelSelector.empty.recommendedFreeOpenCode')
: effectiveProviderId === 'opencode' && freeOnly
- ? 'No free OpenCode models are available in the current runtime list.'
+ ? t('modelSelector.empty.freeOpenCode')
: effectiveProviderId === 'opencode' && recommendedOnly
- ? 'No recommended OpenCode models are available in the current runtime list.'
- : 'No models are available in the current runtime list.';
+ ? t('modelSelector.empty.recommendedOpenCode')
+ : t('modelSelector.empty.noModels');
const activeProviderDisabledReason = activeProviderSelectable
? null
: getProviderDisabledReason(effectiveProviderId);
@@ -1274,9 +1328,9 @@ export const TeamModelSelector: React.FC = ({
activeProviderDisabledReason && effectiveProviderId === 'opencode'
? {
tone: 'warning' as const,
- title: 'OpenCode is not ready for team launch',
- summary: getOpenCodeReadinessSummary(runtimeProviderStatus),
- message: getOpenCodeReadinessMessage(runtimeProviderStatus),
+ title: t('modelSelector.openCodeStatus.notReadyTitle'),
+ summary: getOpenCodeReadinessSummary(runtimeProviderStatus, t),
+ message: getOpenCodeReadinessMessage(runtimeProviderStatus, t),
reason: activeProviderDisabledReason,
actionLabel: null,
}
@@ -1286,27 +1340,28 @@ export const TeamModelSelector: React.FC = ({
? {
tone: 'warning' as const,
title: hasFreeOpenCodeModelRoute(runtimeProviderStatus)
- ? 'OpenCode free models are available'
- : 'OpenCode provider is not connected',
- summary: getOpenCodeReadinessSummary(runtimeProviderStatus),
- message: getOpenCodeReadinessMessage(runtimeProviderStatus),
+ ? t('modelSelector.openCodeStatus.freeModelsAvailableTitle')
+ : t('modelSelector.openCodeStatus.providerNotConnectedTitle'),
+ summary: getOpenCodeReadinessSummary(runtimeProviderStatus, t),
+ message: getOpenCodeReadinessMessage(runtimeProviderStatus, t),
reason: null,
actionLabel: null,
}
: canActivateInspectedOpenCode
? {
tone: 'ready' as const,
- title: 'OpenCode is ready',
- summary: getOpenCodeReadinessSummary(runtimeProviderStatus),
- message:
- 'OpenCode passed provider readiness. Select it to use OpenCode models for this team.',
+ title: t('modelSelector.openCodeStatus.readyTitle'),
+ summary: getOpenCodeReadinessSummary(runtimeProviderStatus, t),
+ message: t('modelSelector.openCodeStatus.readyMessage'),
reason: null,
- actionLabel: 'Use OpenCode',
+ actionLabel: t('modelSelector.openCodeStatus.useOpenCode'),
}
: null;
const activeProviderNotice = providerNoticeById?.[effectiveProviderId] ?? null;
const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
- reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
+ reason?.toLowerCase().includes('ping not confirmed')
+ ? t('modelSelector.advisory.pingNotConfirmed')
+ : t('modelSelector.advisory.note');
const renderModelOption = (opt: TeamRuntimeModelOption): React.JSX.Element => {
const modelDisabledReason = getTeamModelUiDisabledReason(
effectiveProviderId,
@@ -1318,7 +1373,7 @@ export const TeamModelSelector: React.FC = ({
const availabilityReason = opt.value === '' ? null : (opt.availabilityReason ?? null);
const runtimeUnavailableReason =
opt.value !== '' && availabilityStatus === 'unavailable'
- ? (availabilityReason ?? 'Unavailable in current runtime')
+ ? (availabilityReason ?? t('modelSelector.unavailableInRuntime'))
: null;
const modelAdvisoryReason =
opt.value === '' ? null : (modelAdvisoryReasonByValue?.[opt.value] ?? null);
@@ -1418,39 +1473,39 @@ export const TeamModelSelector: React.FC = ({
- Free
+ {t('modelSelector.badges.free')}
) : null}
{openCodeRouteKind === 'configured_local' ? (
- Local
+ {t('modelSelector.badges.local')}
) : null}
{openCodeRouteKind === 'configured_local' ? (
- Configured
+ {t('modelSelector.badges.configured')}
) : null}
{openCodeRouteKind === 'connected_provider' ? (
- Connected
+ {t('modelSelector.badges.connected')}
) : null}
{openCodeProofState === 'verified' ? (
- Verified
+ {t('modelSelector.badges.verified')}
) : null}
{openCodeProofState === 'needs_probe' ? (
- Needs test
+ {t('modelSelector.badges.needsTest')}
) : null}
{openCodeProofState === 'failed' ? (
- Failed
+ {t('modelSelector.badges.failed')}
) : null}
{modelRecommendation ? (
@@ -1501,7 +1556,11 @@ export const TeamModelSelector: React.FC = ({
title={modelStatusMessage ?? undefined}
>
- {modelUnavailableReason ? 'Unavailable' : 'Issue'}
+
+ {modelUnavailableReason
+ ? t('modelSelector.badges.unavailable')
+ : t('modelSelector.badges.issue')}
+
{modelStatusMessage ? (
= ({
return (
- Model (optional)
+ {t('modelSelector.label')}
= ({
{!multimodelAvailable ? (
- Codex and Gemini require Multimodel mode.
+ {t('modelSelector.multimodelRequired')}
) : null}
@@ -1670,7 +1729,11 @@ export const TeamModelSelector: React.FC = ({
{activeProviderStatusPanel.summary}
{activeProviderStatusPanel.message}
{activeProviderStatusPanel.reason ? (
- Reason: {activeProviderStatusPanel.reason}
+
+ {t('modelSelector.reason', {
+ reason: activeProviderStatusPanel.reason,
+ })}
+
) : null}
{activeProviderStatusPanel.actionLabel ? (
= ({
) : null}
{shouldAwaitRuntimeModelList ? (
- Explicit models load from the current runtime. Default remains available while the
- list is syncing.
+ {t('modelSelector.runtimeModelsSyncing')}
) : null}
{showAnthropicCompatibleCustomModelInput ? (
@@ -1700,14 +1762,14 @@ export const TeamModelSelector: React.FC = ({
htmlFor="anthropic-compatible-custom-model"
className="mb-1 block text-[11px] font-medium text-[var(--color-text-secondary)]"
>
- Custom model id
+ {t('modelSelector.customModelId')}
onValueChange(event.currentTarget.value.trim())}
- placeholder="openai/gpt-oss-20b"
+ placeholder={t('modelSelector.placeholders.customModelId')}
className="h-8 text-xs"
disabled={isInspectingInactiveProvider || !activeProviderSelectable}
/>
@@ -1725,8 +1787,8 @@ export const TeamModelSelector: React.FC = ({
data-testid="team-model-selector-model-search"
value={modelQuery}
onChange={(event) => setModelQuery(event.target.value)}
- placeholder="Search models"
- aria-label="Search models"
+ placeholder={t('modelSelector.searchModels')}
+ aria-label={t('modelSelector.searchModels')}
className="h-9 pr-3 text-sm"
style={{ paddingLeft: 40 }}
/>
@@ -1751,7 +1813,7 @@ export const TeamModelSelector: React.FC = ({
selectedOpenCodeSourceIds.size > 0 &&
'border-[var(--color-border-emphasis)] text-[var(--color-text)]'
)}
- aria-label="Filter OpenCode sources"
+ aria-label={t('modelSelector.openCode.filterSources')}
>
{openCodeSourceFilterLabel}
@@ -1767,13 +1829,13 @@ export const TeamModelSelector: React.FC = ({
- No sources found.
+ {t('modelSelector.openCode.noSourcesFound')}
{selectedOpenCodeSourceIds.size > 0 && !openCodeSourceQuery.trim() ? (
= ({
className="flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-[var(--color-text-muted)] outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
- All OpenCode sources
+ {t('modelSelector.openCode.allSources')}
) : null}
{filteredOpenCodeSourceOptions.map((source) => {
@@ -1799,7 +1861,9 @@ export const TeamModelSelector: React.FC = ({
onCheckedChange={() => toggleOpenCodeSourceFilter(source.id)}
onClick={(event) => event.stopPropagation()}
className="size-3.5"
- aria-label={`Filter ${source.label}`}
+ aria-label={t('modelSelector.openCode.filterSource', {
+ source: source.label,
+ })}
/>
{source.label}
@@ -1827,7 +1891,7 @@ export const TeamModelSelector: React.FC = ({
htmlFor="opencode-team-model-recommended-only"
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
>
- Recommended only
+ {t('modelSelector.openCode.recommendedOnly')}
) : null}
@@ -1843,7 +1907,7 @@ export const TeamModelSelector: React.FC = ({
htmlFor="opencode-team-model-free-only"
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
>
- Free only
+ {t('modelSelector.openCode.freeOnly')}
) : null}
diff --git a/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx b/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx
index 73e6b9e4..ca969bd0 100644
--- a/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx
+++ b/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { AlertTriangle, Info } from 'lucide-react';
@@ -14,6 +15,8 @@ export const TeammateRuntimeCompatibilityNotice = ({
analysis,
onOpenDashboard,
}: TeammateRuntimeCompatibilityNoticeProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
+
if (!analysis.visible) {
return null;
}
@@ -50,7 +53,7 @@ export const TeammateRuntimeCompatibilityNotice = ({
className="mt-1 h-7 px-2 text-[11px]"
onClick={onOpenDashboard}
>
- Open Dashboard
+ {t('dialogs.actions.openDashboard')}
) : null}
diff --git a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx
index 38003e2f..6a837da4 100644
--- a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx
+++ b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Checkbox } from '@renderer/components/ui/checkbox';
import {
Select,
@@ -17,31 +18,36 @@ import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/ty
export const ToolApprovalSettingsToggle: React.FC<{ expanded: boolean; onToggle: () => void }> = ({
expanded,
onToggle,
-}) => (
-
{
- Object.assign(e.currentTarget.style, {
- backgroundColor: 'var(--color-surface-raised)',
- });
- }}
- onMouseLeave={(e) => {
- Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
- }}
- >
-
- Settings
- {expanded ? : }
-
-);
+}) => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
{
+ Object.assign(e.currentTarget.style, {
+ backgroundColor: 'var(--color-surface-raised)',
+ });
+ }}
+ onMouseLeave={(e) => {
+ Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
+ }}
+ >
+
+ {t('toolApproval.settings')}
+ {expanded ? : }
+
+ );
+};
export const ToolApprovalSettingsContent: React.FC<{
expanded: boolean;
teamName?: string;
}> = ({ expanded, teamName }) => {
+ const { t } = useAppTranslation('team');
const [localSeconds, setLocalSeconds] = useState
('');
const settings = useStore(useShallow((s) => s.toolApprovalSettings));
const rawUpdateSettings = useStore((s) => s.updateToolApprovalSettings);
@@ -69,7 +75,7 @@ export const ToolApprovalSettingsContent: React.FC<{
checked={settings.autoAllowAll}
onCheckedChange={(checked) => void updateSettings({ autoAllowAll: checked === true })}
/>
- Auto-allow all tools
+ {t('toolApproval.autoAllowAllTools')}
{/* Separator */}
@@ -90,7 +96,7 @@ export const ToolApprovalSettingsContent: React.FC<{
void updateSettings({ autoAllowFileEdits: checked === true })
}
/>
- Auto-allow file edits (Edit, Write, NotebookEdit)
+ {t('toolApproval.autoAllowFileEdits')}
{/* Auto-allow safe bash */}
@@ -108,7 +114,7 @@ export const ToolApprovalSettingsContent: React.FC<{
void updateSettings({ autoAllowSafeBash: checked === true })
}
/>
- Auto-allow safe commands (git, pnpm, npm, ls...)
+ {t('toolApproval.autoAllowSafeCommands')}
{/* Separator */}
@@ -119,7 +125,7 @@ export const ToolApprovalSettingsContent: React.FC<{
className="flex items-center gap-2 text-xs"
style={{ color: 'var(--color-text-secondary)' }}
>
- On timeout:
+ {t('toolApproval.onTimeout')}
@@ -130,15 +136,15 @@ export const ToolApprovalSettingsContent: React.FC<{
- Wait forever
- Allow
- Deny
+ {t('toolApproval.timeoutActions.wait')}
+ {t('toolApproval.timeoutActions.allow')}
+ {t('toolApproval.timeoutActions.deny')}
{settings.timeoutAction !== 'wait' && (
<>
- after
+ {t('toolApproval.after')}
- sec
+ {t('toolApproval.secondsShort')}
>
)}
diff --git a/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx b/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx
index d86f7b03..45173a54 100644
--- a/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx
+++ b/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { AlertTriangle, CheckCircle2, GitBranch, Loader2 } from 'lucide-react';
@@ -154,6 +155,7 @@ export const WorktreeGitReadinessBanner = ({
state: WorktreeGitReadinessState;
showReady?: boolean;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const { status, loading, actionLoading, error, initializeRepository, createInitialCommit } =
state;
@@ -161,7 +163,7 @@ export const WorktreeGitReadinessBanner = ({
return (
-
Checking Git repository status for teammate worktrees...
+
{t('worktreeGitReadiness.checking')}
);
}
@@ -185,8 +187,9 @@ export const WorktreeGitReadinessBanner = ({
- Git worktrees are ready
- {status.branch ? ` on branch ${status.branch}` : ''}.
+ {status.branch
+ ? t('worktreeGitReadiness.readyOnBranch', { branch: status.branch })
+ : t('worktreeGitReadiness.ready')}
);
@@ -197,15 +200,15 @@ export const WorktreeGitReadinessBanner = ({
-
Worktree isolation needs Git setup
+
{t('worktreeGitReadiness.needsSetup')}
{status.message ??
'Worktree isolation requires a Git repository with an initial commit.'}
{status.reason === 'missing_head' ? (
- The initial commit action stages and commits all current files with message{' '}
- chore: initial commit .
+ {t('worktreeGitReadiness.initialCommitNotice')}{' '}
+ {t('worktreeGitReadiness.initialCommitMessage')} .
) : null}
@@ -221,7 +224,7 @@ export const WorktreeGitReadinessBanner = ({
onClick={initializeRepository}
>
{actionLoading === 'init' ?
: null}
- Initialize Git repository
+ {t('worktreeGitReadiness.initializeRepository')}
) : null}
{status.reason === 'missing_head' ? (
@@ -234,7 +237,7 @@ export const WorktreeGitReadinessBanner = ({
onClick={createInitialCommit}
>
{actionLoading === 'commit' ?
: null}
- Create initial commit
+ {t('worktreeGitReadiness.createInitialCommit')}
) : null}
diff --git a/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx b/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx
index 3b19beea..097de296 100644
--- a/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx
+++ b/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx
@@ -2,6 +2,7 @@
* Placeholder for non-previewable binary files — shows file info and "Open in System Viewer" button.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { FileQuestion } from 'lucide-react';
@@ -17,6 +18,7 @@ export const EditorBinaryPlaceholder = ({
fileName,
size,
}: EditorBinaryPlaceholderProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.editorProjectPath);
const sizeFormatted =
size < 1024
@@ -33,9 +35,9 @@ export const EditorBinaryPlaceholder = ({
{fileName}
-
Binary file ({sizeFormatted})
+
{t('editor.binaryPlaceholder.file', { size: sizeFormatted })}
- Open in System Viewer
+ {t('editor.imagePreview.openSystemViewer')}
);
diff --git a/src/renderer/components/team/editor/EditorEmptyState.tsx b/src/renderer/components/team/editor/EditorEmptyState.tsx
index 0e657b10..79d89c2f 100644
--- a/src/renderer/components/team/editor/EditorEmptyState.tsx
+++ b/src/renderer/components/team/editor/EditorEmptyState.tsx
@@ -3,25 +3,30 @@
* Shows keyboard shortcuts cheatsheet.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { FileCode } from 'lucide-react';
-const SHORTCUTS = [
- { keys: shortcutLabel('⌘ P', 'Ctrl+P'), label: 'Quick Open' },
- { keys: shortcutLabel('⌘ ⇧ F', 'Ctrl+Shift+F'), label: 'Search in Files' },
- { keys: shortcutLabel('⌘ S', 'Ctrl+S'), label: 'Save' },
- { keys: shortcutLabel('⌘ B', 'Ctrl+B'), label: 'Toggle Sidebar' },
- { keys: shortcutLabel('⌘ G', 'Ctrl+G'), label: 'Go to Line' },
- { keys: 'Esc', label: 'Close Editor' },
-];
-
export const EditorEmptyState = (): React.ReactElement => {
+ const { t } = useAppTranslation('team');
+ const shortcuts = [
+ { keys: shortcutLabel('⌘ P', 'Ctrl+P'), label: t('editor.shortcuts.actions.quickOpen') },
+ {
+ keys: shortcutLabel('⌘ ⇧ F', 'Ctrl+Shift+F'),
+ label: t('editor.shortcuts.actions.searchInFiles'),
+ },
+ { keys: shortcutLabel('⌘ S', 'Ctrl+S'), label: t('editor.shortcuts.actions.save') },
+ { keys: shortcutLabel('⌘ B', 'Ctrl+B'), label: t('editor.shortcuts.actions.toggleSidebar') },
+ { keys: shortcutLabel('⌘ G', 'Ctrl+G'), label: t('editor.shortcuts.actions.goToLine') },
+ { keys: 'Esc', label: t('editor.shortcuts.actions.closeEditor') },
+ ];
+
return (
-
Select a file from the tree to edit
+
{t('editor.empty.selectFile')}
- {SHORTCUTS.map((s) => (
+ {shortcuts.map((s) => (
{s.label}
diff --git a/src/renderer/components/team/editor/EditorErrorBoundary.tsx b/src/renderer/components/team/editor/EditorErrorBoundary.tsx
index 67fd4db2..6c0b9753 100644
--- a/src/renderer/components/team/editor/EditorErrorBoundary.tsx
+++ b/src/renderer/components/team/editor/EditorErrorBoundary.tsx
@@ -7,12 +7,18 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle } from 'lucide-react';
interface Props {
filePath: string;
onRetry?: () => void;
children: React.ReactNode;
+ labels?: {
+ crashed: string;
+ unknownError: string;
+ retry: string;
+ };
}
interface State {
@@ -20,7 +26,7 @@ interface State {
error: string | null;
}
-export class EditorErrorBoundary extends React.Component {
+class EditorErrorBoundaryInner extends React.Component {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
@@ -46,14 +52,14 @@ export class EditorErrorBoundary extends React.Component {
>
- Editor crashed: {this.state.error ?? 'Unknown error'}
+ {this.props.labels?.crashed}: {this.state.error ?? this.props.labels?.unknownError}
- Retry
+ {this.props.labels?.retry}
);
@@ -61,3 +67,22 @@ export class EditorErrorBoundary extends React.Component
{
return <>{this.props.children}>;
}
}
+
+export const EditorErrorBoundary = ({
+ children,
+ ...props
+}: Omit): React.ReactElement => {
+ const { t } = useAppTranslation('team');
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/renderer/components/team/editor/EditorErrorState.tsx b/src/renderer/components/team/editor/EditorErrorState.tsx
index c0b64d2b..83b74dd9 100644
--- a/src/renderer/components/team/editor/EditorErrorState.tsx
+++ b/src/renderer/components/team/editor/EditorErrorState.tsx
@@ -2,6 +2,7 @@
* Error state for file read failures (EACCES, ENOENT, etc.).
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { AlertTriangle } from 'lucide-react';
@@ -16,6 +17,7 @@ export const EditorErrorState = ({
onRetry,
onClose,
}: EditorErrorStateProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
return (
{onRetry && (
- Retry
+ {t('editor.actions.retry')}
)}
{onClose && (
- Close Tab
+ {t('editor.actions.closeTab')}
)}
diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx
index b637abe9..092064da 100644
--- a/src/renderer/components/team/editor/EditorFileTree.tsx
+++ b/src/renderer/components/team/editor/EditorFileTree.tsx
@@ -16,6 +16,7 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@@ -106,6 +107,7 @@ export const EditorFileTree = ({
onCreateTask,
onSendMessage,
}: EditorFileTreeProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
fileTreeRenderCount++;
if (fileTreeRenderCount % 5 === 0) {
console.debug(`[perf] EditorFileTree render #${fileTreeRenderCount}`);
@@ -446,15 +448,19 @@ export const EditorFileTree = ({
// ─── Early returns ─────────────────────────────────────────────────────────
if (error) {
- return Failed to load files: {error}
;
+ return (
+
+ {t('editor.fileTree.failedToLoadFiles', { error })}
+
+ );
}
if (loading && !fileTree) {
- return Loading files...
;
+ return {t('editor.fileTree.loading')}
;
}
if (treeNodes.length === 0) {
- return No files found
;
+ return {t('editor.fileTree.empty')}
;
}
return (
@@ -546,7 +552,10 @@ export const EditorFileTree = ({
{/* Spacer at bottom — drop here to move to project root */}
{draggedItem && (
-
+
)}
@@ -558,17 +567,19 @@ export const EditorFileTree = ({
!open && handleCancelDelete()}>
- Move to Trash
+ {t('editor.fileTree.moveToTrash')}
- Move “{deleteConfirmPath ? getBasename(deleteConfirmPath) : ''}” to Trash?
+ {t('editor.fileTree.moveToTrashConfirm', {
+ name: deleteConfirmPath ? getBasename(deleteConfirmPath) : '',
+ })}
- Cancel
+ {t('editor.fileTree.cancel')}
void handleConfirmDelete()}>
- Move to Trash
+ {t('editor.fileTree.moveToTrash')}
diff --git a/src/renderer/components/team/editor/EditorImagePreview.tsx b/src/renderer/components/team/editor/EditorImagePreview.tsx
index 44c74435..41586973 100644
--- a/src/renderer/components/team/editor/EditorImagePreview.tsx
+++ b/src/renderer/components/team/editor/EditorImagePreview.tsx
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
@@ -25,6 +26,7 @@ export const EditorImagePreview = ({
fileName,
size,
}: EditorImagePreviewProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.editorProjectPath);
const [dataUrl, setDataUrl] = useState(null);
const [loading, setLoading] = useState(true);
@@ -87,7 +89,7 @@ export const EditorImagePreview = ({
return (
-
Loading preview…
+
{t('editor.imagePreview.loading')}
);
}
@@ -102,7 +104,7 @@ export const EditorImagePreview = ({
type="button"
className="checkerboard-bg flex max-h-[60vh] max-w-[80%] cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border border-border-subtle p-1"
onClick={() => setLightboxOpen(true)}
- aria-label="Open full-size preview"
+ aria-label={t('editor.imagePreview.openFullSize')}
>
- Open in System Viewer
+ {t('editor.imagePreview.openSystemViewer')}
diff --git a/src/renderer/components/team/editor/EditorSearchPanel.tsx b/src/renderer/components/team/editor/EditorSearchPanel.tsx
index 4af3a3ab..f9fdbd08 100644
--- a/src/renderer/components/team/editor/EditorSearchPanel.tsx
+++ b/src/renderer/components/team/editor/EditorSearchPanel.tsx
@@ -20,6 +20,7 @@ import {
setSearchQuery,
} from '@codemirror/search';
import { EditorView, type Panel } from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import {
@@ -48,6 +49,9 @@ import type { ViewUpdate } from '@codemirror/view';
// =============================================================================
const MAX_MATCH_COUNT = 999;
+const SHORTCUT_PREVIOUS_MATCH = '⇧Enter';
+const SHORTCUT_NEXT_MATCH = 'Enter';
+const SHORTCUT_CLOSE = 'Esc';
// =============================================================================
// SearchToggleButton
@@ -136,6 +140,7 @@ const EditorSearchPanelContent = ({
initialWholeWord,
registerUpdateNotifier,
}: EditorSearchPanelContentProps) => {
+ const { t } = useAppTranslation('team');
const [searchText, setSearchText] = useState(initialSearch);
const [replaceText, setReplaceText] = useState(initialReplace);
const [caseSensitive, setCaseSensitive] = useState(initialCaseSensitive);
@@ -270,14 +275,14 @@ const EditorSearchPanelContent = ({
)}
- Toggle Replace
+ {t('editor.search.toggleReplace')}
{/* Search input */}
setSearchText(e.target.value)}
onKeyDown={handleSearchKeyDown}
@@ -339,7 +344,8 @@ const EditorSearchPanelContent = ({
- Previous Match ⇧Enter
+ {t('editor.searchPanel.previousMatch')}{' '}
+ {SHORTCUT_PREVIOUS_MATCH}
@@ -357,7 +363,8 @@ const EditorSearchPanelContent = ({
- Next Match Enter
+ {t('editor.searchPanel.nextMatch')}{' '}
+ {SHORTCUT_NEXT_MATCH}
@@ -375,7 +382,8 @@ const EditorSearchPanelContent = ({
- Close Esc
+ {t('editor.searchPanel.close')}{' '}
+ {SHORTCUT_CLOSE}
@@ -388,7 +396,7 @@ const EditorSearchPanelContent = ({
setReplaceText(e.target.value)}
onKeyDown={handleReplaceKeyDown}
@@ -405,10 +413,10 @@ const EditorSearchPanelContent = ({
disabled={matchCount === 0}
tabIndex={-1}
>
- Replace
+ {t('editor.searchPanel.replace')}
- Replace Next
+ {t('editor.searchPanel.replaceNext')}
@@ -421,10 +429,10 @@ const EditorSearchPanelContent = ({
disabled={matchCount === 0}
tabIndex={-1}
>
- All
+ {t('editor.searchPanel.all')}
- Replace All
+ {t('editor.searchPanel.replaceAll')}
)}
diff --git a/src/renderer/components/team/editor/EditorShortcutsHelp.tsx b/src/renderer/components/team/editor/EditorShortcutsHelp.tsx
index eb79fa90..e1fff362 100644
--- a/src/renderer/components/team/editor/EditorShortcutsHelp.tsx
+++ b/src/renderer/components/team/editor/EditorShortcutsHelp.tsx
@@ -5,8 +5,7 @@
* the appropriate modifier symbols.
*/
-import { useMemo } from 'react';
-
+import { useAppTranslation } from '@features/localization/renderer';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog';
import { IS_MAC } from '@renderer/utils/platformKeys';
@@ -24,82 +23,106 @@ interface ShortcutDef {
description: string;
}
-// =============================================================================
-// Shortcuts data
-// =============================================================================
-
-const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
- {
- title: 'File Operations',
- shortcuts: [
- { mac: '⌘ P', other: 'Ctrl+P', description: 'Quick Open' },
- { mac: '⌘ S', other: 'Ctrl+S', description: 'Save' },
- { mac: '⌘ ⇧ S', other: 'Ctrl+Shift+S', description: 'Save All' },
- { mac: '⌘ W', other: 'Ctrl+W', description: 'Close Tab' },
- ],
- },
- {
- title: 'Search',
- shortcuts: [
- { mac: '⌘ F', other: 'Ctrl+F', description: 'Find in File' },
- { mac: '⌘ ⇧ F', other: 'Ctrl+Shift+F', description: 'Search in Files' },
- { mac: '⌘ G', other: 'Ctrl+G', description: 'Go to Line' },
- ],
- },
- {
- title: 'Navigation',
- shortcuts: [
- { mac: '⌘ ⇧ ]', other: 'Ctrl+Shift+]', description: 'Next Tab' },
- { mac: '⌘ ⇧ [', other: 'Ctrl+Shift+[', description: 'Previous Tab' },
- { mac: '⌃ Tab', other: 'Ctrl+Tab', description: 'Cycle Tabs' },
- { mac: '⌘ B', other: 'Ctrl+B', description: 'Toggle Sidebar' },
- ],
- },
- {
- title: 'Editing',
- shortcuts: [
- { mac: '⌘ Z', other: 'Ctrl+Z', description: 'Undo' },
- { mac: '⌘ ⇧ Z', other: 'Ctrl+Y', description: 'Redo' },
- { mac: '⌘ D', other: 'Ctrl+D', description: 'Select Next Match' },
- { mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' },
- ],
- },
- {
- title: 'Markdown',
- shortcuts: [
- { mac: '⌘ ⇧ M', other: 'Ctrl+Shift+M', description: 'Split Preview' },
- { mac: '⌘ ⇧ V', other: 'Ctrl+Shift+V', description: 'Full Preview' },
- ],
- },
- {
- title: 'General',
- shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }],
- },
-];
-
// =============================================================================
// Component
// =============================================================================
export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): React.ReactElement => {
- // Resolve platform-specific keys once
- const resolvedGroups = useMemo(
- () =>
- SHORTCUT_GROUPS.map((group) => ({
- ...group,
- shortcuts: group.shortcuts.map((s) => ({
- keys: IS_MAC ? s.mac : s.other,
- description: s.description,
- })),
+ const { t } = useAppTranslation('team');
+ const resolvedGroups: { title: string; shortcuts: { keys: string; description: string }[] }[] = [
+ {
+ title: t('editor.shortcuts.groups.fileOperations'),
+ shortcuts: [
+ { mac: '⌘ P', other: 'Ctrl+P', description: t('editor.shortcuts.actions.quickOpen') },
+ { mac: '⌘ S', other: 'Ctrl+S', description: t('editor.shortcuts.actions.save') },
+ { mac: '⌘ ⇧ S', other: 'Ctrl+Shift+S', description: t('editor.shortcuts.actions.saveAll') },
+ { mac: '⌘ W', other: 'Ctrl+W', description: t('editor.shortcuts.actions.closeTab') },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
})),
- []
- );
+ },
+ {
+ title: t('editor.shortcuts.groups.search'),
+ shortcuts: [
+ { mac: '⌘ F', other: 'Ctrl+F', description: t('editor.shortcuts.actions.findInFile') },
+ {
+ mac: '⌘ ⇧ F',
+ other: 'Ctrl+Shift+F',
+ description: t('editor.shortcuts.actions.searchInFiles'),
+ },
+ { mac: '⌘ G', other: 'Ctrl+G', description: t('editor.shortcuts.actions.goToLine') },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
+ })),
+ },
+ {
+ title: t('editor.shortcuts.groups.navigation'),
+ shortcuts: [
+ { mac: '⌘ ⇧ ]', other: 'Ctrl+Shift+]', description: t('editor.shortcuts.actions.nextTab') },
+ {
+ mac: '⌘ ⇧ [',
+ other: 'Ctrl+Shift+[',
+ description: t('editor.shortcuts.actions.previousTab'),
+ },
+ { mac: '⌃ Tab', other: 'Ctrl+Tab', description: t('editor.shortcuts.actions.cycleTabs') },
+ { mac: '⌘ B', other: 'Ctrl+B', description: t('editor.shortcuts.actions.toggleSidebar') },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
+ })),
+ },
+ {
+ title: t('editor.shortcuts.groups.editing'),
+ shortcuts: [
+ { mac: '⌘ Z', other: 'Ctrl+Z', description: t('editor.shortcuts.actions.undo') },
+ { mac: '⌘ ⇧ Z', other: 'Ctrl+Y', description: t('editor.shortcuts.actions.redo') },
+ {
+ mac: '⌘ D',
+ other: 'Ctrl+D',
+ description: t('editor.shortcuts.actions.selectNextMatch'),
+ },
+ { mac: '⌘ /', other: 'Ctrl+/', description: t('editor.shortcuts.actions.toggleComment') },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
+ })),
+ },
+ {
+ title: t('editor.shortcuts.groups.markdown'),
+ shortcuts: [
+ {
+ mac: '⌘ ⇧ M',
+ other: 'Ctrl+Shift+M',
+ description: t('editor.shortcuts.actions.splitPreview'),
+ },
+ {
+ mac: '⌘ ⇧ V',
+ other: 'Ctrl+Shift+V',
+ description: t('editor.shortcuts.actions.fullPreview'),
+ },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
+ })),
+ },
+ {
+ title: t('editor.shortcuts.groups.general'),
+ shortcuts: [
+ {
+ keys: 'Esc',
+ description: t('editor.shortcuts.actions.closeEditor'),
+ },
+ ],
+ },
+ ];
return (
!open && onClose()}>
- Keyboard Shortcuts
+ {t('editor.shortcuts.title')}
diff --git a/src/renderer/components/team/editor/EditorStatusBar.tsx b/src/renderer/components/team/editor/EditorStatusBar.tsx
index 7637e234..0dc038ce 100644
--- a/src/renderer/components/team/editor/EditorStatusBar.tsx
+++ b/src/renderer/components/team/editor/EditorStatusBar.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { GitBranch } from 'lucide-react';
@@ -20,6 +21,7 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({
col,
language,
}: EditorStatusBarProps): React.ReactElement {
+ const { t } = useAppTranslation('team');
const { gitBranch, isGitRepo, watcherEnabled } = useStore(
useShallow((s) => ({
gitBranch: s.editorGitBranch,
@@ -32,9 +34,7 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({
return (
-
- Ln {line}, Col {col}
-
+ {t('editor.statusBar.position', { line, col })}
{isGitRepo && gitBranch && (
@@ -53,19 +53,25 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({
? 'bg-green-500/15 text-green-400 hover:bg-green-500/20'
: 'text-text-muted hover:bg-surface-raised hover:text-text-secondary'
}`}
- aria-label={watcherEnabled ? 'Disable file watcher' : 'Enable file watcher'}
+ aria-label={
+ watcherEnabled
+ ? t('editor.statusBar.disableWatcher')
+ : t('editor.statusBar.enableWatcher')
+ }
aria-pressed={watcherEnabled}
>
- {watcherEnabled ? 'watching' : 'watch'}
+ {watcherEnabled ? t('editor.statusBar.watching') : t('editor.statusBar.watch')}
- {watcherEnabled ? 'Disable external change watcher' : 'Watch for external changes'}
+ {watcherEnabled
+ ? t('editor.statusBar.disableExternalWatcher')
+ : t('editor.statusBar.watchExternalChanges')}
{language}
- UTF-8
- Spaces: 2
+ {t('editor.statusBar.encodingUtf8')}
+ {t('editor.statusBar.spaces', { count: 2 })}
);
diff --git a/src/renderer/components/team/editor/EditorTabBar.tsx b/src/renderer/components/team/editor/EditorTabBar.tsx
index b624e02f..58bd343f 100644
--- a/src/renderer/components/team/editor/EditorTabBar.tsx
+++ b/src/renderer/components/team/editor/EditorTabBar.tsx
@@ -7,6 +7,7 @@
import { useCallback, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
closestCenter,
DndContext,
@@ -163,6 +164,7 @@ const SortableEditorTab = ({
onCloseToRight,
onCloseAll,
}: SortableEditorTabProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
});
@@ -224,7 +226,7 @@ const SortableEditorTab = ({
{isModified && (
)}
diff --git a/src/renderer/components/team/editor/EditorToolbar.tsx b/src/renderer/components/team/editor/EditorToolbar.tsx
index bc0123ec..6a63c167 100644
--- a/src/renderer/components/team/editor/EditorToolbar.tsx
+++ b/src/renderer/components/team/editor/EditorToolbar.tsx
@@ -5,6 +5,7 @@
import React from 'react';
import { redo, undo } from '@codemirror/commands';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
@@ -36,6 +37,7 @@ export const EditorToolbar = ({
onToggleSplit,
onToggleFullPreview,
}: EditorToolbarProps): React.ReactElement | null => {
+ const { t } = useAppTranslation('team');
const { activeTabId, modifiedFiles, saving, lineWrap } = useStore(
useShallow((s) => ({
activeTabId: s.editorActiveTabId,
@@ -70,27 +72,27 @@ export const EditorToolbar = ({
}
- label="Save"
+ label={t('editor.shortcuts.actions.save')}
shortcut={shortcutLabel('⌘ S', 'Ctrl+S')}
onClick={handleSave}
disabled={!isDirty || isSaving}
/>
}
- label="Undo"
+ label={t('editor.shortcuts.actions.undo')}
shortcut={shortcutLabel('⌘ Z', 'Ctrl+Z')}
onClick={handleUndo}
/>
}
- label="Redo"
+ label={t('editor.shortcuts.actions.redo')}
shortcut={shortcutLabel('⌘ ⇧ Z', 'Ctrl+Y')}
onClick={handleRedo}
/>
}
- label={lineWrap ? 'Disable word wrap' : 'Enable word wrap'}
+ label={lineWrap ? t('editor.toolbar.disableWordWrap') : t('editor.toolbar.enableWordWrap')}
shortcut={shortcutLabel('⌘ ⇧ W', 'Ctrl+Shift+W')}
onClick={toggleLineWrap}
active={lineWrap}
@@ -100,14 +102,22 @@ export const EditorToolbar = ({
}
- label={mdPreviewMode === 'split' ? 'Close split preview' : 'Split preview'}
+ label={
+ mdPreviewMode === 'split'
+ ? t('editor.toolbar.closeSplitPreview')
+ : t('editor.shortcuts.actions.splitPreview')
+ }
shortcut={shortcutLabel('⌘ ⇧ M', 'Ctrl+Shift+M')}
onClick={onToggleSplit ?? (() => {})}
active={mdPreviewMode === 'split'}
/>
}
- label={mdPreviewMode === 'preview' ? 'Close preview' : 'Full preview'}
+ label={
+ mdPreviewMode === 'preview'
+ ? t('editor.toolbar.closePreview')
+ : t('editor.shortcuts.actions.fullPreview')
+ }
shortcut={shortcutLabel('⌘ ⇧ V', 'Ctrl+Shift+V')}
onClick={onToggleFullPreview ?? (() => {})}
active={mdPreviewMode === 'preview'}
diff --git a/src/renderer/components/team/editor/GoToLineDialog.tsx b/src/renderer/components/team/editor/GoToLineDialog.tsx
index dd1c9ef8..68b62318 100644
--- a/src/renderer/components/team/editor/GoToLineDialog.tsx
+++ b/src/renderer/components/team/editor/GoToLineDialog.tsx
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { EditorView } from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { editorBridge } from '@renderer/utils/editorBridge';
@@ -73,6 +74,7 @@ function parseLineInput(input: string, view: EditorView): ParsedTarget | null {
// =============================================================================
export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const [value, setValue] = useState('');
const inputRef = useRef
(null);
@@ -145,15 +147,15 @@ export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactEle
- Go to Line{' '}
+ {t('editor.goToLine.title')}{' '}
- (current: {currentLine}, total: {totalLines})
+ {t('editor.goToLine.position', { current: currentLine, total: totalLines })}
@@ -162,7 +164,7 @@ export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactEle
setValue(e.target.value)}
onKeyDown={handleKeyDown}
@@ -176,7 +178,7 @@ export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactEle
onClick={handleGo}
disabled={!value.trim()}
>
- Go
+ {t('editor.goToLine.go')}
diff --git a/src/renderer/components/team/editor/NewFileDialog.tsx b/src/renderer/components/team/editor/NewFileDialog.tsx
index 8e5ad5a9..5b68f2d6 100644
--- a/src/renderer/components/team/editor/NewFileDialog.tsx
+++ b/src/renderer/components/team/editor/NewFileDialog.tsx
@@ -9,6 +9,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { FilePlus, FolderPlus } from 'lucide-react';
// =============================================================================
@@ -29,12 +30,12 @@ interface NewFileDialogProps {
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
const INVALID_CHARS = /[\x00-\x1f/\\:*?"<>|]/;
-function validateName(name: string): string | null {
+function validateName(name: string, t: ReturnType['t']): string | null {
const trimmed = name.trim();
- if (trimmed.length === 0) return 'Name cannot be empty';
- if (trimmed === '.' || trimmed === '..') return 'Invalid name';
- if (INVALID_CHARS.test(trimmed)) return 'Name contains invalid characters';
- if (trimmed.length > 255) return 'Name is too long';
+ if (trimmed.length === 0) return t('editor.newFile.validation.nameRequired');
+ if (trimmed === '.' || trimmed === '..') return t('editor.newFile.validation.invalidName');
+ if (INVALID_CHARS.test(trimmed)) return t('editor.newFile.validation.invalidCharacters');
+ if (trimmed.length > 255) return t('editor.newFile.validation.nameTooLong');
return null;
}
@@ -48,6 +49,7 @@ export const NewFileDialog = ({
onSubmit,
onCancel,
}: NewFileDialogProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const [value, setValue] = useState('');
const [error, setError] = useState(null);
const inputRef = useRef(null);
@@ -80,13 +82,13 @@ export const NewFileDialog = ({
const handleSubmit = useCallback(() => {
const trimmed = value.trim();
- const validationError = validateName(trimmed);
+ const validationError = validateName(trimmed, t);
if (validationError) {
setError(validationError);
return;
}
onSubmit(trimmed);
- }, [value, onSubmit]);
+ }, [value, onSubmit, t]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -120,9 +122,17 @@ export const NewFileDialog = ({
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={() => requestAnimationFrame(() => inputRef.current?.focus())}
- placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
+ placeholder={
+ type === 'file'
+ ? t('editor.newFile.placeholders.fileName')
+ : t('editor.newFile.placeholders.folderName')
+ }
className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500"
- aria-label={type === 'file' ? 'New file name' : 'New folder name'}
+ aria-label={
+ type === 'file'
+ ? t('editor.newFile.aria.newFileName')
+ : t('editor.newFile.aria.newFolderName')
+ }
/>
{error &&
{error} }
diff --git a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx
index 47aa0b5f..1c5eba41 100644
--- a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx
+++ b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@@ -77,6 +78,7 @@ export const ProjectEditorOverlay = ({
onClose,
onEditorAction,
}: ProjectEditorOverlayProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
// Data selectors — grouped with useShallow to prevent unnecessary re-renders
const { activeTabId, openTabs, modifiedFiles, saveErrors, externalChanges, conflictFile } =
useStore(
@@ -529,7 +531,7 @@ export const ProjectEditorOverlay = ({
tabIndex={-1}
role="dialog"
aria-modal="true"
- aria-label="Project Editor"
+ aria-label={t('editor.ariaLabel')}
>
{/* Header */}
- Refresh git status (F5)
+ {t('editor.actions.refreshTooltip')}
@@ -562,12 +564,12 @@ export const ProjectEditorOverlay = ({
size="icon"
className="size-7 text-text-muted"
onClick={() => setShortcutsHelpVisible(true)}
- aria-label="Keyboard shortcuts"
+ aria-label={t('editor.actions.keyboardShortcuts')}
>
- Keyboard shortcuts
+ {t('editor.actions.keyboardShortcuts')}
@@ -576,12 +578,12 @@ export const ProjectEditorOverlay = ({
size="icon"
className="size-7 text-text-muted"
onClick={handleCloseRequest}
- aria-label="Close editor"
+ aria-label={t('editor.actions.closeEditor')}
>
- Close editor (Esc)
+ {t('editor.actions.closeTooltip')}
@@ -604,7 +606,7 @@ export const ProjectEditorOverlay = ({
- Explorer
+ {t('editor.sidebar.explorer')}
@@ -613,13 +615,15 @@ export const ProjectEditorOverlay = ({
size="icon"
className="size-6 text-text-muted"
onClick={toggleSidebar}
- aria-label="Hide sidebar"
+ aria-label={t('editor.sidebar.hide')}
>
- Hide sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
+ {t('editor.sidebar.hideWithShortcut', {
+ shortcut: shortcutLabel('⌘ B', 'Ctrl+B'),
+ })}
@@ -652,13 +656,15 @@ export const ProjectEditorOverlay = ({
variant="ghost"
className="flex h-full w-6 shrink-0 items-start justify-center rounded-none border-r border-border bg-surface-sidebar pt-2 text-text-muted"
onClick={toggleSidebar}
- aria-label="Show sidebar"
+ aria-label={t('editor.sidebar.show')}
>
- Show sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
+ {t('editor.sidebar.showWithShortcut', {
+ shortcut: shortcutLabel('⌘ B', 'Ctrl+B'),
+ })}
)}
@@ -687,14 +693,14 @@ export const ProjectEditorOverlay = ({
}}
>
-
Recovered unsaved changes from a previous session.
+
{t('editor.draftRecovered')}
- Keep
+ {t('editor.actions.keep')}
- Discard
+ {t('editor.actions.discard')}
)}
@@ -711,14 +717,14 @@ export const ProjectEditorOverlay = ({
{activeSaveError && (
-
Save failed: {activeSaveError}
+
{t('editor.saveFailed', { error: activeSaveError })}
activeTabId && void saveFile(activeTabId)}
>
- Retry
+ {t('editor.actions.retry')}
)}
@@ -729,8 +735,8 @@ export const ProjectEditorOverlay = ({
{externalChanges[activeTabId] === 'delete'
- ? 'File no longer exists on disk.'
- : 'File changed on disk.'}
+ ? t('editor.externalChange.deleted')
+ : t('editor.externalChange.changed')}
{externalChanges[activeTabId] === 'delete' ? (
closeEditorTab(activeTabId)}
>
- Close tab
+ {t('editor.actions.closeTab')}
) : (
<>
@@ -749,7 +755,7 @@ export const ProjectEditorOverlay = ({
className="ml-auto h-auto px-2 py-0.5"
onClick={handleReloadExternalChange}
>
- Reload
+ {t('editor.actions.reload')}
- Keep mine
+ {t('editor.actions.keepMine')}
>
)}
@@ -863,20 +869,18 @@ export const ProjectEditorOverlay = ({
!open && handleCancelClose()}>
- Unsaved Changes
-
- You have unsaved changes. What would you like to do?
-
+ {t('editor.dialogs.unsavedTitle')}
+ {t('editor.dialogs.unsavedDescription')}
- Cancel
+ {t('editor.actions.cancel')}
- Discard & Close
+ {t('editor.actions.discardAndClose')}
void handleSaveAndClose()}>
- Save All & Close
+ {t('editor.actions.saveAllAndClose')}
@@ -886,18 +890,15 @@ export const ProjectEditorOverlay = ({
!open && handleCancelConflict()}>
- Save Conflict
-
- The file has been modified externally since you opened it. Overwrite with your
- changes?
-
+ {t('editor.dialogs.conflictTitle')}
+ {t('editor.dialogs.conflictDescription')}
- Cancel
+ {t('editor.actions.cancel')}
- Overwrite
+ {t('editor.actions.overwrite')}
@@ -907,20 +908,18 @@ export const ProjectEditorOverlay = ({
!open && handleCancelCloseTab()}>
- Unsaved Changes
-
- This file has unsaved changes. What would you like to do?
-
+ {t('editor.dialogs.unsavedTitle')}
+ {t('editor.dialogs.unsavedFileDescription')}
- Cancel
+ {t('editor.actions.cancel')}
- Discard
+ {t('editor.actions.discard')}
void handleSaveAndCloseTab()}>
- Save
+ {t('editor.actions.save')}
diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx
index f8c8082b..759f5aa1 100644
--- a/src/renderer/components/team/editor/QuickOpenDialog.tsx
+++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { Command } from 'cmdk';
import { Loader2 } from 'lucide-react';
@@ -32,6 +33,7 @@ export const QuickOpenDialog = ({
onClose,
onSelectFile,
}: QuickOpenDialogProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.editorProjectPath);
const dialogRef = useRef(null);
const [allFiles, setAllFiles] = useState([]);
@@ -116,12 +118,12 @@ export const QuickOpenDialog = ({
ref={dialogRef}
role="dialog"
aria-modal="true"
- aria-label="Quick Open"
+ aria-label={t('editor.quickOpen.title')}
className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
>
-
+
@@ -129,12 +131,12 @@ export const QuickOpenDialog = ({
{loading && (
- Loading files...
+ {t('editor.quickOpen.loading')}
)}
{!loading && (
- No files found
+ {t('editor.quickOpen.empty')}
)}
{fileItems.map((file) => {
diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
index 81d57b58..d7fa6e6e 100644
--- a/src/renderer/components/team/editor/SearchInFilesPanel.tsx
+++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
@@ -7,6 +7,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -46,6 +47,7 @@ export const SearchInFilesPanel = ({
onClose,
onSelectMatch,
}: SearchInFilesPanelProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const [query, setQuery] = useState('');
const [caseSensitive, setCaseSensitive] = useState(false);
const [results, setResults] = useState(null);
@@ -159,7 +161,9 @@ export const SearchInFilesPanel = ({
{/* Header */}
- Search in Files
+
+ {t('editor.searchInFiles.title')}
+
- Close search (Esc)
+
+ {t('editor.searchInFiles.closeSearchShortcut')}
+
@@ -185,7 +191,7 @@ export const SearchInFilesPanel = ({
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
- placeholder="Search..."
+ placeholder={t('editor.searchInFiles.searchPlaceholder')}
className="flex-1 bg-transparent text-xs text-text outline-none placeholder:text-text-muted"
/>
{searching &&
}
@@ -200,13 +206,13 @@ export const SearchInFilesPanel = ({
? 'bg-blue-500/20 text-blue-400'
: 'text-text-muted hover:bg-surface-raised'
}`}
- aria-label="Match Case"
+ aria-label={t('editor.searchInFiles.matchCase')}
aria-pressed={caseSensitive}
>
- Aa
+ {t('editor.searchInFiles.matchCaseToggle')}
-
Match Case
+
{t('editor.searchInFiles.matchCase')}
@@ -216,15 +222,19 @@ export const SearchInFilesPanel = ({
{error && {error}
}
{results?.totalMatches === 0 && query.trim() && (
- No results found
+
+ {t('editor.searchInFiles.noResults')}
+
)}
{results && results.totalMatches > 0 && (
<>
- {results.totalMatches} match{results.totalMatches !== 1 ? 'es' : ''} in{' '}
- {results.results.length} file{results.results.length !== 1 ? 's' : ''}
- {results.truncated && ' (truncated)'}
+ {t('editor.searchInFiles.resultsSummary', {
+ count: results.totalMatches,
+ fileCount: results.results.length,
+ })}
+ {results.truncated && ` ${t('editor.searchInFiles.truncated')}`}
{results.results.map((fileResult) => (
(null);
const scrollRestoreTimeoutsRef = useRef([]);
const [viewMode, setViewMode] = useState('grid');
@@ -538,7 +540,7 @@ export const KanbanBoard = memo(function KanbanBoard({
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
- Add task
+ {t('kanban.board.addTask')}
) : null;
@@ -546,7 +548,7 @@ export const KanbanBoard = memo(function KanbanBoard({
return (
addButton ?? (
- No tasks
+ {t('kanban.board.noTasks')}
)
);
@@ -562,9 +564,9 @@ export const KanbanBoard = memo(function KanbanBoard({
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
- Show {nextRevealCount} more
+ {t('kanban.board.showMore', { count: nextRevealCount })}
- {hiddenTaskCount} hidden
+ {t('kanban.board.hiddenCount', { count: hiddenTaskCount })}
) : null;
@@ -741,7 +743,7 @@ export const KanbanBoard = memo(function KanbanBoard({
const accent = COLUMN_ACCENTS[column.id];
return {
id: column.id,
- title: column.title,
+ title: t(column.titleKey),
count: columnTasks.length,
icon: accent.icon,
headerBg: accent.headerBg,
@@ -762,6 +764,7 @@ export const KanbanBoard = memo(function KanbanBoard({
renderableColumnTasks,
kanbanState,
hasReviewers,
+ t,
]
);
@@ -804,7 +807,7 @@ export const KanbanBoard = memo(function KanbanBoard({
{deletedTaskCount}
- Trash
+ {t('kanban.board.trash')}
) : null}
@@ -820,12 +823,12 @@ export const KanbanBoard = memo(function KanbanBoard({
: 'text-[var(--color-text-muted)]'
)}
onClick={() => switchViewMode('grid')}
- aria-label="Grid view"
+ aria-label={t('kanban.board.gridView')}
>
- Grid view
+ {t('kanban.board.gridView')}
@@ -839,12 +842,12 @@ export const KanbanBoard = memo(function KanbanBoard({
: 'text-[var(--color-text-muted)]'
)}
onClick={() => switchViewMode('columns')}
- aria-label="Columns view"
+ aria-label={t('kanban.board.columnsView')}
>
- Columns view
+ {t('kanban.board.columnsView')}
@@ -870,7 +873,7 @@ export const KanbanBoard = memo(function KanbanBoard({
{
+ const { t } = useAppTranslation('team');
const activeCount = useMemo(() => {
let count = 0;
if (filter.sessionId !== null) count += 1;
@@ -89,7 +91,7 @@ export const KanbanFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter tasks"
+ aria-label={t('kanban.filter.title')}
>
{activeCount > 0 && (
@@ -100,13 +102,13 @@ export const KanbanFilterPopover = ({
- Filter tasks
+ {t('kanban.filter.title')}
{/* Session section */}
- Session
+ {t('kanban.filter.session')}
handleSessionSelect(null)}
>
- All sessions
+ {t('kanban.filter.allSessions')}
{sessions.map((session) => {
const isLead = session.id === leadSessionId;
@@ -146,7 +148,7 @@ export const KanbanFilterPopover = ({
{/* Teammate section */}
- Teammate
+ {t('kanban.filter.teammate')}
{members.map((member) => (
@@ -167,7 +169,7 @@ export const KanbanFilterPopover = ({
checked={filter.selectedOwners.has(UNASSIGNED_OWNER)}
onCheckedChange={() => handleOwnerToggle(UNASSIGNED_OWNER)}
/>
- (unassigned)
+ {t('kanban.filter.unassigned')}
@@ -175,7 +177,7 @@ export const KanbanFilterPopover = ({
{/* Column section */}
- Column
+ {t('kanban.filter.column')}
{KANBAN_COLUMNS.map((col) => (
@@ -188,7 +190,7 @@ export const KanbanFilterPopover = ({
checked={filter.columns.has(col.id)}
onCheckedChange={() => handleColumnToggle(col.id)}
/>
- {col.label}
+ {t(col.labelKey)}
))}
@@ -203,7 +205,7 @@ export const KanbanFilterPopover = ({
disabled={activeCount === 0}
onClick={handleClearAll}
>
- Clear all
+ {t('kanban.filter.clearAll')}
diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx
index 2bc527b5..43640be9 100644
--- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx
+++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx
@@ -2,6 +2,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
+import { useAppTranslation } from '@features/localization/renderer';
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
import { cn } from '@renderer/lib/utils';
import { browserGridLayoutRepository } from '@renderer/services/layout-system/BrowserGridLayoutRepository';
@@ -164,6 +165,7 @@ const LoadingKanbanGridLayout = ({
onPrimaryColumnWidthChange,
className,
}: Readonly
): ReactElement => {
+ const { t } = useAppTranslation('team');
const columnMap = new Map(columns.map((column) => [column.id, column]));
const loadingItems =
visibleItems.length > 0
@@ -253,17 +255,17 @@ const LoadingKanbanGridLayout = ({
))}
{showAddButton ? (
- Add task
+ {t('kanban.grid.addTask')}
) : null}
>
) : showAddButton ? (
- Add task
+ {t('kanban.grid.addTask')}
) : (
- No tasks
+ {t('kanban.grid.noTasks')}
)}
diff --git a/src/renderer/components/team/kanban/KanbanSearchInput.tsx b/src/renderer/components/team/kanban/KanbanSearchInput.tsx
index bfc0586d..ae441bbf 100644
--- a/src/renderer/components/team/kanban/KanbanSearchInput.tsx
+++ b/src/renderer/components/team/kanban/KanbanSearchInput.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers';
@@ -29,6 +30,7 @@ export const KanbanSearchInput = ({
tasks,
members,
}: KanbanSearchInputProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [showDropdown, setShowDropdown] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const containerRef = useRef(null);
@@ -130,7 +132,7 @@ export const KanbanSearchInput = ({
onChange(e.target.value)}
onKeyDown={handleKeyDown}
@@ -147,7 +149,7 @@ export const KanbanSearchInput = ({
- Clear search
+ {t('kanban.search.clearSearch')}
)}
@@ -160,7 +162,7 @@ export const KanbanSearchInput = ({
- Tasks
+ {t('kanban.search.tasks')}
{suggestions.map((task, index) => (
@@ -201,6 +203,7 @@ const TaskSuggestionItem = React.memo(function TaskSuggestionItem({
onSelect,
onHover,
}: TaskSuggestionItemProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const displayId = getTaskDisplayId(task);
const statusLabel = TASK_STATUS_LABELS[task.status] ?? task.status;
const memberColorMap = useMemo(() => {
@@ -272,10 +275,14 @@ const TaskSuggestionItem = React.memo(function TaskSuggestionItem({
/>
)}
{createdAgo && (
- created {createdAgo}
+
+ {t('kanban.search.createdAgo', { time: createdAgo })}
+
)}
{updatedAgo && updatedAgo !== createdAgo && (
- updated {updatedAgo}
+
+ {t('kanban.search.updatedAgo', { time: updatedAgo })}
+
)}
diff --git a/src/renderer/components/team/kanban/KanbanSortPopover.tsx b/src/renderer/components/team/kanban/KanbanSortPopover.tsx
index f05c2491..2178a90e 100644
--- a/src/renderer/components/team/kanban/KanbanSortPopover.tsx
+++ b/src/renderer/components/team/kanban/KanbanSortPopover.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -10,37 +11,37 @@ export interface KanbanSortState {
field: KanbanSortField;
}
-const SORT_OPTIONS: {
- field: KanbanSortField;
- label: string;
- description: string;
- icon: React.ReactNode;
-}[] = [
+const SORT_OPTIONS = [
{
field: 'updatedAt',
- label: 'Last updated',
- description: 'Recently updated first',
+ labelKey: 'kanban.sort.options.updatedAt.label',
+ descriptionKey: 'kanban.sort.options.updatedAt.description',
icon: ,
},
{
field: 'createdAt',
- label: 'Created',
- description: 'Newest first',
+ labelKey: 'kanban.sort.options.createdAt.label',
+ descriptionKey: 'kanban.sort.options.createdAt.description',
icon: ,
},
{
field: 'owner',
- label: 'Owner',
- description: 'Alphabetically by assignee',
+ labelKey: 'kanban.sort.options.owner.label',
+ descriptionKey: 'kanban.sort.options.owner.description',
icon: ,
},
{
field: 'manual',
- label: 'Manual',
- description: 'Drag-and-drop order',
+ labelKey: 'kanban.sort.options.manual.label',
+ descriptionKey: 'kanban.sort.options.manual.description',
icon: ,
},
-];
+] as const satisfies readonly {
+ field: KanbanSortField;
+ labelKey: string;
+ descriptionKey: string;
+ icon: React.ReactNode;
+}[];
interface KanbanSortPopoverProps {
sort: KanbanSortState;
@@ -51,6 +52,7 @@ export const KanbanSortPopover = ({
sort,
onSortChange,
}: KanbanSortPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const isNonDefault = sort.field !== 'updatedAt';
return (
@@ -62,7 +64,7 @@ export const KanbanSortPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Sort tasks"
+ aria-label={t('kanban.sort.title')}
>
{isNonDefault && (
@@ -73,12 +75,12 @@ export const KanbanSortPopover = ({
- Sort tasks
+ {t('kanban.sort.title')}
- Sort by
+ {t('kanban.sort.sortBy')}
{SORT_OPTIONS.map((option) => {
@@ -104,14 +106,14 @@ export const KanbanSortPopover = ({
{option.icon}
-
{option.label}
+
{t(option.labelKey)}
- {option.description}
+ {t(option.descriptionKey)}
{isSelected && (
@@ -130,7 +132,7 @@ export const KanbanSortPopover = ({
className="h-6 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => onSortChange({ field: 'updatedAt' })}
>
- Reset
+ {t('kanban.sort.reset')}
)}
diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
index b46cdc79..273d2917 100644
--- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx
+++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
@@ -204,6 +205,7 @@ const CancelTaskButton = ({
taskId: string;
onConfirm: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [open, setOpen] = useState(false);
return (
@@ -215,14 +217,14 @@ const CancelTaskButton = ({
variant="destructive"
size="icon"
className="size-6 rounded-full shadow-sm"
- aria-label={`Cancel task ${taskId}`}
+ aria-label={t('kanban.taskCard.cancelTask', { taskId })}
onClick={(e) => e.stopPropagation()}
>
-
Cancel
+
{t('kanban.taskCard.cancel')}
e.stopPropagation()}
>
- Move this task back to TODO and notify the team?
+ {t('kanban.taskCard.moveBackToTodoConfirm')}
- Confirm
+ {t('kanban.taskCard.confirm')}
setOpen(false)}>
- Keep
+ {t('kanban.taskCard.keep')}
@@ -311,6 +313,7 @@ export const KanbanTaskCard = memo(
onViewChanges,
onDeleteTask,
}: KanbanTaskCardProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
const commentPulseTaskKey = `${teamName}/${task.id}`;
@@ -351,7 +354,11 @@ export const KanbanTaskCard = memo(
<>
{canOpenChanges ? (
}
variant="ghost"
className={
@@ -372,7 +379,7 @@ export const KanbanTaskCard = memo(
/>
{onDeleteTask ? (
}
variant="ghost"
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
@@ -406,8 +413,8 @@ export const KanbanTaskCard = memo(
{formatTaskDisplayLabel(task)}
{hasLiveTaskLogs ? (
-
-
+
+
) : null}
@@ -427,7 +434,9 @@ export const KanbanTaskCard = memo(
}`}
>
- {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
+ {task.needsClarification === 'user'
+ ? t('kanban.taskCard.awaitingUser')
+ : t('kanban.taskCard.awaitingLead')}
) : null}
{isTeamTaskNeedsFixActionable(task) ? (
@@ -444,7 +453,7 @@ export const KanbanTaskCard = memo(
- Blocked by
+ {t('kanban.taskCard.blockedBy')}
{blockedByIds.map((id) => (
- Blocks
+ {t('kanban.taskCard.blocks')}
{blocksIds.map((id) => (
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -488,7 +497,7 @@ export const KanbanTaskCard = memo(
}}
/>
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -502,7 +511,7 @@ export const KanbanTaskCard = memo(
{columnId === 'in_progress' ? (
<>
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -517,7 +526,7 @@ export const KanbanTaskCard = memo(
{columnId === 'done' ? (
<>
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -526,7 +535,7 @@ export const KanbanTaskCard = memo(
}}
/>
}
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
onClick={(e) => {
@@ -541,12 +550,12 @@ export const KanbanTaskCard = memo(
{isReviewManual ? (
- Manual review
+ {t('kanban.taskCard.manualReview')}
) : null}
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -555,7 +564,7 @@ export const KanbanTaskCard = memo(
}}
/>
}
variant="destructive"
className="bg-red-500/90 text-white hover:bg-red-500"
diff --git a/src/renderer/components/team/kanban/TrashDialog.tsx b/src/renderer/components/team/kanban/TrashDialog.tsx
index 064b8b94..9eef2880 100644
--- a/src/renderer/components/team/kanban/TrashDialog.tsx
+++ b/src/renderer/components/team/kanban/TrashDialog.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@@ -31,19 +32,21 @@ export const TrashDialog = ({
onClose,
onRestore,
}: TrashDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
return (
!v && onClose()}>
- Trash
+ {t('kanban.trash.title')}
{tasks.length === 0 ? (
- No deleted tasks
+ {t('kanban.trash.empty')}
) : (
@@ -52,9 +55,9 @@ export const TrashDialog = ({
#
- Subject
- Owner
- Deleted
+ {t('kanban.trash.subject')}
+ {t('kanban.trash.owner')}
+ {t('kanban.trash.deleted')}
{onRestore ? : null}
@@ -69,7 +72,7 @@ export const TrashDialog = ({
{task.subject}
- {task.owner ?? 'Unassigned'}
+ {task.owner ?? t('kanban.trash.unassigned')}
{task.deletedAt
@@ -84,12 +87,12 @@ export const TrashDialog = ({
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={() => onRestore(task.id)}
- aria-label="Restore task"
+ aria-label={t('kanban.trash.restoreTask')}
>
- Restore
+ {t('kanban.trash.restore')}
) : null}
@@ -103,7 +106,7 @@ export const TrashDialog = ({
- Close
+ {t('kanban.trash.close')}
diff --git a/src/renderer/components/team/lead-load-guards.ts b/src/renderer/components/team/lead-load-guards.ts
new file mode 100644
index 00000000..8e5d8523
--- /dev/null
+++ b/src/renderer/components/team/lead-load-guards.ts
@@ -0,0 +1 @@
+export { deriveLeadContextButtonLabel as deriveLeadLoadButtonLabel } from './leadContextLoadGuards';
diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx
index 5c7d7255..9fe5de3f 100644
--- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx
+++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx
@@ -1,5 +1,6 @@
import { memo, useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import {
formatMemberActivityElapsed,
@@ -95,6 +96,7 @@ export const CurrentTaskIndicator = memo(
isTimerRunning = true,
onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const timerLabel = useActivityTimerLabel(activityTimer, isTimerRunning);
const subjectText =
typeof maxSubjectLength === 'number' &&
@@ -114,7 +116,7 @@ export const CurrentTaskIndicator = memo(
{
e.stopPropagation();
onOpenTask?.();
diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx
index e68a5549..3b4e5c7d 100644
--- a/src/renderer/components/team/members/LeadModelRow.tsx
+++ b/src/renderer/components/team/members/LeadModelRow.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import {
ANTHROPIC_LONG_CONTEXT_PRICING_URL,
@@ -76,14 +77,18 @@ export const LeadModelRow = ({
showAnthropicContextLimit = providerId === 'anthropic',
disableAnthropicContextLimit,
}: LeadModelRowProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const hasActiveProviderNotice = Boolean(providerNoticeById?.[providerId]);
const [modelExpanded, setModelExpanded] = useState(hasActiveProviderNotice);
const leadColorSet = getTeamColorSet(resolveTeamLeadColorName());
const modelButtonLabel = model.trim()
? getProviderScopedTeamModelLabel(providerId, model.trim())
- : 'Default';
- const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`;
+ : t('members.leadModel.defaultModel');
+ const modelButtonAriaLabel = t('members.leadModel.providerModelAria', {
+ provider: getTeamProviderLabel(providerId),
+ model: modelButtonLabel,
+ });
const selectedModelIssueText =
model.trim() && modelIssueReasonByValue?.[model.trim()]
? modelIssueReasonByValue[model.trim()]
@@ -143,8 +148,12 @@ export const LeadModelRow = ({
loading="lazy"
/>
- lead
- Team Lead
+
+ {t('members.leadModel.leadShort')}
+
+
+ {t('members.leadModel.teamLead')}
+
@@ -160,7 +169,7 @@ export const LeadModelRow = ({
htmlFor="sync-models-with-lead"
className="cursor-pointer truncate text-xs font-normal text-text-secondary"
>
- Sync model with teammates
+ {t('members.leadModel.syncWithTeammates')}
@@ -237,15 +246,17 @@ export const LeadModelRow = ({
checked={limitContext}
onCheckedChange={onLimitContextChange}
disabled={contextLimitDisabled}
- scopeLabel={providerId === 'anthropic' ? undefined : 'Anthropic team-wide'}
+ scopeLabel={
+ providerId === 'anthropic' ? undefined : t('members.leadModel.anthropicTeamWide')
+ }
/>
) : null}
- Lead runtime applies to teammates unless they set their own provider or model.
+ {t('members.leadModel.runtimeInheritance')}
{showAnthropicContextLimit
- ? ' The 200K context limit is team-wide for Anthropic runtimes in this launch, including custom Anthropic teammates.'
+ ? ` ${t('members.leadModel.anthropicContextLimit')}`
: null}
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index 361664e2..9cfd5332 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -350,6 +351,7 @@ const RuntimeTelemetryTooltipContent = ({
}: Readonly<{
runtimeEntry: TeamAgentRuntimeEntry | undefined;
}>): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
if (!runtimeEntry) {
return null;
}
@@ -377,10 +379,10 @@ const RuntimeTelemetryTooltipContent = ({
- Local runtime load
+ {t('members.runtimeTelemetry.title')}
- Parent and child processes only. Remote LLM inference is not included.
+ {t('members.runtimeTelemetry.description')}
@@ -389,7 +391,7 @@ const RuntimeTelemetryTooltipContent = ({
- CPU
+ {t('members.runtimeTelemetry.cpu')}
{aggregateCpuLabel ?? 'unknown'}
@@ -404,12 +406,14 @@ const RuntimeTelemetryTooltipContent = ({
- Memory
+ {t('members.runtimeTelemetry.memory')}
{rssLabel ?? 'unknown'}
-
summed RSS
+
+ {t('members.runtimeTelemetry.summedRss')}
+
@@ -430,20 +434,20 @@ const RuntimeTelemetryTooltipContent = ({
{runtimeEntry.runtimeLoadScope === 'shared-host' ? (
- Shared OpenCode host metric. It is not exclusive to this member.
+ {t('members.runtimeTelemetry.sharedHost')}
) : null}
{runtimeEntry.runtimeLoadTruncated ? (
- Process tree was capped for this sample.
+ {t('members.runtimeTelemetry.processTreeCapped')}
) : null}
- RSS can include shared pages, so it is best read as a load signal, not exclusive memory.
+ {t('members.runtimeTelemetry.rssHint')}
);
@@ -640,6 +644,7 @@ export const MemberCard = memo(function MemberCard({
onSkipMemberForLaunch,
onRestoreMember,
}: MemberCardProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
// NOTE: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName);
// const leadContext = useStore((s) =>
@@ -1079,7 +1084,7 @@ export const MemberCard = memo(function MemberCard({
className="shrink-0 rounded border border-emerald-400/35 bg-emerald-400/10 px-1 py-0.5 text-[9px] font-semibold uppercase leading-none text-emerald-300"
data-runtime-telemetry-exempt="true"
>
- worktree
+ {t('members.badges.worktree')}
@@ -1488,7 +1493,7 @@ export const MemberCard = memo(function MemberCard({
- Send message
+ {t('members.actions.sendMessage')}
@@ -1503,7 +1508,7 @@ export const MemberCard = memo(function MemberCard({
- Assign task
+ {t('members.actions.assignTask')}
)}
diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx
index 531e6656..3222cbdf 100644
--- a/src/renderer/components/team/members/MemberDetailDialog.tsx
+++ b/src/renderer/components/team/members/MemberDetailDialog.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
isMemberLogStreamUiEnabled,
MemberLogStreamSection,
@@ -143,6 +144,7 @@ export const MemberDetailDialog = ({
updatingRole,
onViewMemberChanges,
}: MemberDetailDialogProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const memberTasks = useMemo(
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
[tasks, member]
@@ -248,7 +250,9 @@ export const MemberDetailDialog = ({
? OPENCODE_BOOTSTRAP_STALLED_MESSAGE
: undefined;
const isOpenCodeMember = member?.providerId === 'opencode';
- const restartButtonLabel = isOpenCodeMember ? 'Relaunch OpenCode' : 'Restart';
+ const restartButtonLabel = isOpenCodeMember
+ ? t('members.detail.relaunchOpenCode')
+ : t('members.detail.restart');
const hasLiveRestartContext = isTeamAlive === true || isTeamProvisioning === true;
const canControlledOpenCodeRelaunch =
member == null
@@ -406,7 +410,7 @@ export const MemberDetailDialog = ({
{showLegacyLogsFallback ? (
- Legacy Logs Fallback
+ {t('members.detail.legacyLogsFallback')}
@@ -429,7 +433,7 @@ export const MemberDetailDialog = ({
{launchDiagnosticsPayload && showCopyDiagnostics ? (
) : null}
@@ -442,7 +446,7 @@ export const MemberDetailDialog = ({
) : runtimeEntry?.pid ? (
- PID {runtimeEntry.pid}
+ {t('members.detail.pid', { pid: runtimeEntry.pid })}
{memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
) : (
@@ -450,7 +454,9 @@ export const MemberDetailDialog = ({
)}
{member.removedAt ? (
- Removed {new Date(member.removedAt).toLocaleDateString()}
+ {t('members.detail.removedAt', {
+ date: new Date(member.removedAt).toLocaleDateString(),
+ })}
) : (
<>
@@ -468,7 +474,9 @@ export const MemberDetailDialog = ({
await onRestartMember(member.name);
} catch (error) {
setRestartError(
- error instanceof Error ? error.message : 'Failed to restart member'
+ error instanceof Error
+ ? error.message
+ : t('members.detail.failedToRestartMember')
);
} finally {
setRestarting(false);
@@ -485,11 +493,11 @@ export const MemberDetailDialog = ({
)}
- Send Message
+ {t('members.detail.sendMessage')}
- Assign Task
+ {t('members.detail.assignTask')}
{onRemoveMember && !isLeadMember(member) && (
- Remove
+ {t('members.detail.remove')}
)}
>
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx
index 14624594..b1c9acb7 100644
--- a/src/renderer/components/team/members/MemberDetailHeader.tsx
+++ b/src/renderer/components/team/members/MemberDetailHeader.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors';
@@ -72,6 +73,7 @@ export const MemberDetailHeader = ({
onUpdateRole,
updatingRole,
}: MemberDetailHeaderProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [editing, setEditing] = useState(false);
const selectedTeamName = useStore((s) => s.selectedTeamName);
const teamMembers = useStore((s) =>
@@ -170,7 +172,7 @@ export const MemberDetailHeader = ({
type="button"
className="inline-flex items-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setEditing(true)}
- aria-label="Edit role"
+ aria-label={t('members.actions.editRole')}
>
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx
index 83702416..d7f868b8 100644
--- a/src/renderer/components/team/members/MemberDraftRow.tsx
+++ b/src/renderer/components/team/members/MemberDraftRow.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { AnthropicExtraUsageWarning } from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
@@ -168,6 +169,7 @@ export const MemberDraftRow = ({
agentTeamsMcpLocked = false,
lockedModelAction,
}: MemberDraftRowProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const memberColorSet = getTeamColorSet(
resolvedColor ??
@@ -244,8 +246,8 @@ export const MemberDraftRow = ({
: mcpMode === 'strictAllowlist'
? `MCP ${mcpServerNames.length || 'strict'}`
: mcpMode === 'inheritScopes'
- ? 'MCP scopes'
- : 'MCP inherit';
+ ? t('memberDraft.mcp.buttonScopes')
+ : t('memberDraft.mcp.buttonInherit');
const updateMcpPolicy = useCallback(
(policy: TeamMemberMcpPolicy | undefined) => {
if (agentTeamsMcpLocked) {
@@ -307,6 +309,17 @@ export const MemberDraftRow = ({
[mcpScopes, updateMcpPolicy]
);
+ const getMcpScopeLabel = (scope: 'user' | 'project' | 'local'): string => {
+ switch (scope) {
+ case 'user':
+ return t('memberDraft.mcp.scopes.user');
+ case 'project':
+ return t('memberDraft.mcp.scopes.project');
+ case 'local':
+ return t('memberDraft.mcp.scopes.local');
+ }
+ };
+
useEffect(() => {
if (
onWorkflowChange &&
@@ -331,14 +344,17 @@ export const MemberDraftRow = ({
: (member.effort ?? inheritedEffort);
const modelButtonLabelBase = effectiveModel?.trim()
? getProviderScopedTeamModelLabel(effectiveProviderId, effectiveModel.trim())
- : 'Default';
+ : t('memberDraft.model.default');
const modelButtonLabel = forceInheritedModelSettings
- ? `${modelButtonLabelBase} (lead)`
+ ? t('memberDraft.model.leadSuffix', { label: modelButtonLabelBase })
: modelButtonLabelBase;
- const modelButtonAriaLabel = `${getTeamProviderLabel(effectiveProviderId)} provider, ${modelButtonLabel}`;
+ const modelButtonAriaLabel = t('memberDraft.model.ariaLabel', {
+ provider: getTeamProviderLabel(effectiveProviderId),
+ model: modelButtonLabel,
+ });
const canOpenLockedModelPanel = lockProviderModel && !isRemoved && Boolean(lockedModelAction);
const modelTooltipText = forceInheritedModelSettings
- ? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
+ ? t('memberDraft.model.inheritedTooltip')
: lockProviderModel
? (lockedModelAction?.description ?? modelLockReason)
: undefined;
@@ -347,7 +363,7 @@ export const MemberDraftRow = ({
const worktreeIsolationDescription =
worktreeIsolationDisabledReason && member.isolation !== 'worktree'
? worktreeIsolationDisabledReason
- : 'Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.';
+ : t('memberDraft.worktree.description');
const worktreeIsolationDescriptionId = showWorktreeIsolationControls
? `member-${member.id}-worktree-isolation-description`
: undefined;
@@ -413,16 +429,17 @@ export const MemberDraftRow = ({
Boolean(message)
);
const hasWarnings = warningMessages.length > 0 || showSonnetExtraUsageWarning;
- const anthropicContextModeLabel = limitContext ? '200K limit enabled' : 'default context setting';
+ const anthropicContextModeLabel = limitContext
+ ? t('memberDraft.anthropicContext.limitEnabled')
+ : t('memberDraft.anthropicContext.defaultSetting');
const workflowTooltipText = workflowDraft.value.trim()
- ? 'Edit teammate workflow'
- : 'Add teammate workflow';
- const mcpTooltipText = `${mcpButtonLabel}: Control this member's MCP inheritance policy`;
- const mcpLockedInfoText =
- 'Agent Teams MCP only is enabled for all teammates. This teammate will launch with only the Agent Teams server.';
+ ? t('memberDraft.workflow.editTooltip')
+ : t('memberDraft.workflow.addTooltip');
+ const mcpTooltipText = t('memberDraft.mcp.tooltip', { label: mcpButtonLabel });
+ const mcpLockedInfoText = t('memberDraft.mcp.lockedInfo');
const mcpSettingInfoText = agentTeamsMcpLocked
? mcpLockedInfoText
- : 'Agent Teams MCP launches this teammate with only the Agent Teams server. Scope and allowlist modes apply only to this teammate launch.';
+ : t('memberDraft.mcp.settingInfo');
const runtimeSummary = formatTeamModelSummary(
effectiveProviderId,
effectiveModel?.trim() ?? '',
@@ -457,11 +474,11 @@ export const MemberDraftRow = ({
onNameChange(member.id, event.target.value)}
- placeholder="member-name"
+ placeholder={t('memberDraft.placeholders.name')}
/>
{nameError ?
{nameError}
: null}
@@ -469,7 +486,10 @@ export const MemberDraftRow = ({
{lockRole ? (
- {lockedRoleLabel || member.customRole || member.roleSelection || 'No role'}
+ {lockedRoleLabel ||
+ member.customRole ||
+ member.roleSelection ||
+ t('memberDraft.noRole')}
) : (
- Worktree
+ {t('memberDraft.worktree.label')}
@@ -654,8 +674,10 @@ export const MemberDraftRow = ({
variant="outline"
size="sm"
className="size-8 shrink-0 px-0"
- aria-label={`Restore ${member.name || `member ${index + 1}`}`}
- title="Restore member"
+ aria-label={t('memberDraft.actions.restoreAria', {
+ name: member.name || t('memberDraft.nameFallback', { index: index + 1 }),
+ })}
+ title={t('memberDraft.actions.restore')}
onClick={() => onRestore?.(member.id)}
>
@@ -665,8 +687,10 @@ export const MemberDraftRow = ({
variant="outline"
size="sm"
className="size-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200"
- aria-label={`Remove ${member.name || `member ${index + 1}`}`}
- title="Remove member"
+ aria-label={t('memberDraft.actions.removeAria', {
+ name: member.name || t('memberDraft.nameFallback', { index: index + 1 }),
+ })}
+ title={t('memberDraft.actions.remove')}
onClick={() => onRemove(member.id)}
>
@@ -674,7 +698,9 @@ export const MemberDraftRow = ({
)}
{isRemoved ? (
- Removed
+
+ {t('memberDraft.removed')}
+
) : null}
{!isRemoved && hasWarnings ? (
@@ -707,7 +733,7 @@ export const MemberDraftRow = ({
htmlFor={`member-${member.id}-mcp-mode`}
className="text-[10px] text-[var(--color-text-muted)]"
>
- MCP mode
+ {t('memberDraft.mcp.mode')}
- Inherit lead
- Choose scopes
- Strict allowlist
- Agent Teams MCP
+ {t('memberDraft.mcp.inheritLead')}
+
+ {t('memberDraft.mcp.chooseScopes')}
+
+
+ {t('memberDraft.mcp.strictAllowlist')}
+
+ {t('memberDraft.mcp.agentTeamsMcp')}
@@ -749,7 +779,7 @@ export const MemberDraftRow = ({
}
onCheckedChange={(checked) => updateMcpScope(scope, checked === true)}
/>
- {scope}
+ {getMcpScopeLabel(scope)}
))}
@@ -759,7 +789,7 @@ export const MemberDraftRow = ({
htmlFor={`member-${member.id}-mcp-servers`}
className="text-[10px] text-[var(--color-text-muted)]"
>
- Server names
+ {t('memberDraft.mcp.serverNames')}
updateMcpServerNames(event.target.value)}
- placeholder="github, sentry"
+ placeholder={t('memberDraft.placeholders.mcpServers')}
/>
) : null}
@@ -785,7 +815,7 @@ export const MemberDraftRow = ({
htmlFor={`member-${member.id}-workflow`}
className="block text-[10px] font-medium text-[var(--color-text-muted)]"
>
- Workflow (optional)
+ {t('memberDraft.workflow.label')}
Saved
+
+ {t('memberDraft.workflow.saved')}
+
) : null
}
/>
@@ -816,16 +848,15 @@ export const MemberDraftRow = ({
- Current lead runtime
+ {t('memberDraft.model.currentLeadRuntime')}
{runtimeSummary}
- {lockedModelAction.description ??
- 'Lead runtime changes open Relaunch Team, where provider, model, and effort can be updated.'}
+ {lockedModelAction.description ?? t('memberDraft.model.lockedActionFallback')}
- Saving those runtime changes restarts the whole team.
+ {t('memberDraft.model.restartWholeTeam')}
- Anthropic context is team-wide for this launch: {anthropicContextModeLabel}. Use
- the lead runtime panel's Limit context checkbox to change it.
+ {t('memberDraft.anthropicContext.description', {
+ mode: anthropicContextModeLabel,
+ })}
) : null}
{lockProviderModel && (
- {modelLockReason ??
- 'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
+ {modelLockReason ?? t('memberDraft.model.liveDisabled')}
)}
>
diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx
index 734a7f94..3f7b0dc3 100644
--- a/src/renderer/components/team/members/MemberExecutionLog.tsx
+++ b/src/renderer/components/team/members/MemberExecutionLog.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
@@ -34,6 +35,7 @@ export const MemberExecutionLog = ({
teamName,
hideMemberHeading,
}: MemberExecutionLogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
// Show newest groups first — most recent activity is most relevant in execution logs.
@@ -49,7 +51,7 @@ export const MemberExecutionLog = ({
if (!orderedItems.length) {
return (
- Nothing to display
+ {t('members.executionLog.empty')}
);
}
@@ -113,6 +115,7 @@ function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[]
}
const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const text = group.content.rawText ?? group.content.text ?? '';
const { humanText, agentInfo } = useMemo(() => splitAgentBlocks(text), [text]);
const [agentInfoOpen, setAgentInfoOpen] = useState(false);
@@ -120,7 +123,9 @@ const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
if (!humanText && agentInfo.length === 0) {
return (
- {format(group.timestamp, 'h:mm:ss a')} — (empty)
+ {t('members.executionLog.emptyUserMessage', {
+ time: format(group.timestamp, 'h:mm:ss a'),
+ })}
);
}
@@ -147,7 +152,7 @@ const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
className={`shrink-0 transition-transform ${agentInfoOpen ? 'rotate-90' : ''}`}
/>
- Agent instructions
+ {t('members.executionLog.agentInstructions')}
{agentInfoOpen && (
@@ -183,6 +188,7 @@ const AIExecutionGroup = ({
onToggleExpanded,
onToggleItem,
}: AIExecutionGroupProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const enhanced = useMemo(() => {
if (!memberName) {
@@ -195,7 +201,9 @@ const AIExecutionGroup = ({
return enhanceAIGroup({ ...group, processes: filteredProcesses });
}, [group, memberName]);
const normalizedMemberName = memberName?.trim();
- const groupLabel = normalizedMemberName ? `${normalizedMemberName} turn` : 'Agent turn';
+ const groupLabel = normalizedMemberName
+ ? t('members.executionLog.memberTurn', { member: normalizedMemberName })
+ : t('members.executionLog.agentTurn');
const hasToggleContent = enhanced.displayItems.length > 0;
const visibleLastOutput =
enhanced.lastOutput?.type === 'tool_result' && hasToggleContent ? null : enhanced.lastOutput;
@@ -225,12 +233,12 @@ const AIExecutionGroup = ({
disableHoverCard
/>
- turn
+ {t('members.executionLog.turn')}
>
) : hideMemberHeading ? (
- turn
+ {t('members.executionLog.turn')}
) : (
<>
diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx
index c2cde842..f5a47c13 100644
--- a/src/renderer/components/team/members/MemberHoverCard.tsx
+++ b/src/renderer/components/team/members/MemberHoverCard.tsx
@@ -1,5 +1,6 @@
import { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card';
import {
@@ -69,6 +70,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
onOpenTask,
children,
}: MemberHoverCardProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
@@ -326,7 +328,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
}}
>
- Open profile
+ {t('members.actions.openProfile')}
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx
index 443e9831..5e3732a9 100644
--- a/src/renderer/components/team/members/MemberList.tsx
+++ b/src/renderer/components/team/members/MemberList.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useTheme } from '@renderer/hooks/useTheme';
import {
deriveReviewActivityTimerAnchor,
@@ -614,6 +615,7 @@ const MemberListLoadingSkeleton = ({
}: Readonly<{
expectedTeammateCount?: number;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const skeletonCount = getMemberLoadingSkeletonCount(expectedTeammateCount);
const { isLight } = useTheme();
@@ -621,7 +623,7 @@ const MemberListLoadingSkeleton = ({
{Array.from({ length: skeletonCount }, (_, index) => {
@@ -681,17 +683,14 @@ const MemberRosterUnavailableState = ({
}: Readonly<{
expectedTeammateCount?: number;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const count = Number.isFinite(expectedTeammateCount)
? Math.max(0, Math.floor(expectedTeammateCount ?? 0))
: 0;
- const teammateLabel = count === 1 ? '1 teammate is' : `${count || 'Some'} teammates are`;
-
return (
-
Member roster unavailable
-
- {teammateLabel} known from team metadata, but roster details are missing.
-
+
{t('members.list.unavailable')}
+
{t('members.list.unavailableDescription', { count })}
);
};
@@ -720,6 +719,7 @@ export const MemberList = memo(function MemberList({
onSkipMemberForLaunch,
onRestoreMember,
}: MemberListProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const containerRef = useRef
(null);
const [isWide, setIsWide] = useState(false);
@@ -909,7 +909,7 @@ export const MemberList = memo(function MemberList({
return (
- Solo team - lead only
+ {t('members.list.soloLeadOnly')}
);
}
@@ -1020,7 +1020,7 @@ export const MemberList = memo(function MemberList({
{removedMembers.length > 0 && (
<>
- Removed ({removedMembers.length})
+ {t('members.list.removedCount', { count: removedMembers.length })}
{removedMembers.map((member) => (
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx
index 32a21a95..53b8ae9c 100644
--- a/src/renderer/components/team/members/MemberLogsTab.tsx
+++ b/src/renderer/components/team/members/MemberLogsTab.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import {
@@ -130,6 +131,7 @@ export const MemberLogsTab = ({
showLeadPreview = false,
onPreviewOnlineChange,
}: MemberLogsTabProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
// Visibility check: skip polling when tab is hidden (display:none) to avoid OOM
const tabId = useTabIdOptional();
const activeTabId = useStore((s) => s.activeTabId);
@@ -718,7 +720,7 @@ export const MemberLogsTab = ({
return (
- Searching logs...
+ {t('members.logs.searching')}
);
}
@@ -736,13 +738,13 @@ export const MemberLogsTab = ({
return (
- No logs found
+ {t('members.logs.empty')}
{taskId != null
? taskStatus === 'in_progress'
- ? 'Task is in progress — waiting for session activity (auto-refreshing)...'
- : 'No session activity for this task yet'
- : 'This member has no recorded session activity yet'}
+ ? t('members.logs.waitingForTaskActivity')
+ : t('members.logs.noTaskActivity')
+ : t('members.logs.noMemberActivity')}
);
@@ -787,6 +789,7 @@ const LogCard = ({
detailLoading,
onToggle,
}: LogCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const createdAgo = formatRelativeTime(log.startTime);
const lastActivityTime = useMemo(() => {
const startMs = new Date(log.startTime).getTime();
@@ -837,8 +840,8 @@ const LogCard = ({
{log.kind === 'lead_session'
- ? 'Full team lead session logs - useful for global orchestration context, not specific to this agent'
- : 'Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file'}
+ ? t('members.logs.leadSessionTooltip')
+ : t('members.logs.memberSessionTooltip')}
)}
@@ -850,7 +853,9 @@ const LogCard = ({
{updatedAgo}
-
started {createdAgo}
+
+ {t('members.logs.startedAt', { time: createdAgo })}
+
>
) : (
@@ -864,7 +869,9 @@ const LogCard = ({
{log.messageCount}
{log.isOngoing && (
-
active
+
+ {t('members.logs.active')}
+
)}
{log.lastOutputPreview && !expanded && (
@@ -878,7 +885,9 @@ const LogCard = ({
-
{expanded ? 'Hide details' : 'Show details'}
+
+ {expanded ? t('members.logs.hideDetails') : t('members.logs.showDetails')}
+
{expanded && (
@@ -886,12 +895,12 @@ const LogCard = ({
{detailLoading && (
- Loading details...
+ {t('members.logs.loadingDetails')}
)}
{!detailLoading && !detailChunks && (
- Failed to load details
+ {t('members.logs.failedToLoadDetails')}
)}
{!detailLoading && detailChunks && (
diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx
index 6e5c9eac..98119816 100644
--- a/src/renderer/components/team/members/MemberMessagesTab.tsx
+++ b/src/renderer/components/team/members/MemberMessagesTab.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
import {
buildMessageContext,
@@ -30,11 +31,11 @@ interface MemberMessagesTabProps {
}
const MAX_MESSAGES = 100;
-const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [
- { value: 'all', label: 'All' },
- { value: 'messages', label: 'Messages' },
- { value: 'comments', label: 'Comments' },
-];
+const FILTER_OPTIONS = [
+ { value: 'all', labelKey: 'members.messages.filters.all' },
+ { value: 'messages', labelKey: 'members.messages.filters.messages' },
+ { value: 'comments', labelKey: 'members.messages.filters.comments' },
+] as const satisfies readonly { value: MemberActivityFilter; labelKey: string }[];
export const MemberMessagesTab = ({
teamName,
@@ -45,6 +46,7 @@ export const MemberMessagesTab = ({
onCreateTask,
onTaskClick,
}: MemberMessagesTabProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [activityFilter, setActivityFilter] = useState
(initialFilter);
const [expandedItem, setExpandedItem] = useState(null);
const { messages, messagesState, loadOlderTeamMessages } = useStore(
@@ -132,16 +134,16 @@ export const MemberMessagesTab = ({
const initialPageLoading = loading && activityEntries.length === 0;
const emptyStateText = initialPageLoading
- ? 'Loading activity...'
+ ? t('members.messages.empty.loading')
: activityFilter === 'comments'
- ? 'No comments for this member'
+ ? t('members.messages.empty.noComments')
: activityFilter === 'messages'
? hasMore
- ? 'No loaded messages for this member yet'
- : 'No messages with this member'
+ ? t('members.messages.empty.noLoadedMessages')
+ : t('members.messages.empty.noMessages')
: hasMore
- ? 'No loaded activity for this member yet'
- : 'No activity with this member';
+ ? t('members.messages.empty.noLoadedActivity')
+ : t('members.messages.empty.noActivity');
const canLoadOlderMessages = hasMore && activityFilter !== 'comments';
return (
@@ -161,7 +163,7 @@ export const MemberMessagesTab = ({
].join(' ')}
onClick={() => setActivityFilter(option.value)}
>
- {option.label}
+ {t(option.labelKey)}
);
})}
@@ -227,7 +229,7 @@ export const MemberMessagesTab = ({
disabled={loadingOlderMessages}
onClick={() => void loadOlderMessages()}
>
- Load older messages
+ {t('members.messages.loadOlder')}
)}
diff --git a/src/renderer/components/team/members/MemberStatsTab.tsx b/src/renderer/components/team/members/MemberStatsTab.tsx
index 97a93e49..d64e5cd6 100644
--- a/src/renderer/components/team/members/MemberStatsTab.tsx
+++ b/src/renderer/components/team/members/MemberStatsTab.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { cn } from '@renderer/lib/utils';
import { formatRelativeTime } from '@renderer/utils/formatters';
@@ -36,6 +37,7 @@ export const MemberStatsTab = ({
onFileClick,
onShowAllFiles,
}: MemberStatsTabProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const usePrefetched = prefetchedStats !== undefined;
const [localStats, setLocalStats] = useState(null);
@@ -73,7 +75,7 @@ export const MemberStatsTab = ({
return (
- Computing stats...
+ {t('members.stats.computing')}
);
}
@@ -91,7 +93,7 @@ export const MemberStatsTab = ({
return (
- No stats available
+ {t('members.stats.empty')}
);
}
@@ -153,25 +155,30 @@ const SummaryCards = ({
stats: MemberFullStats;
totalTokens: number;
totalToolCalls: number;
-}): React.JSX.Element => (
-
- 0 ? `-${stats.linesRemoved}` : undefined}
- info="Approximate. Accurate for Edit and Write tools. Bash file writes are estimated from command patterns (heredoc, echo, sed) and may be underreported."
- />
-
-
-
-
-);
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
+ 0 ? `-${stats.linesRemoved}` : undefined}
+ info={t('members.stats.linesInfo')}
+ />
+
+
+
+
+ );
+};
const ToolUsageBars = ({
toolUsage,
}: {
toolUsage: Record;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const entries = Object.entries(toolUsage).sort(([, a], [, b]) => b - a);
if (entries.length === 0) return null;
@@ -179,7 +186,9 @@ const ToolUsageBars = ({
return (
-
Tool Usage
+
+ {t('members.stats.toolUsage')}
+
{entries.map(([name, count]) => (
@@ -235,6 +244,7 @@ const FilesTouchedSection = ({
onFileClick?: (filePath: string) => void;
onShowAll?: () => void;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(false);
const validFiles = files.filter((f) => !isInvalidPath(f));
@@ -248,11 +258,11 @@ const FilesTouchedSection = ({
- Files Touched ({validFiles.length})
+ {t('members.stats.filesTouched', { count: validFiles.length })}
{onShowAll && (
- View All Changes
+ {t('members.stats.viewAllChanges')}
)}
@@ -291,7 +301,9 @@ const FilesTouchedSection = ({
onClick={() => setExpanded(!expanded)}
>
{expanded ?
:
}
- {expanded ? 'Show less' : `+${hiddenCount} more`}
+ {expanded
+ ? t('members.stats.showLess')
+ : t('members.stats.moreFiles', { count: hiddenCount })}
)}
@@ -299,11 +311,12 @@ const FilesTouchedSection = ({
};
const StatsFooter = ({ stats }: { stats: MemberFullStats }): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const computedAgo = formatRelativeTime(stats.computedAt);
return (
- {stats.sessionCount} session{stats.sessionCount !== 1 ? 's' : ''} · computed {computedAgo}
+ {t('members.stats.footer', { count: stats.sessionCount, computedAgo })}
);
};
diff --git a/src/renderer/components/team/members/MemberTasksTab.tsx b/src/renderer/components/team/members/MemberTasksTab.tsx
index 15d90066..75fb3232 100644
--- a/src/renderer/components/team/members/MemberTasksTab.tsx
+++ b/src/renderer/components/team/members/MemberTasksTab.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import {
KANBAN_COLUMN_DISPLAY,
@@ -27,6 +28,7 @@ const STATUS_ORDER: Record
= {
};
export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const visibleTasks = useMemo(
() =>
tasks
@@ -38,7 +40,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
if (visibleTasks.length === 0) {
return (
- No tasks assigned to this member
+ {t('members.tasks.empty')}
);
}
diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx
index 6c1ad20e..f84b65f0 100644
--- a/src/renderer/components/team/members/MembersEditorSection.tsx
+++ b/src/renderer/components/team/members/MembersEditorSection.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
@@ -196,6 +197,7 @@ export const MembersEditorSection = ({
worktreeIsolationDisabledReason,
onTeammateWorktreeDefaultChange,
}: MembersEditorSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState(null);
@@ -439,7 +441,7 @@ export const MembersEditorSection = ({
return (
-
Members
+
{t('members.editor.title')}
{!hideContent && (
- Add member
+ {t('members.editor.addMember')}
{showJsonEditor && !jsonEditorOpen ? (
- Edit as JSON
+ {t('members.editor.editAsJson')}
) : null}
@@ -499,7 +501,7 @@ export const MembersEditorSection = ({
className="flex min-w-0 cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
>
-
Run teammates in separate worktrees
+
{t('members.editor.runInSeparateWorktrees')}
@@ -513,7 +515,9 @@ export const MembersEditorSection = ({
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
>
-
Agent Teams MCP only
+
+ {t('members.editor.agentTeamsMcpOnly')}
+
@@ -568,7 +572,7 @@ export const MembersEditorSection = ({
{softDeleteMembers && removedMembers.length > 0 ? (
- Removed ({removedMembers.length})
+ {t('members.editor.removedCount', { count: removedMembers.length })}
{removedMembers.map((member, index) => (
@@ -605,7 +609,7 @@ export const MembersEditorSection = ({
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel
- modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
+ modelLockReason={t('members.editor.removedModelLockReason')}
isRemoved
warningText={null}
disableGeminiOption={disableGeminiOption}
@@ -619,7 +623,7 @@ export const MembersEditorSection = ({
{hasDuplicates ? (
- Member names must be unique
+ {t('members.editor.memberNamesUnique')}
) : fieldError ? (
diff --git a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx
index b4611407..7db8cb87 100644
--- a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx
+++ b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { displayMemberName } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
@@ -38,6 +39,7 @@ export const SubagentRecentMessagesPreview = ({
hasMore = false,
onLoadMore,
}: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const [expandedAll, setExpandedAll] = useState(false);
// Strip agent-only blocks from message content before display
@@ -58,7 +60,9 @@ export const SubagentRecentMessagesPreview = ({
- Latest messages{memberName ? ` — ${displayMemberName(memberName)}` : ''}
+ {memberName
+ ? t('members.recentMessages.latestForMember', { member: displayMemberName(memberName) })
+ : t('members.recentMessages.latest')}
@@ -93,7 +97,7 @@ export const SubagentRecentMessagesPreview = ({
onClick={onLoadMore}
>
- Load more
+ {t('members.recentMessages.loadMore')}
) : null}
@@ -107,7 +111,7 @@ export const SubagentRecentMessagesPreview = ({
onClick={() => setExpandedAll(true)}
>
- Expand
+ {t('members.recentMessages.expand')}
) : (
setExpandedAll(false)}
>
- Collapse
+ {t('members.recentMessages.collapse')}
)}
diff --git a/src/renderer/components/team/messages/ActionModeSelector.tsx b/src/renderer/components/team/messages/ActionModeSelector.tsx
index d5184e12..fccfc6d0 100644
--- a/src/renderer/components/team/messages/ActionModeSelector.tsx
+++ b/src/renderer/components/team/messages/ActionModeSelector.tsx
@@ -4,6 +4,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import type { AgentActionMode } from '@shared/types';
@@ -53,6 +54,7 @@ export const ActionModeSelector = ({
showDelegate,
disabled = false,
}: ActionModeSelectorProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const modes = showDelegate ? MODE_CONFIG : MODE_CONFIG.filter((m) => m.mode !== 'delegate');
return (
@@ -60,7 +62,7 @@ export const ActionModeSelector = ({
{modes.map((cfg, idx) => {
const isActive = value === cfg.mode;
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index d6e383d6..06c41b15 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -10,6 +10,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
+import { useAppTranslation } from '@features/localization/renderer';
import { useComposerDraft } from '@renderer/hooks/useComposerDraft';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
@@ -123,6 +124,7 @@ export const MessageComposer = ({
onSend,
onCrossTeamSend,
}: MessageComposerProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const internalTextareaRef = useRef(null);
const textareaRef = useMemo(() => {
// Merge internal and external refs into a single callback ref
@@ -217,9 +219,7 @@ export const MessageComposer = ({
const isCrossTeam = selectedTeam !== null;
const selectedTarget = sortedCrossTeamTargets.find((t) => t.teamName === selectedTeam);
const targetDisplayName = selectedTarget?.displayName ?? selectedTeam;
- const crossTeamHintText = isCrossTeam
- ? 'Tip: Cross-team messages go to the target team lead. If you want the reply to come back to your team lead instead of you, say that explicitly in the message.'
- : undefined;
+ const crossTeamHintText = isCrossTeam ? t('messageComposer.crossTeam.hint') : undefined;
// Members load async with team data; keep recipient stable if valid, otherwise default to lead/first.
useEffect(() => {
@@ -370,19 +370,19 @@ export const MessageComposer = ({
const canAttach = supportsAttachments && draft.canAddMore && !sending;
const attachmentRestrictionReason = !supportsAttachments
? isCrossTeam
- ? 'File attachments are not supported for cross-team messages'
+ ? t('messageComposer.attachments.restrictions.crossTeam')
: !isTeamAlive
- ? 'Team must be online to attach files'
+ ? t('messageComposer.attachments.restrictions.teamOffline')
: !showAttachmentControl
- ? 'Files can be sent to the team lead or OpenCode teammates'
+ ? t('messageComposer.attachments.restrictions.unsupportedRecipient')
: (memberAttachmentUnavailableReason ??
(isOpenCodeRecipient
- ? 'Team must be online to attach files for OpenCode teammates'
- : 'Team must be online to attach files'))
+ ? t('messageComposer.attachments.restrictions.openCodeOffline')
+ : t('messageComposer.attachments.restrictions.teamOffline')))
: sending
- ? 'Wait for current message to finish sending before adding files'
+ ? t('messageComposer.attachments.restrictions.sending')
: !draft.canAddMore
- ? 'Maximum attachments reached'
+ ? t('messageComposer.attachments.restrictions.maximumReached')
: undefined;
const attachmentPayloadRestrictionReason = validateAttachmentPayloadsForMember({
member: selectedMember,
@@ -393,13 +393,13 @@ export const MessageComposer = ({
(!supportsAttachments || attachmentPayloadRestrictionReason != null);
const slashCommandRestrictionReason = standaloneSlashCommand
? draft.attachments.length > 0
- ? 'Slash commands require a live team lead and cannot be sent with attachments'
+ ? t('messageComposer.slash.restrictions.attachments')
: isCrossTeam
- ? 'Slash commands can only be run on the current team lead'
+ ? t('messageComposer.slash.restrictions.crossTeam')
: !isLeadRecipient
- ? 'Slash commands can only be sent to the team lead'
+ ? t('messageComposer.slash.restrictions.notLead')
: !isTeamAlive
- ? 'Slash commands require the team lead to be online'
+ ? t('messageComposer.slash.restrictions.leadOffline')
: null
: null;
const canSend =
@@ -532,7 +532,7 @@ export const MessageComposer = ({
setFileRestrictionError(
attachmentRestrictionReason ??
attachmentPayloadRestrictionReason ??
- 'Files can only be sent to the team lead'
+ t('messageComposer.attachments.restrictions.leadOnly')
);
window.clearTimeout(fileRestrictionTimerRef.current);
fileRestrictionTimerRef.current = window.setTimeout(() => {
@@ -735,7 +735,7 @@ export const MessageComposer = ({
) : lastResult?.deduplicated ? (
- Reused recent cross-team request
+ {t('messageComposer.status.reusedCrossTeamRequest')}
) : null;
const shouldShowFooterCharCount = remaining < 200;
@@ -750,11 +750,13 @@ export const MessageComposer = ({
- {remaining} chars left
+ {t('messageComposer.input.charsLeft', { count: remaining })}
) : null}
{shouldShowSavedIndicator ? (
- Saved
+
+ {t('tasks.createTask.saved')}
+
) : null}
) : null}
@@ -808,8 +810,8 @@ export const MessageComposer = ({
{canAttach
- ? 'Attach files (paste or drag & drop)'
- : (attachmentRestrictionReason ?? 'Attachments are unavailable')}
+ ? t('messageComposer.attachments.attachFiles')
+ : (attachmentRestrictionReason ?? t('messageComposer.attachments.unavailable'))}
>
@@ -818,7 +820,7 @@ export const MessageComposer = ({
{!isTeamAlive && !isLaunchBlocking && (
- Team offline
+ {t('messageComposer.status.teamOffline')}
)}
@@ -873,7 +875,9 @@ export const MessageComposer = ({
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
-
This team
+
+ {t('messageComposer.teamSelector.thisTeam')}
+
>
)}
@@ -900,9 +904,11 @@ export const MessageComposer = ({
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
-
This team
+
+ {t('messageComposer.teamSelector.thisTeam')}
+
- current
+ {t('messageComposer.teamSelector.current')}
{!isCrossTeam ? (
@@ -942,7 +948,11 @@ export const MessageComposer = ({
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}}
- title={target.isOnline ? 'Online' : 'Offline'}
+ title={
+ target.isOnline
+ ? t('messageComposer.teamSelector.onlineTitle')
+ : t('messageComposer.teamSelector.offlineTitle')
+ }
/>
@@ -957,7 +967,9 @@ export const MessageComposer = ({
: 'text-[var(--color-text-muted)]'
)}
>
- {target.isOnline ? 'online' : 'offline'}
+ {target.isOnline
+ ? t('messageComposer.teamSelector.online')
+ : t('messageComposer.teamSelector.offline')}
{target.description ? (
@@ -1005,7 +1017,9 @@ export const MessageComposer = ({
disableHoverCard
/>
) : (
-
Select...
+
+ {t('messageComposer.recipient.select')}
+
)}
@@ -1029,7 +1043,7 @@ export const MessageComposer = ({
ref={recipientSearchRef}
type="text"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
- placeholder="Search..."
+ placeholder={t('messageComposer.recipient.searchPlaceholder')}
value={recipientSearch}
onChange={(e) => setRecipientSearch(e.target.value)}
/>
@@ -1045,7 +1059,7 @@ export const MessageComposer = ({
if (filtered.length === 0) {
return (
- No results
+ {t('messageComposer.recipient.noResults')}
);
}
@@ -1111,7 +1125,7 @@ export const MessageComposer = ({
disabledHint={
attachmentPayloadRestrictionReason ??
attachmentRestrictionReason ??
- 'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
+ t('messageComposer.attachments.disabledHint')
}
/>
) : null}
@@ -1128,10 +1142,12 @@ export const MessageComposer = ({
id={`compose-${teamName}`}
placeholder={
isLaunchBlocking
- ? 'Team is launching... message will be queued for inbox delivery.'
+ ? t('messageComposer.input.teamLaunchingPlaceholder')
: isCrossTeam
- ? `Cross-team message to ${targetDisplayName ?? 'team'}...`
- : 'Write a message... (Enter to send, Shift+Enter for new line)'
+ ? t('messageComposer.input.crossTeamPlaceholder', {
+ team: targetDisplayName ?? t('messageComposer.input.teamFallback'),
+ })
+ : t('messageComposer.input.placeholder')
}
value={draft.text}
onValueChange={draft.setText}
@@ -1148,7 +1164,7 @@ export const MessageComposer = ({
onModEnter={handleSend}
onShiftTab={handleCycleActionMode}
dismissMentionsRef={dismissMentionsRef}
- extraTips={['Tip: You can use "/" to run any Claude commands.']}
+ extraTips={[t('messageComposer.input.slashTip')]}
surfaceClassName="message-composer-shell message-composer-orbit-surface border border-transparent bg-[var(--color-surface-raised)] shadow-[0_8px_24px_rgba(0,0,0,0.18),inset_0_1px_0_rgba(255,255,255,0.03)]"
surfaceDecoration="orbit-border"
surfaceFadeColor="var(--color-surface-raised)"
@@ -1181,7 +1197,9 @@ export const MessageComposer = ({
-
Voice to text
+
+ {t('messageComposer.actions.voiceToText')}
+
@@ -1193,7 +1211,7 @@ export const MessageComposer = ({
onClick={handleSend}
>
- Send
+ {t('messageComposer.actions.send')}
@@ -1201,7 +1219,7 @@ export const MessageComposer = ({
{slashCommandRestrictionReason}
) : isLaunchBlocking && !sending ? (
- Sending unavailable while team is launching
+ {t('messageComposer.actions.sendingUnavailableLaunching')}
) : null}
diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx
index 83daddc6..2ad14803 100644
--- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx
+++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -54,6 +55,7 @@ export const MessagesFilterPopover = ({
onOpenChange,
onApply,
}: MessagesFilterPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [draft, setDraft] = useState
({
from: new Set(),
to: new Set(),
@@ -118,7 +120,7 @@ export const MessagesFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter messages"
+ aria-label={t('messages.filter.ariaLabel')}
>
{activeCount > 0 && (
@@ -129,18 +131,20 @@ export const MessagesFilterPopover = ({
- Filter messages
+ {t('messages.filter.tooltip')}
{/* Scrollable filter sections */}
- From
+ {t('messages.filter.from')}
{fromOptions.length === 0 ? (
-
No data
+
+ {t('messages.filter.noData')}
+
) : (
fromOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
@@ -166,11 +170,13 @@ export const MessagesFilterPopover = ({
- To
+ {t('messages.filter.to')}
{toOptions.length === 0 ? (
-
No data
+
+ {t('messages.filter.noData')}
+
) : (
toOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
@@ -204,7 +210,7 @@ export const MessagesFilterPopover = ({
setDraft((prev) => ({ ...prev, showNoise: !prev.showNoise }))
}
/>
-
Show status updates (idle/shutdown)
+
{t('messages.filter.showStatusUpdates')}
@@ -215,10 +221,10 @@ export const MessagesFilterPopover = ({
disabled={draftCount === 0 && !draft.showNoise}
onClick={handleReset}
>
- Reset
+ {t('messages.filter.actions.reset')}
- Save
+ {t('messages.filter.actions.save')}
diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx
index 5a438df4..8c17e5a1 100644
--- a/src/renderer/components/team/messages/MessagesPanel.tsx
+++ b/src/renderer/components/team/messages/MessagesPanel.tsx
@@ -11,6 +11,7 @@ import {
} from 'react';
import { Sheet, type SheetRef } from 'react-modal-sheet';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -279,6 +280,7 @@ const MessagesTimelineSection = memo(function MessagesTimelineSection({
onExpandContent,
viewport,
}: MessagesTimelineSectionProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
return (
<>
- Load older messages
+ {t('messages.actions.loadOlder')}
)}
@@ -363,6 +365,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onFloatingComposerHeightChange,
inlineScrollContainerRef,
}: MessagesPanelProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const {
sendTeamMessage,
sendCrossTeamMessage,
@@ -916,26 +919,26 @@ export const MessagesPanel = memo(function MessagesPanel({
variant="ghost"
size="sm"
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] data-[state=open]:bg-[var(--color-surface-raised)] data-[state=open]:text-[var(--color-text-secondary)]"
- aria-label="Message panel mode"
+ aria-label={t('messages.panelMode')}
>
-
Message panel mode
+
{t('messages.panelMode')}
- Move to inline
+ {t('messages.actions.moveToInline')}
- Move to bottom sheet
+ {t('messages.actions.moveToBottomSheet')}
- Move to sidebar
+ {t('messages.actions.moveToSidebar')}
@@ -1046,7 +1049,7 @@ export const MessagesPanel = memo(function MessagesPanel({
setMessagesSearchQuery(e.target.value)}
onPointerDown={(e) => e.stopPropagation()}
@@ -1114,7 +1117,9 @@ export const MessagesPanel = memo(function MessagesPanel({
{/* Header */}
-
Messages
+
+ {t('messages.title')}
+
{filteredMessages.length > 0 && (
- {messagesUnreadCount} new
+ {t('messages.unread.new', { count: messagesUnreadCount })}
-
{messagesUnreadCount} unread
+
+ {t('messages.unread.unread', { count: messagesUnreadCount })}
+
)}
{messagesUnreadCount > 0 && (
@@ -1147,7 +1154,7 @@ export const MessagesPanel = memo(function MessagesPanel({
-
Mark all as read
+
{t('messages.actions.markAllRead')}
)}
@@ -1159,13 +1166,15 @@ export const MessagesPanel = memo(function MessagesPanel({
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] data-[state=open]:bg-[var(--color-surface-raised)] data-[state=open]:text-[var(--color-text-secondary)]"
- aria-label="Message panel actions"
+ aria-label={t('messages.actions.panelActions')}
>
-
Message actions
+
+ {t('messages.actions.messageActions')}
+
setMessagesCollapsed((v) => !v)}>
@@ -1174,7 +1183,11 @@ export const MessagesPanel = memo(function MessagesPanel({
) : (
)}
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
+
+ {messagesCollapsed
+ ? t('messages.actions.expandAll')
+ : t('messages.actions.collapseAll')}
+
setMessagesSearchBarVisible((v) => !v)}>
{messagesSearchBarVisible ? (
@@ -1182,19 +1195,23 @@ export const MessagesPanel = memo(function MessagesPanel({
) : (
)}
- {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
+
+ {messagesSearchBarVisible
+ ? t('messages.actions.hideSearch')
+ : t('messages.actions.searchMessages')}
+
- Move to inline
+ {t('messages.actions.moveToInline')}
- Move to bottom sheet
+ {t('messages.actions.moveToBottomSheet')}
- Float composer
+ {t('messages.actions.floatComposer')}
@@ -1278,7 +1295,9 @@ export const MessagesPanel = memo(function MessagesPanel({
-
Messages
+
+ {t('messages.title')}
+
{filteredMessages.length > 0 && (
- {messagesUnreadCount} new
+ {t('messages.unread.new', { count: messagesUnreadCount })}
-
{messagesUnreadCount} unread
+
+ {t('messages.unread.unread', { count: messagesUnreadCount })}
+
)}
-
Message actions
+
+ {t('messages.actions.messageActions')}
+
{messagesUnreadCount > 0 && (
@@ -1327,7 +1350,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onSelect={handleMarkAllRead}
>
- Mark all as read
+ {t('messages.actions.markAllRead')}
)}
setMessagesCollapsed((value) => !value)}>
@@ -1337,7 +1360,9 @@ export const MessagesPanel = memo(function MessagesPanel({
)}
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
+ {messagesCollapsed
+ ? t('messages.actions.expandAll')
+ : t('messages.actions.collapseAll')}
)}
- {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
+
+ {messagesSearchBarVisible
+ ? t('messages.actions.hideSearch')
+ : t('messages.actions.searchMessages')}
+
{isBottomSheetCollapsed ? (
@@ -1356,19 +1385,23 @@ export const MessagesPanel = memo(function MessagesPanel({
) : (
)}
- {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
+
+ {isBottomSheetCollapsed
+ ? t('messages.actions.expandSheet')
+ : t('messages.actions.collapseSheet')}
+
- Move to inline
+ {t('messages.actions.moveToInline')}
- Move to sidebar
+ {t('messages.actions.moveToSidebar')}
- Float composer
+ {t('messages.actions.floatComposer')}
@@ -1410,7 +1443,7 @@ export const MessagesPanel = memo(function MessagesPanel({
return (
}
badge={filteredMessages.length}
secondaryBadge={
@@ -1431,7 +1464,7 @@ export const MessagesPanel = memo(function MessagesPanel({
-
Mark all as read
+
{t('messages.actions.markAllRead')}
) : undefined
}
@@ -1447,12 +1480,12 @@ export const MessagesPanel = memo(function MessagesPanel({
e.stopPropagation();
moveToBottomSheet();
}}
- aria-label="Move messages to bottom sheet"
+ aria-label={t('messages.actions.moveMessagesToBottomSheet')}
>
-
Move to bottom sheet
+
{t('messages.actions.moveToBottomSheet')}
@@ -1464,12 +1497,12 @@ export const MessagesPanel = memo(function MessagesPanel({
e.stopPropagation();
moveToFloatingComposer();
}}
- aria-label="Float messages composer"
+ aria-label={t('messages.actions.floatMessagesComposer')}
>
- Float composer
+ {t('messages.actions.floatComposer')}
@@ -1481,12 +1514,12 @@ export const MessagesPanel = memo(function MessagesPanel({
e.stopPropagation();
moveToSidebar();
}}
- aria-label="Move messages to sidebar"
+ aria-label={t('messages.actions.moveMessagesToSidebar')}
>
- Move to sidebar
+ {t('messages.actions.moveToSidebar')}
}
diff --git a/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx b/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx
index 18a304b8..6926ea5a 100644
--- a/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx
+++ b/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
formatOpenCodeRuntimeDeliveryDebugDetails,
type OpenCodeRuntimeDeliveryDebugDetails,
@@ -19,6 +20,7 @@ export const OpenCodeDeliveryWarning = ({
debugDetails,
pendingDelayMs = 10_000,
}: OpenCodeDeliveryWarningProps): JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}:${debugDetails?.statusMessageId ?? ''}:${debugDetails?.userVisibleState ?? ''}`;
const delayPendingWarning =
debugDetails?.userVisibleState === 'checking' ||
@@ -112,46 +114,80 @@ export const OpenCodeDeliveryWarning = ({
setExpandedKey((currentKey) => (currentKey === detailsKey ? null : detailsKey))
}
>
- Details
+ {t('messages.delivery.details')}
) : null}
{expanded && debugDetails ? (
- messageId
+
+ {t('messages.delivery.fields.messageId')}
+
{debugDetails.messageId}
- statusMessageId
+
+ {t('messages.delivery.fields.statusMessageId')}
+
{debugDetails.statusMessageId}
- providerId
+
+ {t('messages.delivery.fields.providerId')}
+
{debugDetails.providerId}
- delivered
+
+ {t('messages.delivery.fields.delivered')}
+
{String(debugDetails.delivered)}
- responsePending
+
+ {t('messages.delivery.fields.responsePending')}
+
{String(debugDetails.responsePending)}
- responseState
+
+ {t('messages.delivery.fields.responseState')}
+
{debugDetails.responseState ?? 'null'}
- ledgerStatus
+
+ {t('messages.delivery.fields.ledgerStatus')}
+
{debugDetails.ledgerStatus ?? 'null'}
- visibleReplyMessageId
+
+ {t('messages.delivery.fields.visibleReplyMessageId')}
+
{debugDetails.visibleReplyMessageId ?? 'null'}
- visibleReplyCorrelation
+
+ {t('messages.delivery.fields.visibleReplyCorrelation')}
+
{debugDetails.visibleReplyCorrelation ?? 'null'}
- queuedBehindMessageId
+
+ {t('messages.delivery.fields.queuedBehindMessageId')}
+
{debugDetails.queuedBehindMessageId ?? 'null'}
- acceptanceUnknown
+
+ {t('messages.delivery.fields.acceptanceUnknown')}
+
{String(debugDetails.acceptanceUnknown)}
- userVisibleState
+
+ {t('messages.delivery.fields.userVisibleState')}
+
{debugDetails.userVisibleState ?? 'null'}
- userVisibleReasonCode
+
+ {t('messages.delivery.fields.userVisibleReasonCode')}
+
{debugDetails.userVisibleReasonCode ?? 'null'}
- userVisibleNextReviewAt
+
+ {t('messages.delivery.fields.userVisibleNextReviewAt')}
+
{debugDetails.userVisibleNextReviewAt ?? 'null'}
- userVisibleMessage
+
+ {t('messages.delivery.fields.userVisibleMessage')}
+
{debugDetails.userVisibleMessage ?? 'null'}
- reason
+
+ {t('messages.delivery.fields.reason')}
+
{debugDetails.reason ?? 'null'}
- diagnostics
+
+ {t('messages.delivery.fields.diagnostics')}
+
{debugDetails.diagnostics.length ? debugDetails.diagnostics.join('; ') : '[]'}
@@ -161,7 +197,7 @@ export const OpenCodeDeliveryWarning = ({
className="mt-2 rounded border border-amber-500/20 px-2 py-1 text-[10px] text-amber-200 hover:border-amber-400/40 hover:text-amber-100"
onClick={() => void handleCopy()}
>
- {copied ? 'Copied' : 'Copy debug details'}
+ {copied ? t('messages.delivery.copied') : t('messages.delivery.copyDebugDetails')}
) : null}
diff --git a/src/renderer/components/team/messages/StatusBlock.tsx b/src/renderer/components/team/messages/StatusBlock.tsx
index 116a0dee..b17cbf4b 100644
--- a/src/renderer/components/team/messages/StatusBlock.tsx
+++ b/src/renderer/components/team/messages/StatusBlock.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { ChevronRight } from 'lucide-react';
@@ -38,6 +39,7 @@ export const StatusBlock = ({
onMemberClick,
onTaskClick,
}: StatusBlockProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const [collapsed, setCollapsed] = useState(false);
const [nowMs, setNowMs] = useState(() => Date.now());
@@ -86,7 +88,7 @@ export const StatusBlock = ({
size={12}
className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
/>
- Status
+ {t('messages.status.title')}
);
const flowInlineToggle = layout === 'flow' && !collapsed ? toggleButton : null;
diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts
index 776eaecc..8d76f810 100644
--- a/src/renderer/components/team/provisioningSteps.ts
+++ b/src/renderer/components/team/provisioningSteps.ts
@@ -13,10 +13,10 @@ interface LaunchJoinMemberLike {
/** Display steps for the provisioning stepper (0-indexed). */
export const DISPLAY_STEPS = [
- { key: 'starting', label: 'Starting' },
- { key: 'configuring', label: 'Team setup' },
- { key: 'assembling', label: 'Members joining' },
- { key: 'finalizing', label: 'Finalizing' },
+ { key: 'starting', labelKey: 'provisioning.steps.starting' },
+ { key: 'configuring', labelKey: 'provisioning.steps.configuring' },
+ { key: 'assembling', labelKey: 'provisioning.steps.assembling' },
+ { key: 'finalizing', labelKey: 'provisioning.steps.finalizing' },
] as const;
export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length;
diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx
index 16649e96..25d5cf62 100644
--- a/src/renderer/components/team/review/ChangeReviewDialog.tsx
+++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { undo } from '@codemirror/commands';
import { rejectChunk } from '@codemirror/merge';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu';
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav';
@@ -77,6 +78,7 @@ const TaskChangesEmptyState = ({
}: {
changeSet: TaskChangeSetV2 | null;
}): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const status = changeSet ? classifyTaskChangeReviewability(changeSet) : null;
const diagnosticMessages =
status && status.diagnostics.length > 0
@@ -91,17 +93,17 @@ const TaskChangesEmptyState = ({
const hasDiagnosticContext = uniqueMessages.length > 0;
const Icon = isAttention ? AlertTriangle : hasDiagnosticContext ? Info : FileSearch;
const title = isDiagnosticOnly
- ? 'No safe diff available'
+ ? t('review.empty.noSafeDiff')
: isAttention
- ? 'No reviewable file changes'
- : 'No file changes recorded';
+ ? t('review.continuousScroll.empty')
+ : t('review.empty.noFileChangesRecorded');
const description = isNoSafeDiff
? isDiagnosticOnly
- ? 'The task ledger did not expose a safe file diff for this task.'
- : 'The task ledger did not expose a safe file diff for this task. The diagnostics below explain why.'
+ ? t('review.empty.noSafeDiffDescription')
+ : t('review.empty.noSafeDiffDiagnosticsDescription')
: hasDiagnosticContext
- ? 'The task ledger has no file events for this task yet.'
- : 'The task ledger has no file events for this task.';
+ ? t('review.empty.noFileEventsYet')
+ : t('review.empty.noFileEvents');
return (
@@ -142,6 +144,7 @@ export const ChangeReviewDialog = ({
projectPath,
onEditorAction,
}: ChangeReviewDialogProps): React.ReactElement | null => {
+ const { t } = useAppTranslation('team');
const {
activeChangeSet,
changeSetLoading,
@@ -1345,7 +1348,11 @@ export const ChangeReviewDialog = ({
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs text-text-secondary hover:text-text"
>
- Edit Timeline ({activeFile.timeline.events.length})
+
+ {t('review.timeline.titleWithCount', {
+ count: activeFile.timeline.events.length,
+ })}
+
{
/* Component */
export const ChangesLoadingAnimation = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [phaseIdx, setPhaseIdx] = useState(0);
const [phaseFading, setPhaseFading] = useState(false);
const [visibleLines, setVisibleLines] = useState([]);
@@ -228,7 +246,7 @@ export const ChangesLoadingAnimation = (): React.JSX.Element => {
- DIFF
+ {t('review.loading.diff')}
@@ -306,10 +324,10 @@ export const ChangesLoadingAnimation = (): React.JSX.Element => {
}}
>
- {phase.label}
+ {t(phase.labelKey)}
- {fileCount} ledger objects processed
+ {t('review.loading.ledgerObjectsProcessed', { count: fileCount })}
diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx
index c098d4f9..1f39d8a8 100644
--- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx
+++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx
@@ -6,6 +6,7 @@ import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import {
getAsyncLanguageDesc,
getSyncLanguageExtension,
@@ -207,6 +208,7 @@ export const CodeMirrorDiffView = ({
globalHunkOffset = 0,
totalReviewHunks,
}: CodeMirrorDiffViewProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const rootRef = useRef
(null);
const containerRef = useRef(null);
const viewRef = useRef(null);
@@ -800,7 +802,7 @@ export const CodeMirrorDiffView = ({
{
e.preventDefault();
moveBetweenChunks('prev');
@@ -815,7 +817,7 @@ export const CodeMirrorDiffView = ({
{
e.preventDefault();
moveBetweenChunks('next');
@@ -832,14 +834,16 @@ export const CodeMirrorDiffView = ({
backgroundColor: 'var(--diff-merge-undo-bg)',
border: '1px solid var(--diff-merge-undo-border)',
}}
- title="Reject change (⌘N)"
+ title={t('review.diffControls.rejectChange')}
onMouseDown={(e) => {
e.preventDefault();
actOnActiveChunk('reject');
}}
>
- {'Undo '}
- {'\u2318N'}
+ {t('review.diffControls.undo')}{' '}
+
+ {t('review.diffControls.rejectShortcut')}
+
{
e.preventDefault();
actOnActiveChunk('accept');
}}
>
- {'Keep '}
- {'\u2318Y'}
+ {t('review.diffControls.keep')}{' '}
+
+ {t('review.diffControls.acceptShortcut')}
+
)}
diff --git a/src/renderer/components/team/review/ConfidenceBadge.tsx b/src/renderer/components/team/review/ConfidenceBadge.tsx
index a4578061..b9ac54ab 100644
--- a/src/renderer/components/team/review/ConfidenceBadge.tsx
+++ b/src/renderer/components/team/review/ConfidenceBadge.tsx
@@ -1,3 +1,5 @@
+import { useAppTranslation } from '@features/localization/renderer';
+
import type { TaskScopeConfidence } from '@shared/types';
interface ConfidenceBadgeProps {
@@ -13,24 +15,27 @@ const TIER_COLORS: Record = {
4: 'bg-red-500/20 text-red-400 border-red-500/30',
};
-const TIER_LABELS: Record = {
- 1: 'High confidence',
- 2: 'Medium confidence',
- 3: 'Low confidence',
- 4: 'Best effort',
-};
-
export const ConfidenceBadge = ({
confidence,
showTooltip = true,
label,
}: ConfidenceBadgeProps) => {
+ const { t } = useAppTranslation('team');
+ const fallbackLabel =
+ confidence.tier === 1
+ ? t('review.scope.confidence.high')
+ : confidence.tier === 2
+ ? t('review.scope.confidence.medium')
+ : confidence.tier === 3
+ ? t('review.scope.confidence.low')
+ : t('review.scope.confidence.bestEffort');
+
return (
- {label ?? TIER_LABELS[confidence.tier] ?? TIER_LABELS[4]}
+ {label ?? fallbackLabel}
);
};
diff --git a/src/renderer/components/team/review/ConflictDialog.tsx b/src/renderer/components/team/review/ConflictDialog.tsx
index 4698b3eb..06c11320 100644
--- a/src/renderer/components/team/review/ConflictDialog.tsx
+++ b/src/renderer/components/team/review/ConflictDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, X } from 'lucide-react';
@@ -22,6 +23,7 @@ export const ConflictDialog = ({
onResolveUseOriginal,
onResolveManual,
}: ConflictDialogProps) => {
+ const { t } = useAppTranslation('team');
const [editMode, setEditMode] = useState(false);
const [editContent, setEditContent] = useState(conflictContent);
@@ -39,10 +41,8 @@ export const ConflictDialog = ({
-
Conflict Detected
-
- This file has been modified since the agent's changes
-
+
{t('review.conflict.title')}
+
{t('review.conflict.description')}
onOpenChange(false)}
@@ -98,13 +98,13 @@ export const ConflictDialog = ({
onClick={() => setEditMode(false)}
className="rounded px-3 py-1.5 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
- Cancel
+ {t('review.conflict.cancel')}
- Save Resolution
+ {t('review.conflict.saveResolution')}
>
) : (
@@ -116,7 +116,7 @@ export const ConflictDialog = ({
}}
className="rounded px-3 py-1.5 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
- Edit Manually
+ {t('review.conflict.editManually')}
- Use Original
+ {t('review.conflict.useOriginal')}
- Keep Current
+ {t('review.conflict.keepCurrent')}
>
)}
diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx
index ca933049..602c3cf8 100644
--- a/src/renderer/components/team/review/ContinuousScrollView.tsx
+++ b/src/renderer/components/team/review/ContinuousScrollView.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
import { useStore } from '@renderer/store';
@@ -116,6 +117,7 @@ export const ContinuousScrollView = ({
globalHunkOffsets,
totalReviewHunks,
}: ContinuousScrollViewProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const setFileChunkCount = useStore((s) => s.setFileChunkCount);
const [localCollapsedFiles, setLocalCollapsedFiles] = useState
>(() => new Set());
const collapsedFiles = collapsedFilesProp ?? localCollapsedFiles;
@@ -240,7 +242,7 @@ export const ContinuousScrollView = ({
if (files.length === 0) {
return (
- No reviewable file changes
+ {t('review.continuousScroll.empty')}
);
}
diff --git a/src/renderer/components/team/review/DiffErrorBoundary.tsx b/src/renderer/components/team/review/DiffErrorBoundary.tsx
index ff51aa78..5b5159f7 100644
--- a/src/renderer/components/team/review/DiffErrorBoundary.tsx
+++ b/src/renderer/components/team/review/DiffErrorBoundary.tsx
@@ -1,5 +1,6 @@
import { Component, type JSX, type ReactNode } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle } from 'lucide-react';
interface DiffErrorBoundaryProps {
@@ -15,8 +16,17 @@ interface DiffErrorBoundaryState {
error: Error | null;
}
-export class DiffErrorBoundary extends Component {
- constructor(props: DiffErrorBoundaryProps) {
+type ReviewT = ReturnType['t'];
+
+interface DiffErrorBoundaryInnerProps extends DiffErrorBoundaryProps {
+ t: ReviewT;
+}
+
+class DiffErrorBoundaryInner extends Component<
+ DiffErrorBoundaryInnerProps,
+ DiffErrorBoundaryState
+> {
+ constructor(props: DiffErrorBoundaryInnerProps) {
super(props);
this.state = { hasError: false, error: null };
}
@@ -39,18 +49,18 @@ export class DiffErrorBoundary extends Component{this.props.children}>;
}
- const { filePath, oldString, newString, onRetry } = this.props;
+ const { filePath, oldString, newString, onRetry, t } = this.props;
const { error } = this.state;
return (
-
Failed to render diff view
+
{t('review.diffError.title')}
- {error?.message ?? 'An unexpected error occurred while rendering the diff.'}
+ {error?.message ?? t('review.diffError.unexpected')}
@@ -62,7 +72,7 @@ export class DiffErrorBoundary extends Component
- Retry
+ {t('review.diffError.actions.retry')}
)}
@@ -70,25 +80,31 @@ export class DiffErrorBoundary extends Component
- Show raw diff data
+ {t('review.diffError.raw.show')}
-
File: {filePath}
+
+ {t('review.diffError.raw.file', { file: filePath })}
+
{oldString && (
-
--- Original
+
{t('review.diffError.raw.original')}
{oldString.slice(0, 2000)}
{oldString.length > 2000 && (
-
... ({oldString.length} chars total)
+
+ {t('review.diffError.raw.charsTotal', { count: oldString.length })}
+
)}
)}
{newString && (
-
+++ Modified
+
{t('review.diffError.raw.modified')}
{newString.slice(0, 2000)}
{newString.length > 2000 && (
-
... ({newString.length} chars total)
+
+ {t('review.diffError.raw.charsTotal', { count: newString.length })}
+
)}
)}
@@ -99,3 +115,8 @@ export class DiffErrorBoundary extends Component
;
+}
diff --git a/src/renderer/components/team/review/FileEditTimeline.tsx b/src/renderer/components/team/review/FileEditTimeline.tsx
index 51838f62..06bfc266 100644
--- a/src/renderer/components/team/review/FileEditTimeline.tsx
+++ b/src/renderer/components/team/review/FileEditTimeline.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import type { FileEditTimeline as FileEditTimelineType } from '@shared/types/review';
@@ -13,8 +14,10 @@ export const FileEditTimeline = ({
onEventClick,
activeSnippetIndex,
}: FileEditTimelineProps) => {
+ const { t } = useAppTranslation('team');
+
if (timeline.events.length === 0) {
- return
No edit events
;
+ return
{t('review.timeline.empty')}
;
}
return (
diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx
index 80a24946..bce6fdfd 100644
--- a/src/renderer/components/team/review/FileSectionDiff.tsx
+++ b/src/renderer/components/team/review/FileSectionDiff.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
import { DiffErrorBoundary } from './DiffErrorBoundary';
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
@@ -56,6 +57,7 @@ export const FileSectionDiff = ({
globalHunkOffset = 0,
totalReviewHunks,
}: FileSectionDiffProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const localEditorViewRef = useRef
(null);
const sentinelRef = useRef(null);
const hasSnippetText = hasReviewSnippetText(file);
@@ -206,8 +208,8 @@ export const FileSectionDiff = ({
className="border-b border-border bg-red-500/10 px-4 py-2 text-xs"
style={{ color: 'var(--diff-removed-text)' }}
>
- File is missing on disk. This diff may be only a preview from agent logs. Use{' '}
- Restore to create the file on disk.
+ {t('review.fileMissingPrefix')} {t('review.restore')} {' '}
+ {t('review.fileMissingSuffix')}
)}
= {
- 'ledger-exact': 'Task Ledger',
- 'ledger-snapshot': 'Ledger Snapshot',
- 'file-history': 'File History',
- 'snippet-reconstruction': 'Reconstructed',
- 'disk-current': 'Current Disk',
- 'git-fallback': 'Git Fallback',
- unavailable: 'Content unavailable',
-};
-
interface FileSectionHeaderProps {
file: FileChangeSummary;
fileContent: FileChangeWithContent | null;
@@ -65,6 +56,7 @@ export const FileSectionHeader = ({
onAcceptFile,
onRejectFile,
}: FileSectionHeaderProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const restoreContent = getResolvedReviewModifiedContent(file, fileContent);
const isMissingOnDisk = isReviewFileMissingOnDisk(fileContent);
const isContentUnavailable = isReviewTextContentUnavailable(file, fileContent);
@@ -76,12 +68,18 @@ export const FileSectionHeader = ({
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent != null;
const externalChangeLabel =
externalChange?.type === 'unlink'
- ? 'Deleted on disk'
+ ? t('review.fileHeader.externalChange.deletedOnDisk')
: externalChange?.type === 'add'
- ? 'Recreated on disk'
+ ? t('review.fileHeader.externalChange.recreatedOnDisk')
: externalChange?.type === 'change'
- ? 'Changed on disk'
+ ? t('review.fileHeader.externalChange.changedOnDisk')
: null;
+ const contentSourceLabel =
+ fileContent?.contentSource != null
+ ? t(`review.fileHeader.contentSource.${fileContent.contentSource}`, {
+ defaultValue: fileContent.contentSource,
+ })
+ : null;
const handleHeaderClick = (e: React.MouseEvent): void => {
// Don't collapse when clicking action buttons
@@ -112,13 +110,13 @@ export const FileSectionHeader = ({
{file.isNewFile && (
- NEW
+ {t('review.fileHeader.badges.new')}
)}
{pathChangeLabel?.kind === 'deleted' && (
- DELETED
+ {t('review.fileHeader.badges.deleted')}
)}
@@ -130,7 +128,9 @@ export const FileSectionHeader = ({
- {pathChangeLabel.direction === 'from' ? 'From' : 'To'} {pathChangeLabel.otherPath}
+ {pathChangeLabel.direction === 'from'
+ ? t('review.fileHeader.pathChange.from', { path: pathChangeLabel.otherPath })
+ : t('review.fileHeader.pathChange.to', { path: pathChangeLabel.otherPath })}
)}
@@ -145,45 +145,49 @@ export const FileSectionHeader = ({
].join(' ')}
>
{isContentUnavailable
- ? 'Content unavailable'
+ ? t('review.fileHeader.contentUnavailable.badge')
: isMissingOnDisk
- ? 'Missing on disk'
- : (CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource)}
+ ? t('review.fileHeader.missingOnDisk.badge')
+ : contentSourceLabel}
{isContentUnavailable ? (
-
Text content is unavailable
-
- The ledger recorded metadata for this change, but full text content is not
- available. This usually means binary, large, or hash-only content.
+
+ {t('review.fileHeader.contentUnavailable.title')}
- Automatic accept/reject is disabled for this file to avoid unsafe disk writes.
+ {t('review.fileHeader.contentUnavailable.description')}
+
+
+ {t('review.fileHeader.contentUnavailable.safety')}
) : isMissingOnDisk ? (
-
File is missing on disk
+
+ {t('review.fileHeader.missingOnDisk.title')}
+
- We can still show a preview from agent logs, but your filesystem is out of sync.
+ {t('review.fileHeader.missingOnDisk.description')}
{restoreContent != null ? (
- Use Restore to write the preview
- content back to disk.
+ {t('review.fileHeader.missingOnDisk.restorePrefix')}{' '}
+
+ {t('review.fileHeader.actions.restore')}
+ {' '}
+ {t('review.fileHeader.missingOnDisk.restoreSuffix')}
) : (
- Full file content is not available to restore automatically.
+ {t('review.fileHeader.missingOnDisk.restoreUnavailable')}
)}
) : (
-
- {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
-
+
{contentSourceLabel}
)}
@@ -194,13 +198,13 @@ export const FileSectionHeader = ({
- WORKTREE
+ {t('review.fileHeader.badges.worktree')}
- {file.ledgerSummary.worktreeBranch ?? 'Isolated worktree'}
+ {file.ledgerSummary.worktreeBranch ?? t('review.fileHeader.worktree.isolated')}
{file.ledgerSummary.worktreePath}
{file.ledgerSummary.dirtyLeaderWarning && (
@@ -233,7 +237,7 @@ export const FileSectionHeader = ({
{manualLedgerReviewRequired && (
- MANUAL REVIEW
+ {t('review.fileHeader.badges.manualReview')}
)}
@@ -245,14 +249,14 @@ export const FileSectionHeader = ({
disabled={applying}
className="rounded bg-blue-500/15 px-2 py-1 text-xs font-medium text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
>
- Reload from disk
+ {t('review.fileHeader.actions.reloadFromDisk')}
onKeepDraft(file.filePath)}
disabled={applying}
className="rounded bg-amber-500/15 px-2 py-1 text-xs font-medium text-amber-300 transition-colors hover:bg-amber-500/25 disabled:opacity-50"
>
- Keep my draft
+ {t('review.fileHeader.actions.keepMyDraft')}
)}
@@ -273,15 +277,15 @@ export const FileSectionHeader = ({
: 'bg-green-500/15 text-green-400 hover:bg-green-500/25',
].join(' ')}
>
- Accept
+ {t('review.fileHeader.actions.accept')}
{isPreviewOnly && (
{isContentUnavailable
- ? 'Accept/Reject is disabled because full text content is unavailable.'
- : 'Accept/Reject is disabled while the file is missing on disk.'}
+ ? t('review.fileHeader.disabled.acceptRejectContentUnavailable')
+ : t('review.fileHeader.disabled.acceptRejectMissingOnDisk')}
)}
@@ -300,19 +304,19 @@ export const FileSectionHeader = ({
: 'bg-red-500/15 text-red-400 hover:bg-red-500/25',
].join(' ')}
>
- Reject
+ {t('review.fileHeader.actions.reject')}
{rejectDisabled && (
{rejectBlockReason === 'manual-ledger-review'
- ? 'Reject is disabled because this ledger change has binary, large, or unavailable content.'
+ ? t('review.fileHeader.disabled.rejectManualLedgerReview')
: rejectBlockReason === 'content-unavailable'
- ? 'Reject is disabled because full text content is unavailable.'
+ ? t('review.fileHeader.disabled.rejectContentUnavailable')
: rejectBlockReason === 'missing-on-disk'
- ? 'Accept/Reject is disabled while the file is missing on disk.'
- : 'Reject is disabled because the original baseline is unavailable.'}
+ ? t('review.fileHeader.disabled.acceptRejectMissingOnDisk')
+ : t('review.fileHeader.disabled.rejectBaselineUnavailable')}
)}
@@ -328,11 +332,11 @@ export const FileSectionHeader = ({
className="flex items-center gap-1 rounded bg-blue-500/15 px-2 py-1 text-xs text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
>
- Restore
+ {t('review.fileHeader.actions.restore')}
- Create/restore this file on disk from the preview
+ {t('review.fileHeader.actions.restoreTooltip')}
)}
@@ -345,10 +349,12 @@ export const FileSectionHeader = ({
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
>
- Discard
+ {t('review.fileHeader.actions.discard')}
- Discard all edits for this file
+
+ {t('review.fileHeader.actions.discardTooltip')}
+
@@ -362,11 +368,11 @@ export const FileSectionHeader = ({
) : (
)}
- Save File
+ {t('review.fileHeader.actions.saveFile')}
- Save file to disk
+ {t('review.fileHeader.actions.saveFileTooltip')}
{shortcutLabel('⌘ S', 'Ctrl+S')}
diff --git a/src/renderer/components/team/review/FileSectionPlaceholder.tsx b/src/renderer/components/team/review/FileSectionPlaceholder.tsx
index 3c7dac55..b0de8c40 100644
--- a/src/renderer/components/team/review/FileSectionPlaceholder.tsx
+++ b/src/renderer/components/team/review/FileSectionPlaceholder.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { FileDiff, LoaderCircle } from 'lucide-react';
interface FileSectionPlaceholderProps {
@@ -8,32 +9,35 @@ interface FileSectionPlaceholderProps {
export const FileSectionPlaceholder = ({
fileName,
-}: FileSectionPlaceholderProps): React.ReactElement => (
-
-
-
-
-
-
-
-
{fileName}
-
-
- Loading
-
+}: FileSectionPlaceholderProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
+ return (
+
+
+
+
+
+
+
+ {fileName}
+
+
+ {t('review.filePlaceholder.loading')}
+
+
+
{t('review.filePlaceholder.description')}
-
Preparing a full editor diff for this file.
-
-
-);
+ );
+};
diff --git a/src/renderer/components/team/review/FullDiffLoadingBanner.tsx b/src/renderer/components/team/review/FullDiffLoadingBanner.tsx
index 9cbca533..758ba643 100644
--- a/src/renderer/components/team/review/FullDiffLoadingBanner.tsx
+++ b/src/renderer/components/team/review/FullDiffLoadingBanner.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Clock3, FileDiff, LoaderCircle, Sparkles } from 'lucide-react';
interface FullDiffLoadingBannerProps {
@@ -17,14 +18,17 @@ export const FullDiffLoadingBanner = ({
snippetCount,
activeFileName,
}: FullDiffLoadingBannerProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const title =
- loadingFilesCount === 1 ? 'Preparing Full Diff' : `Preparing ${loadingFilesCount} Full Diffs`;
+ loadingFilesCount === 1
+ ? t('review.fullDiffLoading.titleOne')
+ : t('review.fullDiffLoading.titleMany', { count: loadingFilesCount });
const subtitle =
loadingFilesCount === 1
? activeFileName
- ? `Finalizing the exact editor diff for ${activeFileName}.`
- : 'Finalizing the exact editor diff for the current file.'
- : 'Resolving exact before/after baselines for the files currently loading.';
+ ? t('review.fullDiffLoading.subtitleForFile', { file: activeFileName })
+ : t('review.fullDiffLoading.subtitleCurrentFile')
+ : t('review.fullDiffLoading.subtitleMany');
const showFileProgress = totalFilesCount > 1;
const progressPercent =
totalFilesCount > 0 ? Math.max(0, Math.min(100, (readyFilesCount / totalFilesCount) * 100)) : 0;
@@ -57,20 +61,23 @@ export const FullDiffLoadingBanner = ({
- {snippetCount} preview{snippetCount === 1 ? '' : 's'} ready
+ {t('review.fullDiffLoading.previewsReady', { count: snippetCount })}
- Editor view loading
+ {t('review.fullDiffLoading.editorViewLoading')}
- {loadingFilesCount} file{loadingFilesCount === 1 ? '' : 's'} in progress
+ {t('review.fullDiffLoading.filesInProgress', { count: loadingFilesCount })}
{showFileProgress ? (
- {readyFilesCount}/{totalFilesCount} files ready
+ {t('review.fullDiffLoading.filesReady', {
+ ready: readyFilesCount,
+ total: totalFilesCount,
+ })}
) : null}
@@ -91,8 +98,11 @@ export const FullDiffLoadingBanner = ({
{showFileProgress
- ? `${readyFilesCount} ready, ${loadingFilesCount} still loading. Preview diffs stay visible below while the remaining baselines are resolved.`
- : 'Preview diffs stay visible below while the exact baseline is resolved.'}
+ ? t('review.fullDiffLoading.progressDescription', {
+ ready: readyFilesCount,
+ loading: loadingFilesCount,
+ })
+ : t('review.fullDiffLoading.singleDescription')}
diff --git a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx
index 1bb9fc06..55b5d785 100644
--- a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx
+++ b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { IS_MAC } from '@renderer/utils/platformKeys';
import { X } from 'lucide-react';
@@ -13,29 +14,31 @@ const alt = IS_MAC ? '\u2325' : 'Alt';
const shift = IS_MAC ? '\u21E7' : 'Shift';
const shortcuts = [
- { keys: [`${alt}+J`], action: 'Next change' },
- { keys: [`${alt}+K`], action: 'Previous change' },
- { keys: [`${alt}+\u2193`], action: 'Next file' },
- { keys: [`${alt}+\u2191`], action: 'Previous file' },
- { keys: [`${mod}+Y`], action: 'Accept change' },
- { keys: [`${mod}+N`], action: 'Reject change' },
- { keys: [`${mod}+S`], action: 'Save file' },
- { keys: [`${mod}+Z`], action: 'Undo' },
- { keys: [`${mod}+${shift}+Z`], action: 'Redo' },
- { keys: ['?'], action: 'Toggle shortcuts' },
- { keys: ['Esc'], action: 'Close dialog' },
-];
+ { keys: [`${alt}+J`], actionKey: 'nextChange' },
+ { keys: [`${alt}+K`], actionKey: 'previousChange' },
+ { keys: [`${alt}+\u2193`], actionKey: 'nextFile' },
+ { keys: [`${alt}+\u2191`], actionKey: 'previousFile' },
+ { keys: [`${mod}+Y`], actionKey: 'acceptChange' },
+ { keys: [`${mod}+N`], actionKey: 'rejectChange' },
+ { keys: [`${mod}+S`], actionKey: 'saveFile' },
+ { keys: [`${mod}+Z`], actionKey: 'undo' },
+ { keys: [`${mod}+${shift}+Z`], actionKey: 'redo' },
+ { keys: ['?'], actionKey: 'toggleShortcuts' },
+ { keys: ['Esc'], actionKey: 'closeDialog' },
+] as const;
export const KeyboardShortcutsHelp = ({
open,
onOpenChange,
}: KeyboardShortcutsHelpProps): React.ReactElement | null => {
+ const { t } = useAppTranslation('team');
+
if (!open) return null;
return (
- Keyboard Shortcuts
+ {t('review.shortcuts.title')}
onOpenChange(false)}
className="rounded p-0.5 text-text-muted hover:bg-surface-raised hover:text-text"
@@ -44,9 +47,11 @@ export const KeyboardShortcutsHelp = ({
- {shortcuts.map(({ keys, action }) => (
-
-
{action}
+ {shortcuts.map(({ keys, actionKey }) => (
+
+
+ {t(`review.shortcuts.actions.${actionKey}` as const)}
+
{keys.map((key) => (
void;
pathChangeLabels?: ReviewFileTreeProps['pathChangeLabels'];
}): JSX.Element => {
+ const { t } = useAppTranslation('team');
if (node.isFile && node.data) {
const isSelected = node.data.filePath === selectedFilePath;
const isActive = node.data.filePath === activeFilePath && !isSelected;
@@ -160,7 +162,7 @@ const TreeItem = ({
- Viewed
+ {t('review.fileTree.viewed')}
)}
{node.data.isNewFile && (
- new
+ {t('review.fileTree.badges.new')}
)}
{label?.kind === 'deleted' && (
- deleted
+ {t('review.fileTree.badges.deleted')}
)}
{label && label.kind !== 'deleted' && (
@@ -208,7 +210,11 @@ const TreeItem = ({
onClick={() => onToggleFolder(node.fullPath)}
className="flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
- aria-label={isOpen ? `Collapse ${node.name}` : `Expand ${node.name}`}
+ aria-label={
+ isOpen
+ ? t('review.fileTree.collapseFolder', { name: node.name })
+ : t('review.fileTree.expandFolder', { name: node.name })
+ }
>
{
+ const { t } = useAppTranslation('team');
const hunkDecisions = useStore((state) => state.hunkDecisions);
const fileDecisions = useStore((state) => state.fileDecisions);
const fileChunkCounts = useStore((state) => state.fileChunkCounts);
@@ -385,7 +392,11 @@ export const ReviewFileTree = ({
}, [activeFilePath]);
if (files.length === 0) {
- return No changed files
;
+ return (
+
+ {t('review.fileTree.empty.noChangedFiles')}
+
+ );
}
return (
@@ -396,7 +407,7 @@ export const ReviewFileTree = ({
setQuery(e.target.value)}
- placeholder="Search files…"
+ placeholder={t('review.fileTree.searchPlaceholder')}
className="h-8 w-full rounded border border-border bg-surface px-7 text-xs text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500/30"
/>
@@ -412,7 +423,7 @@ export const ReviewFileTree = ({
: 'bg-surface-raised text-text-muted hover:text-text'
)}
>
- Unresolved
+ {t('review.fileTree.filters.unresolved')}
- Rejected
+ {t('review.fileTree.filters.rejected')}
- New
+ {t('review.fileTree.filters.new')}
{(filterUnresolved || filterRejected || filterNew || normalizedQuery.length > 0) && (
- Clear
+ {t('review.fileTree.filters.clear')}
)}
{filteredFiles.length === 0 ? (
-
No matching files
+
+ {t('review.fileTree.empty.noMatchingFiles')}
+
) : (
{sortTreeNodes(tree).map((node) => (
diff --git a/src/renderer/components/team/review/ReviewToolbar.tsx b/src/renderer/components/team/review/ReviewToolbar.tsx
index 6ec4f3b3..54d049f7 100644
--- a/src/renderer/components/team/review/ReviewToolbar.tsx
+++ b/src/renderer/components/team/review/ReviewToolbar.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { Check, Eye, EyeOff, GitMerge, Loader2, Pencil, Undo2, X } from 'lucide-react';
@@ -41,6 +42,7 @@ export const ReviewToolbar = ({
canUndo = false,
onUndo,
}: ReviewToolbarProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const hasRejected = stats.rejected > 0;
const canApply = hasRejected && !applying;
const totalChanges = stats.pending + stats.accepted + stats.rejected;
@@ -53,19 +55,19 @@ export const ReviewToolbar = ({
{stats.pending > 0 && (
- {stats.pending} pending
+ {t('review.toolbar.stats.pending', { count: stats.pending })}
)}
{stats.accepted > 0 && (
- {stats.accepted} accepted
+ {t('review.toolbar.stats.accepted', { count: stats.accepted })}
)}
{stats.rejected > 0 && (
- {stats.rejected} rejected
+ {t('review.toolbar.stats.rejected', { count: stats.rejected })}
)}
@@ -74,7 +76,9 @@ export const ReviewToolbar = ({
+{changeStats.linesAdded}
-{changeStats.linesRemoved}
- across {changeStats.filesChanged} files
+
+ {t('review.toolbar.stats.acrossFiles', { count: changeStats.filesChanged })}
+
{/* Review progress */}
@@ -125,13 +129,11 @@ export const ReviewToolbar = ({
)}
>
{autoViewed ?
:
}
-
Auto
+
{t('review.toolbar.actions.auto')}
- {autoViewed
- ? 'Auto-mark files as viewed when scrolled to end (ON)'
- : 'Auto-mark files as viewed when scrolled to end (OFF)'}
+ {autoViewed ? t('review.toolbar.tooltips.autoOn') : t('review.toolbar.tooltips.autoOff')}
@@ -139,7 +141,7 @@ export const ReviewToolbar = ({
{editedCount > 0 && (
- {editedCount} edited
+ {t('review.toolbar.stats.edited', { count: editedCount })}
)}
@@ -153,10 +155,10 @@ export const ReviewToolbar = ({
className="flex items-center gap-1 rounded bg-zinc-500/15 px-2.5 py-1 text-xs text-zinc-300 transition-colors hover:bg-zinc-500/25"
>
- Undo
+ {t('review.toolbar.actions.undo')}
-
Undo last review operation (Ctrl+Z)
+
{t('review.toolbar.tooltips.undo')}
)}
@@ -170,10 +172,10 @@ export const ReviewToolbar = ({
className="flex items-center gap-1 rounded bg-green-500/15 px-2.5 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25"
>
- Accept All
+ {t('review.toolbar.actions.acceptAll')}
-
Accept all changes across all files
+
{t('review.toolbar.tooltips.acceptAll')}
@@ -190,14 +192,14 @@ export const ReviewToolbar = ({
)}
>
- Reject All
+ {t('review.toolbar.actions.rejectAll')}
{canRejectAll
- ? 'Reject all safely rejectable changes across all files'
- : 'No pending files have a safe original baseline to reject.'}
+ ? t('review.toolbar.tooltips.rejectAll')
+ : t('review.toolbar.tooltips.rejectAllDisabled')}
>
@@ -221,11 +223,13 @@ export const ReviewToolbar = ({
) : (
)}
- {applying ? 'Applying...' : 'Apply Rejections'}
+ {applying
+ ? t('review.toolbar.actions.applying')
+ : t('review.toolbar.actions.applyRejections')}
- Apply rejected hunks to disk; accepted changes are kept as-is
+ {t('review.toolbar.tooltips.applyRejections')}
)}
diff --git a/src/renderer/components/team/review/ScopeWarningBanner.tsx b/src/renderer/components/team/review/ScopeWarningBanner.tsx
index 3d850ce7..922c260b 100644
--- a/src/renderer/components/team/review/ScopeWarningBanner.tsx
+++ b/src/renderer/components/team/review/ScopeWarningBanner.tsx
@@ -1,5 +1,6 @@
import { type JSX, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react';
@@ -25,52 +26,48 @@ interface TierConfig {
badgeLabel?: string;
}
-const TIER_CONFIGS: Record
= {
- 1: {
- Icon: ShieldCheck,
- border: 'border-emerald-500/15',
- bg: 'bg-emerald-500/5',
- accentColor: 'text-emerald-400',
- title: 'Task scope determined precisely',
- detail:
- 'Both start and completion markers found in the session log. The diff includes only changes made during this specific task - other tasks that modified the same files are excluded.',
- },
- 2: {
- Icon: Info,
- border: 'border-blue-500/15',
- bg: 'bg-blue-500/5',
- accentColor: 'text-blue-400',
- title: 'End boundary estimated',
- detail:
- 'Only the start marker was found - the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included.',
- },
- 3: {
- Icon: AlertTriangle,
- border: 'border-orange-500/20',
- bg: 'bg-orange-500/5',
- accentColor: 'text-orange-400',
- title: 'Start boundary estimated',
- detail:
- 'Only the completion marker was found - the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included.',
- },
- 4: {
- Icon: AlertTriangle,
- border: 'border-red-500/20',
- bg: 'bg-red-500/5',
- accentColor: 'text-red-400',
- title: 'Showing all session changes',
- detail:
- 'No task markers found in the session log. Cannot isolate this task - all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows.',
- },
-};
-
export const ScopeWarningBanner = ({
warnings,
confidence,
sourceKind = 'legacy',
onDismiss,
}: ScopeWarningBannerProps): JSX.Element => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(false);
+ const tierConfigs: Record = {
+ 1: {
+ Icon: ShieldCheck,
+ border: 'border-emerald-500/15',
+ bg: 'bg-emerald-500/5',
+ accentColor: 'text-emerald-400',
+ title: t('review.scope.tiers.exact.title'),
+ detail: t('review.scope.tiers.exact.detail'),
+ },
+ 2: {
+ Icon: Info,
+ border: 'border-blue-500/15',
+ bg: 'bg-blue-500/5',
+ accentColor: 'text-blue-400',
+ title: t('review.scope.tiers.endEstimated.title'),
+ detail: t('review.scope.tiers.endEstimated.detail'),
+ },
+ 3: {
+ Icon: AlertTriangle,
+ border: 'border-orange-500/20',
+ bg: 'bg-orange-500/5',
+ accentColor: 'text-orange-400',
+ title: t('review.scope.tiers.startEstimated.title'),
+ detail: t('review.scope.tiers.startEstimated.detail'),
+ },
+ 4: {
+ Icon: AlertTriangle,
+ border: 'border-red-500/20',
+ bg: 'bg-red-500/5',
+ accentColor: 'text-red-400',
+ title: t('review.scope.tiers.allSession.title'),
+ detail: t('review.scope.tiers.allSession.detail'),
+ },
+ };
const ledgerConfig: TierConfig | null =
sourceKind === 'ledger'
? {
@@ -95,18 +92,18 @@ export const ScopeWarningBanner = ({
: 'text-orange-400',
title:
confidence.tier <= 1
- ? 'Changes captured by task ledger'
- : 'Changes captured with limited reviewability',
+ ? t('review.scope.ledger.exact.title')
+ : t('review.scope.ledger.limited.title'),
detail:
confidence.tier <= 1
- ? 'The orchestrator captured these file changes while the agent was working on this task.'
- : 'The orchestrator captured these file changes for this task, but at least one change was captured from a snapshot or metadata-only source. Review exact text diffs where available; binary or unavailable content may require manual review.',
+ ? t('review.scope.ledger.exact.detail')
+ : t('review.scope.ledger.limited.detail'),
badgeLabel:
confidence.tier <= 1
- ? 'Ledger exact'
+ ? t('review.scope.ledger.exact.badge')
: confidence.tier === 2
- ? 'Mixed reviewability'
- : 'Needs review',
+ ? t('review.scope.ledger.limited.mixedBadge')
+ : t('review.scope.ledger.limited.needsReviewBadge'),
}
: null;
const workIntervalConfig: TierConfig | null =
@@ -116,14 +113,13 @@ export const ScopeWarningBanner = ({
border: 'border-blue-500/15',
bg: 'bg-blue-500/5',
accentColor: 'text-blue-400',
- title: 'Scoped by persisted work interval',
- detail:
- 'The task start marker was not available in the session log, so the diff is scoped by the task work interval stored on the board.',
- badgeLabel: 'Interval scoped',
+ title: t('review.scope.workInterval.title'),
+ detail: t('review.scope.workInterval.detail'),
+ badgeLabel: t('review.scope.workInterval.badge'),
}
: null;
const config =
- ledgerConfig ?? workIntervalConfig ?? TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
+ ledgerConfig ?? workIntervalConfig ?? tierConfigs[confidence.tier] ?? tierConfigs[4];
const { Icon } = config;
return (
@@ -135,7 +131,7 @@ export const ScopeWarningBanner = ({
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-0.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
>
- Read more
+ {t('review.scope.readMore')}
diff --git a/src/renderer/components/team/review/ViewedProgressBar.tsx b/src/renderer/components/team/review/ViewedProgressBar.tsx
index b4dca702..e9054823 100644
--- a/src/renderer/components/team/review/ViewedProgressBar.tsx
+++ b/src/renderer/components/team/review/ViewedProgressBar.tsx
@@ -1,3 +1,5 @@
+import { useAppTranslation } from '@features/localization/renderer';
+
interface ViewedProgressBarProps {
viewed: number;
total: number;
@@ -5,6 +7,8 @@ interface ViewedProgressBarProps {
}
export const ViewedProgressBar = ({ viewed, total, progress }: ViewedProgressBarProps) => {
+ const { t } = useAppTranslation('team');
+
if (total === 0) return null;
return (
@@ -15,9 +19,7 @@ export const ViewedProgressBar = ({ viewed, total, progress }: ViewedProgressBar
style={{ width: `${progress}%` }}
/>
-
- {viewed}/{total} viewed
-
+
{t('review.progress.viewed', { viewed, total })}
);
};
diff --git a/src/renderer/components/team/schedule/CronScheduleInput.tsx b/src/renderer/components/team/schedule/CronScheduleInput.tsx
index cb9a1062..e455552d 100644
--- a/src/renderer/components/team/schedule/CronScheduleInput.tsx
+++ b/src/renderer/components/team/schedule/CronScheduleInput.tsx
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
@@ -33,11 +34,11 @@ const TIMEZONE_PRESETS = [
] as const;
const WARMUP_OPTIONS = [
- { value: 0, label: 'No warm-up' },
- { value: 5, label: '5 min' },
- { value: 10, label: '10 min' },
- { value: 15, label: '15 min' },
- { value: 30, label: '30 min' },
+ { value: 0, labelKey: 'none' },
+ { value: 5, labelKey: 'fiveMinutes' },
+ { value: 10, labelKey: 'tenMinutes' },
+ { value: 15, labelKey: 'fifteenMinutes' },
+ { value: 30, labelKey: 'thirtyMinutes' },
] as const;
// =============================================================================
@@ -45,12 +46,12 @@ const WARMUP_OPTIONS = [
// =============================================================================
const CRON_PRESETS = [
- { label: 'Every hour', cron: '0 * * * *' },
- { label: 'Every 6 hours', cron: '0 */6 * * *' },
- { label: 'Daily at 9am', cron: '0 9 * * *' },
- { label: 'Weekdays at 9am', cron: '0 9 * * 1-5' },
- { label: 'Monday at 9am', cron: '0 9 * * 1' },
- { label: 'Every 30 min', cron: '*/30 * * * *' },
+ { labelKey: 'everyHour', cron: '0 * * * *' },
+ { labelKey: 'everySixHours', cron: '0 */6 * * *' },
+ { labelKey: 'dailyAtNine', cron: '0 9 * * *' },
+ { labelKey: 'weekdaysAtNine', cron: '0 9 * * 1-5' },
+ { labelKey: 'mondayAtNine', cron: '0 9 * * 1' },
+ { labelKey: 'everyThirtyMinutes', cron: '*/30 * * * *' },
] as const;
// =============================================================================
@@ -78,10 +79,16 @@ export const CronScheduleInput = ({
warmUpMinutes,
onWarmUpMinutesChange,
}: CronScheduleInputProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
// Parse and validate cron expression
const cronInfo = useMemo(() => {
if (!cronExpression.trim()) {
- return { valid: false, description: null, nextRuns: [], error: 'Enter a cron expression' };
+ return {
+ valid: false,
+ description: null,
+ nextRuns: [],
+ error: t('schedule.cron.errors.enterExpression'),
+ };
}
try {
@@ -115,7 +122,7 @@ export const CronScheduleInput = ({
valid: false,
description: null,
nextRuns: [],
- error: err instanceof Error ? err.message : 'Invalid cron expression',
+ error: err instanceof Error ? err.message : t('schedule.cron.errors.invalidExpression'),
};
}
}, [cronExpression, timezone]);
@@ -138,7 +145,7 @@ export const CronScheduleInput = ({
- Cron expression
+ {t('schedule.cron.expression')}
@@ -159,7 +166,7 @@ export const CronScheduleInput = ({
className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-[10px] text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
onClick={() => onCronExpressionChange(preset.cron)}
>
- {preset.label}
+ {t(`schedule.cron.presets.${preset.labelKey}`)}
))}
@@ -182,7 +189,7 @@ export const CronScheduleInput = ({
style={{ color: 'var(--warning-text)' }}
>
-
High frequency schedule (less than 5 min interval)
+
{t('schedule.cron.highFrequencyWarning')}
) : null}
@@ -191,7 +198,7 @@ export const CronScheduleInput = ({
{cronInfo.valid && cronInfo.nextRuns.length > 0 ? (
- Next runs:
+ {t('schedule.cron.nextRuns')}
{cronInfo.nextRuns.map((run, i) => (
@@ -211,11 +218,11 @@ export const CronScheduleInput = ({
- Timezone
+ {t('schedule.cron.timezone')}
-
+
{TIMEZONE_PRESETS.map((tz) => (
@@ -229,7 +236,7 @@ export const CronScheduleInput = ({
{/* Warm-up time */}
-
Warm-up time
+
{t('schedule.cron.warmUpTime')}
onWarmUpMinutesChange(Number(val))}
@@ -240,13 +247,13 @@ export const CronScheduleInput = ({
{WARMUP_OPTIONS.map((opt) => (
- {opt.label}
+ {t(`schedule.cron.warmUpOptions.${opt.labelKey}`)}
))}
- Prepares selected providers before scheduled execution
+ {t('schedule.cron.warmUpDescription')}
diff --git a/src/renderer/components/team/schedule/ScheduleEmptyState.tsx b/src/renderer/components/team/schedule/ScheduleEmptyState.tsx
index b66aa67f..a4c587ee 100644
--- a/src/renderer/components/team/schedule/ScheduleEmptyState.tsx
+++ b/src/renderer/components/team/schedule/ScheduleEmptyState.tsx
@@ -1,15 +1,21 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Calendar } from 'lucide-react';
-export const ScheduleEmptyState = (): React.JSX.Element => (
-
-
-
-
No schedules yet
-
- Create a schedule to run Claude tasks automatically on a cron schedule.
-
+export const ScheduleEmptyState = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ return (
+
+
+
+
+ {t('schedule.empty.title')}
+
+
+ {t('schedule.empty.description')}
+
+
-
-);
+ );
+};
diff --git a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx
index e26cc6c2..4a9e9e99 100644
--- a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx
+++ b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
@@ -72,6 +73,7 @@ export const ScheduleRunLogDialog = ({
scheduleId,
onClose,
}: ScheduleRunLogDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
// Read live run data from store — falls back to initial prop if not found
const liveRun = useStore(
useShallow((s) => {
@@ -145,7 +147,7 @@ export const ScheduleRunLogDialog = ({
- Run Log
+ {t('schedule.runLog.title')}
@@ -166,12 +168,14 @@ export const ScheduleRunLogDialog = ({
- exit {run.exitCode}
+ {t('schedule.runLog.exitCode', { code: run.exitCode })}
) : null}
{run.retryCount > 0 ? (
-
retry {run.retryCount}/2
+
+ {t('schedule.runLog.retryCount', { count: run.retryCount, max: 2 })}
+
) : null}
@@ -181,7 +185,7 @@ export const ScheduleRunLogDialog = ({
{isRunning ? (
- Task is still running...
+ {t('schedule.runLog.stillRunning')}
{run.summary ? (
{run.summary}
) : null}
@@ -192,7 +196,7 @@ export const ScheduleRunLogDialog = ({
{loading ? (
- Loading logs...
+ {t('schedule.runLog.loadingLogs')}
) : null}
@@ -216,7 +220,9 @@ export const ScheduleRunLogDialog = ({
{/* Stderr */}
{hasStderr ? (
-
Errors
+
+ {t('schedule.runLog.errors')}
+
{logs.stderr}
@@ -236,7 +242,7 @@ export const ScheduleRunLogDialog = ({
- Close
+ {t('schedule.runLog.close')}
diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx
index 2a755123..8375e9c3 100644
--- a/src/renderer/components/team/schedule/ScheduleSection.tsx
+++ b/src/renderer/components/team/schedule/ScheduleSection.tsx
@@ -1,5 +1,6 @@
import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -58,6 +59,7 @@ const ScheduleRow = ({
onResume,
onTriggerNow,
}: ScheduleRowProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(false);
const [selectedRun, setSelectedRun] = useState
(null);
const runs = useStore(useShallow((s) => s.scheduleRuns[schedule.id] ?? []));
@@ -97,7 +99,9 @@ const ScheduleRow = ({
{schedule.label ? {getCronDescription(schedule.cronExpression)} : null}
- Next: {formatNextRun(schedule.nextRunAt)}
+
+ {t('schedule.nextRun', { next: formatNextRun(schedule.nextRunAt) })}
+
{schedule.nextRunAt ? (
@@ -123,7 +127,7 @@ const ScheduleRow = ({
- Run now
+ {t('schedule.actions.runNow')}
@@ -139,7 +143,7 @@ const ScheduleRow = ({
onClick={() => onEdit(schedule)}
>
- Edit
+ {t('schedule.actions.edit')}
{schedule.status === 'active' ? (
onPause(schedule.id)}
>
- Pause
+ {t('schedule.actions.pause')}
) : (
onResume(schedule.id)}
>
- Resume
+ {t('schedule.actions.resume')}
)}
onDelete(schedule.id)}
>
- Delete
+ {t('schedule.actions.delete')}
@@ -178,11 +182,11 @@ const ScheduleRow = ({
{runsLoading ? (
- Loading run history...
+ {t('schedule.runHistory.loading')}
) : runs.length === 0 ? (
- No runs yet
+ {t('schedule.runHistory.empty')}
) : (
@@ -210,6 +214,7 @@ const ScheduleRow = ({
// =============================================================================
export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { schedules, pauseSchedule, resumeSchedule, deleteSchedule, triggerNow, fetchSchedules } =
useStore(
useShallow((s) => ({
@@ -272,9 +277,7 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
{/* Header with create button */}
- {schedules.length > 0
- ? `${schedules.length} schedule${schedules.length > 1 ? 's' : ''}`
- : ''}
+ {schedules.length > 0 ? t('schedule.count', { count: schedules.length }) : ''}
- Add Schedule
+ {t('schedule.actions.addSchedule')}
diff --git a/src/renderer/components/team/schedule/ScheduleStatusBadge.tsx b/src/renderer/components/team/schedule/ScheduleStatusBadge.tsx
index bc991958..a9a90310 100644
--- a/src/renderer/components/team/schedule/ScheduleStatusBadge.tsx
+++ b/src/renderer/components/team/schedule/ScheduleStatusBadge.tsx
@@ -1,31 +1,49 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
import type { ScheduleRunStatus, ScheduleStatus } from '@shared/types';
// =============================================================================
// Schedule Status Badge
// =============================================================================
-const SCHEDULE_STATUS_CONFIG: Record
= {
+const SCHEDULE_STATUS_CONFIG: Record = {
active: {
- label: 'Active',
+ labelKey: 'schedule.status.active',
className: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
},
- paused: { label: 'Paused', className: 'bg-amber-500/15 text-amber-400 border-amber-500/20' },
- disabled: { label: 'Disabled', className: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/20' },
+ paused: {
+ labelKey: 'schedule.status.paused',
+ className: 'bg-amber-500/15 text-amber-400 border-amber-500/20',
+ },
+ disabled: {
+ labelKey: 'schedule.status.disabled',
+ className: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/20',
+ },
};
+function getScheduleStatusLabel(
+ status: ScheduleStatus,
+ t: ReturnType['t']
+): string {
+ if (status === 'active') return t('schedule.status.active');
+ if (status === 'paused') return t('schedule.status.paused');
+ return t('schedule.status.disabled');
+}
+
interface ScheduleStatusBadgeProps {
status: ScheduleStatus;
}
export const ScheduleStatusBadge = ({ status }: ScheduleStatusBadgeProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const config = SCHEDULE_STATUS_CONFIG[status];
return (
- {config.label}
+ {getScheduleStatusLabel(status, t)}
);
};
@@ -34,22 +52,53 @@ export const ScheduleStatusBadge = ({ status }: ScheduleStatusBadgeProps): React
// Run Status Badge
// =============================================================================
-const RUN_STATUS_CONFIG: Record = {
- pending: { label: 'Pending', className: 'text-zinc-400' },
- warming_up: { label: 'Warming up', className: 'text-blue-400' },
- warm: { label: 'Warm', className: 'text-cyan-400' },
- running: { label: 'Running', className: 'text-emerald-400' },
- completed: { label: 'Completed', className: 'text-emerald-400' },
- failed: { label: 'Failed', className: 'text-red-400' },
- failed_interrupted: { label: 'Interrupted', className: 'text-amber-400' },
- cancelled: { label: 'Cancelled', className: 'text-zinc-400' },
+const RUN_STATUS_CONFIG: Record = {
+ pending: { labelKey: 'schedule.runStatus.pending', className: 'text-zinc-400' },
+ warming_up: { labelKey: 'schedule.runStatus.warmingUp', className: 'text-blue-400' },
+ warm: { labelKey: 'schedule.runStatus.warm', className: 'text-cyan-400' },
+ running: { labelKey: 'schedule.runStatus.running', className: 'text-emerald-400' },
+ completed: { labelKey: 'schedule.runStatus.completed', className: 'text-emerald-400' },
+ failed: { labelKey: 'schedule.runStatus.failed', className: 'text-red-400' },
+ failed_interrupted: { labelKey: 'schedule.runStatus.interrupted', className: 'text-amber-400' },
+ cancelled: { labelKey: 'schedule.runStatus.cancelled', className: 'text-zinc-400' },
};
+function getRunStatusLabel(
+ status: ScheduleRunStatus,
+ t: ReturnType['t']
+): string {
+ switch (status) {
+ case 'pending':
+ return t('schedule.runStatus.pending');
+ case 'warming_up':
+ return t('schedule.runStatus.warmingUp');
+ case 'warm':
+ return t('schedule.runStatus.warm');
+ case 'running':
+ return t('schedule.runStatus.running');
+ case 'completed':
+ return t('schedule.runStatus.completed');
+ case 'failed':
+ return t('schedule.runStatus.failed');
+ case 'failed_interrupted':
+ return t('schedule.runStatus.interrupted');
+ case 'cancelled':
+ return t('schedule.runStatus.cancelled');
+ default:
+ return status;
+ }
+}
+
interface RunStatusBadgeProps {
status: ScheduleRunStatus;
}
export const RunStatusBadge = ({ status }: RunStatusBadgeProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const config = RUN_STATUS_CONFIG[status];
- return {config.label} ;
+ return (
+
+ {getRunStatusLabel(status, t)}
+
+ );
};
diff --git a/src/renderer/components/team/session-injection-types.ts b/src/renderer/components/team/session-injection-types.ts
new file mode 100644
index 00000000..6b78f779
--- /dev/null
+++ b/src/renderer/components/team/session-injection-types.ts
@@ -0,0 +1 @@
+export type { ContextInjection as SessionInjection } from '@renderer/types/contextInjection';
diff --git a/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx b/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx
index a7b240ba..0986a54c 100644
--- a/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx
+++ b/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { ChevronDown, ChevronRight, Clock, FileText, Loader2 } from 'lucide-react';
@@ -67,6 +68,7 @@ export const ExactTaskLogCard = ({
detailState,
onToggle,
}: ExactTaskLogCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const loadStateText = describeDetailState(detailState);
return (
@@ -101,7 +103,7 @@ export const ExactTaskLogCard = ({
{formatRelativeTime(summary.timestamp)}
{anchorKindLabel(summary)}
- {!summary.canLoadDetail ? summary only : null}
+ {!summary.canLoadDetail ? {t('taskLogs.exact.summaryOnly')} : null}
@@ -111,7 +113,7 @@ export const ExactTaskLogCard = ({
{detailState?.status === 'loading' ? (
- Loading exact task logs...
+ {t('taskLogs.exact.loading')}
) : null}
{detailState?.status === 'ok' && detailState.chunks ? (
diff --git a/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx b/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx
index 732b2cb3..4a584c98 100644
--- a/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx
+++ b/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { AlertCircle, FileText, Loader2 } from 'lucide-react';
@@ -17,6 +18,7 @@ export const ExactTaskLogsSection = ({
teamName,
taskId,
}: ExactTaskLogsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [summaries, setSummaries] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -197,12 +199,12 @@ export const ExactTaskLogsSection = ({
- Exact Task Logs
+ {t('taskLogs.exact.title')}
- Loading exact task logs...
+ {t('taskLogs.exact.loading')}
);
@@ -213,7 +215,7 @@ export const ExactTaskLogsSection = ({
- Exact Task Logs
+ {t('taskLogs.exact.title')}
@@ -228,21 +230,16 @@ export const ExactTaskLogsSection = ({
- Exact Task Logs
+ {t('taskLogs.exact.title')}
-
- Exact transcript slices rendered with the same execution-log components used in Logs.
-
+
{t('taskLogs.exact.description')}
{visibleSummaries.length === 0 ? (
- No exact task logs yet
-
- Exact transcript bundles will appear here when explicit task-linked transcript metadata
- is available.
-
+ {t('taskLogs.exact.emptyTitle')}
+
{t('taskLogs.exact.emptyDescription')}
) : (
diff --git a/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx b/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx
index 8249c8c5..096298b5 100644
--- a/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx
+++ b/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Loader2 } from 'lucide-react';
@@ -13,18 +14,19 @@ export const ExecutionSessionsSection = ({
isPreviewOnline = false,
...props
}: ExecutionSessionsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
return (
- Execution Sessions
+ {t('taskLogs.executionSessions.title')}
{isRefreshing || isPreviewOnline ? (
{isPreviewOnline ? (
@@ -33,14 +35,14 @@ export const ExecutionSessionsSection = ({
{isRefreshing ? (
- Updating...
+ {t('taskLogs.executionSessions.updating')}
) : null}
) : null}
- Legacy session-centric transcript browsing and previews.
+ {t('taskLogs.executionSessions.description')}
diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx
index e41ec239..ef4a2e65 100644
--- a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx
+++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx
@@ -1,5 +1,6 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
@@ -163,11 +164,13 @@ const ActivityMetadata = ({ detail }: ActivityMetadataProps): React.JSX.Element
};
const ActivityDetailPanel = ({ detailState }: ActivityDetailPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
if (detailState.status === 'loading') {
return (
- Loading activity details...
+ {t('taskActivity.loadingDetails')}
);
}
@@ -184,7 +187,7 @@ const ActivityDetailPanel = ({ detailState }: ActivityDetailPanelProps): React.J
if (detailState.status === 'missing') {
return (
- Detailed transcript context is no longer available for this activity.
+ {t('taskActivity.contextUnavailable')}
);
}
@@ -265,6 +268,7 @@ export const TaskActivitySection = ({
taskId,
enabled = true,
}: TaskActivitySectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [detailStates, setDetailStates] = useState
>({});
const [entries, setEntries] = useState([]);
const [expandedId, setExpandedId] = useState(null);
@@ -400,7 +404,7 @@ export const TaskActivitySection = ({
return (
- Loading task activity...
+ {t('taskActivity.loading')}
);
}
@@ -417,9 +421,7 @@ export const TaskActivitySection = ({
if (visibleEntries.length === 0) {
return (
- {hasOnlyLowSignalExecution
- ? 'No key task activity was found yet. Low-level execution details are available below in Task Log Stream.'
- : 'No explicit task activity was found in the available transcripts yet. Older or heuristic session logs may still be available below in Execution Sessions.'}
+ {hasOnlyLowSignalExecution ? t('taskActivity.lowSignalOnly') : t('taskActivity.empty')}
);
}
@@ -451,12 +453,10 @@ export const TaskActivitySection = ({
- Task Activity
+ {t('taskActivity.title')}
-
- Key explicit runtime activity linked to this task from transcript metadata.
-
+
{t('taskActivity.description')}
{content}
);
diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx
index 80c4638f..c9467782 100644
--- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx
+++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
ExecutionLogStreamView,
normalizeExecutionLogStream,
@@ -54,6 +55,7 @@ export const TaskLogStreamSection = ({
taskStatus,
liveEnabled = true,
}: TaskLogStreamSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [stream, setStream] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -179,7 +181,7 @@ export const TaskLogStreamSection = ({
return (
{
+ const { t } = useAppTranslation('team');
const [ownerFilter, setOwnerFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
@@ -36,7 +39,7 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
if (tasks.length === 0) {
return (
- No tasks in this team
+ {t('tasks.list.empty')}
);
}
@@ -47,10 +50,10 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
setOwnerFilter(event.target.value)}
>
- All owners
+ {t('tasks.list.filters.allOwners')}
{ownerOptions.map((owner) => (
{owner}
@@ -61,19 +64,19 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
setStatusFilter(event.target.value)}
>
- All statuses
- pending
- in_progress
- completed
- deleted
+ {t('tasks.list.filters.allStatuses')}
+ {t('tasks.status.pending')}
+ {t('tasks.status.inProgress')}
+ {t('tasks.status.completed')}
+ {t('tasks.status.deleted')}
) : null}
{ownerFilter !== 'all' || statusFilter !== 'all' ? (
- Showing {filteredTasks.length} of {tasks.length}
+ {t('tasks.list.showing', { shown: filteredTasks.length, total: tasks.length })}
) : null}
@@ -81,22 +84,22 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
- ID
+ {t('tasks.list.columns.id')}
- Subject
+ {t('tasks.list.columns.subject')}
- Owner
+ {t('tasks.list.columns.owner')}
- Status
+ {t('tasks.list.columns.status')}
- Blocked By
+ {t('tasks.list.columns.blockedBy')}
- Blocks
+ {t('tasks.list.columns.blocks')}
diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts
index aeb3839c..040b220a 100644
--- a/src/renderer/components/team/useTeamProvisioningPresentation.ts
+++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
@@ -22,6 +23,7 @@ export function useTeamProvisioningPresentation(teamName: string): {
memberDiagnostics: MemberLaunchDiagnosticsPayload[];
runInstanceKey: string | null;
} {
+ const { t } = useAppTranslation('team');
const {
progress,
cancelProvisioning,
@@ -49,8 +51,9 @@ export function useTeamProvisioningPresentation(teamName: string): {
members: teamMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
+ t,
}),
- [memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers]
+ [memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers, t]
);
const memberDiagnostics = useMemo(
() =>
diff --git a/src/renderer/components/terminal/TerminalModal.tsx b/src/renderer/components/terminal/TerminalModal.tsx
index 88c8dc37..b5b1de54 100644
--- a/src/renderer/components/terminal/TerminalModal.tsx
+++ b/src/renderer/components/terminal/TerminalModal.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { CheckCircle, Terminal, X, XCircle } from 'lucide-react';
import { EmbeddedTerminal } from './EmbeddedTerminal';
@@ -29,7 +30,7 @@ interface TerminalModalProps {
}
export function TerminalModal({
- title = 'Terminal',
+ title,
command,
args,
cwd,
@@ -37,12 +38,16 @@ export function TerminalModal({
onClose,
onExit,
autoCloseOnSuccessMs = 0,
- successMessage = 'Completed successfully',
- failureMessage = 'Process failed',
+ successMessage,
+ failureMessage,
}: TerminalModalProps): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const [exited, setExited] = useState
(null);
const [countdown, setCountdown] = useState(0);
const dialogRef = useRef(null);
+ const resolvedTitle = title ?? t('terminal.title');
+ const resolvedSuccessMessage = successMessage ?? t('terminal.completedSuccessfully');
+ const resolvedFailureMessage = failureMessage ?? t('terminal.processFailed');
const handleExit = useCallback(
(exitCode: number): void => {
@@ -97,7 +102,7 @@ export function TerminalModal({
- {title}
+ {resolvedTitle}
@@ -139,9 +144,13 @@ export function TerminalModal({
- {successMessage}
+
+ {resolvedSuccessMessage}
+
{countdown > 0 && (
- Closing in {countdown}s...
+
+ {t('terminal.closingInSeconds', { count: countdown })}
+
)}
@@ -150,11 +159,13 @@ export function TerminalModal({
- {failureMessage}{' '}
- (exit code {exited})
+ {resolvedFailureMessage}{' '}
+
+ {t('terminal.exitCode', { code: exited })}
+
- Check terminal output above for details
+ {t('terminal.checkOutputForDetails')}
@@ -163,7 +174,7 @@ export function TerminalModal({
onClick={onClose}
className="shrink-0 rounded-md bg-surface-raised px-4 py-1.5 text-sm text-text transition-colors hover:bg-border-emphasis"
>
- Close
+ {t('actions.close')}
diff --git a/src/renderer/components/ui/ChipInteractionLayer.tsx b/src/renderer/components/ui/ChipInteractionLayer.tsx
index 18d24ce7..521c1365 100644
--- a/src/renderer/components/ui/ChipInteractionLayer.tsx
+++ b/src/renderer/components/ui/ChipInteractionLayer.tsx
@@ -15,6 +15,7 @@ import { syntaxHighlighting } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { chipDisplayLabel } from '@renderer/types/inlineChip';
@@ -64,6 +65,7 @@ const ChipFilePreview = ({
onOpenInEditor?: (filePath: string) => void;
onRevealFolder?: (folderPath: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const displayPath = chip.displayPath ?? chip.filePath;
const isFolder = chip.isFolder === true;
return (
@@ -82,7 +84,7 @@ const ChipFilePreview = ({
}}
>
- Reveal
+ {t('actions.reveal')}
) : !isFolder && onOpenInEditor ? (
- Open
+ {t('actions.open')}
) : null}
@@ -104,6 +106,7 @@ const ChipFilePreview = ({
};
const ChipCodePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const containerRef = React.useRef
(null);
const allLines = chip.codeText.split('\n');
const truncated = allLines.length > MAX_PREVIEW_LINES;
@@ -111,8 +114,8 @@ const ChipCodePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => {
const label = chipDisplayLabel(chip);
const lineRef =
chip.fromLine === chip.toLine
- ? `line ${String(chip.fromLine)}`
- : `lines ${String(chip.fromLine)}-${String(chip.toLine)}`;
+ ? t('code.line', { line: chip.fromLine })
+ : t('code.lines', { from: chip.fromLine, to: chip.toLine });
React.useEffect(() => {
const container = containerRef.current;
@@ -148,7 +151,7 @@ const ChipCodePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => {
{truncated ? (
- ({allLines.length - MAX_PREVIEW_LINES} more lines...)
+ {t('code.moreLines', { count: allLines.length - MAX_PREVIEW_LINES })}
) : null}
diff --git a/src/renderer/components/ui/ExpandableContent.tsx b/src/renderer/components/ui/ExpandableContent.tsx
index 76820818..fc621058 100644
--- a/src/renderer/components/ui/ExpandableContent.tsx
+++ b/src/renderer/components/ui/ExpandableContent.tsx
@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ChevronDown, ChevronUp } from 'lucide-react';
const DEFAULT_COLLAPSED_HEIGHT = 200; // px
@@ -29,6 +30,7 @@ export const ExpandableContent = ({
className,
onExpand,
}: ExpandableContentProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const anchorRef = useRef(null);
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
@@ -84,7 +86,7 @@ export const ExpandableContent = ({
}}
>
- Show more
+ {t('actions.showMore')}
) : null}
@@ -101,7 +103,7 @@ export const ExpandableContent = ({
}}
>
- Show less
+ {t('actions.showLess')}
) : null}
diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx
index 0f80cccf..4d7840c3 100644
--- a/src/renderer/components/ui/MemberSelect.tsx
+++ b/src/renderer/components/ui/MemberSelect.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
@@ -41,6 +42,7 @@ export const MemberSelect = ({
disabled = false,
className,
}: MemberSelectProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const listboxId = React.useId();
@@ -102,7 +104,9 @@ export const MemberSelect = ({
{selectedMember ? (
renderMemberInline(selectedMember)
) : value === null && allowUnassigned ? (
-
Unassigned
+
+ {t('members.unassigned')}
+
) : (
{placeholder}
)}
@@ -125,7 +129,7 @@ export const MemberSelect = ({
@@ -135,7 +139,7 @@ export const MemberSelect = ({
onWheel={(e) => e.stopPropagation()}
>
- No members found.
+ {t('members.emptyMessage')}
{allowUnassigned && !search.trim() ? (
- Unassigned
+ {t('members.unassigned')}
{value === null ? (
) : null}
diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx
index e64d6341..ad01139f 100644
--- a/src/renderer/components/ui/MentionSuggestionList.tsx
+++ b/src/renderer/components/ui/MentionSuggestionList.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { getTeamColorSet, getThemedText } from '@renderer/constants/teamColors';
@@ -57,6 +58,7 @@ export const MentionSuggestionList = ({
hasFileSearch,
filesLoading,
}: MentionSuggestionListProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const listRef = useRef(null);
const { isLight } = useTheme();
@@ -71,10 +73,10 @@ export const MentionSuggestionList = ({
if (suggestions.length === 0) {
const emptyStateText = filesLoading
- ? 'Searching...'
+ ? t('search.searching')
: hasFileSearch
- ? 'No matching suggestions'
- : 'No matching suggestions';
+ ? t('search.noMatchingSuggestions')
+ : t('search.noMatchingSuggestions');
return (
{emptyStateText}
@@ -195,9 +197,9 @@ export const MentionSuggestionList = ({
? { color: 'rgb(245 158 11)' }
: isSkill
? { color: 'rgb(6 182 212)' }
- : colorSet
- ? { color: getThemedText(colorSet, isLight) }
- : undefined
+ : colorSet
+ ? { color: getThemedText(colorSet, isLight) }
+ : undefined
}
>
) : null}
{s.subtitle && isFileOrFolder ? (
@@ -266,7 +268,7 @@ export const MentionSuggestionList = ({
{filesLoading ? (
- Searching files...
+ {t('search.searchingFiles')}
) : null}
diff --git a/src/renderer/components/ui/dialog.tsx b/src/renderer/components/ui/dialog.tsx
index be5af7ec..94be3b6d 100644
--- a/src/renderer/components/ui/dialog.tsx
+++ b/src/renderer/components/ui/dialog.tsx
@@ -1,6 +1,7 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '@renderer/lib/utils';
import { X } from 'lucide-react';
@@ -28,31 +29,35 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef
,
React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
-
-
-
-
- Close
-
-
- {children}
-
+>(({ className, children, ...props }, ref) => {
+ const { t } = useAppTranslation('common');
+
+ return (
+
+
+
+
+
+
+ {t('actions.close')}
+
+
+ {children}
+
+
-
-
-));
+
+ );
+});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
diff --git a/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx b/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx
index 0b71dc1e..40a0541b 100644
--- a/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx
+++ b/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx
@@ -1,9 +1,11 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { useCurrentEditor, useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import { Bold, Code, Italic, Strikethrough } from 'lucide-react';
export const TiptapBubbleMenu = () => {
+ const { t } = useAppTranslation('common');
const { editor } = useCurrentEditor();
const state = useEditorState({
@@ -42,7 +44,7 @@ export const TiptapBubbleMenu = () => {
type="button"
className={btnClass(state.isBold)}
onClick={() => editor.chain().focus().toggleBold().run()}
- aria-label="Bold"
+ aria-label={t('editorFormatting.bold')}
>
@@ -50,7 +52,7 @@ export const TiptapBubbleMenu = () => {
type="button"
className={btnClass(state.isItalic)}
onClick={() => editor.chain().focus().toggleItalic().run()}
- aria-label="Italic"
+ aria-label={t('editorFormatting.italic')}
>
@@ -58,7 +60,7 @@ export const TiptapBubbleMenu = () => {
type="button"
className={btnClass(state.isStrike)}
onClick={() => editor.chain().focus().toggleStrike().run()}
- aria-label="Strike"
+ aria-label={t('editorFormatting.strike')}
>
@@ -66,7 +68,7 @@ export const TiptapBubbleMenu = () => {
type="button"
className={btnClass(state.isCode)}
onClick={() => editor.chain().focus().toggleCode().run()}
- aria-label="Code"
+ aria-label={t('editorFormatting.code')}
>
diff --git a/src/renderer/hooks/useOptionalTabId.ts b/src/renderer/hooks/useOptionalTabId.ts
new file mode 100644
index 00000000..bbc7c3dc
--- /dev/null
+++ b/src/renderer/hooks/useOptionalTabId.ts
@@ -0,0 +1 @@
+export { useTabIdOptional as useOptionalTabId } from '@renderer/contexts/useTabUIContext';
diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts
index 99a14d26..0592bbad 100644
--- a/src/renderer/utils/teamProvisioningPresentation.ts
+++ b/src/renderer/utils/teamProvisioningPresentation.ts
@@ -51,9 +51,26 @@ type PendingDiagnosticBucket =
| 'noRuntime';
type PendingDiagnosticNameGroups = Record
;
+type TeamProvisioningTranslator = unknown;
const MAX_PENDING_DIAGNOSTIC_NAMES = 4;
+function translateProvisioning(
+ t: TeamProvisioningTranslator | undefined,
+ key: string,
+ fallback: string,
+ options?: Record
+): string {
+ if (!t) {
+ return fallback;
+ }
+
+ return (t as (translationKey: string, options?: Record) => string)(key, {
+ defaultValue: fallback,
+ ...options,
+ });
+}
+
function parseStatusUpdatedAtMs(value: string | undefined): number | null {
if (!value) {
return null;
@@ -182,16 +199,28 @@ function countPermissionBlockedMembers(params: {
return count;
}
-function buildAwaitingPermissionPhrase(count: number): string {
- return count === 1
- ? '1 teammate awaiting permission approval'
- : `${count} teammates awaiting permission approval`;
+function buildAwaitingPermissionPhrase(count: number, t?: TeamProvisioningTranslator): string {
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.awaitingPermission',
+ count === 1
+ ? '1 teammate awaiting permission approval'
+ : `${count} teammates awaiting permission approval`,
+ { count }
+ );
}
-function formatMemberNameList(names: readonly string[]): string {
+function formatMemberNameList(names: readonly string[], t?: TeamProvisioningTranslator): string {
const listedNames = names.slice(0, MAX_PENDING_DIAGNOSTIC_NAMES).join(', ');
const remainingCount = names.length - Math.min(names.length, MAX_PENDING_DIAGNOSTIC_NAMES);
- return `${listedNames}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`;
+ return remainingCount > 0
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.nameListWithMore',
+ `${listedNames}, +${remainingCount} more`,
+ { names: listedNames, count: remainingCount }
+ )
+ : listedNames;
}
function getMemberNamesFromSpawnSources(params: {
@@ -317,6 +346,7 @@ function buildOpenCodeSecondaryWaitPhrase(params: {
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
+ t?: TeamProvisioningTranslator;
}): string | null {
const pendingNames = getPendingSpawnNames({
memberSpawnStatuses: params.memberSpawnStatuses,
@@ -341,25 +371,63 @@ function buildOpenCodeSecondaryWaitPhrase(params: {
memberSpawnSnapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
});
if (groups.bootstrapStalled.length === 0) {
- return `Waiting for OpenCode: ${formatMemberNameList(pendingNames)}`;
+ return translateProvisioning(
+ params.t,
+ 'provisioning.presentation.waitingForOpenCode',
+ `Waiting for OpenCode: ${formatMemberNameList(pendingNames, params.t)}`,
+ { names: formatMemberNameList(pendingNames, params.t) }
+ );
}
- const stalled = `Bootstrap stalled: ${formatMemberNameList(groups.bootstrapStalled)}`;
+ const stalled = translateProvisioning(
+ params.t,
+ 'provisioning.presentation.bootstrapStalled',
+ `Bootstrap stalled: ${formatMemberNameList(groups.bootstrapStalled, params.t)}`,
+ { names: formatMemberNameList(groups.bootstrapStalled, params.t) }
+ );
const waitingNames = pendingNames.filter((name) => !groups.bootstrapStalled.includes(name));
return waitingNames.length > 0
- ? `${stalled}; Waiting for OpenCode: ${formatMemberNameList(waitingNames)}`
+ ? translateProvisioning(
+ params.t,
+ 'provisioning.presentation.bootstrapStalledWithOpenCodeWait',
+ `${stalled}; Waiting for OpenCode: ${formatMemberNameList(waitingNames, params.t)}`,
+ { stalled, names: formatMemberNameList(waitingNames, params.t) }
+ )
: stalled;
}
-function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null {
+function formatNamedPendingDiagnostic(
+ label: string,
+ names: readonly string[],
+ t?: TeamProvisioningTranslator
+): string | null {
if (names.length === 0) {
return null;
}
- return `${label}: ${formatMemberNameList(names)}`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.namedPendingDiagnostic',
+ `${label}: ${formatMemberNameList(names, t)}`,
+ { label, names: formatMemberNameList(names, t) }
+ );
}
-function formatCountPendingDiagnostic(count: number | undefined, label: string): string | null {
- return count && count > 0 ? `${count} ${label}` : null;
+function formatCountPendingDiagnostic(
+ count: number | undefined,
+ label: string,
+ t?: TeamProvisioningTranslator
+): string | null {
+ return count && count > 0
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.countPendingDiagnostic',
+ `${count} ${label}`,
+ {
+ count,
+ label,
+ }
+ )
+ : null;
}
function buildPendingDiagnosticPhrase({
@@ -368,12 +436,14 @@ function buildPendingDiagnosticPhrase({
memberSpawnSnapshotStatuses,
memberSpawnSnapshotUpdatedAt,
fallbackJoiningPhrase,
+ t,
}: {
summary: MemberSpawnStatusesSnapshot['summary'] | undefined;
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
fallbackJoiningPhrase: string;
+ t?: TeamProvisioningTranslator;
}): string {
const groups = getPendingDiagnosticNameGroups({
memberSpawnStatuses,
@@ -381,12 +451,56 @@ function buildPendingDiagnosticPhrase({
memberSpawnSnapshotUpdatedAt,
});
const namedParts = [
- formatNamedPendingDiagnostic('Bootstrap stalled', groups.bootstrapStalled),
- formatNamedPendingDiagnostic('Shell-only', groups.shellOnly),
- formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
- formatNamedPendingDiagnostic('Bootstrap unconfirmed', groups.runtimeCandidate),
- formatNamedPendingDiagnostic('Awaiting permission', groups.permission),
- formatNamedPendingDiagnostic('Waiting for runtime', groups.noRuntime),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.bootstrapStalled',
+ 'Bootstrap stalled'
+ ),
+ groups.bootstrapStalled,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(t, 'provisioning.presentation.pendingLabels.shellOnly', 'Shell-only'),
+ groups.shellOnly,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.waitingForBootstrap',
+ 'Waiting for bootstrap'
+ ),
+ groups.runtimeProcess,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.bootstrapUnconfirmed',
+ 'Bootstrap unconfirmed'
+ ),
+ groups.runtimeCandidate,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.awaitingPermission',
+ 'Awaiting permission'
+ ),
+ groups.permission,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.waitingForRuntime',
+ 'Waiting for runtime'
+ ),
+ groups.noRuntime,
+ t
+ ),
].filter(Boolean);
if (namedParts.length > 0) {
return namedParts.join(', ');
@@ -395,11 +509,51 @@ function buildPendingDiagnosticPhrase({
return fallbackJoiningPhrase;
}
const countParts = [
- formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'),
- formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
- formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'bootstrap unconfirmed'),
- formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
- formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'waiting for runtime'),
+ formatCountPendingDiagnostic(
+ summary.shellOnlyPendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.shellOnlyLower',
+ 'shell-only'
+ ),
+ t
+ ),
+ formatCountPendingDiagnostic(
+ summary.runtimeProcessPendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.waitingForBootstrapLower',
+ 'waiting for bootstrap'
+ ),
+ t
+ ),
+ formatCountPendingDiagnostic(
+ summary.runtimeCandidatePendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.bootstrapUnconfirmedLower',
+ 'bootstrap unconfirmed'
+ ),
+ t
+ ),
+ formatCountPendingDiagnostic(
+ summary.permissionPendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.awaitingPermissionLower',
+ 'awaiting permission'
+ ),
+ t
+ ),
+ formatCountPendingDiagnostic(
+ summary.noRuntimePendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.waitingForRuntimeLower',
+ 'waiting for runtime'
+ ),
+ t
+ ),
].filter(Boolean);
return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase;
}
@@ -567,45 +721,79 @@ function normalizeFailureReason(reason: string): string {
}
function buildFailedSpawnPanelMessage(
- failedSpawnDetails: readonly FailedSpawnDetail[]
+ failedSpawnDetails: readonly FailedSpawnDetail[],
+ t?: TeamProvisioningTranslator
): string | null {
if (failedSpawnDetails.length === 0) {
return null;
}
if (failedSpawnDetails.length === 1) {
const [failed] = failedSpawnDetails;
- return `${failed.name} failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.memberFailedToStart',
+ `${failed.name} failed to start`,
+ { name: failed.name }
+ );
}
- return `${failedSpawnDetails.length} teammates failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ `${failedSpawnDetails.length} teammates failed to start`,
+ { count: failedSpawnDetails.length }
+ );
}
function buildFailedSpawnCompactDetail(
- failedSpawnDetails: readonly FailedSpawnDetail[]
+ failedSpawnDetails: readonly FailedSpawnDetail[],
+ t?: TeamProvisioningTranslator
): string | null {
if (failedSpawnDetails.length === 0) {
return null;
}
if (failedSpawnDetails.length === 1) {
- return `${failedSpawnDetails[0].name} failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.memberFailedToStart',
+ `${failedSpawnDetails[0].name} failed to start`,
+ { name: failedSpawnDetails[0].name }
+ );
}
- return `${failedSpawnDetails.length} teammates failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ `${failedSpawnDetails.length} teammates failed to start`,
+ { count: failedSpawnDetails.length }
+ );
}
function buildGenericFailedSpawnPanelMessage(
failedSpawnCount: number,
- expectedTeammateCount: number
+ expectedTeammateCount: number,
+ t?: TeamProvisioningTranslator
): string | null {
if (failedSpawnCount <= 0) {
return null;
}
if (failedSpawnCount === 1) {
- return '1 teammate failed to start';
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ '1 teammate failed to start',
+ { count: failedSpawnCount }
+ );
}
- return `${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedRatio',
+ `${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`,
+ { count: failedSpawnCount, total: Math.max(expectedTeammateCount, failedSpawnCount) }
+ );
}
function buildSkippedSpawnPanelMessage(
- skippedSpawnDetails: readonly SkippedSpawnDetail[]
+ skippedSpawnDetails: readonly SkippedSpawnDetail[],
+ t?: TeamProvisioningTranslator
): string | null {
if (skippedSpawnDetails.length === 0) {
return null;
@@ -613,8 +801,18 @@ function buildSkippedSpawnPanelMessage(
if (skippedSpawnDetails.length === 1) {
const [skipped] = skippedSpawnDetails;
return skipped.reason
- ? `${skipped.name} skipped for this launch - ${normalizeFailureReason(skipped.reason)}`
- : `${skipped.name} skipped for this launch`;
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.memberSkippedWithReason',
+ `${skipped.name} skipped for this launch - ${normalizeFailureReason(skipped.reason)}`,
+ { name: skipped.name, reason: normalizeFailureReason(skipped.reason) }
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.memberSkipped',
+ `${skipped.name} skipped for this launch`,
+ { name: skipped.name }
+ );
}
const listedSkipped = skippedSpawnDetails
.slice(0, 3)
@@ -623,19 +821,35 @@ function buildSkippedSpawnPanelMessage(
)
.join('; ');
const remainingCount = skippedSpawnDetails.length - Math.min(skippedSpawnDetails.length, 3);
- return `Skipped teammates: ${listedSkipped}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkippedList',
+ `Skipped teammates: ${listedSkipped}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`,
+ { list: listedSkipped, count: remainingCount }
+ );
}
function buildSkippedSpawnCompactDetail(
- skippedSpawnDetails: readonly SkippedSpawnDetail[]
+ skippedSpawnDetails: readonly SkippedSpawnDetail[],
+ t?: TeamProvisioningTranslator
): string | null {
if (skippedSpawnDetails.length === 0) {
return null;
}
if (skippedSpawnDetails.length === 1) {
- return `${skippedSpawnDetails[0].name} skipped`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.memberSkippedCompact',
+ `${skippedSpawnDetails[0].name} skipped`,
+ { name: skippedSpawnDetails[0].name }
+ );
}
- return `${skippedSpawnDetails.length} teammates skipped`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkipped',
+ `${skippedSpawnDetails.length} teammates skipped`,
+ { count: skippedSpawnDetails.length }
+ );
}
export interface TeamProvisioningPresentation {
@@ -679,6 +893,7 @@ export function buildTeamProvisioningPresentation({
members,
memberSpawnStatuses,
memberSpawnSnapshot,
+ t,
}: {
progress: TeamProvisioningProgress | null | undefined;
members: readonly ProvisioningMemberLike[];
@@ -689,6 +904,7 @@ export function buildTeamProvisioningPresentation({
> & {
statuses?: MemberSpawnStatusesSnapshot['statuses'];
};
+ t?: TeamProvisioningTranslator;
}): TeamProvisioningPresentation | null {
if (!progress) {
return null;
@@ -725,19 +941,20 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
});
- const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails);
- const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails);
+ const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails, t);
+ const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails, t);
const genericFailedSpawnPanelMessage = buildGenericFailedSpawnPanelMessage(
failedSpawnCount,
- expectedTeammateCount
+ expectedTeammateCount,
+ t
);
const skippedSpawnDetails = getSkippedSpawnDetails({
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
});
- const skippedSpawnPanelMessage = buildSkippedSpawnPanelMessage(skippedSpawnDetails);
- const skippedSpawnCompactDetail = buildSkippedSpawnCompactDetail(skippedSpawnDetails);
+ const skippedSpawnPanelMessage = buildSkippedSpawnPanelMessage(skippedSpawnDetails, t);
+ const skippedSpawnCompactDetail = buildSkippedSpawnCompactDetail(skippedSpawnDetails, t);
const permissionBlockedCount = countPermissionBlockedMembers({
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
@@ -796,11 +1013,19 @@ export function buildTeamProvisioningPresentation({
remainingJoinCount,
retryableOpenCodeSecondaryFailedCount,
retryableOpenCodeSecondaryFailedNames,
- panelTitle: 'Launch failed',
+ panelTitle: translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchFailed',
+ 'Launch failed'
+ ),
panelMessage: progress.error ?? failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage,
panelTone: 'error',
defaultLiveOutputOpen: true,
- compactTitle: 'Launch failed',
+ compactTitle: translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchFailed',
+ 'Launch failed'
+ ),
compactDetail: progress.message ?? null,
compactTone: 'error',
};
@@ -809,14 +1034,24 @@ export function buildTeamProvisioningPresentation({
if (isReady) {
const joiningPhrase =
remainingJoinCount === 1
- ? '1 teammate still joining'
- : `${remainingJoinCount} teammates still joining`;
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesStillJoining',
+ '1 teammate still joining',
+ { count: remainingJoinCount }
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesStillJoining',
+ `${remainingJoinCount} teammates still joining`,
+ { count: remainingJoinCount }
+ );
const pendingMembersAwaitApproval =
failedSpawnCount === 0 &&
permissionBlockedCount > 0 &&
permissionBlockedCount === remainingJoinCount;
const pendingDetailPhrase = pendingMembersAwaitApproval
- ? buildAwaitingPermissionPhrase(permissionBlockedCount)
+ ? buildAwaitingPermissionPhrase(permissionBlockedCount, t)
: (openCodeSecondaryWaitPhrase ??
buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary,
@@ -824,32 +1059,73 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: joiningPhrase,
+ t,
}));
const readyCompactDetail =
failedSpawnCount > 0
? (failedSpawnCompactDetail ??
- `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`,
+ { count: failedSpawnCount }
+ ))
: skippedSpawnCount > 0
? (skippedSpawnCompactDetail ??
- `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkipped',
+ `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`,
+ { count: skippedSpawnCount }
+ ))
: hasMembersStillJoining
? pendingDetailPhrase
: expectedTeammateCount === 0
- ? 'Lead online'
- : `All ${expectedTeammateCount} teammates joined`;
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.leadOnline',
+ 'Lead online'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.allTeammatesJoined',
+ `All ${expectedTeammateCount} teammates joined`,
+ { count: expectedTeammateCount }
+ );
const readyDetailMessage =
failedSpawnCount > 0
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
: skippedSpawnCount > 0
? (skippedSpawnPanelMessage ??
- `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkippedRatio',
+ `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`,
+ {
+ count: skippedSpawnCount,
+ total: Math.max(expectedTeammateCount, skippedSpawnCount),
+ }
+ ))
: expectedTeammateCount === 0
- ? 'Team provisioned - lead online'
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamProvisionedLeadOnline',
+ 'Team provisioned - lead online'
+ )
: allTeammatesConfirmedAlive
- ? `Team provisioned - all ${expectedTeammateCount} teammates joined`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamProvisionedAllJoined',
+ `Team provisioned - all ${expectedTeammateCount} teammates joined`,
+ { count: expectedTeammateCount }
+ )
: hasMembersStillJoining
? pendingDetailPhrase
- : 'Team provisioned - teammates are still joining';
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamProvisionedStillJoining',
+ 'Team provisioned - teammates are still joining'
+ );
const readyDetailSeverity =
failedSpawnCount > 0 || skippedSpawnCount > 0
? 'warning'
@@ -858,16 +1134,46 @@ export function buildTeamProvisioningPresentation({
: undefined;
const readyMessage =
failedSpawnCount > 0
- ? `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.launchFinishedWithErrors',
+ `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`,
+ { count: failedSpawnCount, total: Math.max(expectedTeammateCount, failedSpawnCount) }
+ )
: skippedSpawnCount > 0
- ? `Launch continued - ${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.launchContinuedSkipped',
+ `Launch continued - ${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped`,
+ {
+ count: skippedSpawnCount,
+ total: Math.max(expectedTeammateCount, skippedSpawnCount),
+ }
+ )
: expectedTeammateCount === 0
- ? 'Team launched - lead online'
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamLaunchedLeadOnline',
+ 'Team launched - lead online'
+ )
: allTeammatesConfirmedAlive
- ? `Team launched - all ${expectedTeammateCount} teammates joined`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamLaunchedAllJoined',
+ `Team launched - all ${expectedTeammateCount} teammates joined`,
+ { count: expectedTeammateCount }
+ )
: openCodeSecondaryWaitPhrase
- ? 'Core team ready'
- : 'Finishing launch';
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.coreTeamReady',
+ 'Core team ready'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.finishingLaunch',
+ 'Finishing launch'
+ );
return {
progress,
@@ -886,7 +1192,11 @@ export function buildTeamProvisioningPresentation({
remainingJoinCount,
retryableOpenCodeSecondaryFailedCount,
retryableOpenCodeSecondaryFailedNames,
- panelTitle: 'Launch details',
+ panelTitle: translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchDetails',
+ 'Launch details'
+ ),
panelMessage:
failedSpawnCount > 0 || skippedSpawnCount > 0 || hasMembersStillJoining
? readyDetailMessage
@@ -902,14 +1212,34 @@ export function buildTeamProvisioningPresentation({
defaultLiveOutputOpen: false,
compactTitle:
failedSpawnCount > 0
- ? 'Launch finished with errors'
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchFinishedWithErrors',
+ 'Launch finished with errors'
+ )
: skippedSpawnCount > 0
- ? 'Launch continued with skipped teammates'
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchContinuedSkipped',
+ 'Launch continued with skipped teammates'
+ )
: hasMembersStillJoining
? openCodeSecondaryWaitPhrase
- ? 'Core team ready'
- : 'Finishing launch'
- : 'Team launched',
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.coreTeamReady',
+ 'Core team ready'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.finishingLaunch',
+ 'Finishing launch'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.teamLaunched',
+ 'Team launched'
+ ),
compactDetail: readyCompactDetail,
compactTone:
failedSpawnCount > 0 || skippedSpawnCount > 0
@@ -929,14 +1259,24 @@ export function buildTeamProvisioningPresentation({
if (isActive) {
const activeJoiningPhrase =
remainingJoinCount === 1
- ? '1 teammate still joining'
- : `${remainingJoinCount} teammates still joining`;
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesStillJoining',
+ '1 teammate still joining',
+ { count: remainingJoinCount }
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesStillJoining',
+ `${remainingJoinCount} teammates still joining`,
+ { count: remainingJoinCount }
+ );
const activePendingDetailPhrase =
failedSpawnCount === 0 &&
hasMembersStillJoining &&
permissionBlockedCount > 0 &&
permissionBlockedCount === remainingJoinCount
- ? buildAwaitingPermissionPhrase(permissionBlockedCount)
+ ? buildAwaitingPermissionPhrase(permissionBlockedCount, t)
: (openCodeSecondaryWaitPhrase ??
buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary,
@@ -944,6 +1284,7 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: activeJoiningPhrase,
+ t,
}));
return {
progress,
@@ -963,13 +1304,31 @@ export function buildTeamProvisioningPresentation({
remainingJoinCount,
retryableOpenCodeSecondaryFailedCount,
retryableOpenCodeSecondaryFailedNames,
- panelTitle: openCodeSecondaryWaitPhrase ? 'Core team ready' : 'Launching team',
+ panelTitle: openCodeSecondaryWaitPhrase
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.coreTeamReady',
+ 'Core team ready'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchingTeam',
+ 'Launching team'
+ ),
panelMessage:
failedSpawnCount > 0
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
: skippedSpawnCount > 0
? (skippedSpawnPanelMessage ??
- `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkippedRatio',
+ `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`,
+ {
+ count: skippedSpawnCount,
+ total: Math.max(expectedTeammateCount, skippedSpawnCount),
+ }
+ ))
: openCodeSecondaryWaitPhrase
? openCodeSecondaryWaitPhrase
: hasMembersStillJoining &&
@@ -980,22 +1339,52 @@ export function buildTeamProvisioningPresentation({
panelMessageSeverity:
failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
defaultLiveOutputOpen: false,
- compactTitle: openCodeSecondaryWaitPhrase ? 'Core team ready' : 'Launching team',
+ compactTitle: openCodeSecondaryWaitPhrase
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.coreTeamReady',
+ 'Core team ready'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchingTeam',
+ 'Launching team'
+ ),
compactDetail:
failedSpawnCount > 0
? (failedSpawnCompactDetail ??
- `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`,
+ { count: failedSpawnCount }
+ ))
: skippedSpawnCount > 0
? (skippedSpawnCompactDetail ??
- `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkipped',
+ `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`,
+ { count: skippedSpawnCount }
+ ))
: openCodeSecondaryWaitPhrase
? openCodeSecondaryWaitPhrase
: hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
? permissionBlockedCount === remainingJoinCount
- ? buildAwaitingPermissionPhrase(permissionBlockedCount)
- : `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
+ ? buildAwaitingPermissionPhrase(permissionBlockedCount, t)
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesConfirmedRatio',
+ `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`,
+ { count: heartbeatConfirmedCount, total: expectedTeammateCount }
+ )
: expectedTeammateCount > 0 && progressStepIndex >= 2
- ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesConfirmedRatio',
+ `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`,
+ { count: heartbeatConfirmedCount, total: expectedTeammateCount }
+ )
: progress.message,
compactTone: failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : 'default',
};
diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts
index c9715fb2..1cfde9e7 100644
--- a/src/shared/types/notifications.ts
+++ b/src/shared/types/notifications.ts
@@ -342,6 +342,8 @@ export interface AppConfig {
claudeRootPath: string | null;
/** Agent communication language ('system' = use OS locale) */
agentLanguage: string;
+ /** Application interface locale preference ('system' = use OS locale) */
+ appLocale: string;
/** Whether to auto-expand AI response groups when opening a transcript or receiving new messages */
autoExpandAIGroups: boolean;
/** Whether to use the native OS title bar instead of the custom one (Linux/Windows) */
diff --git a/test/features/localization/core/catalogPolicy.test.ts b/test/features/localization/core/catalogPolicy.test.ts
new file mode 100644
index 00000000..e850af0d
--- /dev/null
+++ b/test/features/localization/core/catalogPolicy.test.ts
@@ -0,0 +1,51 @@
+import {
+ extractInterpolationVariables,
+ validateCatalogCompleteness,
+} from '@features/localization/core/domain/catalogPolicy';
+import { describe, expect, it } from 'vitest';
+
+describe('catalogPolicy', () => {
+ it('accepts matching catalog shape', () => {
+ const issues = validateCatalogCompleteness(
+ {
+ en: { common: { greeting: 'Hello {{name}}' } },
+ pseudo: { common: { greeting: 'Hi {{name}}' } },
+ },
+ 'en'
+ );
+
+ expect(issues).toEqual([]);
+ });
+
+ it('reports missing and extra keys', () => {
+ const issues = validateCatalogCompleteness(
+ {
+ en: { common: { actions: { save: 'Save', cancel: 'Cancel' } } },
+ pseudo: { common: { actions: { save: 'Save', close: 'Close' } } },
+ },
+ 'en'
+ );
+
+ expect(issues.map((issue) => issue.type)).toEqual(['missing-key', 'extra-key']);
+ });
+
+ it('reports interpolation mismatches', () => {
+ const issues = validateCatalogCompleteness(
+ {
+ en: { common: { greeting: 'Hello {{name}}' } },
+ pseudo: { common: { greeting: 'Hello {{user}}' } },
+ },
+ 'en'
+ );
+
+ expect(issues).toHaveLength(1);
+ expect(issues[0].type).toBe('interpolation-mismatch');
+ });
+
+ it('extracts sorted interpolation variables', () => {
+ expect(extractInterpolationVariables('{{count}} items for {{name}}')).toEqual([
+ 'count',
+ 'name',
+ ]);
+ });
+});
diff --git a/test/features/localization/core/localePolicy.test.ts b/test/features/localization/core/localePolicy.test.ts
new file mode 100644
index 00000000..66a77076
--- /dev/null
+++ b/test/features/localization/core/localePolicy.test.ts
@@ -0,0 +1,30 @@
+import {
+ extractPrimaryLocaleSubtag,
+ normalizeAppLocalePreference,
+ resolveAppLocale,
+} from '@features/localization/core/domain/localePolicy';
+import { describe, expect, it } from 'vitest';
+
+describe('localePolicy', () => {
+ it('normalizes unsupported preferences to system', () => {
+ expect(normalizeAppLocalePreference('uk')).toBe('system');
+ expect(normalizeAppLocalePreference(null)).toBe('system');
+ expect(normalizeAppLocalePreference('en')).toBe('en');
+ expect(normalizeAppLocalePreference('ru')).toBe('ru');
+ });
+
+ it('extracts the primary locale subtag', () => {
+ expect(extractPrimaryLocaleSubtag('en-US')).toBe('en');
+ expect(extractPrimaryLocaleSubtag('EN_us')).toBe('en');
+ expect(extractPrimaryLocaleSubtag('')).toBeNull();
+ });
+
+ it('resolves system locale to supported primary locale', () => {
+ expect(resolveAppLocale({ preference: 'system', systemLocale: 'en-US' })).toBe('en');
+ expect(resolveAppLocale({ preference: 'system', systemLocale: 'ru-RU' })).toBe('ru');
+ });
+
+ it('falls back when the system locale is not supported yet', () => {
+ expect(resolveAppLocale({ preference: 'system', systemLocale: 'uk-UA' })).toBe('en');
+ });
+});
diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts
index 45d9a7cd..d9fa8d8a 100644
--- a/test/main/ipc/configValidation.test.ts
+++ b/test/main/ipc/configValidation.test.ts
@@ -42,6 +42,22 @@ describe('configValidation', () => {
}
});
+ it('accepts supported general.appLocale updates', () => {
+ const result = validateConfigUpdatePayload('general', { appLocale: 'ru' });
+ expect(result.valid).toBe(true);
+ if (result.valid) {
+ expect(result.data).toEqual({ appLocale: 'ru' });
+ }
+ });
+
+ it('rejects unsupported general.appLocale updates', () => {
+ const result = validateConfigUpdatePayload('general', { appLocale: 'uk' });
+ expect(result.valid).toBe(false);
+ if (!result.valid) {
+ expect(result.error).toContain('supported app locale');
+ }
+ });
+
it('accepts absolute general.claudeRootPath updates', () => {
const result = validateConfigUpdatePayload('general', {
claudeRootPath: '/Users/test/.claude',
diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts
index 83f36347..ce787ee6 100644
--- a/test/renderer/store/extensionsSlice.test.ts
+++ b/test/renderer/store/extensionsSlice.test.ts
@@ -55,15 +55,15 @@ vi.mock('../../../src/renderer/api', () => ({
}));
import { api } from '../../../src/renderer/api';
-import type { AppConfig, CliInstallationStatus } from '../../../src/shared/types';
import {
getMcpDiagnosticKey,
- getMcpProjectStateKey,
getMcpOperationKey,
+ getMcpProjectStateKey,
getPluginOperationKey,
} from '../../../src/shared/utils/extensionNormalizers';
import { createDefaultCliExtensionCapabilities } from '../../../src/shared/utils/providerExtensionCapabilities';
+import type { AppConfig, CliInstallationStatus } from '../../../src/shared/types';
import type {
EnrichedPlugin,
McpCatalogItem,
@@ -239,6 +239,7 @@ function makeAppConfig(multimodelEnabled: boolean): AppConfig {
multimodelEnabled,
claudeRootPath: null,
agentLanguage: 'system',
+ appLocale: 'system',
autoExpandAIGroups: true,
useNativeTitleBar: false,
telemetryEnabled: false,