Skip to content

Commit c7c3ba5

Browse files
author
7418
committed
feat: v0.13.0 — add i18n support with Chinese/English translations
Implements a zero-dependency i18n framework with TypeScript type safety. Thanks to @gy212 for the original i18n infrastructure design (PR #89) and comprehensive translation files (PR #90-96) that inspired this implementation. ## New files (5) - src/i18n/en.ts: English translations (source of truth, ~270 keys), exports TranslationKey type for compile-time key completeness - src/i18n/zh.ts: Chinese translations, typed as Record<TranslationKey, string> - src/i18n/index.ts: translate() function with {param} interpolation, Locale type, SUPPORTED_LOCALES array - src/components/layout/I18nProvider.tsx: React Context provider, loads/persists locale preference via /api/settings/app - src/hooks/useTranslation.ts: useTranslation() hook returning { locale, setLocale, t } ## Infrastructure wiring - src/app/layout.tsx: I18nProvider wrapped inside ThemeProvider - src/app/api/settings/app/route.ts: added "locale" to ALLOWED_KEYS for persistence ## Language picker - src/components/settings/GeneralSection.tsx: added language selection dropdown (Select component) in Settings > General, using SUPPORTED_LOCALES and setLocale() ## String replacements (33 component files) All hardcoded user-facing strings replaced with t() calls: High priority (chat): - ChatListPanel.tsx: session list, search, empty state, relative time - NavRail.tsx: nav labels, theme toggle, auto-approve indicator - MessageInput.tsx: command descriptions, mode labels, attach tooltip - MessageList.tsx: empty state, load earlier, loading indicator - StreamingMessage.tsx: thinking, permission actions - chat/[id]/page.tsx: session title fallback Settings: - GeneralSection.tsx: update card, auto-approve section, warning dialog - SettingsLayout.tsx: title, description, sidebar labels - CliSettingsSection.tsx: form/json tabs, buttons, dynamic field labels (skipDangerousModePermissionPrompt etc.), Enabled/Disabled states - ProviderForm.tsx: dialog title, form labels, buttons - ProviderManager.tsx: status badges, delete dialog, section headers Extensions: - extensions/page.tsx: title, tab labels - SkillsManager.tsx: empty state, loading, search - SkillEditor.tsx: toolbar buttons, placeholder - SkillListItem.tsx: delete confirmation - CreateSkillDialog.tsx: form labels, scope, templates, validation Plugins: - McpManager.tsx: header, tabs, loading state - McpServerList.tsx: empty state, badges, field labels - McpServerEditor.tsx: dialog title, form labels, buttons - ConfigEditor.tsx: save/format buttons Layout: - RightPanel.tsx: panel header, open/close tooltips - FileTree.tsx: search placeholder, empty states - FilePreview.tsx: back button, line count, copy path - DocPreview.tsx: iframe title - ConnectionStatus.tsx: install prompts, version display - InstallWizard.tsx: phase descriptions, button labels - ImportSessionDialog.tsx: search, relative time, import buttons - FolderPicker.tsx: dialog title, loading, buttons - TaskList.tsx: filter tabs, input placeholder, empty states
1 parent 85940df commit c7c3ba5

38 files changed

+980
-217
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.12.0",
3+
"version": "0.13.0",
44
"private": true,
55
"author": {
66
"name": "op7418",

src/app/api/settings/app/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const ALLOWED_KEYS = [
1010
'anthropic_auth_token',
1111
'anthropic_base_url',
1212
'dangerously_skip_permissions',
13+
'locale',
1314
];
1415

1516
export async function GET() {

src/app/chat/[id]/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Loading02Icon, PencilEdit01Icon } from "@hugeicons/core-free-icons";
99
import { Input } from '@/components/ui/input';
1010
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
1111
import { usePanel } from '@/hooks/usePanel';
12+
import { useTranslation } from '@/hooks/useTranslation';
1213

1314
interface ChatSessionPageProps {
1415
params: Promise<{ id: string }>;
@@ -29,9 +30,10 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
2930
const [editTitle, setEditTitle] = useState('');
3031
const titleInputRef = useRef<HTMLInputElement>(null);
3132
const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel();
33+
const { t } = useTranslation();
3234

3335
const handleStartEditTitle = useCallback(() => {
34-
setEditTitle(sessionTitle || 'New Conversation');
36+
setEditTitle(sessionTitle || t('chat.newConversation'));
3537
setIsEditingTitle(true);
3638
}, [sessionTitle]);
3739

@@ -88,7 +90,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
8890
}
8991
setSessionId(id);
9092
setPanelOpen(true);
91-
const title = data.session.title || 'New Conversation';
93+
const title = data.session.title || t('chat.newConversation');
9294
setSessionTitle(title);
9395
setPanelSessionTitle(title);
9496
setSessionModel(data.session.model || '');

src/app/extensions/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
77
import { Loading02Icon } from "@hugeicons/core-free-icons";
88
import { SkillsManager } from "@/components/skills/SkillsManager";
99
import { McpManager } from "@/components/plugins/McpManager";
10+
import { useTranslation } from "@/hooks/useTranslation";
1011

1112
type ExtTab = "skills" | "mcp";
1213

@@ -28,15 +29,16 @@ function ExtensionsPageInner() {
2829
const searchParams = useSearchParams();
2930
const initialTab = (searchParams.get("tab") as ExtTab) || "skills";
3031
const [tab, setTab] = useState<ExtTab>(initialTab);
32+
const { t } = useTranslation();
3133

3234
return (
3335
<div className="flex h-full flex-col">
3436
<div className="px-6 pt-4 pb-0">
35-
<h1 className="text-xl font-semibold mb-3">Extensions</h1>
37+
<h1 className="text-xl font-semibold mb-3">{t('extensions.title')}</h1>
3638
<Tabs value={tab} onValueChange={(v) => setTab(v as ExtTab)}>
3739
<TabsList>
38-
<TabsTrigger value="skills">Skills</TabsTrigger>
39-
<TabsTrigger value="mcp">MCP Servers</TabsTrigger>
40+
<TabsTrigger value="skills">{t('extensions.skills')}</TabsTrigger>
41+
<TabsTrigger value="mcp">{t('extensions.mcpServers')}</TabsTrigger>
4042
</TabsList>
4143
</Tabs>
4244
</div>

src/app/layout.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
22
import { Geist, Geist_Mono } from "next/font/google";
33
import "./globals.css";
44
import { ThemeProvider } from "@/components/layout/ThemeProvider";
5+
import { I18nProvider } from "@/components/layout/I18nProvider";
56
import { AppShell } from "@/components/layout/AppShell";
67

78
const geistSans = Geist({
@@ -30,7 +31,9 @@ export default function RootLayout({
3031
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
3132
>
3233
<ThemeProvider>
33-
<AppShell>{children}</AppShell>
34+
<I18nProvider>
35+
<AppShell>{children}</AppShell>
36+
</I18nProvider>
3437
</ThemeProvider>
3538
</body>
3639
</html>

src/components/chat/FolderPicker.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '@/components/ui/dropdown-menu';
2020
import { Input } from '@/components/ui/input';
2121
import { ScrollArea } from '@/components/ui/scroll-area';
22+
import { useTranslation } from '@/hooks/useTranslation';
2223

2324
interface FolderEntry {
2425
name: string;
@@ -40,6 +41,7 @@ interface FolderPickerProps {
4041
}
4142

4243
export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: FolderPickerProps) {
44+
const { t } = useTranslation();
4345
const [currentDir, setCurrentDir] = useState('');
4446
const [parentDir, setParentDir] = useState<string | null>(null);
4547
const [directories, setDirectories] = useState<FolderEntry[]>([]);
@@ -99,7 +101,7 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
99101
<Dialog open={open} onOpenChange={onOpenChange}>
100102
<DialogContent className="max-w-lg overflow-hidden">
101103
<DialogHeader>
102-
<DialogTitle>Select Project Folder</DialogTitle>
104+
<DialogTitle>{t('folderPicker.title')}</DialogTitle>
103105
</DialogHeader>
104106

105107
{/* Path input */}
@@ -162,11 +164,11 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
162164
<ScrollArea className="h-64">
163165
{loading ? (
164166
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
165-
Loading...
167+
{t('folderPicker.loading')}
166168
</div>
167169
) : directories.length === 0 ? (
168170
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
169-
No subdirectories
171+
{t('folderPicker.noSubdirs')}
170172
</div>
171173
) : (
172174
<div className="p-1">
@@ -188,11 +190,11 @@ export function FolderPicker({ open, onOpenChange, onSelect, initialPath }: Fold
188190

189191
<DialogFooter>
190192
<Button variant="outline" onClick={() => onOpenChange(false)}>
191-
Cancel
193+
{t('folderPicker.cancel')}
192194
</Button>
193195
<Button onClick={handleSelect} className="gap-2">
194196
<HugeiconsIcon icon={FolderOpenIcon} className="h-4 w-4" />
195-
Select This Folder
197+
{t('folderPicker.select')}
196198
</Button>
197199
</DialogFooter>
198200
</DialogContent>

src/components/chat/MessageInput.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
StopIcon,
2222
} from "@hugeicons/core-free-icons";
2323
import { cn } from '@/lib/utils';
24+
import { useTranslation } from '@/hooks/useTranslation';
25+
import type { TranslationKey } from '@/i18n';
2426
import {
2527
PromptInput,
2628
PromptInputTextarea,
@@ -65,6 +67,7 @@ interface PopoverItem {
6567
label: string;
6668
value: string;
6769
description?: string;
70+
descriptionKey?: TranslationKey;
6871
builtIn?: boolean;
6972
immediate?: boolean;
7073
installedSource?: "agents" | "claude";
@@ -91,15 +94,15 @@ const COMMAND_PROMPTS: Record<string, string> = {
9194
};
9295

9396
const BUILT_IN_COMMANDS: PopoverItem[] = [
94-
{ label: 'help', value: '/help', description: 'Show available commands and tips', builtIn: true, immediate: true, icon: HelpCircleIcon },
95-
{ label: 'clear', value: '/clear', description: 'Clear conversation history', builtIn: true, immediate: true, icon: Delete02Icon },
96-
{ label: 'cost', value: '/cost', description: 'Show token usage statistics', builtIn: true, immediate: true, icon: Coins01Icon },
97-
{ label: 'compact', value: '/compact', description: 'Compress conversation context', builtIn: true, icon: FileZipIcon },
98-
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', builtIn: true, icon: Stethoscope02Icon },
99-
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', builtIn: true, icon: FileEditIcon },
100-
{ label: 'review', value: '/review', description: 'Review code quality', builtIn: true, icon: SearchList01Icon },
101-
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', builtIn: true, icon: CommandLineIcon },
102-
{ label: 'memory', value: '/memory', description: 'Edit project memory file', builtIn: true, icon: BrainIcon },
97+
{ label: 'help', value: '/help', description: 'Show available commands and tips', descriptionKey: 'messageInput.helpDesc', builtIn: true, immediate: true, icon: HelpCircleIcon },
98+
{ label: 'clear', value: '/clear', description: 'Clear conversation history', descriptionKey: 'messageInput.clearDesc', builtIn: true, immediate: true, icon: Delete02Icon },
99+
{ label: 'cost', value: '/cost', description: 'Show token usage statistics', descriptionKey: 'messageInput.costDesc', builtIn: true, immediate: true, icon: Coins01Icon },
100+
{ label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, icon: FileZipIcon },
101+
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, icon: Stethoscope02Icon },
102+
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, icon: FileEditIcon },
103+
{ label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, icon: SearchList01Icon },
104+
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, icon: CommandLineIcon },
105+
{ label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, icon: BrainIcon },
103106
];
104107

105108
interface ModeOption {
@@ -183,11 +186,12 @@ function FileAwareSubmitButton({
183186
*/
184187
function AttachFileButton() {
185188
const attachments = usePromptInputAttachments();
189+
const { t } = useTranslation();
186190

187191
return (
188192
<PromptInputButton
189193
onClick={() => attachments.openFileDialog()}
190-
tooltip="Attach files"
194+
tooltip={t('messageInput.attachFiles')}
191195
>
192196
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
193197
</PromptInputButton>
@@ -331,6 +335,7 @@ export function MessageInput({
331335
mode = 'code',
332336
onModeChange,
333337
}: MessageInputProps) {
338+
const { t } = useTranslation();
334339
const textareaRef = useRef<HTMLTextAreaElement>(null);
335340
const popoverRef = useRef<HTMLDivElement>(null);
336341
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -752,9 +757,9 @@ export function MessageInput({
752757
<HugeiconsIcon icon={CommandLineIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
753758
)}
754759
<span className="font-mono text-xs truncate">{item.label}</span>
755-
{item.description && (
760+
{(item.descriptionKey || item.description) && (
756761
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
757-
{item.description}
762+
{item.descriptionKey ? t(item.descriptionKey) : item.description}
758763
</span>
759764
)}
760765
{!item.builtIn && item.installedSource && (
@@ -919,7 +924,7 @@ export function MessageInput({
919924
)}
920925
onClick={() => onModeChange?.(opt.value)}
921926
>
922-
{opt.label}
927+
{opt.value === 'code' ? t('messageInput.modeCode') : opt.value === 'plan' ? t('messageInput.modePlan') : opt.label}
923928
</button>
924929
);
925930
})}

src/components/chat/MessageList.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useRef, useEffect } from 'react';
4+
import { useTranslation } from '@/hooks/useTranslation';
45
import type { Message, PermissionRequestEvent } from '@/types';
56
import {
67
Conversation,
@@ -57,6 +58,7 @@ export function MessageList({
5758
loadingMore,
5859
onLoadMore,
5960
}: MessageListProps) {
61+
const { t } = useTranslation();
6062
// Scroll anchor: preserve position when older messages are prepended
6163
const anchorIdRef = useRef<string | null>(null);
6264
const prevMessageCountRef = useRef(messages.length);
@@ -86,7 +88,7 @@ export function MessageList({
8688
<div className="flex flex-1 items-center justify-center">
8789
<ConversationEmptyState
8890
title="Claude Chat"
89-
description="Start a conversation with Claude. Ask questions, get help with code, or explore ideas."
91+
description={t('messageList.emptyDescription')}
9092
icon={<CodePilotLogo className="h-16 w-16" />}
9193
/>
9294
</div>
@@ -103,7 +105,7 @@ export function MessageList({
103105
disabled={loadingMore}
104106
className="text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
105107
>
106-
{loadingMore ? 'Loading...' : 'Load earlier messages'}
108+
{loadingMore ? t('messageList.loading') : t('messageList.loadEarlier')}
107109
</button>
108110
</div>
109111
)}

src/components/chat/StreamingMessage.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useState, useEffect, useRef } from 'react';
4+
import { useTranslation } from '@/hooks/useTranslation';
45
import {
56
Message as AIMessage,
67
MessageContent,
@@ -295,6 +296,7 @@ export function StreamingMessage({
295296
permissionResolved,
296297
onForceStop,
297298
}: StreamingMessageProps) {
299+
const { t } = useTranslation();
298300
const runningTools = toolUses.filter(
299301
(tool) => !toolResults.some((r) => r.tool_use_id === tool.id)
300302
);
@@ -430,18 +432,18 @@ export function StreamingMessage({
430432
variant="default"
431433
onClick={() => onPermissionResponse?.('allow_session')}
432434
>
433-
Allow for Session
435+
{t('streaming.allowForSession')}
434436
</ConfirmationAction>
435437
)}
436438
</ConfirmationActions>
437439
</ConfirmationRequest>
438440

439441
<ConfirmationAccepted>
440-
<p className="text-xs text-green-600 dark:text-green-400">Allowed</p>
442+
<p className="text-xs text-green-600 dark:text-green-400">{t('streaming.allowed')}</p>
441443
</ConfirmationAccepted>
442444

443445
<ConfirmationRejected>
444-
<p className="text-xs text-red-600 dark:text-red-400">Denied</p>
446+
<p className="text-xs text-red-600 dark:text-red-400">{t('streaming.denied')}</p>
445447
</ConfirmationRejected>
446448
</Confirmation>
447449
)}
@@ -454,7 +456,7 @@ export function StreamingMessage({
454456
{/* Loading indicator when no content yet */}
455457
{isStreaming && !content && toolUses.length === 0 && !pendingPermission && (
456458
<div className="py-2">
457-
<Shimmer>Thinking...</Shimmer>
459+
<Shimmer>{t('streaming.thinking')}</Shimmer>
458460
</div>
459461
)}
460462

0 commit comments

Comments
 (0)