From 3343dee36cb81ae87878cc4bf78a2c95d97237f1 Mon Sep 17 00:00:00 2001 From: Asterracy <311249772+Asterracy@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:11:58 +0800 Subject: [PATCH] feat(i18n): update Simplified Chinese localization Signed-off-by: Asterracy <311249772+Asterracy@users.noreply.github.com> --- LOCALIZATION.zh-Hans.md | 12 + desktop/package.json | 5 +- desktop/scripts/check-hardcoded-strings.mjs | 1026 ++++ .../scripts/check-hardcoded-strings.test.mjs | 269 + .../scripts/hardcoded-strings-baseline.json | 4067 +++++++++++++ desktop/src-tauri/src/app_menu.rs | 158 +- desktop/src-tauri/src/lib.rs | 3 + desktop/src/app/App.tsx | 15 +- desktop/src/app/AppTopChrome.tsx | 12 +- desktop/src/app/RootErrorBoundary.test.mjs | 10 + desktop/src/app/RootErrorBoundary.tsx | 15 +- .../agent-memory/ui/MemorySection.tsx | 59 +- .../features/agents/cardMintStore.test.mjs | 10 + desktop/src/features/agents/cardMintStore.ts | 22 +- .../agents/lib/agentAccessWarning.test.mjs | 10 + .../features/agents/lib/agentAccessWarning.ts | 18 +- .../agents/lib/useAgentAvailability.test.mjs | 10 + .../agents/ui/AddAgentToChannelDialog.tsx | 41 +- .../agents/ui/AddCustomHarnessDialog.tsx | 6 +- .../agents/ui/AddTeamToChannelDialog.tsx | 42 +- .../agents/ui/AdvancedRequiredBadge.tsx | 5 +- .../agents/ui/AgentAiConfigurationMode.tsx | 21 +- .../features/agents/ui/AgentAiDefaults.tsx | 28 +- .../agents/ui/AgentCardMintDialog.tsx | 39 +- .../features/agents/ui/AgentConfigFields.tsx | 30 +- .../agents/ui/AgentDefaultsDialog.tsx | 20 +- .../agents/ui/AgentDefaultsEditor.tsx | 27 +- .../agents/ui/AgentDefinitionDialog.tsx | 4 +- .../agents/ui/AgentDefinitionDialogFooter.tsx | 13 +- .../agents/ui/AgentDefinitionMetadata.tsx | 23 +- .../agents/ui/AgentDescriptionField.tsx | 12 +- .../features/agents/ui/AgentHarnessField.tsx | 5 +- .../agents/ui/AgentInstanceEditDialog.tsx | 4 +- .../agents/ui/AgentManagementDialogs.tsx | 6 +- .../CompactMessageSummary.tsx | 10 +- .../SentMessageContextDialog.tsx | 29 +- .../AgentSessionToolItem/TodoToolSummary.tsx | 4 +- .../AgentSessionToolItem/ToolDetailBlocks.tsx | 12 +- .../agents/ui/AgentSessionTranscriptList.tsx | 32 +- .../agents/ui/AgentSnapshotExportDialog.tsx | 58 +- desktop/src/features/agents/ui/AgentsView.tsx | 20 +- .../agents/ui/CardMintComposerChip.tsx | 16 +- .../src/features/agents/ui/CardMintKeyCue.tsx | 8 +- .../ui/EditAgentProviderModelFields.tsx | 31 +- .../features/agents/ui/EffortPickerField.tsx | 12 +- .../src/features/agents/ui/EnvVarsEditor.tsx | 29 +- .../agents/ui/HarnessCatalogRetryNotice.tsx | 6 +- .../agents/ui/ManagedAgentLogPanel.tsx | 17 +- .../features/agents/ui/McpServersSection.tsx | 13 +- .../features/agents/ui/PersonaActionsMenu.tsx | 16 +- .../src/features/agents/ui/PersonaAddedBy.tsx | 15 +- .../agents/ui/PersonaAdvancedFields.tsx | 32 +- .../agents/ui/PersonaDeleteDialog.test.mjs | 10 + .../agents/ui/PersonaDeleteDialog.tsx | 27 +- .../agents/ui/PersonaModelCombobox.tsx | 8 +- .../features/agents/ui/PersonaModelField.tsx | 19 +- .../agents/ui/PersonaProviderApiKeyField.tsx | 14 +- .../agents/ui/PersonaShareRecipients.tsx | 16 +- .../agents/ui/PromptSectionAccordion.tsx | 6 +- .../src/features/agents/ui/RawEventRail.tsx | 5 +- .../agents/ui/RemoveMembersConfirmDialog.tsx | 17 +- .../features/agents/ui/RestartDiffBadge.tsx | 10 +- .../agents/ui/RunOnSummarySection.tsx | 14 +- .../features/agents/ui/TeamDeleteDialog.tsx | 12 +- desktop/src/features/agents/ui/TeamDialog.tsx | 40 +- .../features/agents/ui/TeamIdentityCard.tsx | 13 +- .../features/agents/ui/TeamShareDialog.tsx | 11 +- .../agents/ui/TeamSnapshotExportDialog.tsx | 45 +- .../src/features/agents/ui/TeamsSection.tsx | 41 +- .../agents/ui/TurnLivenessIndicator.tsx | 8 +- .../agents/ui/UnifiedAgentsSection.tsx | 18 +- .../UnifiedAgentsSectionCardTarget.test.mjs | 10 + .../features/agents/ui/WhereToRunSection.tsx | 21 +- .../MessageLinkHoverCue.tsx | 4 +- .../UserMessageBubble.tsx | 6 +- .../agents/ui/addCustomHarness.test.mjs | 17 +- .../features/agents/ui/addCustomHarness.ts | 12 +- .../agents/ui/agentDefaultsEditor.test.mjs | 10 + .../ui/agentInstanceEditCancelSafety.test.mjs | 11 + .../agents/ui/agentProfileSyncWarning.ts | 7 +- .../agents/ui/agentSessionFileRead.ts | 10 +- .../agents/ui/agentSessionToolCatalog.ts | 30 +- .../ui/agentSessionToolClassifier.test.mjs | 10 + .../agents/ui/agentSessionToolClassifier.ts | 161 +- .../ui/agentSessionToolSummary.test.mjs | 10 + .../agents/ui/agentSessionTranscript.test.mjs | 10 + .../agents/ui/agentSessionTranscript.ts | 9 +- .../agentSessionTranscriptGrouping.test.mjs | 10 + .../ui/agentSessionTranscriptGrouping.ts | 25 +- .../ui/agentSessionTranscriptHelpers.test.mjs | 10 + .../ui/agentSessionTranscriptHelpers.ts | 164 +- .../features/agents/ui/agentSessionUtils.ts | 7 +- .../agents/ui/buzzAgentModelTuningFields.tsx | 50 +- .../features/agents/ui/effortPicker.test.mjs | 10 + .../src/features/agents/ui/effortPicker.ts | 6 +- .../agents/ui/personaDialogState.test.mjs | 10 + .../features/agents/ui/personaDialogState.ts | 24 +- .../features/agents/ui/personaLibraryCopy.ts | 108 +- .../ui/personaModelDiscoveryStatus.test.mjs | 10 + .../agents/ui/personaModelDiscoveryStatus.ts | 21 +- .../agents/ui/relayMeshModelPicker.test.mjs | 10 + .../agents/ui/relayMeshModelPicker.ts | 18 +- .../src/features/agents/ui/teamLibraryCopy.ts | 43 +- .../agents/ui/teamSnapshotImport.lib.ts | 19 +- .../agents/ui/useManagedAgentActions.ts | 18 +- .../ui/usePersonaModelDiscovery.test.mjs | 10 + .../agents/ui/usePersonaModelDiscovery.ts | 20 +- .../src/features/agents/ui/useTeamActions.ts | 77 +- .../useCreatedAgentChannelAttachment.ts | 36 +- .../channel-templates/useApplyTemplate.ts | 7 +- .../channels/lib/channelDescription.test.mjs | 12 + .../channels/lib/channelDescription.ts | 13 +- .../channels/lib/channelLifecycle.test.mjs | 13 + .../features/channels/lib/channelLifecycle.ts | 11 +- .../channels/lib/dmParticipantDisplay.ts | 5 +- .../channels/lib/ephemeralChannel.test.mjs | 72 +- .../features/channels/lib/ephemeralChannel.ts | 80 +- .../channels/ui/AddChannelBotDialog.tsx | 43 +- .../ui/AddChannelBotGenericSection.tsx | 18 +- .../ui/AddChannelBotPersonasSection.tsx | 18 +- .../channels/ui/AddChannelBotReuseGuard.tsx | 21 +- .../channels/ui/AddChannelBotTeamsSection.tsx | 12 +- .../channels/ui/AddMemberSearchResultRow.tsx | 12 +- .../channels/ui/AgentSessionThreadPanel.tsx | 112 +- .../features/channels/ui/BotActivityBar.tsx | 29 +- .../channels/ui/ChannelBrowserDialog.tsx | 149 +- .../features/channels/ui/ChannelCanvas.tsx | 24 +- .../ui/ChannelManagementModerationActions.tsx | 38 +- .../channels/ui/ChannelManagementSheet.tsx | 99 +- .../ui/ChannelManagementSheetRows.tsx | 34 +- .../channels/ui/ChannelMemberAvatarStack.tsx | 4 +- .../channels/ui/ChannelMemberInviteCard.tsx | 55 +- .../channels/ui/ChannelMembersBar.tsx | 20 +- .../src/features/channels/ui/ChannelPane.tsx | 41 +- .../ui/ChannelPermissionsSettings.tsx | 32 +- .../channels/ui/ChannelScreenEmptyState.tsx | 5 +- .../channels/ui/ChannelScreenHeader.tsx | 19 +- .../channels/ui/ChannelTypePicker.tsx | 38 +- .../channels/ui/ChannelTypeSettings.tsx | 99 +- .../channels/ui/ChannelWorkflowsSection.tsx | 10 +- .../channels/ui/EditRespondToDialog.tsx | 14 +- .../channels/ui/FocusThreadDrawer.tsx | 11 +- .../channels/ui/ForumChannelContent.tsx | 4 +- .../features/channels/ui/MembersSidebar.tsx | 36 +- .../ui/MembersSidebarAgentControls.tsx | 8 +- .../channels/ui/MembersSidebarMemberCard.tsx | 91 +- .../src/features/channels/ui/QuickBotBar.tsx | 13 +- .../channels/ui/RightAuxiliaryPane.tsx | 8 +- .../channels/ui/ThreadViewModeToggle.tsx | 12 +- .../channels/ui/WelcomeAgentCreateDialog.tsx | 17 +- .../channels/ui/WelcomeComposerBanner.tsx | 13 +- .../features/channels/ui/useChannelIntro.tsx | 23 +- .../channels/ui/useMembersSidebarActions.ts | 51 +- .../ui/useMembersSidebarModeration.ts | 24 +- .../features/channels/ui/useQuickBotDrop.ts | 11 +- .../channels/ui/useRoutedMessageEdit.ts | 6 +- .../channels/useActiveChannelHeader.ts | 4 +- .../channels/useChannelPaneHandlers.ts | 6 +- .../channels/useWelcomeAgentCreate.ts | 10 +- desktop/src/features/chat/ui/ChatHeader.tsx | 10 +- .../communities/hostedCommunityApi.ts | 33 +- .../communities/ui/AddCommunityDialog.tsx | 30 +- .../ui/CommunityApplyErrorScreen.tsx | 8 +- .../communities/ui/CommunityChangeOverlay.tsx | 14 +- .../communities/ui/CommunityEditForm.tsx | 35 +- .../ui/CommunityIconSettingsCard.tsx | 8 +- .../communities/ui/CommunitySwitcher.tsx | 75 +- .../communities/ui/EditCommunityDialog.tsx | 36 +- .../ui/HostedCommunityCreateFlow.tsx | 81 +- .../ui/HostedCommunityOnboarding.tsx | 138 +- .../features/communities/ui/WelcomeSetup.tsx | 49 +- .../communities/useNestNotifications.ts | 11 +- .../community-members/lib/joinAlerts.test.mjs | 7 + .../community-members/lib/joinAlerts.ts | 14 +- .../community-members/ui/AddMemberDialog.tsx | 65 +- .../ui/CommunityInviteDialog.tsx | 11 +- .../ui/CommunityMembersCard.tsx | 41 +- .../ui/CommunityMembersSettingsCard.tsx | 55 +- .../ui/ConfirmRemoveDialog.tsx | 19 +- .../ui/InviteLinkSection.tsx | 110 +- .../useCommunityJoinAlerts.test.mjs | 10 + .../ui/CustomEmojiSettingsCard.tsx | 90 +- .../features/forum/ui/DeleteActionMenu.tsx | 4 +- .../features/forum/ui/DeleteConfirmDialog.tsx | 12 +- .../forum/ui/ForumComposer.lifecycle.test.mjs | 22 + .../src/features/forum/ui/ForumComposer.tsx | 8 +- .../forum/ui/ForumComposerCompactLayout.tsx | 8 +- .../forum/ui/ForumComposerMediaStatus.tsx | 8 +- .../features/forum/ui/ForumThreadPanel.tsx | 10 +- desktop/src/features/forum/ui/ForumView.tsx | 14 +- .../forum/ui/useForumMentionPreparation.ts | 6 +- desktop/src/features/gifs/relay.ts | 30 +- .../src/features/gifs/ui/KlipyGifPicker.tsx | 16 +- .../home/lib/homeMessageCapabilities.ts | 7 +- desktop/src/features/home/lib/inbox.test.mjs | 9 + desktop/src/features/home/lib/inbox.ts | 67 +- .../features/home/lib/projectInbox.test.mjs | 9 + desktop/src/features/home/ui/FeedSection.tsx | 44 +- desktop/src/features/home/ui/HomeView.tsx | 18 +- .../src/features/home/ui/InboxDetailPane.tsx | 54 +- .../src/features/home/ui/InboxFilterMenu.tsx | 54 +- .../src/features/home/ui/InboxListPane.tsx | 138 +- .../features/home/ui/ProjectInboxDetail.tsx | 15 +- .../home/ui/ProjectInboxDetailPane.tsx | 21 +- .../features/home/ui/RecentNotesSection.tsx | 20 +- .../home/ui/inboxReopenNavigation.test.mjs | 11 + .../home/useHiddenDmInboxNavigation.ts | 5 +- .../huddle/components/AddAgentDialog.tsx | 12 +- .../huddle/components/AgentVoiceMenu.tsx | 26 +- .../huddle/components/HuddleAttachment.tsx | 24 +- .../features/huddle/components/HuddleBar.tsx | 71 +- .../huddle/components/HuddleIndicator.tsx | 16 +- .../components/HuddleProfileControl.tsx | 12 +- .../huddle/components/HuddleRoomHeader.tsx | 52 +- .../huddle/components/HuddleStartingView.tsx | 6 +- .../components/HuddleTranscriptIntro.tsx | 10 +- .../huddle/components/MicControls.tsx | 70 +- .../huddle/components/ParticipantList.tsx | 46 +- .../features/huddle/lib/huddleError.test.mjs | 12 + .../src/features/huddle/lib/huddleError.ts | 11 +- .../src/features/identity-archive/hooks.ts | 18 +- .../ui/LocalArchiveSettingsCard.tsx | 134 +- .../local-archive/ui/localArchiveKinds.ts | 70 +- .../mesh-compute/servingUsage.test.mjs | 11 + .../src/features/mesh-compute/servingUsage.ts | 39 +- .../ui/MeshComputeSettingsCard.tsx | 133 +- desktop/src/features/messages/hooks.ts | 4 +- .../messages/lib/composerMessageLinkNode.ts | 28 +- .../messages/lib/messageLinkLabel.test.mjs | 27 +- .../features/messages/lib/messageLinkLabel.ts | 8 +- .../src/features/messages/lib/parseDiff.ts | 38 +- .../messages/lib/systemEventCopy.test.mjs | 9 + .../features/messages/lib/systemEventCopy.ts | 34 +- .../features/messages/lib/useLinkEditor.tsx | 22 +- .../messages/lib/useRichTextEditor.ts | 14 +- .../messages/lib/useVoiceNoteRecorder.ts | 14 +- .../messages/ui/AudioMessageAttachment.tsx | 33 +- .../messages/ui/ChannelIntroBlock.tsx | 6 +- .../ui/ComposerAddressControls.test.mjs | 9 + .../messages/ui/ComposerAddressControls.tsx | 34 +- .../messages/ui/ComposerAttachments.tsx | 168 +- .../messages/ui/ComposerEmojiPicker.tsx | 14 +- .../messages/ui/ComposerImageEditor.tsx | 58 +- .../messages/ui/ComposerReplyEditBanner.tsx | 12 +- .../ui/ComposerUploadProgressOverlay.tsx | 4 +- .../ui/DeleteMessageConfirmDialog.tsx | 10 +- .../src/features/messages/ui/DiffMessage.tsx | 24 +- .../messages/ui/DiffMessageExpanded.tsx | 18 +- .../src/features/messages/ui/DiffViewer.tsx | 10 +- .../features/messages/ui/DraftDetailPane.tsx | 39 +- .../src/features/messages/ui/DraftsPanel.tsx | 86 +- .../messages/ui/FormattingToolbar.tsx | 47 +- .../messages/ui/MentionAutocomplete.test.mjs | 9 + .../messages/ui/MentionAutocomplete.tsx | 54 +- .../features/messages/ui/MessageActionBar.tsx | 56 +- .../messages/ui/MessageAgentOwner.tsx | 10 +- .../features/messages/ui/MessageComposer.tsx | 17 +- .../messages/ui/MessageComposerToolbar.tsx | 32 +- .../features/messages/ui/MessageReactions.tsx | 42 +- .../src/features/messages/ui/MessageRow.tsx | 39 +- .../messages/ui/MessageThreadPanel.tsx | 21 +- .../ui/MessageThreadPanelSkeleton.tsx | 9 +- .../ui/MessageThreadReplyState.test.mjs | 9 + .../messages/ui/MessageThreadReplyState.tsx | 9 +- .../messages/ui/MessageThreadSummaryRow.tsx | 6 +- .../features/messages/ui/MessageTimeline.tsx | 41 +- .../messages/ui/MessageTimelineErrorCard.tsx | 4 +- .../messages/ui/NewMessageResultRow.tsx | 12 +- .../features/messages/ui/NewMessageScreen.tsx | 37 +- .../messages/ui/NonMemberMentionDialog.tsx | 28 +- .../messages/ui/SelectionFormattingTray.tsx | 4 +- .../messages/ui/SentFromThreadLine.tsx | 4 +- .../messages/ui/SystemMessageAvatars.tsx | 9 +- .../messages/ui/SystemMessageRow.test.mjs | 10 + .../features/messages/ui/SystemMessageRow.tsx | 58 +- .../features/messages/ui/UnreadDivider.tsx | 7 +- .../messages/ui/VoiceNoteRecorder.tsx | 16 +- .../messages/ui/WaveMessageAttachment.tsx | 6 +- .../messages/ui/useAutoPinMentionedAgents.ts | 12 +- .../messages/ui/useComposerLinkPreviews.tsx | 16 +- .../messages/ui/useComposerVoiceNote.tsx | 12 +- .../ui/useDetachedAgentStart.test.mjs | 10 + .../messages/ui/useDetachedAgentStart.ts | 12 +- .../ui/useMentionSendFlow.test-support.mjs | 24 + .../messages/ui/useMentionSendFlow.ts | 34 +- .../messages/ui/useNonMemberInvite.ts | 5 +- .../messages/ui/useReactionHandler.ts | 5 +- .../src/features/moderation/lib/timeout.ts | 12 +- .../moderation/ui/ComposerTimeoutBanner.tsx | 6 +- .../ui/MessageModerationMenuItems.tsx | 46 +- .../moderation/ui/ReportMessageDialog.tsx | 50 +- .../moderation/ui/TimeoutDurationSubmenu.tsx | 16 +- desktop/src/features/notifications/hooks.ts | 89 +- .../features/notifications/lib/feed.test.mjs | 11 + .../lib/notificationFormat.test.mjs | 23 + .../notifications/lib/notificationFormat.ts | 94 +- .../features/notifications/lib/sound.test.mjs | 49 +- .../src/features/notifications/lib/sound.ts | 76 +- desktop/src/features/onboarding/hooks.ts | 10 +- .../src/features/onboarding/ui/AvatarStep.tsx | 34 +- .../src/features/onboarding/ui/BackupStep.tsx | 69 +- .../features/onboarding/ui/BackupTestFlow.tsx | 62 +- .../onboarding/ui/CommunityOnboardingFlow.tsx | 92 +- .../onboarding/ui/ConnectionMethodSection.tsx | 89 +- .../onboarding/ui/DefaultConfigStep.tsx | 47 +- .../onboarding/ui/DownloadKeyStep.tsx | 22 +- .../onboarding/ui/EncryptedBackupCreator.tsx | 74 +- .../onboarding/ui/IdentityKeyHelpDialog.tsx | 25 +- .../onboarding/ui/IdentityKeyIntroduction.tsx | 17 +- .../onboarding/ui/IdentityRecoveryPairing.tsx | 71 +- .../onboarding/ui/InviteRedeemForm.tsx | 42 +- .../ui/JoinPolicyNotice.i18n.test.mjs | 166 + .../onboarding/ui/JoinPolicyNotice.tsx | 16 +- .../onboarding/ui/KeyringLockedScreen.tsx | 20 +- .../onboarding/ui/MachineOnboardingFlow.tsx | 64 +- .../onboarding/ui/MembershipDenied.tsx | 51 +- .../onboarding/ui/NostrKeyImportForm.tsx | 70 +- .../onboarding/ui/NsecMaskedDisplay.tsx | 14 +- .../features/onboarding/ui/OnboardingFlow.tsx | 38 +- .../onboarding/ui/OnboardingFooter.tsx | 6 +- .../onboarding/ui/PendingInviteGate.tsx | 12 +- .../features/onboarding/ui/ProfileStep.tsx | 45 +- .../features/onboarding/ui/RecoveryScreen.tsx | 4 +- .../onboarding/ui/RelaunchRequiredScreen.tsx | 7 +- .../onboarding/ui/ResetFailedScreen.tsx | 7 +- .../src/features/onboarding/ui/SetupStep.tsx | 207 +- .../onboarding/ui/onboardingRuntimeStatus.ts | 79 + desktop/src/features/presence/lib/presence.ts | 7 +- .../profile/avatarPresentationStore.ts | 7 +- .../ui/AnimatedAvatarBackdropPanel.tsx | 9 +- .../ui/AnimatedAvatarCameraControls.tsx | 6 +- .../profile/ui/AnimatedAvatarCapture.tsx | 22 +- .../profile/ui/AnimatedAvatarControls.tsx | 19 +- .../profile/ui/AnimatedAvatarReviewNav.tsx | 76 +- .../profile/ui/ArchiveConfirmDialog.tsx | 35 +- .../profile/ui/AvatarCustomColorPanel.tsx | 6 +- .../src/features/profile/ui/AvatarUpload.tsx | 14 +- .../profile/ui/NostrBindConsentDialog.tsx | 58 +- .../src/features/profile/ui/ProfileAvatar.tsx | 4 +- .../profile/ui/ProfileAvatarEditor.tsx | 28 +- .../profile/ui/ProfileAvatarModeTabs.tsx | 28 +- .../ui/ProfileInstancesArchived.test.mjs | 11 + .../profile/ui/ProfileInstancesSection.tsx | 15 +- .../features/profile/ui/ProfilePopover.tsx | 17 +- .../profile/ui/SelectedRecipientChip.tsx | 12 +- .../profile/ui/UserProfileAgentActions.tsx | 28 +- .../ui/UserProfileAgentManagementRows.tsx | 50 +- .../features/profile/ui/UserProfilePanel.tsx | 75 +- .../ui/UserProfilePanelAgentDetails.tsx | 9 +- .../profile/ui/UserProfilePanelFields.tsx | 59 +- .../ui/UserProfilePanelFocusedViews.tsx | 24 +- .../ui/UserProfilePanelHeaderContent.tsx | 13 +- .../ui/UserProfilePanelPersonaSubmit.test.mjs | 10 + .../ui/UserProfilePanelPersonaSubmit.ts | 44 +- .../profile/ui/UserProfilePanelUtils.ts | 48 +- .../profile/ui/UserProfilePrimaryActions.tsx | 27 +- .../ui/UserProfileSnapshotExportDialog.tsx | 8 +- .../profile/ui/useAgentLifecycleActions.ts | 26 +- .../ui/useProfileInteractionActions.ts | 8 +- .../src/features/projects/branchMutations.ts | 7 +- .../projects/lib/projectBranches.test.mjs | 8 + .../features/projects/lib/projectBranches.ts | 19 +- .../projects/lib/projectGitError.test.mjs | 10 + .../features/projects/lib/projectGitError.ts | 32 +- .../lib/projectHomeWorkspaceSheet.test.mjs | 10 + .../projects/lib/projectHomeWorkspaceSheet.ts | 16 +- .../features/projects/lib/projectLabels.ts | 5 +- .../lib/projectRepoAvailability.test.mjs | 10 + .../projects/lib/projectRepoAvailability.ts | 84 +- .../projects/lib/projectSelection.test.mjs | 10 + .../features/projects/lib/projectSelection.ts | 29 +- .../lib/projectsActivityDigest.test.mjs | 10 + .../projects/lib/projectsActivityDigest.ts | 37 +- .../features/projects/projectEnumeration.ts | 6 +- .../projects/projectTaskCategories.ts | 17 +- .../features/projects/pullRequestReviews.ts | 17 +- .../src/features/projects/repoSyncHooks.ts | 4 +- .../ui/AddProjectRepositoryDialog.tsx | 32 +- .../ui/AgentContextPayloadPreview.tsx | 8 +- .../ui/AttachProjectRepositoryDialog.tsx | 14 +- .../projects/ui/CreateIssueDialog.tsx | 9 +- .../projects/ui/CreateProjectFormContent.tsx | 28 +- .../projects/ui/CreateProjectFormSettings.tsx | 54 +- .../projects/ui/CreateProjectIssueDialog.tsx | 14 +- .../ui/CreateProjectWorkItemDialog.tsx | 19 +- .../projects/ui/CreatePullRequestDialog.tsx | 49 +- .../projects/ui/DiscussionChannels.tsx | 52 +- .../projects/ui/IssueAssigneesRow.tsx | 53 +- .../projects/ui/MergePullRequestButton.tsx | 60 +- .../projects/ui/ProjectAgentChatPanel.tsx | 25 +- .../projects/ui/ProjectAgentContextStrip.tsx | 6 +- .../ProjectAgentSubmittedContextPill.test.mjs | 8 + .../ui/ProjectAgentSubmittedContextPill.tsx | 12 +- .../projects/ui/ProjectAuthorIdentity.tsx | 7 +- .../projects/ui/ProjectBranchDialogs.tsx | 42 +- .../projects/ui/ProjectBrowserDialog.tsx | 22 +- .../src/features/projects/ui/ProjectCards.tsx | 87 +- .../projects/ui/ProjectChannelHome.tsx | 19 +- .../projects/ui/ProjectChannelManagement.tsx | 18 +- .../ui/ProjectChannelRequestDialog.test.mjs | 8 + .../ui/ProjectChannelRequestDialog.tsx | 52 +- .../projects/ui/ProjectCommitCopyButton.tsx | 4 +- .../projects/ui/ProjectCommitDetailPanel.tsx | 35 +- .../projects/ui/ProjectConversationPanel.tsx | 14 +- .../projects/ui/ProjectCreationDialog.tsx | 8 +- .../projects/ui/ProjectDetailChrome.tsx | 15 +- .../projects/ui/ProjectDetailFeedPanels.tsx | 50 +- .../projects/ui/ProjectDetailScreen.tsx | 53 +- .../ui/ProjectDetailUnavailableState.tsx | 16 +- .../projects/ui/ProjectEntityListRow.tsx | 6 +- .../projects/ui/ProjectHomeCodebasePanel.tsx | 4 +- .../ui/ProjectHomeCommitsPanel.test.mjs | 8 + .../projects/ui/ProjectHomeCommitsPanel.tsx | 25 +- .../projects/ui/ProjectHomeContextPanel.tsx | 24 +- .../projects/ui/ProjectHomeWorkspaceSheet.tsx | 18 +- .../ui/ProjectIssueCommentTimeline.tsx | 13 +- .../projects/ui/ProjectIssuesPanel.tsx | 72 +- .../projects/ui/ProjectOriginReference.tsx | 19 +- .../ui/ProjectRepositoryUnavailableState.tsx | 6 +- .../projects/ui/ProjectRightPanelControls.tsx | 26 +- .../projects/ui/ProjectSelectableGroup.tsx | 8 +- .../projects/ui/ProjectWorkspaceTabList.tsx | 20 +- .../projects/ui/ProjectWorkspaceTabs.tsx | 34 +- .../ui/ProjectsCategoryCreateDialogs.tsx | 21 +- .../projects/ui/ProjectsChannelsList.tsx | 45 +- .../projects/ui/ProjectsIssuesList.tsx | 26 +- .../projects/ui/ProjectsListHeaderBar.tsx | 14 +- .../ui/ProjectsOverviewChromeActions.tsx | 18 +- .../ui/ProjectsOverviewContextSheet.tsx | 6 +- .../projects/ui/ProjectsOverviewItems.tsx | 11 +- .../projects/ui/ProjectsOverviewPanel.tsx | 8 +- .../projects/ui/ProjectsPullRequestsList.tsx | 30 +- .../projects/ui/ProjectsSectionSearch.tsx | 16 +- .../ui/ProjectsSelectionCountMenu.tsx | 8 +- .../features/projects/ui/ProjectsToolbar.tsx | 25 +- .../src/features/projects/ui/ProjectsView.tsx | 10 +- .../projects/ui/ProjectsWorkItemTable.tsx | 11 +- .../ui/ProjectsWorkItemsLoadNotice.tsx | 43 +- .../projects/ui/PullRequestMetaRail.tsx | 9 +- .../projects/ui/PullRequestsPanelSurface.tsx | 16 +- .../features/projects/ui/RepositoryCards.tsx | 8 +- .../features/projects/ui/ShareLinkButton.tsx | 11 +- .../ui/UnavailableProjectRepositories.tsx | 4 +- .../projects/ui/projectDetailHelpers.ts | 33 +- .../ui/projectsOverviewContext.test.mjs | 10 + .../projects/ui/projectsOverviewContext.ts | 73 +- .../projects/ui/projectsSectionMeta.ts | 14 +- .../projects/ui/projectsViewWorkItems.ts | 10 +- .../ui/useCreateProjectFormSettings.ts | 3 +- .../projects/ui/useOpenProjectTerminal.ts | 21 +- .../projects/ui/useProjectDetailCrumbs.ts | 11 +- .../ui/useProjectRepositoryOpenActions.ts | 12 +- .../ui/useRetainedProjectGitViews.test.mjs | 8 + .../projects/useAddProjectRepository.ts | 3 +- .../src/features/projects/useCreateProject.ts | 6 +- .../projects/useProjectChannelRequests.ts | 14 +- .../features/projects/useProjectRepoHost.ts | 5 +- .../src/features/pulse/lib/useNoteActions.ts | 38 +- .../pulse/ui/AgentActivityCard.test.mjs | 7 + .../features/pulse/ui/AgentActivityCard.tsx | 9 +- desktop/src/features/pulse/ui/NoteCard.tsx | 57 +- desktop/src/features/pulse/ui/PulseTabBar.tsx | 16 +- desktop/src/features/pulse/ui/PulseView.tsx | 28 +- .../reminders/lib/reminderFilters.test.mjs | 12 + .../features/reminders/lib/reminderFilters.ts | 21 +- .../reminders/lib/timePresets.test.mjs | 12 + .../src/features/reminders/lib/timePresets.ts | 38 +- .../reminders/ui/RemindMeLaterDialog.tsx | 24 +- .../features/reminders/ui/RemindersPanel.tsx | 119 +- .../src/features/reminders/ui/SnoozeMenu.tsx | 14 +- .../reminders/useReminderNotifications.ts | 11 +- .../src/features/search/lib/searchMatch.ts | 4 +- .../search/ui/SearchPromptPlaceholder.tsx | 36 +- .../features/search/ui/SearchResultItem.tsx | 31 +- .../search/ui/SearchScopeControls.tsx | 24 +- .../src/features/search/ui/TopbarSearch.tsx | 81 +- .../settings/EncryptedBackupProvider.tsx | 37 +- .../features/settings/SidebarUpdateCard.tsx | 25 +- .../src/features/settings/UpdateChecker.tsx | 48 +- .../src/features/settings/UpdateIndicator.tsx | 19 +- .../settings/ui/AgentDefaultsSettingsCard.tsx | 7 +- .../settings/ui/AgentsSettingsPanel.tsx | 14 +- .../ui/AppearanceSettingsControls.tsx | 233 +- .../features/settings/ui/BackupTestFlow.tsx | 47 +- .../settings/ui/CustomHarnessForm.tsx | 41 +- .../settings/ui/EncryptedBackupCreator.tsx | 83 +- .../settings/ui/ExperimentalFeaturesCard.tsx | 13 +- ...arnessCatalogDialog.acpForcedGate.test.mjs | 2 + .../settings/ui/HarnessCatalogDialog.tsx | 28 +- .../src/features/settings/ui/HarnessRow.tsx | 51 +- .../settings/ui/HarnessesSettingsPanel.tsx | 34 +- .../settings/ui/KeyboardShortcutsCard.tsx | 6 +- .../settings/ui/LanguageSetting.test.mjs | 121 + .../features/settings/ui/LanguageSetting.tsx | 62 + .../settings/ui/NotificationSettingsCard.tsx | 56 +- .../settings/ui/PreventSleepSettingsCard.tsx | 16 +- .../settings/ui/PrivateKeyBackupRow.tsx | 38 +- .../features/settings/ui/SettingsPanels.tsx | 112 +- .../src/features/settings/ui/SettingsView.tsx | 80 +- .../features/settings/ui/SignOutSection.tsx | 58 +- .../src/features/settings/ui/SoundPicker.tsx | 8 +- .../settings/ui/VoiceSettingsCard.tsx | 207 +- .../settings/ui/harnessCatalogLogic.test.mjs | 8 + .../settings/ui/harnessCatalogLogic.ts | 6 +- .../settings/ui/harnessGalleryLogic.test.mjs | 7 + .../settings/ui/harnessGalleryLogic.ts | 7 +- .../src/features/sidebar/ui/AppSidebar.tsx | 12 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 24 +- .../sidebar/ui/ChannelActivityPopover.tsx | 26 +- .../sidebar/ui/ChannelContextMenu.tsx | 47 +- .../sidebar/ui/ChannelSectionDialogs.tsx | 52 +- .../src/features/sidebar/ui/CommunityRail.tsx | 25 +- .../sidebar/ui/CreateChannelDialog.tsx | 10 +- .../sidebar/ui/CreateChannelFormFields.tsx | 55 +- .../sidebar/ui/CustomChannelSection.tsx | 52 +- .../sidebar/ui/SidebarProfileCard.tsx | 13 +- .../sidebar/ui/SidebarProjectsSection.tsx | 77 +- .../sidebar/ui/SidebarRelayConnectionCard.tsx | 22 +- .../features/sidebar/ui/SidebarSection.tsx | 7 +- .../terminal/TerminalBootstrap.test.mjs | 10 + .../terminal/TerminalSubstrate.test.mjs | 12 + .../features/terminal/TerminalSubstrate.tsx | 39 +- .../user-status/ui/SetStatusDialog.tsx | 60 +- .../user-status/ui/UserNameIndicators.tsx | 12 +- .../features/workflows/ui/ChannelCombobox.tsx | 27 +- .../workflows/ui/CronExpressionInput.tsx | 20 +- .../workflows/ui/WorkflowActionsMenu.tsx | 14 +- .../workflows/ui/WorkflowApprovalCard.tsx | 17 +- .../workflows/ui/WorkflowAuthorPicker.tsx | 21 +- .../features/workflows/ui/WorkflowCard.tsx | 13 +- .../workflows/ui/WorkflowDeleteDialog.tsx | 18 +- .../workflows/ui/WorkflowDetailPanel.tsx | 66 +- .../features/workflows/ui/WorkflowDialog.tsx | 118 +- .../workflows/ui/WorkflowDurationField.tsx | 13 +- .../workflows/ui/WorkflowEmojiField.tsx | 6 +- .../workflows/ui/WorkflowFormBuilder.tsx | 83 +- .../workflows/ui/WorkflowMessagePicker.tsx | 34 +- .../ui/WorkflowMessageTextConditionEditor.tsx | 53 +- .../ui/WorkflowRichTriggerDescription.tsx | 4 +- .../workflows/ui/WorkflowRunTrace.tsx | 10 +- .../workflows/ui/WorkflowScheduleFields.tsx | 99 +- .../workflows/ui/WorkflowStepCard.tsx | 123 +- .../workflows/ui/WorkflowTemplateTextarea.tsx | 10 +- .../ui/WorkflowTriggerConditions.tsx | 44 +- .../ui/WorkflowUnavailableDialog.tsx | 16 +- .../ui/WorkflowWebhookHeadersEditor.tsx | 16 +- .../ui/WorkflowWebhookSecretDialog.tsx | 32 +- .../features/workflows/ui/WorkflowsView.tsx | 32 +- .../workflows/ui/cronExpression.test.mjs | 10 + .../features/workflows/ui/cronExpression.ts | 65 +- .../ui/workflowActivationWarning.test.mjs | 10 + .../workflows/ui/workflowActivationWarning.ts | 28 +- .../ui/workflowConditionExpression.test.mjs | 10 + .../ui/workflowConditionExpression.ts | 62 +- .../workflows/ui/workflowDefinition.test.mjs | 9 + .../workflows/ui/workflowDefinition.ts | 119 +- .../workflows/ui/workflowDuration.test.mjs | 10 + .../features/workflows/ui/workflowDuration.ts | 26 +- .../workflows/ui/workflowFormTypes.test.mjs | 10 + .../workflows/ui/workflowFormTypes.ts | 194 +- .../ui/workflowMessageTextCondition.ts | 38 +- .../features/workflows/ui/workflowSchedule.ts | 29 +- .../ui/workflowStepDescription.test.mjs | 9 + .../workflows/ui/workflowStepDescription.ts | 26 +- .../workflows/ui/workflowTemplateVariables.ts | 224 +- .../ui/workflowTriggerDescription.ts | 4 +- desktop/src/i18n/catalogValidation.mjs | 310 + desktop/src/i18n/i18n.test.mjs | 378 ++ desktop/src/i18n/index.ts | 125 + desktop/src/i18n/language.test.mjs | 179 + desktop/src/i18n/language.ts | 225 + desktop/src/i18n/nativeMenu.test.mjs | 79 + desktop/src/locales/GLOSSARY.zh-Hans.md | 160 + desktop/src/locales/STRINGS-MANIFEST.md | 4132 +++++++++++++ desktop/src/locales/en.json | 5086 +++++++++++++++++ desktop/src/locales/zh-Hans.json | 5086 +++++++++++++++++ desktop/src/main.tsx | 4 + .../api/tauriTeams.snapshotImport.test.mjs | 10 + desktop/src/shared/api/useReconnectRelay.ts | 3 +- .../src/shared/features/useFeatureEnabled.ts | 3 +- .../src/shared/layout/AuxiliaryPanelShell.tsx | 6 +- desktop/src/shared/lib/clipboard.ts | 7 +- .../src/shared/lib/datetime.locale.test.mjs | 111 + desktop/src/shared/lib/datetime.test.mjs | 14 + desktop/src/shared/lib/datetime.ts | 69 +- desktop/src/shared/lib/formatters.test.mjs | 96 + desktop/src/shared/lib/formatters.ts | 211 + .../lib/formatters.uninitialized.test.mjs | 43 + desktop/src/shared/lib/keyboard-shortcuts.ts | 259 +- desktop/src/shared/ui/HoverCopyIndicator.tsx | 12 +- desktop/src/shared/ui/PubKey.test.mjs | 12 + desktop/src/shared/ui/PubKey.tsx | 11 +- desktop/src/shared/ui/SimpleImageLightbox.tsx | 7 +- desktop/src/shared/ui/UnreadPill.tsx | 4 - desktop/src/shared/ui/UserAvatar.tsx | 4 +- .../shared/ui/VideoReviewTimecodeButton.tsx | 4 +- desktop/src/shared/ui/ViewLoadingFallback.tsx | 4 +- desktop/src/shared/ui/carousel.tsx | 7 +- .../ui/compact-link-preview-attachment.tsx | 30 +- .../ui/config-nudge-attachment.test.mjs | 12 + .../src/shared/ui/config-nudge-attachment.tsx | 53 +- desktop/src/shared/ui/dialog.tsx | 115 +- .../src/shared/ui/link-preview-controls.tsx | 34 +- desktop/src/shared/ui/link-preview-list.tsx | 16 +- desktop/src/shared/ui/markdown.test.mjs | 11 + .../shared/ui/markdown/AgentSnapshotCard.tsx | 29 +- .../src/shared/ui/markdown/BuzzLinkChip.tsx | 8 +- .../ui/markdown/ChannelDeepLink.test.mjs | 11 + .../shared/ui/markdown/ChannelDeepLink.tsx | 59 +- desktop/src/shared/ui/markdown/CodeBlock.tsx | 18 +- .../shared/ui/markdown/ExternalLinkAnchor.tsx | 10 +- desktop/src/shared/ui/markdown/FileCard.tsx | 7 +- .../shared/ui/markdown/ImageGalleryStatus.tsx | 8 +- .../ui/markdown/ImageLightboxZoomControls.tsx | 8 +- .../shared/ui/markdown/InlineEmojiPopover.tsx | 4 +- .../ui/markdown/LinkPreviewImageLightbox.tsx | 4 +- .../src/shared/ui/markdown/MarkdownInput.tsx | 8 +- .../ui/markdown/MarkdownVideoPlayer.tsx | 9 +- .../shared/ui/markdown/MessageLinkPill.tsx | 53 +- .../src/shared/ui/markdown/SpoilerInline.tsx | 6 +- .../src/shared/ui/markdown/entityLinks.tsx | 32 +- .../src/shared/ui/markdown/imageActions.ts | 15 +- .../ui/rich-link-preview-attachment.tsx | 22 +- desktop/src/shared/ui/sheet.tsx | 36 +- desktop/src/shared/ui/sidebar.tsx | 16 +- desktop/src/shared/ui/step-progress.tsx | 7 +- desktop/src/shared/ui/useVideoContextMenu.tsx | 19 +- pnpm-lock.yaml | 55 + 628 files changed, 33655 insertions(+), 5983 deletions(-) create mode 100644 LOCALIZATION.zh-Hans.md create mode 100644 desktop/scripts/check-hardcoded-strings.mjs create mode 100644 desktop/scripts/check-hardcoded-strings.test.mjs create mode 100644 desktop/scripts/hardcoded-strings-baseline.json create mode 100644 desktop/src/features/onboarding/ui/JoinPolicyNotice.i18n.test.mjs create mode 100644 desktop/src/features/onboarding/ui/onboardingRuntimeStatus.ts create mode 100644 desktop/src/features/settings/ui/LanguageSetting.test.mjs create mode 100644 desktop/src/features/settings/ui/LanguageSetting.tsx create mode 100644 desktop/src/i18n/catalogValidation.mjs create mode 100644 desktop/src/i18n/i18n.test.mjs create mode 100644 desktop/src/i18n/index.ts create mode 100644 desktop/src/i18n/language.test.mjs create mode 100644 desktop/src/i18n/language.ts create mode 100644 desktop/src/i18n/nativeMenu.test.mjs create mode 100644 desktop/src/locales/GLOSSARY.zh-Hans.md create mode 100644 desktop/src/locales/STRINGS-MANIFEST.md create mode 100644 desktop/src/locales/en.json create mode 100644 desktop/src/locales/zh-Hans.json create mode 100644 desktop/src/shared/lib/datetime.locale.test.mjs create mode 100644 desktop/src/shared/lib/formatters.test.mjs create mode 100644 desktop/src/shared/lib/formatters.ts create mode 100644 desktop/src/shared/lib/formatters.uninitialized.test.mjs diff --git a/LOCALIZATION.zh-Hans.md b/LOCALIZATION.zh-Hans.md new file mode 100644 index 00000000000..99e2fba3398 --- /dev/null +++ b/LOCALIZATION.zh-Hans.md @@ -0,0 +1,12 @@ +# Buzz 简体中文本地化(非官方) + +本分支是 [Buzz](https://github.com/block/buzz) 桌面端的非官方简体中文本地化项目。 + +- 上游项目: +- 上游许可证:Apache License 2.0 +- 本分支重点:桌面端国际化基础设施、`zh-Hans` 目录、界面文案和 macOS 原生菜单同步 +- 本分支不代表 Block 官方发布,也不代表获得了官方商标或品牌背书 + +上游代码、版权声明和许可证继续受 Apache License 2.0 约束。本地化修改应与上游许可证及其归属要求一并保留。 + +本工作由 Asterracy 维护,并在实现和验证过程中使用了 OpenAI Codex 的协助;GitHub 提交作者和 DCO 签署人为 Asterracy。 diff --git a/desktop/package.json b/desktop/package.json index fb8e3138719..4b8cce8cc09 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -11,8 +11,9 @@ "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:px-text": "node ./scripts/check-px-text.mjs", "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", + "check:strings": "node ./scripts/check-hardcoded-strings.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation", + "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation && pnpm check:strings", "format": "biome format --write .", "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" \"scripts/*.test.mjs\"", "preview": "vite preview", @@ -66,6 +67,7 @@ "date-fns": "^4.4.0", "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", + "i18next": "^26.4.2", "jdenticon": "^3.3.0", "linkifyjs": "^4.3.2", "lucide-react": "^1.0.0", @@ -77,6 +79,7 @@ "react-day-picker": "^10.0.1", "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", + "react-i18next": "^17.0.14", "react-markdown": "^10.1.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", diff --git a/desktop/scripts/check-hardcoded-strings.mjs b/desktop/scripts/check-hardcoded-strings.mjs new file mode 100644 index 00000000000..367a2f47e2b --- /dev/null +++ b/desktop/scripts/check-hardcoded-strings.mjs @@ -0,0 +1,1026 @@ +/** + * PR6 static gate (spec BUZZ-DESKTOP-I18N-ZH-HANS-001, NFR-007): catch new + * hardcoded user-facing English in the desktop UI. + * + * Why an AST instead of line regexes: the first draft of this file matched + * `>text<` and `prop="text"` with regular expressions. That missed conditional + * children, template literals, concatenations, `toast.*` messages and anything + * inside a JSX comment, and it could not tell a `placeholder` from a + * `className`. The guard now parses each production source file with the + * TypeScript parser (a desktop devDependency) and reports only string values + * that sit in a position which renders as UI copy. + * + * Candidate positions (a "candidate" = a literal that must be justified): + * - JSX text children, incl. `{"Literal"}`, `{cond ? "A" : "B"}`, + * `{cond && "text"}` and the children of Dialog / Tooltip / AlertDialog / + * Popover / Sheet elements. + * - Static values of copy-bearing JSX props: placeholder, title, aria-label, + * aria-labelledby, label, alt, tooltip, description, legend, help text, + * confirm/cancel labels, empty/error text, … + * - First string argument of `toast` / `toast.error|success|warning|info|…`, + * `alert`, `confirm`, `prompt`, `notify` (also the `window.` forms). + * - Initializers of copy-bearing object properties / consts (`label`, + * `titleText`, `emptyMessage`, …). + * - Single-line concatenations and template literals in any position above; + * interpolated parts are normalised to `{}` so the baseline key survives a + * refactor of the interpolated expression. + * + * Never a candidate (structural exclusions — `shouldScanFile`, + * `findCommentRanges`, `isExcludedValue`, `flattenValue`): test / spec / + * test-support files, fixture directories, `src/testing`, `src/locales`, + * `src/i18n`, generated route trees, comments (JS, JSDoc and JSX), arguments of + * `t()` / logger calls, comparison and other data positions, `className` / + * `class` / `style` / `id` / `key` / `data-*` / `src` / `href` / `variant` / + * `type` / `role` / `name` props, code elements (`pre`, `code`, `kbd`, `samp`, + * `script`, `style`), hex colours, CSS fragments, URLs, emails, paths, npm + * specifiers, locale tags, protocol and Nostr identifiers (npub/nsec/kind / + * snake_case payload keys), SCREAMING_SNAKE enum values and brand-only strings + * (Buzz, Nostr, Tauri, …). + * + * Every remaining candidate must appear in hardcoded-strings-baseline.json with + * a category from the fixed vocabulary below AND a note. Categories are data, + * not vibes: an unknown category, a missing note, a malformed or duplicate + * entry is a hard error (exit 2), and a `true-violation` entry is a hard + * failure (exit 1) that records debt which still has to be fixed. There is no + * directory-level, prefix-level or wildcard allowlist: an entry matches exactly + * one file + one candidate kind + one literal, so a new file can never be + * covered by an entry written for another file. + * + * Baseline shape — one record per literal, grouped only to avoid repeating the + * same file/surface reason 200 times: + * { "entries": [ { "file": "src/...", "category": "...", "note": "...", + * "items": [ { "kind": "jsx-text", "literal": "Delete", + * "note": "per-string reason (required unless the + * category is a surface deferral)" } ] } ] } + * + * Baseline key: `||` — deliberately line-free, because the + * concurrent localization waves reflow source lines constantly. + * + * Exit codes: + * 0 every candidate is baselined and no entry is a `true-violation` + * 1 new unbaselined candidate(s), and/or `true-violation` entries + * 2 the gate itself is broken (missing src dir, unparsable baseline, unknown + * category, unparsable source file) — never treated as a pass + * + * Usage (from `desktop/`): + * node scripts/check-hardcoded-strings.mjs # the CI gate + * node scripts/check-hardcoded-strings.mjs --report # dump all candidates + * node scripts/check-hardcoded-strings.mjs --report-unbaselined + * + * Stale entries (baselined, no longer produced by the scan) are advisory, not + * fatal: waves in flight delete strings faster than this branch re-reviews the + * baseline, and a stale entry can never admit a new string. Prune them when the + * owning surface is closed out. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const SCRIPT_DIR = path.dirname(SCRIPT_PATH); +/** `desktop/` — resolved from this file, so the gate is cwd-independent. */ +export const PROJECT_ROOT = path.resolve(SCRIPT_DIR, ".."); +const SRC_DIR = path.join(PROJECT_ROOT, "src"); +export const BASELINE_PATH = path.join( + SCRIPT_DIR, + "hardcoded-strings-baseline.json", +); + +/* -------------------------------------------------------------------------- */ +/* Baseline vocabulary */ +/* -------------------------------------------------------------------------- */ + +/** + * The fixed set of reasons a hardcoded English literal may stay. Every baseline + * entry must use one of these keys; anything else fails the gate. + */ +export const BASELINE_CATEGORIES = Object.freeze({ + "non-ui-identifier": + "Sits in a copy position but is an identifier/technical token (enum value, event or route name, cache key), not prose.", + "test-fixture": + "Sample/mock/seed data that only surfaces in tests, the e2e bridge or demo fixtures.", + "protocol-field": + "Nostr / ACP / relay wire vocabulary: field names, payload keys, kind labels, relay or NIP identifiers.", + "brand-name": + "Product, vendor or model name the glossary freezes in English (Buzz, Nostr, Tauri, GitHub, Codex, Claude, model ids).", + url: "A URL, deep link, wss:// relay address or path shown verbatim.", + "class-name": + "Tailwind/class/style value or CSS-ish token that reached a copy-bearing position.", + "code-sample": + "Code, command, JSON, clipboard markup or shell output quoted for developers or security notices.", + "user-content": + "Literal standing in for user/agent-authored content (display names, channel names, message bodies) that FR-010 forbids translating.", + "deferred-pr4-surface": + "Owned by a localization surface that is still in flight (PR3 wave 4 or any PR4 surface); noted so the gate stays green for work in progress. The owning wave must re-triage its own files.", + "deferred-wave-residual": + "Real untranslated UI copy the PR6 scanner found in a surface whose wave already merged (sidebar / channels / messages / onboarding). It is owed debt, tracked for the pending PR3 wrap-up: not marked `true-violation` only because PR6 must not fail the gate on files another wave owns.", + "true-violation": + "Genuine untranslated UI copy in a finished surface. Always fails the gate until it moves into the catalog.", +}); + +/** Categories that make the gate exit non-zero. */ +export const FAILING_CATEGORIES = Object.freeze(new Set(["true-violation"])); + +/* -------------------------------------------------------------------------- */ +/* File selection */ +/* -------------------------------------------------------------------------- */ + +const SCAN_EXTENSIONS = new Set([".ts", ".tsx"]); + +const SKIP_DIR_NAMES = new Set([ + "node_modules", + "dist", + "build", + "coverage", + "test-results", + "playwright-report", + "fixtures", + "__fixtures__", + "__tests__", + "__mocks__", + "__snapshots__", + "snapshots", +]); + +/** POSIX paths relative to `desktop/` that are never scanned. */ +const SKIP_PATH_PREFIXES = ["src/locales", "src/i18n", "src/testing"]; + +const SKIP_FILE_RE = /(\.test|\.spec|\.test-support)\.[cm]?[jt]sx?$/; + +/** Mock/fixture modules that live inside production directories. */ +const FIXTURE_FILE_RE = + /(^|\/)(mocks?|fixtures?|samples?|seed|demo-data|test-support)(\.|\/)/i; + +export function shouldScanFile(relPath) { + const posix = relPath.replace(/\\/g, "/"); + const base = path.posix.basename(posix); + if (!SCAN_EXTENSIONS.has(path.posix.extname(base))) return false; + if (base.endsWith(".d.ts") || base.endsWith(".gen.ts")) return false; + if (SKIP_FILE_RE.test(base)) return false; + // Fixture/mock/seed modules are test material even when they live inside a + // production feature directory (spec §PR6: exclude test fixtures). + if (FIXTURE_FILE_RE.test(posix)) return false; + if (SKIP_PATH_PREFIXES.some((p) => posix === p || posix.startsWith(`${p}/`))) + return false; + if (posix.split("/").some((s) => SKIP_DIR_NAMES.has(s))) return false; + return true; +} + +/** + * Surfaces whose English is still owned by an in-flight localization wave, so a + * leftover there is `deferred-pr4-surface` rather than a violation. Enumerated + * on purpose instead of inverted: a brand-new directory is NOT silently + * deferred — it has to be added here with its owner, or be triaged for real. + * Every dir listed here is still deliberately English on this branch (spec §7 + * PR3 wave 4 and PR4). Merged waves 1-3 (onboarding, sidebar, channels, + * messages, settings.appearance) are absent, so leftovers in those files must + * carry a real non-deferral reason. + */ +export const DEFERRED_SURFACES = Object.freeze([ + { dir: "src/features/notifications", owner: "PR3 wave 4 (notifications)" }, + { dir: "src/features/search", owner: "PR3 wave 4 (search)" }, + { dir: "src/features/home", owner: "PR3 wave 4 (home)" }, + { dir: "src/features/agents", owner: "PR4 (agents)" }, + { dir: "src/features/settings", owner: "PR4 (settings sections)" }, + { dir: "src/features/projects", owner: "PR4 (projects/repos/issues/PRs)" }, + { dir: "src/features/workflows", owner: "PR4 (workflows)" }, + { dir: "src/features/profile", owner: "PR4 (profile)" }, + { dir: "src/features/moderation", owner: "PR4 (moderation)" }, + { dir: "src/features/channel-templates", owner: "PR4 (templates)" }, + { dir: "src/features/terminal", owner: "PR4 (terminal)" }, + { dir: "src/features/huddle", owner: "PR4 (huddle)" }, + { dir: "src/features/chat", owner: "PR4 long tail (chat)" }, + { dir: "src/features/communities", owner: "PR4 long tail (communities)" }, + { + dir: "src/features/community-members", + owner: "PR4 long tail (community members)", + }, + { dir: "src/features/presence", owner: "PR4 long tail (presence)" }, + { dir: "src/features/user-status", owner: "PR4 long tail (user status)" }, + { dir: "src/features/custom-emoji", owner: "PR4 long tail (custom emoji)" }, + { dir: "src/features/gifs", owner: "PR4 long tail (gifs)" }, + { dir: "src/features/pulse", owner: "PR4 long tail (pulse)" }, + { dir: "src/features/reminders", owner: "PR4 long tail (reminders)" }, + { dir: "src/features/mesh-compute", owner: "PR4 long tail (mesh compute)" }, + { dir: "src/features/local-archive", owner: "PR4 long tail (local archive)" }, + { + dir: "src/features/identity-archive", + owner: "PR4 long tail (identity archive)", + }, + { dir: "src/features/agent-memory", owner: "PR4 long tail (agent memory)" }, + // Wave 3 touched only `forum/ui/ForumComposer.lifecycle.test.mjs`; the forum + // views themselves have never been extracted. + { dir: "src/features/forum", owner: "PR4 long tail (forum views)" }, + // Shared primitives carry no `t()` calls at all; their chrome text is + // extracted together with the surfaces that adopt them (PR4). + { dir: "src/shared/ui", owner: "PR4 (shared UI primitives)" }, + { dir: "src/shared/layout", owner: "PR4 (shared layout chrome)" }, + { + dir: "src/shared", + owner: "PR4 (shared lib) / PR5 (formatters, datetime)", + }, + { dir: "src/protectedFeatures", owner: "PR4 (protected feature chrome)" }, + { dir: "src/app", owner: "PR4 (app shell chrome)" }, +]); + +export function deferredOwner(relPath) { + const posix = relPath.replace(/\\/g, "/"); + const hit = DEFERRED_SURFACES.find( + (s) => posix === s.dir || posix.startsWith(`${s.dir}/`), + ); + return hit ? hit.owner : null; +} + +/* -------------------------------------------------------------------------- */ +/* Value classification */ +/* -------------------------------------------------------------------------- */ + +const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u; +const LETTER_RE = /\p{L}/u; + +/** Words that carry no translatable meaning on their own. */ +const BRAND_WORDS = new Set([ + "buzz", + "nostr", + "tauri", + "github", + "gitlab", + "codex", + "claude", + "openai", + "anthropic", + "macos", + "ios", + "android", + "linux", + "windows", + "acp", + "mcp", + "nip", + "url", + "urls", + "uri", + "api", + "apis", + "json", + "jsonrpc", + "http", + "https", + "wss", + "css", + "html", + "md", + "pr", + "prs", + "ui", + "ux", + "id", + "ids", + "ok", + "vs", + "eta", +]); + +/** JSX attribute names whose value is UI copy. */ +const COPY_ATTRIBUTE_RE = + /^(aria-label|aria-labelledby|aria-description|arialabel|placeholder|title|tooltip|label|alt|legend|description|help-?text|helptext|confirm(?:ation)?-?(?:label|text|title)|cancel-?(?:label|text)|heading|headline|caption|empty-?(?:state|text|title|message)|error-?(?:text|message|title)|success-?(?:text|message)|button-?(?:text|label)|action-?(?:text|label)|hint|summary|cta)$/i; + +/** + * Attribute names that look copy-bearing but never hold prose. Only consulted + * for names `COPY_ATTRIBUTE_RE` already accepted, and deliberately free of any + * `aria-*` veto — `aria-label` is a required detection target. + */ +const NEVER_ATTRIBUTE_RE = + /^(class|classname|class-name|style|id|key|ref|testid|data-testid|data-.+|role|src|srcset|href|to|target|rel|type|name|value|defaultValue|variant|size|color|colour|background|border|padding|margin|gap|rounded|position|zindex|z-index|flex|grid|weight|shadow|transition|animation|duration|delay|icon|iconname|icon-name|shape|scheme|scheme-name|scheme-slug|scheme-identifier|bundle-id|identifier|version|width|height|as|slot|aschild|dir|lang|htmlfor|for|accept|capture|wrap|part|exportparts|inputmode|autocomplete|crossorigin|referrerpolicy|loading|fetchpriority|decoding|kind|media|sizes|srcdoc|pattern|dirname|enctype|form|formaction|ismap|usemap|span|cols|rows|colspan|rowspan|start|step|multiple|checked|selected|disabled|readonly|required|autofocus|auto-focus|tabindex|contenteditable|draggable|spellcheck|autosave|nonce|csp|accesskey|itemid|itemprop|property|about|datatype|inlist|prefix|resource|typeof|vocab|fill|stroke|viewbox|points|transform|clip-path|mask|filter|opacity|offset|stop-color|font|font-family|font-size|letter-spacing|line-height|x|y|x1|x2|y1|y2|cx|cy|rx|ry|r|dx|dy|k|path|route|routes|pathname|url|link|link-to|component|render|children|of|in|on|by|at|use|from|test|match|matcher|debugid|event|protocol|schemeid)$/i; + +/** Object property names whose value is UI copy. */ +const COPY_PROPERTY_RE = + /^(?:[a-z][a-z0-9]*)?(label|title|text|message|tooltip|placeholder|description|heading|caption|hint|summary|legend|body|copy|confirm|cancel|cta)(?:[A-Z][a-zA-Z0-9]*)?$/; + +/** `const confirmTitle = …`, `const deleteLabel = …` */ +const COPY_VARIABLE_RE = + /^(?:[a-z][a-zA-Z0-9]*)?(label|title|text|message|tooltip|placeholder|description|heading|caption|hint|body|copy|headline|confirm|cancel)(?:[A-Z][a-zA-Z0-9]*)?$/; + +const CLASS_NAME_SUFFIX_RE = + /(?:class|classes|classname|classnames|style|styles)$/i; + +const CODE_ELEMENT_RE = /^(pre|code|kbd|samp|script|style)$/i; + +/** Calls whose first string argument is shown to the user. */ +const UI_CALL_RE = + /^(?:window\.|globalThis\.)?(toast|notify|alert|confirm|prompt|showToast|pushToast|openToast|toastError|toastSuccess|toastMessage)$/; +const TOAST_METHODS = new Set([ + "error", + "success", + "warning", + "warn", + "info", + "message", + "loading", +]); +const TOAST_OBJECT_RE = /^(toast|notify)$/; + +/** `t()`, `i18n.t()`, `api.t()` — their arguments are catalog keys, not copy. */ +const TRANSLATE_CALLEE_RE = /^(?:[a-z][\w$]*\.)*(?:t|tx|translate|text)$/; +/** console/logger/metrics — their arguments are developer text. */ +const LOGGER_CALLEE_RE = + /^(?:console\.\w+|(?:[a-z][\w$]*\.)*(?:logger|log|debug|trace|telemetry|metrics|analytics|sentry|assert|invariant)(?:\.\w+)?)$/; + +const ALWAYS_SKIP_RES = [ + /^https?:\/\//i, // URL + /^www\./i, + /^[a-z][a-z0-9+.-]*:\/\/\S*$/i, // any scheme (wss://, buzz://, file://, mailto:) + /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/, // email + /^([.]{0,2}[\\/]|[a-zA-Z]:[\\/]|\/)\S*$/, // path + /^#[0-9a-f]{3,8}$/i, // hex colour + /^(rgba?|hsla?|lab|lch|oklch|color|linear-gradient|radial-gradient|url)\(/i, + /^v?\d+(\.\d+){1,3}([-+]\S*)?$/, // semver + /^\d+(\.\d+)?(px|rem|em|ch|%|vh|vw|ms|s|deg|x)?$/i, // dimension + /^[a-z]{2}(?:-[A-Za-z]{2,4})?(?:-[A-Za-z]{2})?$/, // bcp47-ish locale + /^@?[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9._@-]+)?$/, // npm-ish bare specifier + /^\/[\w*/:{}.[\]|+-]+\/[a-z]*$/, // regex source as a string +]; + +/** Wire identifiers / payload keys that survive the copy shape test. */ +const PROTOCOLISH_RES = [ + /^(?:kind|event|tag|content|sig|id|pubkey|created_at|author|tags)\s*[:=]\s*\S*$/i, + /^nostr:/i, + /^n(pub|sec|pubkey|profile|event|addr|encrypt(ed)?)[a-z0-9]+$/i, + /^[0-9a-f]{6,}$/i, // hex ids + /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/, // snake_case payload key + /^[a-z]+(?:-[a-z0-9]+){1,}$/, // kebab identifier, e.g. image-png + /^[A-Z][A-Z0-9_]*$/, // SCREAMING_SNAKE + /^[A-Z]{2,}(?:[-_][A-Z0-9]{2,})*$/, // all-caps token(s), e.g. UTC, NPA + /^[a-z]+(?:\.[a-z0-9_-]+)+$/, // dotted config key + /^[a-z]+(?:[A-Z][a-z0-9]+)+$/, // camelCase identifier + /\{\{\s*[\w.]+\s*\}\}\s*$/u, // ends in a bare interpolation, e.g. "{{count}}" +]; + +/** True when the literal can never be UI copy. */ +export function isExcludedValue(value) { + if (value.length < 2) return true; + if (!LETTER_RE.test(value)) return true; // numbers / punctuation only + if (CJK_RE.test(value)) return true; // already translated + const collapsed = value.replace(/\s+/g, " ").trim(); + if (collapsed.length < 2) return true; + if (ALWAYS_SKIP_RES.some((re) => re.test(collapsed))) return true; + if (PROTOCOLISH_RES.some((re) => re.test(collapsed))) return true; + if (/^[a-z-]+\s*:\s*[^;{}]+;?$/.test(collapsed)) return true; // CSS declaration + if (/\[[^\]]*(px|rem|vh|vw|%)[^\]]*\]/.test(collapsed)) return true; // tw arbitrary + // `{}` is the marker this scanner substitutes for an interpolated expression, + // so it must not be mistaken for code or markup. + const prose = collapsed.replace(/\{\}/g, " ").trim(); + if (/[{}<>`;\\]|=>|\$/.test(prose)) return true; // code / markup + if (/^[A-Za-z][\w.]*\([^)]*\)$/.test(prose)) return true; // fn call shape + const words = collapsed.split(/[\s/]+/).filter(Boolean); + if (words.length === 0) return true; + if (words.every((w) => isBrandOrAcronym(w))) return true; // "Buzz", "Nostr PR" + // A single lowercase word ("submit", "npub", "cancel") is an identifier or a + // value in every position this scanner looks at; capitalised words are copy. + if ( + words.length === 1 && + !/^[A-Z]/.test(collapsed) && + !/[.!?]$/.test(collapsed) + ) + return true; + return false; +} + +function isBrandOrAcronym(word) { + const stripped = word.replace(/[.,!?;:]/g, ""); + if (BRAND_WORDS.has(stripped.toLowerCase())) return true; + return /^[A-Z]{2,6}s?$/.test(stripped); +} + +/** Positive test for prose-shaped UI copy. */ +export function looksLikeCopy(value) { + const collapsed = value.replace(/\s+/g, " ").trim(); + if (collapsed.length < 2) return false; + if (!/[A-Za-z]/.test(collapsed)) return false; + if (/[A-Za-z]{2,}\s+[A-Za-z]{2,}/.test(collapsed)) return true; // "Sign out" + if (/^[A-Z][A-Za-z]*$/.test(collapsed)) return true; // "Delete", "Loading" + return /^[A-Z]/.test(collapsed) || /[.!?]$/.test(collapsed); +} + +/* -------------------------------------------------------------------------- */ +/* Extraction */ +/* -------------------------------------------------------------------------- */ + +/** Every comment span in a file, from TypeScript's own lexer. */ +export function findCommentRanges(text) { + const scanner = ts.createScanner( + ts.ScriptTarget.Latest, + /* skipTrivia */ false, + ts.LanguageVariant.Standard, + text, + ); + const ranges = []; + let token = scanner.scan(); + let guard = 0; + while (token !== ts.SyntaxKind.EndOfFileToken && guard++ < 5_000_000) { + if ( + token === ts.SyntaxKind.SingleLineCommentTrivia || + token === ts.SyntaxKind.MultiLineCommentTrivia + ) { + ranges.push([scanner.getTokenPos(), scanner.getTextPos()]); + } + token = scanner.scan(); + } + return ranges; +} + +function inComments(pos, ranges) { + for (const [start, end] of ranges) { + if (start > pos) break; + if (pos >= start && pos < end) return true; + } + return false; +} + +function stripQuotes(s) { + return s.replace(/^["']|["']$/g, ""); +} + +function attributeText(name) { + const raw = ts.isJsxNamespacedName(name) + ? `${name.namespace.getText()}:${name.name.getText()}` + : name.getText(); + return stripQuotes(raw); +} + +function calleeText(expr) { + if (!expr) return ""; + if (ts.isIdentifier(expr)) return expr.text; + if (ts.isPropertyAccessExpression(expr)) { + const inner = calleeText(expr.expression); + return inner ? `${inner}.${expr.name.text}` : expr.name.text; + } + if (ts.isCallExpression(expr)) return calleeText(expr.expression); + if (expr.kind === ts.SyntaxKind.ThisKeyword) return "this"; + return ""; +} + +function isTranslateCall(dotted) { + if (!dotted) return false; + const tail = dotted.split(".").pop() ?? ""; + if (!TRANSLATE_CALLEE_RE.test(dotted)) return false; + // `t(`, `i18n.t(`, `api.t(` count; `setTimeout(`, `getToken(` do not. + return ( + tail === "t" || tail === "tx" || tail === "translate" || tail === "text" + ); +} + +function isLoggerCall(dotted) { + return dotted ? LOGGER_CALLEE_RE.test(dotted) : false; +} + +function isUiCall(dotted) { + if (!dotted) return false; + if (UI_CALL_RE.test(dotted)) return true; + const parts = dotted.split("."); + return ( + parts.length === 2 && + TOAST_OBJECT_RE.test(parts[0]) && + TOAST_METHODS.has(parts[1]) + ); +} + +function normalizeLiteral(raw) { + return raw.replace(/\s+/g, " ").trim(); +} + +function stripGlobal(dotted) { + return dotted.replace(/^window\./, "").replace(/^globalThis\./, ""); +} + +/** + * Flatten one value expression into literal text. String literals and + * substitution-free templates contribute their text; template spans and + * non-literal operands contribute `{}`; single-line `+` chains, `&&` and `??` + * are flattened into one string. Returns whether a real literal was found. + */ +function flattenValue(node, acc, depth = 0) { + if (!node || depth > 8) return false; + if (ts.isParenthesizedExpression(node)) + return flattenValue(node.expression, acc, depth + 1); + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + acc.push(node.text); + return true; + } + if (node.kind === ts.SyntaxKind.TemplateExpression) { + acc.push(node.head.text); + for (const span of node.templateSpans) { + acc.push("{}"); + if (span.literal && typeof span.literal.text === "string") + acc.push(span.literal.text); + } + return true; + } + if (ts.isBinaryExpression(node)) { + const kind = node.operatorToken.kind; + if (kind === ts.SyntaxKind.PlusToken) { + const left = flattenValue(node.left, acc, depth + 1); + const right = flattenValue(node.right, acc, depth + 1); + return left || right; + } + if ( + kind === ts.SyntaxKind.AmpersandAmpersandToken || + kind === ts.SyntaxKind.QuestionQuestionToken + ) + return flattenValue(node.right, acc, depth + 1); + return false; + } + acc.push("{}"); + return false; +} + +/** + * Candidates for one already-parsed source file. + * + * @returns {Array<{file:string,line:number,column:number,kind:string,literal:string,occurrences:number}>} + */ +export function extractCandidates(sourceFile, relPath) { + const comments = findCommentRanges(sourceFile.getFullText()); + const found = new Map(); + + const lineOf = (pos) => + sourceFile.getLineAndCharacterOfPosition(pos).line + 1; + const columnOf = (pos) => + sourceFile.getLineAndCharacterOfPosition(pos).character + 1; + + function record(node, kind, rawLiteral) { + const literal = normalizeLiteral(rawLiteral ?? ""); + if (!literal) return; + if (isExcludedValue(literal)) return; + if (!looksLikeCopy(literal)) return; + // A JsxText node spans the surrounding indentation, so report the line its + // text actually starts on. + const isText = ts.isJsxText(node); + const leading = isText + ? (node.text.match(/^\s*/)?.[0].length ?? 0) + : node.getLeadingTriviaWidth(sourceFile); + const pos = node.getStart(sourceFile) + leading; + if (inComments(node.getStart(sourceFile), comments)) return; + // Multi-line concatenations and template literals usually hold structured + // content; the spec scopes that detection to single-line strings. Multi-line + // JSX text is the normal shape of a dialog description, so it stays. + if (!isText && lineOf(node.getEnd(sourceFile)) !== lineOf(pos)) return; + const key = `${kind}|${literal}`; + const existing = found.get(key); + if (existing) { + existing.occurrences += 1; + return; + } + found.set(key, { + file: relPath, + line: lineOf(pos), + column: columnOf(pos), + kind, + literal, + occurrences: 1, + }); + } + + /** Every literal branch of a value expression becomes its own candidate. */ + function collectFromValue(node, kind, depth = 0) { + if (!node || depth > 8) return; + if (ts.isParenthesizedExpression(node)) + return collectFromValue(node.expression, kind, depth + 1); + if (ts.isConditionalExpression(node)) { + collectFromValue(node.whenTrue, kind, depth + 1); + collectFromValue(node.whenFalse, kind, depth + 1); + return; + } + const acc = []; + if (flattenValue(node, acc)) record(node, kind, acc.join("")); + } + + function isJsxChildrenPosition(node) { + const parent = node.parent; + return Boolean( + parent && (ts.isJsxElement(parent) || ts.isJsxFragment(parent)), + ); + } + + function insideCodeElement(node) { + let cur = node.parent; + while (cur) { + if ( + ts.isJsxElement(cur) && + CODE_ELEMENT_RE.test(cur.openingElement.tagName.getText(sourceFile)) + ) + return true; + cur = cur.parent; + } + return false; + } + + function visit(node, inert) { + if (ts.isJsxText(node)) { + if (!insideCodeElement(node)) record(node, "jsx-text", node.text); + return; + } + + if (ts.isJsxAttribute(node)) { + const name = attributeText(node.name); + if ( + COPY_ATTRIBUTE_RE.test(name) && + !NEVER_ATTRIBUTE_RE.test(name) && + node.initializer + ) { + const value = ts.isJsxExpression(node.initializer) + ? node.initializer.expression + : node.initializer; + if (!inert || isUiCall(calleeText(value))) + collectFromValue(value, `attr:${name}`); + } + ts.forEachChild(node, (child) => visit(child, inert)); + return; + } + + if (ts.isJsxExpression(node) && isJsxChildrenPosition(node)) { + const value = node.expression; + if (!inert || isUiCall(calleeText(value))) + collectFromValue(value, "jsx-expr"); + ts.forEachChild(node, (child) => visit(child, inert)); + return; + } + + if (ts.isCallExpression(node)) { + const dotted = calleeText(node.expression); + const uiCall = isUiCall(dotted); + const childInert = + inert || isTranslateCall(dotted) || isLoggerCall(dotted); + if (uiCall && !inert && node.arguments.length > 0) + collectFromValue(node.arguments[0], `call:${stripGlobal(dotted)}`); + ts.forEachChild(node, (child) => { + if (child === node.expression) return; + visit(child, childInert); + }); + return; + } + + if (ts.isPropertyAssignment(node)) { + const name = stripQuotes(node.name.getText(sourceFile)); + if ( + !inert && + COPY_PROPERTY_RE.test(name) && + !CLASS_NAME_SUFFIX_RE.test(name) && + !NEVER_ATTRIBUTE_RE.test(name) + ) + collectFromValue(node.initializer, `prop:${name}`); + ts.forEachChild(node, (child) => visit(child, inert)); + return; + } + + if (ts.isVariableDeclaration(node) && node.initializer) { + const name = node.name.getText(sourceFile); + if ( + !inert && + COPY_VARIABLE_RE.test(name) && + !CLASS_NAME_SUFFIX_RE.test(name) + ) + collectFromValue(node.initializer, `const:${name}`); + ts.forEachChild(node, (child) => visit(child, inert)); + return; + } + + ts.forEachChild(node, (child) => visit(child, inert)); + } + + visit(sourceFile, false); + return [...found.values()].sort( + (a, b) => a.line - b.line || a.kind.localeCompare(b.kind), + ); +} + +/* -------------------------------------------------------------------------- */ +/* Corpus walk */ +/* -------------------------------------------------------------------------- */ + +export function parseSourceFile(absPath, text) { + return ts.createSourceFile( + path.basename(absPath), + text, + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + absPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +/** Parse + extract for one snippet (used by the unit test and `--report` peers). */ +export function scanSourceText(text, relPath = "src/sample/Sample.tsx") { + return extractCandidates(parseSourceFile(relPath, text), relPath); +} + +function* walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (SKIP_DIR_NAMES.has(entry.name)) continue; + const rel = path.relative(PROJECT_ROOT, full).replace(/\\/g, "/"); + if (SKIP_PATH_PREFIXES.some((p) => rel === p || rel.startsWith(`${p}/`))) + continue; + yield* walk(full); + } else if (entry.isFile()) { + yield full; + } + } +} + +/** + * Scan the whole production source tree. + * + * @returns {{candidates: Array, filesScanned:number, parseFailures:string[]}} + */ +export function scanCorpus({ root = SRC_DIR } = {}) { + if (!existsSync(root)) + throw new Error(`desktop source directory not found: ${root}`); + const candidates = []; + const parseFailures = []; + let filesScanned = 0; + for (const abs of walk(root)) { + const rel = path.relative(PROJECT_ROOT, abs).replace(/\\/g, "/"); + if (!shouldScanFile(rel)) continue; + filesScanned += 1; + const sourceFile = parseSourceFile(abs, readFileSync(abs, "utf8")); + const errors = (sourceFile.parseDiagnostics ?? []).filter( + (d) => d.category === ts.DiagnosticCategory.Error, + ); + if (errors.length > 0) { + // A file we cannot parse is a gate failure, never a silent skip. + parseFailures.push( + `${rel}: ${errors.length} parse error(s): ${ts.flattenDiagnosticMessageText( + errors[0].messageText, + " ", + )}`, + ); + continue; + } + for (const c of extractCandidates(sourceFile, rel)) candidates.push(c); + } + return { candidates, filesScanned, parseFailures }; +} + +/* -------------------------------------------------------------------------- */ +/* Baseline */ +/* -------------------------------------------------------------------------- */ + +export function entryId(entry) { + return `${entry.file}|${entry.kind}|${entry.literal}`; +} + +/** + * Only a per-surface deferral may lean on the group note. Every other reason is + * a claim about one specific string, so it has to be written next to that + * string — that is what stops the baseline becoming a blanket accept. + */ +const GROUP_NOTE_IS_ENOUGH = new Set(["deferred-pr4-surface"]); + +/** + * Validate the raw baseline document and flatten it into one record per + * literal: `{file, kind, literal, category, note}`. + */ +export function parseBaseline(raw) { + const errors = []; + const entries = []; + if (!raw || !Array.isArray(raw.entries)) + return { entries, errors: ["baseline document has no `entries` array"] }; + const seen = new Set(); + raw.entries.forEach((group, index) => { + const where = `group #${index + 1}`; + if (!group || typeof group !== "object") { + errors.push(`${where}: not an object`); + return; + } + for (const field of ["file", "category", "note"]) { + if (typeof group[field] !== "string" || group[field].trim() === "") { + errors.push(`${where}: \`${field}\` must be a non-empty string`); + return; + } + } + if (!Object.hasOwn(BASELINE_CATEGORIES, group.category)) { + errors.push( + `${where}: unknown category \`${group.category}\`; allowed: ${Object.keys( + BASELINE_CATEGORIES, + ).join(", ")}`, + ); + return; + } + if (!Array.isArray(group.items) || group.items.length === 0) { + errors.push(`${where}: \`items\` must be a non-empty array`); + return; + } + if (typeof group.file !== "string" || !/^src\//.test(group.file)) { + errors.push( + `${where}: \`file\` must be a src/... path relative to desktop/`, + ); + return; + } + group.items.forEach((item, itemIndex) => { + const at = `${where} item #${itemIndex + 1}`; + if (!item || typeof item !== "object") { + errors.push(`${at}: not an object`); + return; + } + for (const field of ["kind", "literal"]) { + if (typeof item[field] !== "string" || item[field].trim() === "") { + errors.push(`${at}: \`${field}\` must be a non-empty string`); + return; + } + } + const needsOwnNote = !GROUP_NOTE_IS_ENOUGH.has(group.category); + const note = + typeof item.note === "string" && item.note.trim() + ? item.note.trim() + : group.note.trim(); + if ( + needsOwnNote && + !(typeof item.note === "string" && item.note.trim()) + ) { + errors.push( + `${at}: category \`${group.category}\` requires its own note on the literal`, + ); + return; + } + const record = { + file: group.file, + kind: item.kind, + literal: item.literal, + category: group.category, + note, + }; + const id = entryId(record); + if (seen.has(id)) { + errors.push(`${at}: duplicate entry ${id}`); + return; + } + seen.add(id); + entries.push(record); + }); + }); + return { entries, errors }; +} + +export function loadBaseline(file = BASELINE_PATH) { + if (!existsSync(file)) throw new Error(`missing baseline file: ${file}`); + let doc; + try { + doc = JSON.parse(readFileSync(file, "utf8")); + } catch (error) { + throw new Error(`baseline at ${file} is not valid JSON: ${error.message}`); + } + const { entries, errors } = parseBaseline(doc); + if (errors.length > 0) + throw new Error( + `invalid baseline at ${file}:\n - ${errors.slice(0, 20).join("\n - ")}`, + ); + return entries; +} + +/* -------------------------------------------------------------------------- */ +/* Gate */ +/* -------------------------------------------------------------------------- */ + +/** + * Compare candidates against the baseline. Pure, so the unit test can falsify + * it without touching the repository. + */ +export function evaluateGate({ candidates, entries }) { + const byId = new Map(entries.map((e) => [entryId(e), e])); + const candidateIds = new Set(candidates.map((c) => entryId(c))); + const unbaselined = candidates.filter((c) => !byId.has(entryId(c))); + const violations = entries.filter( + (e) => FAILING_CATEGORIES.has(e.category) && candidateIds.has(entryId(e)), + ); + const stale = entries.filter((e) => !candidateIds.has(entryId(e))); + return { + ok: unbaselined.length === 0 && violations.length === 0, + unbaselined, + violations, + stale, + candidateCount: candidates.length, + baselineSize: entries.length, + }; +} + +function formatCandidate(c) { + return ` ${c.file}:${c.line} [${c.kind}] ${JSON.stringify(c.literal)}`; +} + +export function main({ + argv = process.argv.slice(2), + write = (s) => process.stdout.write(`${s}\n`), + writeErr = (s) => process.stderr.write(`${s}\n`), +} = {}) { + const flags = new Set(argv); + let scan; + try { + scan = scanCorpus(); + } catch (error) { + writeErr(`check-hardcoded-strings: ${error.message}`); + return 2; + } + + if (flags.has("--report") || flags.has("--report-unbaselined")) { + let rows = scan.candidates; + if (flags.has("--report-unbaselined")) { + let entries; + try { + entries = loadBaseline(); + } catch (error) { + writeErr(`check-hardcoded-strings: ${error.message}`); + return 2; + } + rows = evaluateGate({ candidates: scan.candidates, entries }).unbaselined; + } + write(JSON.stringify(rows, null, 2)); + return 0; + } + + let entries; + try { + entries = loadBaseline(); + } catch (error) { + writeErr(`check-hardcoded-strings: ${error.message}`); + return 2; + } + + const result = evaluateGate({ candidates: scan.candidates, entries }); + + if (scan.parseFailures.length > 0) { + writeErr( + `check-hardcoded-strings: ${scan.parseFailures.length} file(s) could not be parsed (counted as gate failures, not skipped):`, + ); + for (const f of scan.parseFailures.slice(0, 10)) writeErr(` ${f}`); + return 2; + } + + if (result.violations.length > 0) { + writeErr( + `check-hardcoded-strings: ${result.violations.length} baselined entr${ + result.violations.length === 1 ? "y is" : "ies are" + } marked true-violation — the text must be translated:`, + ); + for (const v of result.violations.slice(0, 40)) + writeErr(`${formatCandidate(v)} — ${v.note}`); + if (result.violations.length > 40) + writeErr(` … and ${result.violations.length - 40} more`); + } + + if (result.unbaselined.length > 0) { + writeErr( + `check-hardcoded-strings: ${result.unbaselined.length} hardcoded user-facing English string(s) with no baseline entry:`, + ); + for (const c of result.unbaselined.slice(0, 40)) + writeErr(formatCandidate(c)); + if (result.unbaselined.length > 40) + writeErr(` … and ${result.unbaselined.length - 40} more`); + writeErr( + "\nFix by moving the text into src/locales/en.json + zh-Hans.json and\n" + + "rendering it through t(). If the literal is genuinely not UI copy, add an entry\n" + + "to scripts/hardcoded-strings-baseline.json naming one of the fixed categories\n" + + "and why. Text still owned by a surface being localized in another wave goes in\n" + + "as `deferred-pr4-surface` with that wave noted.", + ); + } + + if (result.stale.length > 0) { + write( + `check-hardcoded-strings: ${result.stale.length} baseline entr${ + result.stale.length === 1 ? "y" : "ies" + } no longer match a scanned candidate (advisory — prune when the owning surface closes):`, + ); + for (const s of result.stale.slice(0, 15)) + write(` ${s.file} [${s.kind}] ${JSON.stringify(s.literal)}`); + if (result.stale.length > 15) + write(` … and ${result.stale.length - 15} more`); + } + + const counts = new Map(); + for (const e of entries) + counts.set(e.category, (counts.get(e.category) ?? 0) + 1); + const summary = + `scanned ${scan.filesScanned} files, ${result.candidateCount} candidates; ` + + `baseline ${result.baselineSize} entries (${[...counts] + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${k}=${v}`) + .join(" ")}); ` + + `unbaselined ${result.unbaselined.length}, true-violations ${result.violations.length}, stale ${result.stale.length}.`; + + if (!result.ok) { + writeErr(`FAIL — ${summary}`); + return 1; + } + write(`OK — ${summary}`); + return 0; +} + +const invokedDirectly = + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(SCRIPT_PATH); +if (invokedDirectly) process.exitCode = main(); diff --git a/desktop/scripts/check-hardcoded-strings.test.mjs b/desktop/scripts/check-hardcoded-strings.test.mjs new file mode 100644 index 00000000000..964d81dbca5 --- /dev/null +++ b/desktop/scripts/check-hardcoded-strings.test.mjs @@ -0,0 +1,269 @@ +/** + * Unit test for the PR6 hardcoded-string gate (spec NFR-007). + * + * Run from `desktop/`: + * node --import ./test-loader.mjs --experimental-strip-types --test scripts/check-hardcoded-strings.test.mjs + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + BASELINE_CATEGORIES, + evaluateGate, + parseBaseline, + scanSourceText, + shouldScanFile, +} from "./check-hardcoded-strings.mjs"; + +const REL = "src/features/messages/ui/Sample.tsx"; + +/** `kind=literal` for every candidate the scanner reports in `code`. */ +const hits = (code, relPath = REL) => + scanSourceText(code, relPath).map((c) => `${c.kind}=${c.literal}`); + +const candidate = (code, relPath = REL) => scanSourceText(code, relPath)[0]; + +test("flags a plain JSX text literal", () => { + const code = `export const C = () => ( +

Send the message now

+ );`; + assert.ok(hits(code).includes("jsx-text=Send the message now")); +}); + +test("flags a single-word JSX text literal (button labels)", () => { + assert.deepEqual(hits(`export const C = () => ;`), [ + "jsx-text=Delete", + ]); +}); + +test("flags an aria-label, in both the DOM and camelCase spelling", () => { + assert.ok( + hits( + `export const C = () => ); } @@ -59,6 +62,7 @@ export function AppTopChrome({ onGoForward, hasCommunityRail = false, }: AppTopChromeProps) { + const { t } = useTranslation(); const topChromeRef = React.useRef(null); const isFullscreen = useIsFullscreen(); // On macOS the traffic-light buttons overlay the chrome (see @@ -133,7 +137,7 @@ export function AppTopChrome({
); diff --git a/desktop/src/features/agent-memory/ui/MemorySection.tsx b/desktop/src/features/agent-memory/ui/MemorySection.tsx index d63ceeb3cd9..72546ea0c8b 100644 --- a/desktop/src/features/agent-memory/ui/MemorySection.tsx +++ b/desktop/src/features/agent-memory/ui/MemorySection.tsx @@ -3,6 +3,7 @@ import { AlertTriangle, Brain, ChevronDown, RefreshCw } from "lucide-react"; import { useAgentMemoryGraph } from "@/features/agent-memory/hooks"; import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph"; +import { useTranslation } from "@/i18n"; import type { EngramEntry } from "@/shared/api/tauriEngrams"; import { cn } from "@/shared/lib/cn"; import { Button, type ButtonProps } from "@/shared/ui/button"; @@ -12,12 +13,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const MEMORY_LIST_PREVIEW_LIMIT = 3; type MemorySectionVariant = "cards" | "grouped"; -const MEMORY_TRUNCATED_TOOLTIP = - "This list may be incomplete — the relay returned the maximum number of memories."; - -const MEMORY_DANGLING_REF_TOOLTIP = - "This memory links to a slug that wasn't found in the loaded memory list."; - /** * Memory section — IXI-7 phase 1 read-only viewer. * @@ -68,6 +63,7 @@ export function MemoryRefreshButton({ iconClassName?: string; variant?: ButtonProps["variant"]; }): React.ReactElement | null { + const { t } = useTranslation(); const { query } = useAgentMemoryGraph(agentPubkey, { enabled: viewerIsOwner, }); @@ -76,7 +72,7 @@ export function MemoryRefreshButton({ return ( ); @@ -218,6 +218,7 @@ function MemoryStaleErrorBanner({ onRetry: () => void; variant: MemorySectionVariant; }) { + const { t } = useTranslation(); return (
- Refresh failed. + + {t("agent-memory.section.refresh-failed")} +
); @@ -248,6 +251,7 @@ function MemoryGraphView({ truncated: boolean; variant: MemorySectionVariant; }) { + const { t } = useTranslation(); const { rootedTree, orphans, dangling } = graph; const [showAllEntries, setShowAllEntries] = React.useState(false); const danglingSlugs = React.useMemo( @@ -263,9 +267,11 @@ function MemoryGraphView({ data-testid="agent-memory-empty" > -

Build this agent's memory

+

+ {t("agent-memory.empty.title")} +

- Try telling this agent to remember something for next time. + {t("agent-memory.empty.description")}

); @@ -292,8 +298,9 @@ function MemoryGraphView({ )} data-testid="agent-memory-no-core" > - No core memory yet — agent - identity is unrooted. + {t("agent-memory.graph.no-core-prefix")} + core + {t("agent-memory.graph.no-core-suffix")}

) : null} @@ -338,7 +345,7 @@ function MemoryGraphView({ onClick={() => setShowAllEntries(false)} type="button" > - Show less + {t("agent-memory.graph.show-less")} ) : null} @@ -356,6 +363,7 @@ function MemoryShowMoreButton({ truncated: boolean; variant: MemorySectionVariant; }) { + const { t } = useTranslation(); const button = ( ); @@ -380,13 +388,14 @@ function MemoryShowMoreButton({ {button} - {MEMORY_TRUNCATED_TOOLTIP} + {t("agent-memory.tooltip.truncated")} ); } function MemoryTruncatedHint({ variant }: { variant: MemorySectionVariant }) { + const { t } = useTranslation(); return ( @@ -401,7 +410,7 @@ function MemoryTruncatedHint({ variant }: { variant: MemorySectionVariant }) { - {MEMORY_TRUNCATED_TOOLTIP} + {t("agent-memory.tooltip.truncated")} ); @@ -447,6 +456,7 @@ function MemoryBodyText({ body }: { body: string }) { } function MemoryDanglingRefsHint({ slugs }: { slugs: string[] }) { + const { t } = useTranslation(); if (slugs.length === 0) return null; return ( @@ -456,7 +466,7 @@ function MemoryDanglingRefsHint({ slugs }: { slugs: string[] }) { >

- Missing {slugs.length === 1 ? "link" : "links"}:{" "} + {t("agent-memory.entry.missing-links", { count: slugs.length })} {slugs.map((slug, index) => ( {index > 0 ? ", " : null} @@ -470,7 +480,7 @@ function MemoryDanglingRefsHint({ slugs }: { slugs: string[] }) { ))}

- {MEMORY_DANGLING_REF_TOOLTIP} + {t("agent-memory.entry.dangling-ref-tooltip")}

@@ -532,6 +542,7 @@ function MemoryEntryAccordion({ entry: EngramEntry; variant: MemorySectionVariant; }) { + const { t } = useTranslation(); const [open, setOpen] = React.useState(false); const [showCaret, setShowCaret] = React.useState(false); const articleRef = React.useRef(null); @@ -588,7 +599,9 @@ function MemoryEntryAccordion({ ref={bodyRef} > {isEmpty ? ( - (empty) + + {t("agent-memory.entry.empty")} + ) : ( )} diff --git a/desktop/src/features/agents/cardMintStore.test.mjs b/desktop/src/features/agents/cardMintStore.test.mjs index 6c2f4522abd..7c055a3d7ee 100644 --- a/desktop/src/features/agents/cardMintStore.test.mjs +++ b/desktop/src/features/agents/cardMintStore.test.mjs @@ -217,3 +217,13 @@ describe("cardMintStore", () => { assert.notEqual(before, after, "snapshot identity must change on update"); }); }); + +// Boot i18n for the assertions below: Node defines a global `navigator`, so language +// detection must be pinned to English before initializeI18n() reads it. +import { initializeI18n } from "@/i18n"; + +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { languages: ["en-US", "en"], userAgent: "buzz-unit-test" }, +}); +initializeI18n(); diff --git a/desktop/src/features/agents/cardMintStore.ts b/desktop/src/features/agents/cardMintStore.ts index c38bf393cc8..9c3c76f1eb0 100644 --- a/desktop/src/features/agents/cardMintStore.ts +++ b/desktop/src/features/agents/cardMintStore.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { toast } from "sonner"; +import { i18n } from "@/i18n"; import { mintAgentCard, NO_OPENAI_KEY_PREFIX, @@ -109,15 +110,18 @@ export async function runCardMintJob( input.memoryLevel, ); updateJob(jobId, { phase: "done", card }); - toast.success(`${input.agentName}'s card is ready`, { + toast.success(i18n.t("agents.card-mint.ready", { name: input.agentName }), { action: { - label: "View card", + label: i18n.t("agents.card-mint.view"), onClick: () => viewMintedCardJob(jobId), }, duration: 10_000, }); } catch (error) { - let message = error instanceof Error ? error.message : "Card mint failed."; + let message = + error instanceof Error + ? error.message + : i18n.t("agents.card-mint.failed"); if (message.startsWith(NO_OPENAI_KEY_PREFIX)) { // The dialog pre-checks the key, so this only happens when the key was // removed between dialog-open and mint. The dialog's key-setup panel is @@ -130,13 +134,15 @@ export async function runCardMintJob( // The saved OpenAI key is invalid or expired. Only match the OpenAI-call // envelope prefix and the specific Incorrect-API-key message to avoid // rewriting unrelated 401s (e.g. "Avatar fetch failed: HTTP 401 …"). - message = - 'The OpenAI API key is invalid or expired. Open the mint dialog and use "Update API key" to replace it.'; + message = i18n.t("agents.card-mint.invalid-key"); } updateJob(jobId, { phase: "error", error: message }); - toast.error(`Minting ${input.agentName}'s card failed`, { - description: message, - }); + toast.error( + i18n.t("agents.card-mint.mint-failed", { name: input.agentName }), + { + description: message, + }, + ); } } diff --git a/desktop/src/features/agents/lib/agentAccessWarning.test.mjs b/desktop/src/features/agents/lib/agentAccessWarning.test.mjs index 0a3d13ce7b2..1e435f3cdde 100644 --- a/desktop/src/features/agents/lib/agentAccessWarning.test.mjs +++ b/desktop/src/features/agents/lib/agentAccessWarning.test.mjs @@ -87,3 +87,13 @@ test("runLocationForRunOn treats a blank value as unknown", () => { assert.equal(runLocationForRunOn(null), null); assert.equal(runLocationForRunOn(undefined), null); }); + +// Boot i18n for the assertions below: Node defines a global `navigator`, so language +// detection must be pinned to English before initializeI18n() reads it. +import { initializeI18n } from "@/i18n"; + +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { languages: ["en-US", "en"], userAgent: "buzz-unit-test" }, +}); +initializeI18n(); diff --git a/desktop/src/features/agents/lib/agentAccessWarning.ts b/desktop/src/features/agents/lib/agentAccessWarning.ts index d98058f5bf3..87102f2eb0b 100644 --- a/desktop/src/features/agents/lib/agentAccessWarning.ts +++ b/desktop/src/features/agents/lib/agentAccessWarning.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n"; import type { ManagedAgentBackend, RespondToMode } from "@/shared/api/types"; /** @@ -51,13 +52,16 @@ export function agentAccessWarningText( runLocation?: AgentRunLocation | null, ): string | null { if (mode !== "anyone" && mode !== "allowlist") return null; - const audience = mode === "anyone" ? "Anyone" : "Selected people"; // The two locations differ in more than the noun: a local agent reaches the // owner's own files, while a remote host's files aren't theirs to describe — - // only the accounts and tools provisioned there. - const target = - runLocation === "remote" - ? "the server it runs on, including any accounts and tools available there" - : "your computer, including files, accounts, and connected tools"; - return `${audience} can use this agent to access ${target}.`; + // only the accounts and tools provisioned there. Each full sentence is its + // own key so no language has to be assembled from English fragments. + if (mode === "anyone") { + return runLocation === "remote" + ? i18n.t("agents.access-warning.anyone-remote") + : i18n.t("agents.access-warning.anyone-local"); + } + return runLocation === "remote" + ? i18n.t("agents.access-warning.allowlist-remote") + : i18n.t("agents.access-warning.allowlist-local"); } diff --git a/desktop/src/features/agents/lib/useAgentAvailability.test.mjs b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs index f9b2df050a4..95af610f8df 100644 --- a/desktop/src/features/agents/lib/useAgentAvailability.test.mjs +++ b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs @@ -117,3 +117,13 @@ for (const availability of ["online", "away"]) { ); }); } + +// The card renders getPresenceLabel(), which resolves through the catalog, so +// language detection must be pinned to English before initializeI18n() reads it. +import { initializeI18n } from "@/i18n"; + +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { languages: ["en-US", "en"], userAgent: "buzz-unit-test" }, +}); +initializeI18n(); diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index 366a19d7ca3..85fcd7e7a49 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import { useTranslation } from "@/i18n"; import { type AttachManagedAgentToChannelResult, useAttachManagedAgentToChannelMutation, @@ -38,6 +39,7 @@ export function AddAgentToChannelDialog({ ) => void; onOpenChange: (open: boolean) => void; }) { + const { t } = useTranslation(); const channelsQuery = useChannelsQuery(); const [channelId, setChannelId] = React.useState(""); const [role, setRole] = React.useState>("bot"); @@ -121,18 +123,19 @@ export function AddAgentToChannelDialog({
- Add agent to channel + {t("agents.add-agent-to-channel.title")} - Add {agent?.name ?? "this agent"} to a channel so desktop chat can - `@mention` it. Running agents pick up new channels automatically - via membership notifications. + {t("agents.add-agent-to-channel.description", { + name: + agent?.name ?? t("agents.add-agent-to-channel.this-agent"), + })}

- Only channels accessible to the current desktop user are shown - here. + {t("agents.add-agent-to-channel.channels-hint")}

{isAlreadyMember ? (
- Already a member of this channel + {t("agents.add-agent-to-channel.already-member")}
) : null} @@ -170,7 +174,7 @@ export function AddAgentToChannelDialog({ className="text-sm font-medium" htmlFor="agent-channel-role" > - Role + {t("agents.add-agent-to-channel.role")} {channels.length === 0 ? ( - + ) : null} {channels.map((channel) => (
@@ -131,7 +135,10 @@ export function EditAgentProviderModelFields({ isInherited={apiKeyIsInherited} inheritedLabel={apiKeyInheritedLabel} isRequired={apiKeyIsRequired} - label={getProviderApiKeyLabel(effectiveProvider) ?? "API Key"} + label={ + getProviderApiKeyLabel(effectiveProvider) ?? + t("agents.config-fields.api-key") + } onValueChange={onApiKeyChange} value={apiKeyValue} /> @@ -143,13 +150,15 @@ export function EditAgentProviderModelFields({ className="text-sm font-medium text-foreground" htmlFor="edit-agent-model" > - Model + {t("mesh-compute.card.model")} {modelRequired ? ( ) : ( - Optional + + {t("sidebar.channel-form.optional")} + )} {showCustomModelInput ? ( @@ -168,7 +177,7 @@ export function EditAgentProviderModelFields({ )} > onModelChange(event.target.value)} - placeholder="Custom model ID" + placeholder={t("agents.model-field.custom-id")} value={model} />
diff --git a/desktop/src/features/agents/ui/EffortPickerField.tsx b/desktop/src/features/agents/ui/EffortPickerField.tsx index cafe9921b28..2aeed9cf0d7 100644 --- a/desktop/src/features/agents/ui/EffortPickerField.tsx +++ b/desktop/src/features/agents/ui/EffortPickerField.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n"; import type { ManagedAgent, RuntimeConfigSurface } from "@/shared/api/types"; import { PERSONA_LABEL_OPTIONAL_CLASS } from "./agentConfigOptions"; import { @@ -37,6 +38,7 @@ export function EffortPickerField({ value: string | null; onChange: (level: string | null) => void; }) { + const { t } = useTranslation(); const { visible, options, selectValue } = effortPickerState({ backend: agent.backend, effortConfigId: config?.effortConfigId, @@ -54,8 +56,10 @@ export function EffortPickerField({ className="text-sm font-medium text-foreground" htmlFor="edit-agent-effort" > - Thinking effort - Optional + {t("agents.effort.field-label")} + + {t("sidebar.channel-form.optional")} +

- Applied at the next session start. + {t("agents.effort.applies-next-session")}

); diff --git a/desktop/src/features/agents/ui/EnvVarsEditor.tsx b/desktop/src/features/agents/ui/EnvVarsEditor.tsx index 08496eece59..979e166a397 100644 --- a/desktop/src/features/agents/ui/EnvVarsEditor.tsx +++ b/desktop/src/features/agents/ui/EnvVarsEditor.tsx @@ -1,9 +1,11 @@ import { AlertCircle, Lock, Plus, X } from "lucide-react"; import * as React from "react"; +import { useTranslation } from "@/i18n"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; -import { cn } from "@/shared/lib/cn"; + import { PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, @@ -224,6 +226,7 @@ export function EnvVarsEditor({ inheritedRowsLabel = "build", keyAnnotations, }: EnvVarsEditorProps) { + const { t } = useTranslation(); // Keys that render as their own special rows (required amber rows or // file-satisfied read-only rows). These must NEVER enter `rows` state — // they read/write `value` directly via `onChange`/`updateRequiredValue`. @@ -373,7 +376,7 @@ export function EnvVarsEditor({ {isMissing ? ( - Required + {t("agents.advanced-badge.required")} ) : null} @@ -384,7 +387,7 @@ export function EnvVarsEditor({ )} > - Set in goose config + {t("agents.env-vars.set-in-goose")} {/* Spacer columns to align with required-key rows */} @@ -505,7 +508,9 @@ export function EnvVarsEditor({ {irow.key} - Inherited from {inheritedRowsLabel} + {t("agents.env-vars.inherited-from", { + source: inheritedRowsLabel, + })}
- No variables set. + {t("agents.env-vars.empty")}

) : null} {rows.map((row) => { @@ -556,7 +561,7 @@ export function EnvVarsEditor({ )} >
diff --git a/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx b/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx index 34efb54c94d..31d0a43cefc 100644 --- a/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx +++ b/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx @@ -1,5 +1,6 @@ import { AlertCircle } from "lucide-react"; +import { useTranslation } from "@/i18n"; import { useRetryBootWarm } from "@/features/agents/hooks"; import { Button } from "@/shared/ui/button"; @@ -11,13 +12,14 @@ import { Button } from "@/shared/ui/button"; * picker and Agent defaults surfaces both render this instead of a dead end. */ export function HarnessCatalogRetryNotice() { + const { t } = useTranslation(); const retryBootWarm = useRetryBootWarm(); return (
- Couldn't detect agent harnesses. + {t("agents.harness.detect-failed")}
); diff --git a/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx index 4eb5c6e721a..220c9bf5049 100644 --- a/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx @@ -1,5 +1,6 @@ import { CircleAlert } from "lucide-react"; +import { useTranslation } from "@/i18n"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -21,6 +22,7 @@ export function ManagedAgentLogPanel({ selectedAgent: ManagedAgent | null; variant?: "inline" | "section"; }) { + const { t } = useTranslation(); const isInline = variant === "inline"; const isBare = chrome === "bare"; const logFileLabel = selectedAgent @@ -44,10 +46,10 @@ export function ManagedAgentLogPanel({

- Harness Log + {t("agents.log-panel.title")}

- Select a local agent to inspect recent output. + {t("agents.log-panel.select-agent")}

@@ -61,10 +63,10 @@ export function ManagedAgentLogPanel({ )} >

- No local agent selected + {t("agents.log-panel.none-selected")}

- Pick a managed agent to view the latest ACP log output. + {t("agents.log-panel.pick-agent")}

) : isLoading ? ( @@ -132,7 +134,7 @@ export function ManagedAgentLogPanel({ )} data-testid="managed-agent-log-content" > - {logContent?.trim() ? logContent : "No log output yet."} + {logContent?.trim() ? logContent : t("agents.log-panel.no-output")} )} @@ -156,13 +158,14 @@ function HarnessLogHeader({ logFileLabel: string; selectedAgent: ManagedAgent; }) { + const { t } = useTranslation(); const fileTitle = `${selectedAgent.name} · ${logFileLabel}`; return (
- Harness Log + {t("agents.log-panel.title")} {variant === "compact" ? ( -

MCP servers

+

+ {t("agents.mcp-servers.title")} +

) : null} {isBuzzAgent && buzzAgentSlot ? buzzAgentSlot : null} @@ -77,7 +81,7 @@ export function McpServersSection({ : "flex min-h-16 items-center px-4 py-3", )} > - No custom servers configured. + {t("agents.mcp-servers.empty")}

)} @@ -102,6 +106,7 @@ function McpServerRow({ extension: ExtensionEntry; variant: "compact" | "profile"; }) { + const { t } = useTranslation(); return (
- {extension.enabled ? "Enabled" : "Disabled"} + {extension.enabled + ? t("agents.mcp-servers.enabled") + : t("agents.mcp-servers.disabled")}
); diff --git a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx index 30bb7fff6e8..3fac132b73c 100644 --- a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx +++ b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx @@ -6,6 +6,7 @@ import { Trash2, } from "lucide-react"; +import { useTranslation } from "@/i18n"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import { DropdownMenu, @@ -40,6 +41,7 @@ export function PersonaActionsMenu({ onDeactivate: (persona: AgentPersona) => void; onDelete: (persona: AgentPersona) => void; }) { + const { t } = useTranslation(); const disabled = isActionPending || isPending; const canEdit = !persona.sourceTeam; @@ -47,7 +49,9 @@ export function PersonaActionsMenu({
@@ -202,8 +210,10 @@ export function PersonaAdvancedFields({ className="text-sm font-medium text-foreground" htmlFor="persona-parallelism" > - Parallelism - Optional + {t("agents.advanced-fields.parallelism")} + + {t("sidebar.channel-form.optional")} +
- Instance name pool - Optional + {t("agents.advanced-fields.name-pool")} + + {t("sidebar.channel-form.optional")} +
{ test("null persona keeps the generic fallback", () => { assert.equal(personaDeleteDescription(null, 2), "Delete this agent."); }); + +// Boot i18n for the assertions above: Node defines a global `navigator`, so language +// detection must be pinned to English before initializeI18n() reads it. +import { initializeI18n } from "@/i18n"; + +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { languages: ["en-US", "en"], userAgent: "buzz-unit-test" }, +}); +initializeI18n(); diff --git a/desktop/src/features/agents/ui/PersonaDeleteDialog.tsx b/desktop/src/features/agents/ui/PersonaDeleteDialog.tsx index fd9da437511..046447e77dc 100644 --- a/desktop/src/features/agents/ui/PersonaDeleteDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDeleteDialog.tsx @@ -1,3 +1,4 @@ +import { i18n, useTranslation } from "@/i18n"; import type { AgentPersona } from "@/shared/api/types"; import { AlertDialog, @@ -32,16 +33,19 @@ export function personaDeleteDescription( instanceCount: number, ): string { if (!persona) { - return "Delete this agent."; + return i18n.t("agents.persona-delete.confirm-no-persona"); } if (instanceCount === 0) { - return `Delete ${persona.displayName}.`; + return i18n.t("agents.persona-delete.confirm-name", { + name: persona.displayName, + }); } - const cascade = - instanceCount === 1 - ? "Also deletes 1 agent instance and archives its identity on the relay, so it no longer appears in member lists or mention suggestions." - : `Also deletes ${instanceCount} agent instances and archives their identities on the relay, so they no longer appear in member lists or mention suggestions.`; - return `Delete ${persona.displayName}. ${cascade}`; + const cascade = i18n.t("agents.persona-delete.cascade", { + count: instanceCount, + }); + return `${i18n.t("agents.persona-delete.confirm-name", { + name: persona.displayName, + })} ${cascade}`; } export function PersonaDeleteDialog({ @@ -51,11 +55,14 @@ export function PersonaDeleteDialog({ onConfirm, onOpenChange, }: PersonaDeleteDialogProps) { + const { t } = useTranslation(); return ( - Delete agent? + + {t("agents.persona-delete.title")} + {personaDeleteDescription(persona, instanceCount)} @@ -63,7 +70,7 @@ export function PersonaDeleteDialog({ @@ -76,7 +83,7 @@ export function PersonaDeleteDialog({ type="button" variant="destructive" > - Delete + {t("agents.teams-section.delete")} diff --git a/desktop/src/features/agents/ui/PersonaModelCombobox.tsx b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx index 16efbecfea5..f43154c2533 100644 --- a/desktop/src/features/agents/ui/PersonaModelCombobox.tsx +++ b/desktop/src/features/agents/ui/PersonaModelCombobox.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { Check, ChevronDown, Search } from "lucide-react"; +import { useTranslation } from "@/i18n"; import { cn } from "@/shared/lib/cn"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { @@ -26,6 +27,7 @@ export function PersonaModelCombobox({ placeholder, value, }: PersonaModelComboboxProps) { + const { t } = useTranslation(); const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(""); const [highlightedIndex, setHighlightedIndex] = React.useState(0); @@ -153,14 +155,14 @@ export function PersonaModelCombobox({
setQuery(event.target.value)} onKeyDown={handleKeyDown} - placeholder="Search models…" + placeholder={t("agents.model-combobox.search-placeholder")} // Popover supports onOpenAutoFocus; we preventDefault above so // Radix doesn't move focus to the first focusable. But we still // want the input focused immediately, so use the callback ref. @@ -209,7 +211,7 @@ export function PersonaModelCombobox({ )) ) : (

- No models match + {t("agents.model-combobox.no-match")}

)}
diff --git a/desktop/src/features/agents/ui/PersonaModelField.tsx b/desktop/src/features/agents/ui/PersonaModelField.tsx index 79e3cb7bacd..e85f900ea27 100644 --- a/desktop/src/features/agents/ui/PersonaModelField.tsx +++ b/desktop/src/features/agents/ui/PersonaModelField.tsx @@ -1,6 +1,7 @@ import type * as React from "react"; import { motion } from "motion/react"; +import { useTranslation } from "@/i18n"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { PersonaModelCombobox } from "./PersonaModelCombobox"; @@ -39,6 +40,7 @@ export function PersonaModelField({ showCustomModelInput, transition, }: PersonaModelFieldProps) { + const { t } = useTranslation(); return ( - Model + {t("mesh-compute.card.model")} {!isExplicitModelRequired ? ( - Optional + + {t("sidebar.channel-form.optional")} + ) : null} @@ -76,7 +82,7 @@ export function PersonaModelField({ )} > onCustomModelChange(event.target.value)} - placeholder="Custom model ID" + placeholder={t("agents.model-field.custom-id")} value={model} />
) : null} {showSharedComputeAutoHint ? (

- Auto uses Mesh collective intelligence when two or more models stay - available, otherwise it chooses one available model. + {t("agents.model-field.shared-compute-hint")}

) : null} {modelDiscoveryStatus ? ( diff --git a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx index a34f81f9550..b8f7883a9fa 100644 --- a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx +++ b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { Eye, EyeOff } from "lucide-react"; +import { useTranslation } from "@/i18n"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { RequiredFieldLabel } from "./agentConfigControls"; @@ -59,6 +60,7 @@ export function PersonaProviderApiKeyField({ /** Current agent-local value of the secret env var. */ value: string; }) { + const { t } = useTranslation(); const [showValue, setShowValue] = React.useState(false); const uid = React.useId(); const inputId = `persona-provider-api-key-${uid}`; @@ -100,12 +102,18 @@ export function PersonaProviderApiKeyField({ disabled={disabled} id={inputId} onChange={(event) => onValueChange(event.target.value)} - placeholder={isInherited ? inheritedLabel : "Paste API key…"} + placeholder={ + isInherited ? inheritedLabel : t("agents.api-key.paste-placeholder") + } type={showValue ? "text" : "password"} value={value} />
{isValidating ? (

- Checking API key… + {t("agents.api-key.checking")}

) : validationMessage ? (
@@ -270,7 +272,9 @@ export function PersonaShareRecipients({ ) : visibleSearchResults.length > 0 ? ( visibleSearchResults.map((user) => (
diff --git a/desktop/src/features/agents/ui/PromptSectionAccordion.tsx b/desktop/src/features/agents/ui/PromptSectionAccordion.tsx index ad5346db157..d6c74861e22 100644 --- a/desktop/src/features/agents/ui/PromptSectionAccordion.tsx +++ b/desktop/src/features/agents/ui/PromptSectionAccordion.tsx @@ -1,6 +1,7 @@ import React from "react"; import { ChevronDown } from "lucide-react"; +import { useTranslation } from "@/i18n"; import { cn } from "@/shared/lib/cn"; import type { PromptSection } from "./agentSessionTypes"; @@ -31,6 +32,7 @@ export function PromptSectionAccordion({ }: { section: PromptSection; }) { + const { t } = useTranslation(); const [open, setOpen] = React.useState(false); const body = section.body.trim(); @@ -63,7 +65,9 @@ export function PromptSectionAccordion({ {body.length > 0 ? ( body ) : ( - No metadata. + + {t("agents.prompt-section.no-metadata")} + )}
diff --git a/desktop/src/features/agents/ui/RawEventRail.tsx b/desktop/src/features/agents/ui/RawEventRail.tsx index 2841251627f..9770914eb11 100644 --- a/desktop/src/features/agents/ui/RawEventRail.tsx +++ b/desktop/src/features/agents/ui/RawEventRail.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from "@/i18n"; + import type { ObserverEvent } from "./agentSessionTypes"; import { describeRawEvent } from "./agentSessionTranscript"; import { observerEventScrollId } from "./agentSessionPanelLayout"; @@ -5,6 +7,7 @@ import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference"; export function RawEventRail({ events }: { events: ObserverEvent[] }) { + const { t } = useTranslation(); const showTimestamps = useTranscriptTimestampsEnabled(); return ( @@ -12,7 +15,7 @@ export function RawEventRail({ events }: { events: ObserverEvent[] }) {
{events.length === 0 ? (

- No raw events yet. + {t("agents.raw-events.empty")}

) : (
diff --git a/desktop/src/features/agents/ui/RemoveMembersConfirmDialog.tsx b/desktop/src/features/agents/ui/RemoveMembersConfirmDialog.tsx index ff830282ec3..24049f51630 100644 --- a/desktop/src/features/agents/ui/RemoveMembersConfirmDialog.tsx +++ b/desktop/src/features/agents/ui/RemoveMembersConfirmDialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n"; import { AlertDialog, AlertDialogContent, @@ -25,19 +26,21 @@ export function RemoveMembersConfirmDialog({ onKeepAgents, onRemoveAgents, }: RemoveMembersConfirmDialogProps) { + const { t } = useTranslation(); const count = memberNames.length; - const plural = count !== 1; return ( - Remove {count} member{plural ? "s" : ""}? + {t("agents.remove-members.title", { count })} - {memberNames.join(", ")} will be removed from this team. Do you also - want to remove the {plural ? "agents" : "agent"} completely? + {t("agents.remove-members.description", { + count, + memberNames: memberNames.join(", "), + })} @@ -47,7 +50,7 @@ export function RemoveMembersConfirmDialog({ type="button" variant="outline" > - Cancel + {t("agents.remove-members.cancel")} diff --git a/desktop/src/features/agents/ui/RestartDiffBadge.tsx b/desktop/src/features/agents/ui/RestartDiffBadge.tsx index e15a57fde49..95eef79fdcf 100644 --- a/desktop/src/features/agents/ui/RestartDiffBadge.tsx +++ b/desktop/src/features/agents/ui/RestartDiffBadge.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n"; import { RefreshCw } from "lucide-react"; import { Badge } from "@/shared/ui/badge"; @@ -150,10 +151,11 @@ export function RestartDiffBadge({ restartDiff: RestartDiffEntry[]; className?: string; }) { + const { t } = useTranslation(); const badge = ( - Restart required + {t("agents.restart-diff.required")} ); @@ -174,11 +176,13 @@ export function RestartDiffBadge({ data-testid="restart-diff-badge" > - Restart required + {t("agents.restart-diff.required")} -

Config changed since last start:

+

+ {t("agents.restart-diff.changed")} +

{autoRestartEnabled ? AUTO_RESTART_ON_BLURB : AUTO_RESTART_OFF_BLURB} diff --git a/desktop/src/features/agents/ui/RunOnSummarySection.tsx b/desktop/src/features/agents/ui/RunOnSummarySection.tsx index d3d2f303816..7fc9b08bb60 100644 --- a/desktop/src/features/agents/ui/RunOnSummarySection.tsx +++ b/desktop/src/features/agents/ui/RunOnSummarySection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n"; import type { ManagedAgentBackend } from "@/shared/api/types"; import { summarizeRunOn } from "./runOnSummary"; @@ -21,17 +22,20 @@ export function RunOnSummarySection({ }: { backend: ManagedAgentBackend; }) { + const { t } = useTranslation(); const summary = summarizeRunOn(backend); return (

- Run on + + {t("agents.run-on.label")} + {summary.location === "local" ? (

- This computer + {t("agents.run-on.this-computer")}

) : (
@@ -58,15 +62,13 @@ export function RunOnSummarySection({ ) : (

- No saved settings — the provider applies its defaults. + {t("agents.run-on.no-saved-settings")}

)}
)}

- These are the settings saved when the agent was created. Where an agent - runs can't be changed afterwards — create a new agent to run - somewhere else. + {t("agents.run-on.read-only-note")}

); diff --git a/desktop/src/features/agents/ui/TeamDeleteDialog.tsx b/desktop/src/features/agents/ui/TeamDeleteDialog.tsx index 383f92c4a53..da8c6929a01 100644 --- a/desktop/src/features/agents/ui/TeamDeleteDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDeleteDialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "@/i18n"; import type { AgentTeam } from "@/shared/api/types"; import { AlertDialog, @@ -24,21 +25,22 @@ export function TeamDeleteDialog({ onConfirm, onOpenChange, }: TeamDeleteDialogProps) { + const { t } = useTranslation(); return ( - Delete team? + {t("agents.team-delete.title")} {team - ? `Delete "${team.name}". Already-deployed agents are not affected, but this team template will no longer be available.` - : "Delete this team."} + ? t("agents.team-delete.confirm-name", { name: team.name }) + : t("agents.team-delete.confirm-no-team")} @@ -51,7 +53,7 @@ export function TeamDeleteDialog({ type="button" variant="destructive" > - Delete + {t("agents.teams-section.delete")} diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 1796adaa6b7..3d759f5fa33 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -18,6 +18,7 @@ import { } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; +import { useTranslation } from "@/i18n"; import { personaCatalogCopy } from "./personaLibraryCopy"; import { RemoveMembersConfirmDialog } from "./RemoveMembersConfirmDialog"; import { @@ -57,6 +58,7 @@ export function TeamDialog({ const [name, setName] = React.useState(""); const [teamDescription, setTeamDescription] = React.useState(""); const [instructions, setInstructions] = React.useState(""); + const { t } = useTranslation(); const [selectedPersonaIds, setSelectedPersonaIds] = React.useState( [], ); @@ -187,14 +189,14 @@ export function TeamDialog({
setName(event.target.value)} - placeholder="Engineering Squad" + placeholder={t("agents.team-dialog.name-placeholder")} value={name} />
@@ -204,14 +206,14 @@ export function TeamDialog({ className="text-sm font-medium" htmlFor="team-description" > - Description + {t("agents.team-dialog.description")}