feat: add i18n support with English and Chinese locales

Add a lightweight custom i18n system supporting English (default)
and Chinese, with a language switcher in Settings > General.
All 35+ UI components internationalized with ~270 translation keys,
including 189 Chinese spinner verbs and server error code mapping.
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 22:18:16 +08:00
parent 602c61ad4c
commit a2bf92079b
36 changed files with 1362 additions and 331 deletions

View File

@ -1,5 +1,6 @@
import { useState } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button'
type QuestionOption = {
@ -46,6 +47,7 @@ function parseInput(input: unknown): Question[] {
export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
const { sendMessage } = useChatStore()
const t = useTranslation()
const questions = parseInput(input)
const [activeTab, setActiveTab] = useState(0)
const [selections, setSelections] = useState<Record<number, string>>({})
@ -109,11 +111,11 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
Claude needs your input
{t('question.needsInput')}
</span>
{submitted && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
Answered
{t('question.answered')}
</span>
)}
</div>
@ -209,7 +211,7 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
{!submitted && (
<div>
<label className="text-xs text-[var(--color-text-tertiary)] mb-1.5 block">
Or type a custom response:
{t('question.customResponse')}
</label>
<input
type="text"
@ -221,7 +223,7 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
onKeyDown={(e) => {
if (e.key === 'Enter' && allAnswered) handleSubmit()
}}
placeholder="Type your answer..."
placeholder={t('question.typePlaceholder')}
className="w-full px-3 py-2 text-sm bg-[var(--color-surface)] border border-[var(--color-outline-variant)]/40 rounded-[var(--radius-md)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-secondary)] focus:ring-1 focus:ring-[var(--color-secondary)]/30"
/>
</div>
@ -232,7 +234,7 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
<span>
Answered: <strong>{freeText.trim() || Object.values(selections).join(', ')}</strong>
{t('question.answeredPrefix')}<strong>{freeText.trim() || Object.values(selections).join(', ')}</strong>
</span>
</div>
)}
@ -250,7 +252,7 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
<span className="material-symbols-outlined text-[14px]">send</span>
}
>
Submit
{t('question.submit')}
</Button>
</div>
)}

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from '../../i18n'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { sessionsApi } from '../../api/sessions'
@ -26,6 +27,7 @@ type Attachment = {
}
export function ChatInput() {
const t = useTranslation()
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
@ -418,11 +420,11 @@ export function ChatInput() {
</div>
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Up/Down</kbd>
<span>navigate</span>
<span>{t('chat.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Enter</kbd>
<span>select</span>
<span>{t('chat.select')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd>
<span>dismiss</span>
<span>{t('chat.dismiss')}</span>
</div>
</div>
)}
@ -441,8 +443,8 @@ export function ChatInput() {
onPaste={handlePaste}
placeholder={
isWorkspaceMissing
? 'This session points to a missing workspace. Create a new session or pick another project.'
: 'Ask Claude to edit, debug or explain...'
? t('chat.placeholderMissing')
: t('chat.placeholder')
}
disabled={isWorkspaceMissing}
rows={1}
@ -470,14 +472,14 @@ export function ChatInput() {
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
<span className="text-sm text-[var(--color-text-primary)]">Add files or photos</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.addFiles')}</span>
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
>
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
<span className="text-sm text-[var(--color-text-primary)]">Slash commands</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.slashCommands')}</span>
</button>
</div>
)}
@ -491,7 +493,7 @@ export function ChatInput() {
<button
onClick={isActive ? stopGeneration : handleSubmit}
disabled={!isActive && !canSubmit}
title={isActive ? 'Stop generation (Cmd+.)' : undefined}
title={isActive ? t('chat.stopTitle') : undefined}
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30 ${
isActive ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
}`}
@ -499,7 +501,7 @@ export function ChatInput() {
<span className="material-symbols-outlined text-[14px]">
{isActive ? 'stop' : 'arrow_forward'}
</span>
{isActive ? 'Stop' : 'Run'}
{isActive ? t('common.stop') : t('common.run')}
</button>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { forwardRef, useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react'
import { filesystemApi } from '../../api/filesystem'
import { useTranslation } from '../../i18n'
type DirEntry = {
name: string
@ -18,6 +19,7 @@ type Props = {
}
export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, filter = '', onSelect }, ref) => {
const t = useTranslation()
const [entries, setEntries] = useState<DirEntry[]>([])
const [currentPath, setCurrentPath] = useState(cwd)
const [loading, setLoading] = useState(false)
@ -133,10 +135,10 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
{/* File list */}
<div ref={listRef} className="max-h-[300px] overflow-y-auto py-1">
{loading && entries.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">Searching...</div>
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('fileSearch.searching')}</div>
) : entries.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
{filter ? 'No files match' : 'No files in this directory'}
{filter ? t('fileSearch.noMatch') : t('fileSearch.noFiles')}
</div>
) : (
<>
@ -183,11 +185,11 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
{/* Footer hint */}
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-3 py-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono"></kbd>
<span>navigate</span>
<span>{t('fileSearch.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
<span>attach</span>
<span>{t('fileSearch.attach')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
<span>close</span>
<span>{t('fileSearch.close')}</span>
</div>
</div>
)

View File

@ -1,4 +1,5 @@
import type { TaskSummaryItem } from '../../types/chat'
import { useTranslation } from '../../i18n'
const statusIcon: Record<TaskSummaryItem['status'], string> = {
pending: 'radio_button_unchecked',
@ -13,7 +14,8 @@ const statusColor: Record<TaskSummaryItem['status'], string> = {
}
export function InlineTaskSummary({ tasks }: { tasks: TaskSummaryItem[] }) {
const completed = tasks.filter((t) => t.status === 'completed').length
const t = useTranslation()
const completed = tasks.filter((tk) => tk.status === 'completed').length
const total = tasks.length
return (
@ -25,7 +27,7 @@ export function InlineTaskSummary({ tasks }: { tasks: TaskSummaryItem[] }) {
</span>
</div>
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
Tasks completed
{t('tasks.completed')}
</span>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
{completed}/{total}

View File

@ -1,5 +1,7 @@
import { useRef, useEffect } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n/locales/en'
import { UserMessage } from './UserMessage'
import { AssistantMessage } from './AssistantMessage'
import { ThinkingBlock } from './ThinkingBlock'
@ -141,6 +143,8 @@ function MessageBlock({
activeThinkingId: string | null
toolResult?: { content: unknown; isError: boolean } | null
}) {
const t = useTranslation()
switch (message.type) {
case 'user_text':
return <UserMessage content={message.content} attachments={message.attachments} />
@ -181,12 +185,16 @@ function MessageBlock({
description={message.description}
/>
)
case 'error':
case 'error': {
const errorKey = message.code ? `error.${message.code}` as TranslationKey : null
const errorText = errorKey ? t(errorKey) : null
const displayMessage = (errorText && errorText !== errorKey) ? errorText : message.message
return (
<div className="mb-3 px-4 py-2.5 rounded-lg bg-red-50 border border-red-200 text-sm text-[var(--color-error)]">
<strong>Error:</strong> {message.message}
<strong>Error:</strong> {displayMessage}
</div>
)
}
case 'task_summary':
return <InlineTaskSummary tasks={message.tasks} />
case 'system':

View File

@ -1,5 +1,6 @@
import { useState } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
import { Button } from '../shared/Button'
import { DiffViewer } from './DiffViewer'
@ -31,7 +32,7 @@ const TOOL_META: Record<string, { icon: string; label: string; color: string }>
/**
* Extract human-readable detail lines from tool input.
*/
function extractToolDetails(toolName: string, input: unknown): { primary: string; secondary?: string } {
function extractToolDetails(toolName: string, input: unknown, t: (key: any, params?: any) => string): { primary: string; secondary?: string } {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
switch (toolName) {
@ -42,7 +43,7 @@ function extractToolDetails(toolName: string, input: unknown): { primary: string
}
case 'Edit': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath, secondary: obj.old_string ? 'Replacing content in file' : undefined }
return { primary: filePath, secondary: obj.old_string ? t('permission.replacingContent') : undefined }
}
case 'Write': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
@ -67,7 +68,7 @@ function extractToolDetails(toolName: string, input: unknown): { primary: string
}
}
function getPermissionTitle(toolName: string, input: unknown) {
function getPermissionTitle(toolName: string, input: unknown, t: (key: any, params?: any) => string) {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
const fileName = filePath ? filePath.split('/').pop() || filePath : ''
@ -75,11 +76,11 @@ function getPermissionTitle(toolName: string, input: unknown) {
switch (toolName) {
case 'Edit':
case 'Write':
return fileName ? `Allow Claude to ${toolName} ${fileName}?` : `Allow Claude to ${toolName.toLowerCase()} this file?`
return fileName ? t('permission.allowEditFile', { toolName, fileName }) : t('permission.allowEditFileGeneric', { toolName: toolName.toLowerCase() })
case 'Bash':
return 'Allow Claude to run this command?'
return t('permission.allowBash')
default:
return `Allow Claude to use ${toolName}?`
return t('permission.allowTool', { toolName })
}
}
@ -110,14 +111,15 @@ function renderPermissionPreview(toolName: string, input: unknown) {
export function PermissionDialog({ requestId, toolName, input, description }: Props) {
const { respondToPermission, pendingPermission } = useChatStore()
const t = useTranslation()
const isPending = pendingPermission?.requestId === requestId
const [showRaw, setShowRaw] = useState(false)
const meta = TOOL_META[toolName] || { icon: 'shield', label: toolName, color: '#87736D' }
const details = extractToolDetails(toolName, input)
const details = extractToolDetails(toolName, input, t)
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
const preview = renderPermissionPreview(toolName, input)
const title = getPermissionTitle(toolName, input)
const title = getPermissionTitle(toolName, input, t)
const allowRawToggle = !preview
return (
@ -151,12 +153,12 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
{isPending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-warning)]/15 text-[var(--color-warning)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
Awaiting approval
{t('permission.awaitingApproval')}
</span>
)}
{!isPending && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
Responded
{t('permission.responded')}
</span>
)}
</div>
@ -204,7 +206,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<span className="material-symbols-outlined text-[14px]">
{showRaw ? 'expand_less' : 'expand_more'}
</span>
{showRaw ? 'Hide details' : 'Show full input'}
{showRaw ? t('permission.hideDetails') : t('permission.showFullInput')}
</button>
)}
@ -226,7 +228,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<span className="material-symbols-outlined text-[14px]">check</span>
}
>
Allow
{t('permission.allow')}
</Button>
<Button
variant="ghost"
@ -236,7 +238,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<span className="material-symbols-outlined text-[14px]">verified</span>
}
>
Allow for session
{t('permission.allowForSession')}
</Button>
<div className="flex-1" />
<Button
@ -247,7 +249,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
<span className="material-symbols-outlined text-[14px]">close</span>
}
>
Deny
{t('permission.deny')}
</Button>
</div>
)}

View File

@ -1,4 +1,5 @@
import { useCLITaskStore } from '../../stores/cliTaskStore'
import { useTranslation } from '../../i18n'
import type { CLITask } from '../../types/cliTask'
const statusConfig = {
@ -21,14 +22,15 @@ const statusConfig = {
export function SessionTaskBar() {
const { tasks, expanded, toggleExpanded, completedAndDismissed } = useCLITaskStore()
const t = useTranslation()
if (tasks.length === 0) return null
// Don't show sticky bar if tasks were completed and the user already continued chatting
const allCompleted = tasks.every((t) => t.status === 'completed')
const allCompleted = tasks.every((tk) => tk.status === 'completed')
if (allCompleted && completedAndDismissed) return null
const completedCount = tasks.filter((t) => t.status === 'completed').length
const completedCount = tasks.filter((tk) => tk.status === 'completed').length
const totalCount = tasks.length
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
@ -49,7 +51,7 @@ export function SessionTaskBar() {
</div>
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
Tasks
{t('tasks.title')}
</span>
{/* Progress bar */}

View File

@ -1,4 +1,6 @@
import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n/locales/en'
function formatElapsed(seconds: number): string {
if (seconds < 60) return `${seconds}s`
@ -9,9 +11,17 @@ function formatElapsed(seconds: number): string {
export function StreamingIndicator() {
const { chatState, statusVerb, elapsedSeconds, tokenUsage } = useChatStore()
const t = useTranslation()
const verb = statusVerb
|| (chatState === 'thinking' ? 'Thinking' : chatState === 'tool_executing' ? 'Running' : 'Working')
// Translate known server-sent verbs (e.g. "Thinking", "Task started")
let verb: string
if (statusVerb) {
const serverKey = `serverVerb.${statusVerb}` as TranslationKey
const translated = t(serverKey)
verb = translated !== serverKey ? translated : statusVerb
} else {
verb = chatState === 'thinking' ? t('streaming.thinking') : chatState === 'tool_executing' ? t('streaming.running') : t('streaming.working')
}
return (
<div className="mb-2 ml-10 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">

View File

@ -1,6 +1,8 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from '../../i18n'
export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) {
const t = useTranslation()
const [expanded, setExpanded] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
@ -26,7 +28,7 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
{expanded ? '\u25BE' : '\u25B8'}
</span>
<span className="shrink-0 font-medium italic">
Thinking
{t('thinking.label')}
{isActive && <span className="thinking-dots" />}
</span>
{!expanded && preview && (

View File

@ -3,6 +3,7 @@ import { CodeViewer } from './CodeViewer'
import { DiffViewer } from './DiffViewer'
import { TerminalChrome } from './TerminalChrome'
import { CopyButton } from '../shared/CopyButton'
import { useTranslation } from '../../i18n'
type Props = {
toolName: string
@ -27,14 +28,15 @@ const TOOL_ICONS: Record<string, string> = {
export function ToolCallBlock({ toolName, input, result, compact = false }: Props) {
const [expanded, setExpanded] = useState(false)
const t = useTranslation()
const obj = input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
const icon = TOOL_ICONS[toolName] || 'build'
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
const summary = getToolSummary(toolName, obj)
const outputSummary = getToolResultSummary(toolName, result?.content)
const summary = getToolSummary(toolName, obj, t)
const outputSummary = getToolResultSummary(toolName, result?.content, t)
const preview = useMemo(() => renderPreview(toolName, obj, result), [obj, result, toolName])
const details = useMemo(() => renderDetails(toolName, obj), [obj, toolName])
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
const details = useMemo(() => renderDetails(toolName, obj, t), [obj, toolName, t])
const expandable = toolName === 'Edit' || toolName === 'Write'
return (
@ -94,6 +96,7 @@ function renderPreview(
toolName: string,
obj: Record<string, unknown>,
result?: { content: unknown; isError: boolean } | null,
t?: (key: any, params?: any) => string,
) {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
@ -129,7 +132,7 @@ function renderPreview(
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}>
<div className="flex items-center justify-between border-b border-[var(--color-border)]/60 px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{result.isError ? 'Error Output' : 'Tool Output'}</span>
<span>{result.isError ? t?.('tool.errorOutput') ?? 'Error Output' : t?.('tool.toolOutput') ?? 'Tool Output'}</span>
<CopyButton
text={text}
className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[10px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
@ -144,7 +147,7 @@ function renderPreview(
return null
}
function renderDetails(toolName: string, obj: Record<string, unknown>) {
function renderDetails(toolName: string, obj: Record<string, unknown>, t?: (key: any, params?: any) => string) {
if (toolName === 'Edit' || toolName === 'Write') {
return null
}
@ -153,7 +156,7 @@ function renderDetails(toolName: string, obj: Record<string, unknown>) {
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>Tool Input</span>
<span>{t?.('tool.toolInput') ?? 'Tool Input'}</span>
<CopyButton
text={text}
className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[10px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
@ -164,7 +167,7 @@ function renderDetails(toolName: string, obj: Record<string, unknown>) {
)
}
function getToolResultSummary(toolName: string, content: unknown): string {
function getToolResultSummary(toolName: string, content: unknown, t?: (key: any, params?: any) => string): string {
if (toolName === 'Bash') return ''
const text = extractTextContent(content)
@ -172,7 +175,7 @@ function getToolResultSummary(toolName: string, content: unknown): string {
const lineCount = text.split('\n').length
if (lineCount > 1) {
return `${lineCount} lines output`
return t?.('tool.linesOutput', { count: lineCount }) ?? `${lineCount} lines output`
}
const compact = text.replace(/\s+/g, ' ').trim()
@ -181,18 +184,20 @@ function getToolResultSummary(toolName: string, content: unknown): string {
return `${compact.slice(0, 36)}`
}
function getToolSummary(toolName: string, obj: Record<string, unknown>): string {
function getToolSummary(toolName: string, obj: Record<string, unknown>, t?: (key: any, params?: any) => string): string {
switch (toolName) {
case 'Bash':
return typeof obj.command === 'string' ? obj.command : ''
case 'Read':
return typeof obj.limit === 'number' ? `Read file contents` : 'Read file contents'
return t?.('tool.readFileContents') ?? 'Read file contents'
case 'Write':
return typeof obj.content === 'string' ? `${obj.content.split('\n').length} lines created` : 'Create file'
return typeof obj.content === 'string'
? (t?.('tool.linesCreated', { count: obj.content.split('\n').length }) ?? `${obj.content.split('\n').length} lines created`)
: (t?.('tool.createFile') ?? 'Create file')
case 'Edit':
return typeof obj.old_string === 'string' && typeof obj.new_string === 'string'
? changedLineSummary(obj.old_string, obj.new_string)
: 'Update file contents'
? changedLineSummary(obj.old_string, obj.new_string, t)
: (t?.('tool.updateFileContents') ?? 'Update file contents')
case 'Glob':
return typeof obj.pattern === 'string' ? obj.pattern : ''
case 'Grep':
@ -218,7 +223,7 @@ function extractTextContent(content: unknown): string | null {
return null
}
function changedLineSummary(oldString: string, newString: string): string {
function changedLineSummary(oldString: string, newString: string, t?: (key: any, params?: any) => string): string {
const oldLines = oldString.split('\n')
const newLines = newString.split('\n')
let changed = 0
@ -230,5 +235,5 @@ function changedLineSummary(oldString: string, newString: string): string {
}
}
return `${changed} lines changed`
return t?.('tool.linesChanged', { count: changed }) ?? `${changed} lines changed`
}

View File

@ -1,5 +1,6 @@
import { useState } from 'react'
import { ToolCallBlock } from './ToolCallBlock'
import { useTranslation } from '../../i18n'
import type { UIMessage } from '../../types/chat'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
@ -12,19 +13,19 @@ type Props = {
isStreaming?: boolean
}
const TOOL_VERBS: Record<string, (count: number) => string> = {
Read: (n) => `Read ${n} file${n > 1 ? 's' : ''}`,
Write: (n) => `created ${n > 1 ? `${n} files` : 'a file'}`,
Edit: (n) => `edited ${n > 1 ? `${n} files` : 'a file'}`,
Bash: (n) => `ran ${n > 1 ? `${n} commands` : 'a command'}`,
Glob: (_n) => `found files`,
Grep: (n) => `searched ${n > 1 ? `${n} patterns` : 'code'}`,
Agent: (n) => `dispatched ${n > 1 ? `${n} agents` : 'an agent'}`,
WebSearch: (_n) => `searched the web`,
WebFetch: (n) => `fetched ${n > 1 ? `${n} pages` : 'a page'}`,
const TOOL_VERBS: Record<string, (count: number, t: (key: any, params?: any) => string) => string> = {
Read: (n, t) => n === 1 ? t('toolGroup.readOne') : t('toolGroup.readMany', { count: n }),
Write: (n, t) => n === 1 ? t('toolGroup.createdOne') : t('toolGroup.createdMany', { count: n }),
Edit: (n, t) => n === 1 ? t('toolGroup.editedOne') : t('toolGroup.editedMany', { count: n }),
Bash: (n, t) => n === 1 ? t('toolGroup.ranOne') : t('toolGroup.ranMany', { count: n }),
Glob: (_n, t) => t('toolGroup.foundFiles'),
Grep: (n, t) => n === 1 ? t('toolGroup.searchedOne') : t('toolGroup.searchedMany', { count: n }),
Agent: (n, t) => n === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: n }),
WebSearch: (_n, t) => t('toolGroup.searchedWeb'),
WebFetch: (n, t) => n === 1 ? t('toolGroup.fetchedOne') : t('toolGroup.fetchedMany', { count: n }),
}
function generateSummary(toolCalls: ToolCall[]): string {
function generateSummary(toolCalls: ToolCall[], t: (key: any, params?: any) => string): string {
const counts = new Map<string, number>()
for (const tc of toolCalls) {
counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1)
@ -33,7 +34,7 @@ function generateSummary(toolCalls: ToolCall[]): string {
const parts: string[] = []
for (const [name, count] of counts) {
const verbFn = TOOL_VERBS[name]
parts.push(verbFn ? verbFn(count) : `${name} (${count})`)
parts.push(verbFn ? verbFn(count, t) : `${name} (${count})`)
}
return parts.join(', ')
@ -66,7 +67,8 @@ export function ToolCallGroup({ toolCalls, resultMap, isStreaming }: Props) {
/** Separated so the useState hook is never called conditionally. */
function ToolCallGroupMulti({ toolCalls, resultMap, isStreaming }: Props) {
const [expanded, setExpanded] = useState(false)
const summary = generateSummary(toolCalls)
const t = useTranslation()
const summary = generateSummary(toolCalls, t)
const errorPresent = groupHasErrors(toolCalls, resultMap)
const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId))

View File

@ -1,5 +1,6 @@
import { CodeViewer } from './CodeViewer'
import { useState } from 'react'
import { useTranslation } from '../../i18n'
type Props = {
content: unknown
@ -15,6 +16,7 @@ type Props = {
*/
export function ToolResultBlock({ content, isError, toolName, standalone = true }: Props) {
const [expanded, setExpanded] = useState(false)
const t = useTranslation()
// Don't render standalone if this result is already rendered inline
if (!standalone) return null
@ -43,14 +45,14 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true
<span className="material-symbols-outlined text-[12px]">
{isError ? 'error' : 'check_circle'}
</span>
{toolName ? `${toolName} result` : 'Tool result'}
{toolName ? t('tool.result', { toolName }) : t('tool.resultGeneric')}
</span>
<span className={`px-2 py-0.5 rounded-full text-[9px] ${
isError
? 'bg-[var(--color-error)]/10'
: 'bg-[#D4EAB4] text-[#3B4C24]'
}`}>
{isError ? 'ERROR' : 'SUCCESS'}
{isError ? t('tool.error') : t('tool.success')}
</span>
</button>
@ -79,7 +81,7 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true
onClick={() => setExpanded((value) => !value)}
className="w-full py-1 text-[10px] font-medium text-[var(--color-text-accent)] hover:underline bg-[var(--color-surface-container-low)] border-t border-[var(--color-outline-variant)]/10"
>
{expanded ? 'Show less' : `Show ${text.length - 200} more characters`}
{expanded ? t('tool.showLess') : t('tool.showMore', { count: text.length - 200 })}
</button>
)}
</div>

View File

@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTranslation } from '../../i18n'
import type { EffortLevel } from '../../types/settings'
const MODEL_ICONS = {
@ -8,13 +9,6 @@ const MODEL_ICONS = {
haiku: 'bolt',
} as const
const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
]
type Props = {
/** Controlled mode: model ID override */
value?: string
@ -23,10 +17,18 @@ type Props = {
}
export function ModelSelector({ value, onChange }: Props = {}) {
const t = useTranslation()
const { currentModel: storeModel, availableModels, effortLevel, setModel, setEffort } = useSettingsStore()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [
{ value: 'low', label: t('settings.general.effort.low') },
{ value: 'medium', label: t('settings.general.effort.medium') },
{ value: 'high', label: t('settings.general.effort.high') },
{ value: 'max', label: t('settings.general.effort.max') },
]
const isControlled = value !== undefined
const selectedModel = isControlled ? availableModels.find((m) => m.id === value) || null : storeModel
@ -61,7 +63,7 @@ export function ModelSelector({ value, onChange }: Props = {}) {
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">auto_awesome</span>
<span>{selectedModel?.name ?? 'Select model'}</span>
<span>{selectedModel?.name ?? t('model.selectModel')}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
</button>
@ -70,7 +72,7 @@ export function ModelSelector({ value, onChange }: Props = {}) {
{/* Models */}
<div className="p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
Model Configuration
{t('model.configuration')}
</div>
<div className="space-y-1">
{availableModels.map((model) => {
@ -124,7 +126,7 @@ export function ModelSelector({ value, onChange }: Props = {}) {
{/* Effort — hidden in controlled mode (not relevant for task creation) */}
{!isControlled && <div className="border-t border-[var(--color-border)] p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
Effort
{t('model.effort')}
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {

View File

@ -3,43 +3,9 @@ import { createPortal } from 'react-dom'
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
import type { PermissionMode } from '../../types/settings'
const PERMISSION_ITEMS: Array<{
value: PermissionMode
label: string
description: string
icon: string
color?: string
}> = [
{
value: 'default',
label: 'Ask permissions',
description: 'Confirm file edits and higher-risk commands when CLI asks',
icon: 'verified_user',
},
{
value: 'acceptEdits',
label: 'Auto accept edits',
description: 'Claude writes to disk without asking',
icon: 'bolt',
},
{
value: 'plan',
label: 'Plan mode',
description: 'Architecture & reasoning only, no files',
icon: 'architecture',
color: 'text-[var(--color-text-tertiary)]',
},
{
value: 'bypassPermissions',
label: 'Bypass permissions',
description: 'Full tool access for shell and file system',
icon: 'gavel',
color: 'text-[var(--color-error)]',
},
]
const MODE_ICONS: Record<PermissionMode, string> = {
default: 'verified_user',
acceptEdits: 'bolt',
@ -48,14 +14,6 @@ const MODE_ICONS: Record<PermissionMode, string> = {
dontAsk: 'gavel',
}
const MODE_LABELS: Record<PermissionMode, string> = {
default: 'Ask permissions',
acceptEdits: 'Auto accept',
plan: 'Plan mode',
bypassPermissions: 'Bypass',
dontAsk: 'Don\'t ask',
}
type Props = {
workDir?: string
/** Controlled mode: override current value */
@ -65,6 +23,7 @@ type Props = {
}
export function PermissionModeSelector({ workDir: workDirProp, value, onChange }: Props = {}) {
const t = useTranslation()
const { permissionMode: storeMode, setPermissionMode } = useSettingsStore()
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
const sessions = useSessionStore((s) => s.sessions)
@ -76,6 +35,49 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
const isControlled = value !== undefined
const currentMode = isControlled ? value : storeMode
const PERMISSION_ITEMS: Array<{
value: PermissionMode
label: string
description: string
icon: string
color?: string
}> = [
{
value: 'default',
label: t('permMode.askPermissions'),
description: t('permMode.askPermDesc'),
icon: 'verified_user',
},
{
value: 'acceptEdits',
label: t('permMode.autoAccept'),
description: t('permMode.autoAcceptDesc'),
icon: 'bolt',
},
{
value: 'plan',
label: t('permMode.planMode'),
description: t('permMode.planModeDesc'),
icon: 'architecture',
color: 'text-[var(--color-text-tertiary)]',
},
{
value: 'bypassPermissions',
label: t('permMode.bypass'),
description: t('permMode.bypassDesc'),
icon: 'gavel',
color: 'text-[var(--color-error)]',
},
]
const MODE_LABELS: Record<PermissionMode, string> = {
default: t('permMode.label.default'),
acceptEdits: t('permMode.label.acceptEdits'),
plan: t('permMode.label.plan'),
bypassPermissions: t('permMode.label.bypassPermissions'),
dontAsk: t('permMode.label.dontAsk'),
}
const activeSession = sessions.find((s) => s.id === activeSessionId)
const workDir = workDirProp || activeSession?.workDir || '~'
@ -109,7 +111,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
{open && (
<div className="absolute left-0 bottom-full mb-2 w-[320px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50 py-2">
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
Execution Permissions
{t('permMode.executionPermissions')}
</div>
{PERMISSION_ITEMS.map((item) => (
<button
@ -164,16 +166,14 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
<span className="material-symbols-outlined text-[22px] text-[var(--color-error)]">warning</span>
</div>
<div>
<div className="text-sm font-bold text-[var(--color-text-primary)]">Enable bypass permissions?</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">This grants full access to your system</div>
<div className="text-sm font-bold text-[var(--color-text-primary)]">{t('permMode.enableBypassTitle')}</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('permMode.enableBypassSubtitle')}</div>
</div>
</div>
{/* Body */}
<div className="px-5 py-4">
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed mb-3">
Claude will have <strong>unrestricted</strong> access to execute shell commands and modify files within:
</p>
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed mb-3" dangerouslySetInnerHTML={{ __html: t('permMode.enableBypassBody') }} />
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-surface-container)] border border-[var(--color-border)]" title={workDir}>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)] shrink-0">folder</span>
<code className="text-xs font-[var(--font-mono)] text-[var(--color-text-primary)] truncate">{workDir}</code>
@ -181,15 +181,15 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
<ul className="mt-3 space-y-1.5 text-xs text-[var(--color-text-secondary)]">
<li className="flex items-start gap-2">
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)] mt-0.5">check</span>
Read, write, and delete any files
{t('permMode.permReadWrite')}
</li>
<li className="flex items-start gap-2">
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)] mt-0.5">check</span>
Execute arbitrary shell commands
{t('permMode.permShell')}
</li>
<li className="flex items-start gap-2">
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)] mt-0.5">check</span>
Install or remove packages
{t('permMode.permPackages')}
</li>
</ul>
</div>
@ -200,7 +200,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
onClick={() => setConfirmDialog(false)}
className="px-4 py-2 text-xs font-semibold text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={() => {
@ -214,7 +214,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
}}
className="px-4 py-2 text-xs font-semibold text-white bg-[var(--color-error)] hover:opacity-90 rounded-lg transition-colors"
>
Enable bypass
{t('permMode.enableBypassBtn')}
</button>
</div>
</div>

View File

@ -1,7 +1,9 @@
import { useState, useRef, useEffect } from 'react'
import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
export function ProjectFilter() {
const t = useTranslation()
const { availableProjects, selectedProjects, setSelectedProjects } = useSessionStore()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
@ -19,9 +21,9 @@ export function ProjectFilter() {
const isAllSelected = selectedProjects.length === 0
const label = isAllSelected
? 'All projects'
? t('sidebar.allProjects')
: selectedProjects.length === 1
? getDisplayName(selectedProjects[0]!)
? getDisplayName(selectedProjects[0]!, t('sidebar.other'))
: `${selectedProjects.length} projects`
const toggleProject = (path: string) => {
@ -62,7 +64,7 @@ export function ProjectFilter() {
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-sm text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<FolderIcon />
<span className="flex-1 text-[var(--color-text-primary)]">All projects</span>
<span className="flex-1 text-[var(--color-text-primary)]">{t('sidebar.allProjects')}</span>
{isAllSelected && <CheckIcon />}
</button>
@ -78,7 +80,7 @@ export function ProjectFilter() {
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-sm text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<FolderIcon />
<span className="flex-1 truncate text-[var(--color-text-primary)]">{getDisplayName(path)}</span>
<span className="flex-1 truncate text-[var(--color-text-primary)]">{getDisplayName(path, t('sidebar.other'))}</span>
{checked && <CheckIcon />}
</button>
)
@ -89,10 +91,10 @@ export function ProjectFilter() {
)
}
function getDisplayName(sanitizedPath: string): string {
if (!sanitizedPath || sanitizedPath === '_unknown') return 'Other'
function getDisplayName(sanitizedPath: string, fallback: string = 'Other'): string {
if (!sanitizedPath || sanitizedPath === '_unknown') return fallback
const segments = sanitizedPath.split('-').filter(Boolean)
return segments[segments.length - 1] || 'Other'
return segments[segments.length - 1] || fallback
}
function ChevronIcon({ open }: { open: boolean }) {

View File

@ -1,14 +1,15 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import { ProjectFilter } from './ProjectFilter'
import type { SessionListItem } from '../../types/session'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
type TimeGroup = 'Today' | 'Yesterday' | 'Last 7 days' | 'Last 30 days' | 'Older'
type TimeGroup = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'older'
const TIME_GROUP_ORDER: TimeGroup[] = ['Today', 'Yesterday', 'Last 7 days', 'Last 30 days', 'Older']
const TIME_GROUP_ORDER: TimeGroup[] = ['today', 'yesterday', 'last7days', 'last30days', 'older']
export function Sidebar() {
const {
@ -79,6 +80,16 @@ export function Sidebar() {
setRenameValue('')
}, [renamingId, renameValue, renameSession])
const t = useTranslation()
const TIME_GROUP_LABELS: Record<TimeGroup, string> = {
today: t('sidebar.timeGroup.today'),
yesterday: t('sidebar.timeGroup.yesterday'),
last7days: t('sidebar.timeGroup.last7days'),
last30days: t('sidebar.timeGroup.last30days'),
older: t('sidebar.timeGroup.older'),
}
return (
<aside data-tauri-drag-region className="w-[var(--sidebar-width)] h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none">
{/* Brand logo — extra top padding in desktop to clear macOS traffic lights */}
@ -106,14 +117,14 @@ export function Sidebar() {
onClick={() => { setActiveView('code'); setActiveSession(null) }}
icon={<PlusIcon />}
>
New session
{t('sidebar.newSession')}
</NavItem>
<NavItem
active={activeView === 'scheduled'}
onClick={() => setActiveView('scheduled')}
icon={<ClockIcon />}
>
Scheduled
{t('sidebar.scheduled')}
</NavItem>
</div>
@ -127,7 +138,7 @@ export function Sidebar() {
<input
id="sidebar-search"
type="text"
placeholder="Search sessions..."
placeholder={t('sidebar.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-7 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
@ -138,19 +149,19 @@ export function Sidebar() {
<div className="flex-1 overflow-y-auto px-3">
{error && (
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
<div className="text-xs font-medium text-[var(--color-error)]">Session list failed to load</div>
<div className="text-xs font-medium text-[var(--color-error)]">{t('sidebar.sessionListFailed')}</div>
<div className="mt-1 text-[11px] text-[var(--color-text-secondary)] break-words">{error}</div>
<button
onClick={() => fetchSessions()}
className="mt-2 text-[11px] font-medium text-[var(--color-brand)] hover:underline"
>
Retry
{t('common.retry')}
</button>
</div>
)}
{filteredSessions.length === 0 && (
<div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center">
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
</div>
)}
{TIME_GROUP_ORDER.map((group) => {
@ -159,7 +170,7 @@ export function Sidebar() {
return (
<div key={group} className="mb-1">
<div className="px-2 pt-3 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)] tracking-wide">
{group}
{TIME_GROUP_LABELS[group]}
</div>
{items.map((session) => (
<div key={session.id} className="relative">
@ -195,9 +206,9 @@ export function Sidebar() {
{!session.workDirExists && (
<span
className="text-[10px] text-[var(--color-warning)] flex-shrink-0"
title={session.workDir ?? 'Missing workspace'}
title={session.workDir ?? ''}
>
missing dir
{t('sidebar.missingDir')}
</span>
)}
<span className="text-[10px] text-[var(--color-text-tertiary)] flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
@ -219,7 +230,7 @@ export function Sidebar() {
onClick={() => setActiveView('settings')}
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
>
Settings
{t('sidebar.settings')}
</NavItem>
</div>
@ -236,13 +247,13 @@ export function Sidebar() {
}}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
Rename
{t('common.rename')}
</button>
<button
onClick={() => handleDelete(contextMenu.id)}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-error)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
Delete
{t('common.delete')}
</button>
</div>
)}
@ -261,11 +272,11 @@ function groupByTime(sessions: SessionListItem[]): Map<TimeGroup, SessionListIte
for (const session of sessions) {
const ts = new Date(session.modifiedAt).getTime()
let group: TimeGroup
if (ts >= startOfToday) group = 'Today'
else if (ts >= startOfYesterday) group = 'Yesterday'
else if (ts >= sevenDaysAgo) group = 'Last 7 days'
else if (ts >= thirtyDaysAgo) group = 'Last 30 days'
else group = 'Older'
if (ts >= startOfToday) group = 'today'
else if (ts >= startOfYesterday) group = 'yesterday'
else if (ts >= sevenDaysAgo) group = 'last7days'
else if (ts >= thirtyDaysAgo) group = 'last30days'
else group = 'older'
if (!groups.has(group)) groups.set(group, [])
groups.get(group)!.push(session)

View File

@ -1,11 +1,13 @@
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
export function StatusBar() {
const { currentModel } = useSettingsStore()
const { connectionState } = useChatStore()
const { sessions, activeSessionId } = useSessionStore()
const t = useTranslation()
const activeSession = sessions.find((s) => s.id === activeSessionId)
@ -18,12 +20,12 @@ export function StatusBar() {
const statusText =
connectionState === 'connected'
? 'Connected'
? t('status.connected')
: connectionState === 'connecting'
? 'Connecting...'
? t('status.connecting')
: connectionState === 'reconnecting'
? 'Reconnecting...'
: 'Disconnected'
? t('status.reconnecting')
: t('status.disconnected')
const projectName = activeSession?.projectPath
? activeSession.projectPath.split('-').filter(Boolean).pop() || ''

View File

@ -1,7 +1,9 @@
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
export function TitleBar() {
const { activeView, setActiveView } = useUIStore()
const t = useTranslation()
return (
<div
@ -37,21 +39,21 @@ export function TitleBar() {
onClick={() => setActiveView('code')}
icon="code"
>
Code
{t('titlebar.code')}
</TabButton>
<TabButton
active={activeView === 'terminal'}
onClick={() => setActiveView('terminal')}
icon="terminal"
>
Terminal
{t('titlebar.terminal')}
</TabButton>
<TabButton
active={activeView === 'history'}
onClick={() => setActiveView('history')}
icon="history"
>
History
{t('titlebar.history')}
</TabButton>
</div>

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { sessionsApi, type RecentProject } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
import { useTranslation } from '../../i18n'
type Props = {
value: string
@ -14,6 +15,7 @@ function isTauriRuntime() {
}
export function DirectoryPicker({ value, onChange }: Props) {
const t = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [mode, setMode] = useState<'recent' | 'browse'>('recent')
const [projects, setProjects] = useState<RecentProject[]>([])
@ -69,7 +71,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
const selected = await open({
directory: true,
multiple: false,
title: 'Choose project folder',
title: t('dirPicker.chooseProjectFolder'),
})
if (selected) onChange(selected)
} catch (err) {
@ -120,7 +122,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
className="flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<span className="material-symbols-outlined text-[14px]">folder_open</span>
Select a project...
{t('dirPicker.selectProject')}
</button>
)}
@ -130,13 +132,13 @@ export function DirectoryPicker({ value, onChange }: Props) {
{mode === 'recent' ? (
<>
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
Recent
{t('dirPicker.recent')}
</div>
<div className="max-h-[300px] overflow-y-auto">
{loading ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">Loading...</div>
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
) : projects.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">No recent projects</div>
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noRecent')}</div>
) : (
projects.map((project) => {
const isSelected = project.realPath === value
@ -182,7 +184,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">create_new_folder</span>
<span className="text-sm text-[var(--color-text-secondary)]">Choose a different folder</span>
<span className="text-sm text-[var(--color-text-secondary)]">{t('dirPicker.chooseFolder')}</span>
</button>
</div>
</>
@ -191,7 +193,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
<>
<div className="px-3 py-2 border-b border-[var(--color-border)] flex items-center gap-1 flex-wrap">
<button onClick={() => setMode('recent')} className="text-xs text-[var(--color-text-accent)] hover:underline mr-2">
Recent
{'← ' + t('dirPicker.recent')}
</button>
<button onClick={() => loadBrowseDir('/')} className="text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]">/</button>
{browsePath.split('/').filter(Boolean).map((seg, i, arr) => (
@ -207,7 +209,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
<div className="max-h-[240px] overflow-y-auto">
{loading ? (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">Loading...</div>
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
) : (
<>
{browseParent && browseParent !== browsePath && (
@ -217,7 +219,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
</button>
)}
{browseEntries.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">No subdirectories</div>
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noSubdirs')}</div>
) : browseEntries.map((entry) => (
<button
key={entry.path}
@ -226,7 +228,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]" onClick={() => loadBrowseDir(entry.path)}>folder</span>
<span className="text-xs text-[var(--color-text-primary)] flex-1" onClick={() => loadBrowseDir(entry.path)}>{entry.name}</span>
<button onClick={() => handleSelect(entry.path)} className="px-2 py-0.5 text-[10px] font-semibold text-[var(--color-brand)] hover:bg-[var(--color-primary-fixed)] rounded transition-colors">
Select
{t('common.select')}
</button>
</button>
))}
@ -238,7 +240,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
<div className="px-3 py-2 border-t border-[var(--color-border)] flex justify-between items-center">
<span className="text-[10px] text-[var(--color-text-tertiary)] font-[var(--font-mono)] truncate">{browsePath}</span>
<button onClick={() => handleSelect(browsePath)} className="px-3 py-1.5 bg-[var(--color-brand)] text-white text-xs font-semibold rounded-lg hover:opacity-90">
Use this folder
{t('dirPicker.useThisFolder')}
</button>
</div>
</>

View File

@ -5,6 +5,7 @@ import { Modal } from '../shared/Modal'
import { Input } from '../shared/Input'
import { Button } from '../shared/Button'
import { PromptEditor } from './PromptEditor'
import { useTranslation } from '../../i18n'
import type { PermissionMode } from '../../types/settings'
type Props = {
@ -14,14 +15,6 @@ type Props = {
type FrequencyKey = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'monthly'
const FREQUENCY_OPTIONS: Array<{ value: FrequencyKey; label: string; showTime: boolean }> = [
{ value: 'hourly', label: 'Hourly', showTime: false },
{ value: 'daily', label: 'Daily', showTime: true },
{ value: 'weekdays', label: 'Weekdays', showTime: true },
{ value: 'weekly', label: 'Weekly', showTime: true },
{ value: 'monthly', label: 'Monthly', showTime: true },
]
function buildCron(freq: FrequencyKey, time: string): string {
const [hours, minutes] = time.split(':').map(Number)
switch (freq) {
@ -34,12 +27,21 @@ function buildCron(freq: FrequencyKey, time: string): string {
}
export function NewTaskModal({ open, onClose }: Props) {
const t = useTranslation()
const { createTask } = useTaskStore()
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const activeSession = sessions.find((s) => s.id === activeSessionId)
const defaultWorkDir = activeSession?.workDir || ''
const FREQUENCY_OPTIONS: Array<{ value: FrequencyKey; label: string; showTime: boolean }> = [
{ value: 'hourly', label: t('newTask.hourly'), showTime: false },
{ value: 'daily', label: t('newTask.daily'), showTime: true },
{ value: 'weekdays', label: t('newTask.weekdays'), showTime: true },
{ value: 'weekly', label: t('newTask.weekly'), showTime: true },
{ value: 'monthly', label: t('newTask.monthly'), showTime: true },
]
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [prompt, setPrompt] = useState('')
@ -92,11 +94,11 @@ export function NewTaskModal({ open, onClose }: Props) {
<Modal
open={open}
onClose={onClose}
title="New scheduled task"
title={t('newTask.title')}
footer={
<>
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>Create task</Button>
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>{t('newTask.create')}</Button>
</>
}
>
@ -104,25 +106,25 @@ export function NewTaskModal({ open, onClose }: Props) {
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] mb-5">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">info</span>
<span className="text-xs text-[var(--color-text-secondary)]">
Local tasks only run while your computer is awake.
{t('newTask.localWarning')}
</span>
</div>
<div className="flex flex-col gap-4">
<Input
label="Name"
label={t('newTask.name')}
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="daily-code-review"
placeholder={t('newTask.namePlaceholder')}
/>
<Input
label="Description"
label={t('newTask.description')}
required
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Review yesterday's commits and flag anything concerning"
placeholder={t('newTask.descPlaceholder')}
/>
{/* Prompt editor with embedded controls */}
@ -142,7 +144,7 @@ export function NewTaskModal({ open, onClose }: Props) {
{/* Frequency */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">Frequency</label>
<label className="text-sm font-medium text-[var(--color-text-primary)]">{t('newTask.frequency')}</label>
<div className="relative">
<select
value={frequency}
@ -173,7 +175,7 @@ export function NewTaskModal({ open, onClose }: Props) {
)}
<p className="text-xs text-[var(--color-text-tertiary)]">
Scheduled tasks use a randomized delay of several minutes for server performance.
{t('newTask.delayNote')}
</p>
</div>
</Modal>

View File

@ -1,6 +1,7 @@
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
import { ModelSelector } from '../controls/ModelSelector'
import { DirectoryPicker } from '../shared/DirectoryPicker'
import { useTranslation } from '../../i18n'
import type { PermissionMode } from '../../types/settings'
type Props = {
@ -31,9 +32,10 @@ export function PromptEditor({
onModelChange,
folderPath,
onFolderPathChange,
useWorktree,
onUseWorktreeChange,
useWorktree: _useWorktree,
onUseWorktreeChange: _onUseWorktreeChange,
}: Props) {
const t = useTranslation()
return (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] focus-within:border-[var(--color-border-focus)] transition-colors overflow-visible">
{/* Prompt textarea */}
@ -63,7 +65,7 @@ export function PromptEditor({
{permissionMode === 'bypassPermissions' && (
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-[var(--color-error)]/8 text-[10px] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[12px]">warning</span>
Bypass mode grants full system access{folderPath ? ` within ${folderPath}` : ' — select a folder to limit scope'}.
{t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}.
</div>
)}
</div>

View File

@ -1,10 +1,12 @@
import { Button } from '../shared/Button'
import { useTranslation } from '../../i18n'
type Props = {
onCreateTask: () => void
}
export function TaskEmptyState({ onCreateTask }: Props) {
const t = useTranslation()
return (
<div className="flex flex-col items-center justify-center py-20">
{/* Clock icon */}
@ -16,13 +18,13 @@ export function TaskEmptyState({ onCreateTask }: Props) {
</div>
<h3 className="text-sm font-medium text-[var(--color-text-primary)] mb-1">
No scheduled tasks yet.
{t('tasks.emptyTitle')}
</h3>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4 text-center max-w-sm">
Create a recurring task to automate your engineering workflows.
{t('tasks.emptyDesc')}
</p>
<Button onClick={onCreateTask}>+ New task</Button>
<Button onClick={onCreateTask}>{t('tasks.newTask')}</Button>
</div>
)
}

View File

@ -1,20 +1,22 @@
import type { CronTask } from '../../types/task'
import { TaskRow } from './TaskRow'
import { useTranslation } from '../../i18n'
type Props = {
tasks: CronTask[]
}
export function TaskList({ tasks }: Props) {
const enabledCount = tasks.filter((t) => t.enabled).length
const t = useTranslation()
const enabledCount = tasks.filter((task) => task.enabled).length
return (
<div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<StatCard label="Total Tasks" value={String(tasks.length)} />
<StatCard label="Active" value={String(enabledCount)} />
<StatCard label="Disabled" value={String(tasks.length - enabledCount)} />
<StatCard label={t('tasks.totalTasks')} value={String(tasks.length)} />
<StatCard label={t('tasks.active')} value={String(enabledCount)} />
<StatCard label={t('tasks.disabled')} value={String(tasks.length - enabledCount)} />
</div>
{/* Task rows */}

View File

@ -1,5 +1,6 @@
import type { CronTask } from '../../types/task'
import { useTaskStore } from '../../stores/taskStore'
import { useTranslation } from '../../i18n'
type Props = {
task: CronTask
@ -7,6 +8,7 @@ type Props = {
export function TaskRow({ task }: Props) {
const { deleteTask, updateTask } = useTaskStore()
const t = useTranslation()
const toggleEnabled = () => {
updateTask(task.id, { enabled: !task.enabled })
@ -36,13 +38,13 @@ export function TaskRow({ task }: Props) {
onClick={toggleEnabled}
className="px-2 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-selected)] transition-colors"
>
{task.enabled ? 'Disable' : 'Enable'}
{task.enabled ? t('common.disable') : t('common.enable')}
</button>
<button
onClick={() => deleteTask(task.id)}
className="px-2 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-error)] hover:bg-red-50 transition-colors"
>
Delete
{t('common.delete')}
</button>
</div>
</div>

View File

@ -1,14 +1,16 @@
import { useTeamStore } from '../../stores/teamStore'
import { useTranslation } from '../../i18n'
export function BackToLeader() {
const setViewingAgent = useTeamStore((s) => s.setViewingAgent)
const t = useTranslation()
return (
<button
onClick={() => setViewingAgent(null)}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[var(--color-text-accent)] hover:underline transition-colors"
>
Back to Leader
{t('teams.backToLeader')}
</button>
)
}

View File

@ -1,7 +1,9 @@
import { useTeamStore } from '../../stores/teamStore'
import { MemberTag } from './MemberTag'
import { useTranslation } from '../../i18n'
export function TeamStatusBar() {
const t = useTranslation()
const { activeTeam } = useTeamStore()
if (!activeTeam) return null
@ -11,10 +13,10 @@ export function TeamStatusBar() {
{/* Team header */}
<div className="px-4 py-2 flex items-center gap-2">
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
Team: {activeTeam.name}
{t('teams.team')} {activeTeam.name}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
({activeTeam.members.length} members)
({activeTeam.members.length} {t('teams.members')})
</span>
</div>

View File

@ -1,6 +1,8 @@
import { useTeamStore } from '../../stores/teamStore'
import { useTranslation } from '../../i18n'
export function TranscriptView() {
const t = useTranslation()
const { agentTranscript, viewingAgentId, activeTeam } = useTeamStore()
const member = activeTeam?.members.find((m) => m.agentId === viewingAgentId)
@ -11,7 +13,7 @@ export function TranscriptView() {
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-[var(--color-border)]" />
<span className="text-xs text-[var(--color-text-tertiary)]">
Viewing: {member?.role || viewingAgentId} transcript
{t('teams.viewing')} {member?.role || viewingAgentId} {t('teams.transcript')}
</span>
<div className="flex-1 h-px bg-[var(--color-border)]" />
</div>
@ -19,7 +21,7 @@ export function TranscriptView() {
{/* Transcript messages */}
{agentTranscript.length === 0 ? (
<div className="text-center text-sm text-[var(--color-text-tertiary)] py-8">
No messages yet
{t('teams.noMessages')}
</div>
) : (
agentTranscript.map((msg) => (

View File

@ -1,4 +1,6 @@
export const SPINNER_VERBS = [
import { useSettingsStore } from '../stores/settingsStore'
const SPINNER_VERBS_EN = [
'Accomplishing',
'Actioning',
'Actualizing',
@ -188,6 +190,198 @@ export const SPINNER_VERBS = [
'Zigzagging',
]
const SPINNER_VERBS_ZH = [
'成就中',
'行动中',
'实现中',
'架构中',
'烘焙中',
'发射中',
'嘟嘟叫',
'困惑中',
'翻腾中',
'焯水中',
'吹牛中',
'扭扭舞',
'磨蹭中',
'嘟嘟响',
'启动中',
'酿造中',
'揉面中',
'钻洞中',
'计算中',
'亲昵中',
'焦糖化',
'奔涌中',
'弹射中',
'冥想中',
'引导中',
'疏导中',
'编舞中',
'搅拌中',
'Claude中',
'凝聚中',
'沉思中',
'修复中',
'谱曲中',
'运算中',
'调配中',
'考虑中',
'深思中',
'烹饪中',
'打造中',
'创造中',
'咔嚓中',
'结晶中',
'培育中',
'破译中',
'斟酌中',
'决定中',
'磨叽中',
'搅乱中',
'干活中',
'涂鸦中',
'细雨中',
'退潮中',
'生效中',
'阐明中',
'修饰中',
'施魔法',
'畅想中',
'蒸发中',
'发酵中',
'小打闹',
'周旋中',
'烤炙中',
'叨叨中',
'流淌中',
'迷惑中',
'扑腾中',
'锻造中',
'成形中',
'嬉闹中',
'裱花中',
'闲逛中',
'驰骋中',
'点缀中',
'生成中',
'比划中',
'萌芽中',
'Git中',
'摇摆中',
'狂风中',
'和谐中',
'哈希中',
'孵化中',
'放牧中',
'鸣笛中',
'欢呼中',
'超空间',
'构思中',
'想象中',
'即兴中',
'孵育中',
'推断中',
'浸泡中',
'电离中',
'跳舞中',
'切丝中',
'揉面中',
'发酵中',
'悬浮中',
'闲晃中',
'显化中',
'腌制中',
'漫步中',
'蜕变中',
'雾化中',
'太空步',
'溜达中',
'琢磨中',
'集结中',
'遐想中',
'雾化中',
'筑巢中',
'读报中',
'瞎琢磨',
'聚合中',
'环绕中',
'指挥中',
'渗透中',
'漫游中',
'渗滤中',
'细读中',
'哲思中',
'光合中',
'授粉中',
'思索中',
'高谈中',
'扑腾中',
'沉淀中',
'变戏法',
'处理中',
'校对中',
'传播中',
'捣鼓中',
'费解中',
'量子化',
'眼花缭乱',
'闪闪发光',
'重组中',
'编织中',
'栖息中',
'反刍中',
'煎炒中',
'蹦跶中',
'拖拽中',
'奔忙中',
'调味中',
'恶作剧',
'摇曳中',
'炖煮中',
'溜走中',
'素描中',
'滑行中',
'揉搓中',
'跳摇滚',
'探洞中',
'旋转中',
'萌发中',
'炖汤中',
'升华中',
'漩涡中',
'俯冲中',
'共生中',
'合成中',
'回火中',
'思考中',
'雷鸣中',
'修补中',
'胡闹中',
'颠倒中',
'变形中',
'嬗变中',
'扭转中',
'波动中',
'展开中',
'解开中',
'感应中',
'摇摆中',
'游荡中',
'扭曲中',
'不知所云',
'漩涡中',
'嗡嗡响',
'搅打中',
'摇晃中',
'工作中',
'搏斗中',
'调味中',
'曲折中',
]
export function randomSpinnerVerb(): string {
return SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)] ?? 'Thinking'
const locale = useSettingsStore.getState().locale
const list = locale === 'zh' ? SPINNER_VERBS_ZH : SPINNER_VERBS_EN
return list[Math.floor(Math.random() * list.length)] ?? '思考中'
}

54
desktop/src/i18n/index.ts Normal file
View File

@ -0,0 +1,54 @@
import { useSettingsStore } from '../stores/settingsStore'
import { en, type TranslationKey } from './locales/en'
import { zh } from './locales/zh'
export type Locale = 'en' | 'zh'
const translations: Record<Locale, Record<string, string>> = { en, zh }
/**
* Translate a key with optional interpolation params.
* Falls back to the key itself if no translation is found.
*
* @example
* translate('en', 'settings.providers.connected', { latency: '42' })
* // => "Connected (42ms)"
*/
export function translate(
locale: Locale,
key: TranslationKey,
params?: Record<string, string | number>,
): string {
let text = translations[locale]?.[key] ?? translations.en[key] ?? key
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
}
}
return text
}
/**
* React hook that returns a `t()` function bound to the current locale.
* Re-renders when the locale changes.
*
* @example
* const t = useTranslation()
* t('sidebar.newSession') // => "New session" or "新建会话"
*/
export function useTranslation() {
const locale = useSettingsStore((s) => s.locale)
return (key: TranslationKey, params?: Record<string, string | number>) =>
translate(locale, key, params)
}
/**
* Get a translation outside of React (e.g. in stores).
* Reads the current locale from the Zustand store directly.
*/
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
const locale = useSettingsStore.getState().locale
return translate(locale, key, params)
}
export type { TranslationKey }

View File

@ -0,0 +1,324 @@
export const en = {
// ─── Common ──────────────────────────────────────
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.delete': 'Delete',
'common.add': 'Add',
'common.run': 'Run',
'common.stop': 'Stop',
'common.rename': 'Rename',
'common.retry': 'Retry',
'common.loading': 'Loading...',
'common.select': 'Select',
'common.enable': 'Enable',
'common.disable': 'Disable',
'common.active': 'ACTIVE',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': 'New session',
'sidebar.scheduled': 'Scheduled',
'sidebar.settings': 'Settings',
'sidebar.searchPlaceholder': 'Search sessions...',
'sidebar.noSessions': 'No sessions yet',
'sidebar.noMatching': 'No matching sessions',
'sidebar.sessionListFailed': 'Session list failed to load',
'sidebar.missingDir': 'missing dir',
'sidebar.allProjects': 'All projects',
'sidebar.other': 'Other',
'sidebar.timeGroup.today': 'Today',
'sidebar.timeGroup.yesterday': 'Yesterday',
'sidebar.timeGroup.last7days': 'Last 7 days',
'sidebar.timeGroup.last30days': 'Last 30 days',
'sidebar.timeGroup.older': 'Older',
// ─── Title Bar ──────────────────────────────────────
'titlebar.code': 'Code',
'titlebar.terminal': 'Terminal',
'titlebar.history': 'History',
// ─── Status Bar ──────────────────────────────────────
'status.connected': 'Connected',
'status.connecting': 'Connecting...',
'status.reconnecting': 'Reconnecting...',
'status.disconnected': 'Disconnected',
// ─── Settings ──────────────────────────────────────
'settings.title': 'Settings',
'settings.tab.providers': 'Providers',
'settings.tab.permissions': 'Permissions',
'settings.tab.general': 'General',
// Settings > Providers
'settings.providers.title': 'Providers',
'settings.providers.description': 'Manage API providers for model access.',
'settings.providers.addProvider': 'Add Provider',
'settings.providers.officialName': 'Claude Official',
'settings.providers.officialDesc': 'Anthropic native — no API key required',
'settings.providers.connected': 'Connected ({latency}ms)',
'settings.providers.failed': 'Failed: {error}',
'settings.providers.confirmDelete': 'Delete provider "{name}"? This cannot be undone.',
'settings.providers.activate': 'Activate',
'settings.providers.test': 'Test',
'settings.providers.edit': 'Edit',
'settings.providers.requestFailed': 'Request failed',
'settings.providers.addTitle': 'Add Provider',
'settings.providers.editTitle': 'Edit Provider',
'settings.providers.preset': 'Preset',
'settings.providers.name': 'Name',
'settings.providers.namePlaceholder': 'Provider name',
'settings.providers.notes': 'Notes',
'settings.providers.notesPlaceholder': 'Optional notes...',
'settings.providers.baseUrl': 'Base URL',
'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic',
'settings.providers.apiKey': 'API Key',
'settings.providers.apiKeyKeep': 'API Key (leave blank to keep current)',
'settings.providers.modelMapping': 'Model Mapping',
'settings.providers.mainModel': 'Main Model',
'settings.providers.haikuModel': 'Haiku Model',
'settings.providers.sonnetModel': 'Sonnet Model',
'settings.providers.opusModel': 'Opus Model',
'settings.providers.sameAsMain': 'Same as main',
'settings.providers.testConnection': 'Test Connection',
'settings.providers.settingsJson': 'Settings JSON',
'settings.providers.settingsJsonDesc': '~/.claude/settings.json — edit directly, will be written on save.',
'settings.providers.jsonError': 'JSON syntax error: {error}',
// Settings > Permissions
'settings.permissions.title': 'Permission Mode',
'settings.permissions.description': 'Controls how tool execution permissions are handled.',
'settings.permissions.default': 'Ask permissions',
'settings.permissions.defaultDesc': 'Ask before executing tools',
'settings.permissions.acceptEdits': 'Accept edits',
'settings.permissions.acceptEditsDesc': 'Auto-approve file edits, ask for others',
'settings.permissions.plan': 'Plan mode',
'settings.permissions.planDesc': 'Think and plan without executing',
'settings.permissions.bypass': 'Bypass all',
'settings.permissions.bypassDesc': 'Skip all permission checks (dangerous)',
// Settings > General
'settings.general.languageTitle': 'Language',
'settings.general.languageDescription': 'Choose the display language for the application.',
'settings.general.effortTitle': 'Effort Level',
'settings.general.effortDescription': 'Controls how much computation the model uses.',
'settings.general.effort.low': 'Low',
'settings.general.effort.medium': 'Medium',
'settings.general.effort.high': 'High',
'settings.general.effort.max': 'Max',
// ─── Empty Session ──────────────────────────────────────
'empty.title': 'New session',
'empty.subtitle': 'Start a fresh coding session. Claude is ready to help you build, debug, and architect your project.',
'empty.placeholder': 'Ask anything...',
'empty.addFiles': 'Add files or photos',
'empty.slashCommands': 'Slash commands',
'empty.failedToCreate': 'Failed to create session',
// ─── Chat Input ──────────────────────────────────────
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
'chat.placeholderMissing': 'This session points to a missing workspace. Create a new session or pick another project.',
'chat.addFiles': 'Add files or photos',
'chat.slashCommands': 'Slash commands',
'chat.navigate': 'navigate',
'chat.select': 'select',
'chat.dismiss': 'dismiss',
'chat.stopTitle': 'Stop generation (Cmd+.)',
// ─── Streaming Indicator ──────────────────────────────────────
'streaming.thinking': 'Thinking',
'streaming.running': 'Running',
'streaming.working': 'Working',
// ─── Permission Dialog ──────────────────────────────────────
'permission.allowEditFile': 'Allow Claude to {toolName} {fileName}?',
'permission.allowEditFileGeneric': 'Allow Claude to {toolName} this file?',
'permission.allowBash': 'Allow Claude to run this command?',
'permission.allowTool': 'Allow Claude to use {toolName}?',
'permission.awaitingApproval': 'Awaiting approval',
'permission.responded': 'Responded',
'permission.allow': 'Allow',
'permission.allowForSession': 'Allow for session',
'permission.deny': 'Deny',
'permission.hideDetails': 'Hide details',
'permission.showFullInput': 'Show full input',
'permission.replacingContent': 'Replacing content in file',
// ─── Ask User Question ──────────────────────────────────────
'question.needsInput': 'Claude needs your input',
'question.answered': 'Answered',
'question.customResponse': 'Or type a custom response:',
'question.typePlaceholder': 'Type your answer...',
'question.submit': 'Submit',
'question.answeredPrefix': 'Answered: ',
// ─── Thinking Block ──────────────────────────────────────
'thinking.label': 'Thinking',
// ─── Tool Calls ──────────────────────────────────────
'tool.errorOutput': 'Error Output',
'tool.toolOutput': 'Tool Output',
'tool.toolInput': 'Tool Input',
'tool.readFileContents': 'Read file contents',
'tool.createFile': 'Create file',
'tool.updateFileContents': 'Update file contents',
'tool.linesChanged': '{count} lines changed',
'tool.linesCreated': '{count} lines created',
'tool.linesOutput': '{count} lines output',
'tool.result': '{toolName} result',
'tool.resultGeneric': 'Tool result',
'tool.error': 'ERROR',
'tool.success': 'SUCCESS',
'tool.showLess': 'Show less',
'tool.showMore': 'Show {count} more characters',
// ─── Tool Group Verbs ──────────────────────────────────────
'toolGroup.readOne': 'Read 1 file',
'toolGroup.readMany': 'Read {count} files',
'toolGroup.createdOne': 'created a file',
'toolGroup.createdMany': 'created {count} files',
'toolGroup.editedOne': 'edited a file',
'toolGroup.editedMany': 'edited {count} files',
'toolGroup.ranOne': 'ran a command',
'toolGroup.ranMany': 'ran {count} commands',
'toolGroup.foundFiles': 'found files',
'toolGroup.searchedOne': 'searched code',
'toolGroup.searchedMany': 'searched {count} patterns',
'toolGroup.agentOne': 'dispatched an agent',
'toolGroup.agentMany': 'dispatched {count} agents',
'toolGroup.searchedWeb': 'searched the web',
'toolGroup.fetchedOne': 'fetched a page',
'toolGroup.fetchedMany': 'fetched {count} pages',
// ─── Tasks ──────────────────────────────────────
'tasks.title': 'Tasks',
'tasks.completed': 'Tasks completed',
'tasks.newTask': '+ New task',
'tasks.emptyTitle': 'No scheduled tasks yet.',
'tasks.emptyDesc': 'Create a recurring task to automate your engineering workflows.',
'tasks.createNew': 'Create new task',
'tasks.totalTasks': 'Total Tasks',
'tasks.active': 'Active',
'tasks.disabled': 'Disabled',
// ─── New Task Modal ──────────────────────────────────────
'newTask.title': 'New scheduled task',
'newTask.localWarning': 'Local tasks only run while your computer is awake.',
'newTask.name': 'Name',
'newTask.namePlaceholder': 'daily-code-review',
'newTask.description': 'Description',
'newTask.descPlaceholder': "Review yesterday's commits and flag anything concerning",
'newTask.frequency': 'Frequency',
'newTask.hourly': 'Hourly',
'newTask.daily': 'Daily',
'newTask.weekdays': 'Weekdays',
'newTask.weekly': 'Weekly',
'newTask.monthly': 'Monthly',
'newTask.delayNote': 'Scheduled tasks use a randomized delay of several minutes for server performance.',
'newTask.create': 'Create task',
// ─── Prompt Editor ──────────────────────────────────────
'promptEditor.worktree': 'worktree',
'promptEditor.bypassWarning': 'Bypass mode grants full system access',
'promptEditor.within': 'within',
'promptEditor.selectFolder': '— select a folder to limit scope',
// ─── Permission Mode Selector ──────────────────────────────────────
'permMode.executionPermissions': 'Execution Permissions',
'permMode.askPermissions': 'Ask permissions',
'permMode.askPermDesc': 'Confirm file edits and higher-risk commands when CLI asks',
'permMode.autoAccept': 'Auto accept edits',
'permMode.autoAcceptDesc': 'Claude writes to disk without asking',
'permMode.planMode': 'Plan mode',
'permMode.planModeDesc': 'Architecture & reasoning only, no files',
'permMode.bypass': 'Bypass permissions',
'permMode.bypassDesc': 'Full tool access for shell and file system',
'permMode.dontAsk': "Don't ask",
'permMode.enableBypassTitle': 'Enable bypass permissions?',
'permMode.enableBypassSubtitle': 'This grants full access to your system',
'permMode.enableBypassBody': 'Claude will have <strong>unrestricted</strong> access to execute shell commands and modify files within:',
'permMode.permReadWrite': 'Read, write, and delete any files',
'permMode.permShell': 'Execute arbitrary shell commands',
'permMode.permPackages': 'Install or remove packages',
'permMode.enableBypassBtn': 'Enable bypass',
// Mode labels (compact, for chips)
'permMode.label.default': 'Ask permissions',
'permMode.label.acceptEdits': 'Auto accept',
'permMode.label.plan': 'Plan mode',
'permMode.label.bypassPermissions': 'Bypass',
'permMode.label.dontAsk': "Don't ask",
// ─── Model Selector ──────────────────────────────────────
'model.selectModel': 'Select model',
'model.configuration': 'Model Configuration',
'model.effort': 'Effort',
// ─── Directory Picker ──────────────────────────────────────
'dirPicker.selectProject': 'Select a project...',
'dirPicker.recent': 'Recent',
'dirPicker.noRecent': 'No recent projects',
'dirPicker.chooseFolder': 'Choose a different folder',
'dirPicker.chooseProjectFolder': 'Choose project folder',
'dirPicker.useThisFolder': 'Use this folder',
'dirPicker.noSubdirs': 'No subdirectories',
// ─── File Search ──────────────────────────────────────
'fileSearch.searching': 'Searching...',
'fileSearch.noMatch': 'No files match',
'fileSearch.noFiles': 'No files in this directory',
'fileSearch.navigate': 'navigate',
'fileSearch.attach': 'attach',
'fileSearch.close': 'close',
// ─── Teams ──────────────────────────────────────
'teams.backToLeader': '\u2190 Back to Leader',
'teams.viewing': 'Viewing:',
'teams.transcript': 'transcript',
'teams.noMessages': 'No messages yet',
'teams.team': 'Team:',
'teams.members': 'members',
// ─── Scheduled Tasks Pages ──────────────────────────────────────
'scheduledPage.title': 'Scheduled Tasks',
'scheduledPage.subtitle': 'Manage automated operational routines and background maintenance.',
'scheduledPage.executionMode': 'Execution Mode',
'scheduledPage.localMode': 'Local Mode',
'scheduledPage.remoteMode': 'Remote Mode',
'scheduledPage.nextRun': 'Next Run',
'scheduledPage.systemHealth': 'System Health',
'scheduledPage.activeSession': 'Active Session',
'scheduledPage.colTaskName': 'Task Name',
'scheduledPage.colFrequency': 'Frequency',
'scheduledPage.colLastResult': 'Last Result',
'scheduledPage.colNextExecution': 'Next Execution',
'scheduledPage.colActions': 'Actions',
'scheduledPage.endOfList': 'End of scheduled list',
'scheduledPage.pausedTasks': '9 other tasks are currently paused or disabled for maintenance.',
'scheduledPage.recentLogs': 'Recent Output Logs',
'scheduledPage.viewArtifacts': 'View Full Artifacts',
'scheduledPage.resourceAllocation': 'Resource Allocation',
'scheduledPage.cpuCapacity': 'CPU Capacity',
'scheduledPage.memoryLoad': 'Memory Load',
'scheduledPage.connectedLocal': 'Connected to local node',
'scheduledPage.thisMonth': '{count} this month',
// ─── Error Codes ──────────────────────────────────────
'error.CLI_NOT_RUNNING': 'CLI process is not running. The session may have ended or the process crashed.',
'error.CLI_START_FAILED': 'Failed to start CLI process.',
'error.CLI_AUTH_REQUIRED': 'Authentication is required. Please log in.',
'error.CLI_SESSION_CONFLICT': 'Session is already in use by another process.',
'error.CLI_SPAWN_FAILED': 'Failed to spawn CLI subprocess.',
'error.CLI_ERROR': 'An error occurred during processing.',
'error.WORKDIR_INVALID': 'Working directory is invalid or does not exist.',
'error.PARSE_ERROR': 'Invalid message format.',
'error.UNKNOWN_TYPE': 'Unknown message type.',
'error.BAD_REQUEST': 'Bad request.',
'error.NOT_FOUND': 'Resource not found.',
'error.INTERNAL_ERROR': 'Internal server error.',
// ─── Server Status Verbs ──────────────────────────────────────
'serverVerb.Thinking': 'Thinking',
'serverVerb.Task started': 'Task started',
'serverVerb.Task in progress': 'Task in progress',
} as const
export type TranslationKey = keyof typeof en

View File

@ -0,0 +1,324 @@
import type { TranslationKey } from './en'
export const zh: Record<TranslationKey, string> = {
// ─── Common ──────────────────────────────────────
'common.cancel': '取消',
'common.save': '保存',
'common.delete': '删除',
'common.add': '添加',
'common.run': '运行',
'common.stop': '停止',
'common.rename': '重命名',
'common.retry': '重试',
'common.loading': '加载中...',
'common.select': '选择',
'common.enable': '启用',
'common.disable': '禁用',
'common.active': '已激活',
// ─── Sidebar ──────────────────────────────────────
'sidebar.newSession': '新建会话',
'sidebar.scheduled': '定时任务',
'sidebar.settings': '设置',
'sidebar.searchPlaceholder': '搜索会话...',
'sidebar.noSessions': '暂无会话',
'sidebar.noMatching': '没有匹配的会话',
'sidebar.sessionListFailed': '会话列表加载失败',
'sidebar.missingDir': '目录缺失',
'sidebar.allProjects': '所有项目',
'sidebar.other': '其他',
'sidebar.timeGroup.today': '今天',
'sidebar.timeGroup.yesterday': '昨天',
'sidebar.timeGroup.last7days': '最近 7 天',
'sidebar.timeGroup.last30days': '最近 30 天',
'sidebar.timeGroup.older': '更早',
// ─── Title Bar ──────────────────────────────────────
'titlebar.code': '代码',
'titlebar.terminal': '终端',
'titlebar.history': '历史',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '已连接',
'status.connecting': '连接中...',
'status.reconnecting': '重连中...',
'status.disconnected': '已断开',
// ─── Settings ──────────────────────────────────────
'settings.title': '设置',
'settings.tab.providers': '服务商',
'settings.tab.permissions': '权限',
'settings.tab.general': '通用',
// Settings > Providers
'settings.providers.title': '服务商',
'settings.providers.description': '管理 API 服务商以访问模型。',
'settings.providers.addProvider': '添加服务商',
'settings.providers.officialName': 'Claude 官方',
'settings.providers.officialDesc': 'Anthropic 原生接入 — 无需 API 密钥',
'settings.providers.connected': '已连接 ({latency}ms)',
'settings.providers.failed': '失败: {error}',
'settings.providers.confirmDelete': '删除服务商 "{name}"?此操作不可撤销。',
'settings.providers.activate': '激活',
'settings.providers.test': '测试',
'settings.providers.edit': '编辑',
'settings.providers.requestFailed': '请求失败',
'settings.providers.addTitle': '添加服务商',
'settings.providers.editTitle': '编辑服务商',
'settings.providers.preset': '预设',
'settings.providers.name': '名称',
'settings.providers.namePlaceholder': '服务商名称',
'settings.providers.notes': '备注',
'settings.providers.notesPlaceholder': '可选备注...',
'settings.providers.baseUrl': '接口地址',
'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic',
'settings.providers.apiKey': 'API 密钥',
'settings.providers.apiKeyKeep': 'API 密钥(留空保持不变)',
'settings.providers.modelMapping': '模型映射',
'settings.providers.mainModel': '主模型',
'settings.providers.haikuModel': 'Haiku 模型',
'settings.providers.sonnetModel': 'Sonnet 模型',
'settings.providers.opusModel': 'Opus 模型',
'settings.providers.sameAsMain': '与主模型相同',
'settings.providers.testConnection': '测试连接',
'settings.providers.settingsJson': '设置 JSON',
'settings.providers.settingsJsonDesc': '~/.claude/settings.json — 直接编辑,保存时写入。',
'settings.providers.jsonError': 'JSON 语法错误: {error}',
// Settings > Permissions
'settings.permissions.title': '权限模式',
'settings.permissions.description': '控制工具执行权限的处理方式。',
'settings.permissions.default': '询问权限',
'settings.permissions.defaultDesc': '执行工具前先询问',
'settings.permissions.acceptEdits': '接受编辑',
'settings.permissions.acceptEditsDesc': '自动批准文件编辑,其他操作仍询问',
'settings.permissions.plan': '计划模式',
'settings.permissions.planDesc': '仅思考和规划,不执行操作',
'settings.permissions.bypass': '跳过全部',
'settings.permissions.bypassDesc': '跳过所有权限检查(危险)',
// Settings > General
'settings.general.languageTitle': '语言',
'settings.general.languageDescription': '选择应用程序的显示语言。',
'settings.general.effortTitle': '推理强度',
'settings.general.effortDescription': '控制模型使用的计算量。',
'settings.general.effort.low': '低',
'settings.general.effort.medium': '中',
'settings.general.effort.high': '高',
'settings.general.effort.max': '最大',
// ─── Empty Session ──────────────────────────────────────
'empty.title': '新建会话',
'empty.subtitle': '开始一个新的编码会话。Claude 已准备好帮你构建、调试和架构你的项目。',
'empty.placeholder': '随便问点什么...',
'empty.addFiles': '添加文件或图片',
'empty.slashCommands': '斜杠命令',
'empty.failedToCreate': '创建会话失败',
// ─── Chat Input ──────────────────────────────────────
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。',
'chat.addFiles': '添加文件或图片',
'chat.slashCommands': '斜杠命令',
'chat.navigate': '导航',
'chat.select': '选择',
'chat.dismiss': '关闭',
'chat.stopTitle': '停止生成 (Cmd+.)',
// ─── Streaming Indicator ──────────────────────────────────────
'streaming.thinking': '思考中',
'streaming.running': '运行中',
'streaming.working': '工作中',
// ─── Permission Dialog ──────────────────────────────────────
'permission.allowEditFile': '允许 Claude {toolName} {fileName}',
'permission.allowEditFileGeneric': '允许 Claude {toolName}此文件?',
'permission.allowBash': '允许 Claude 执行此命令?',
'permission.allowTool': '允许 Claude 使用 {toolName}',
'permission.awaitingApproval': '等待审批',
'permission.responded': '已响应',
'permission.allow': '允许',
'permission.allowForSession': '本次会话允许',
'permission.deny': '拒绝',
'permission.hideDetails': '隐藏详情',
'permission.showFullInput': '显示完整输入',
'permission.replacingContent': '替换文件内容',
// ─── Ask User Question ──────────────────────────────────────
'question.needsInput': 'Claude 需要你的输入',
'question.answered': '已回答',
'question.customResponse': '或输入自定义回复:',
'question.typePlaceholder': '输入你的回答...',
'question.submit': '提交',
'question.answeredPrefix': '已回答: ',
// ─── Thinking Block ──────────────────────────────────────
'thinking.label': '思考中',
// ─── Tool Calls ──────────────────────────────────────
'tool.errorOutput': '错误输出',
'tool.toolOutput': '工具输出',
'tool.toolInput': '工具输入',
'tool.readFileContents': '读取文件内容',
'tool.createFile': '创建文件',
'tool.updateFileContents': '更新文件内容',
'tool.linesChanged': '{count} 行已更改',
'tool.linesCreated': '{count} 行已创建',
'tool.linesOutput': '{count} 行输出',
'tool.result': '{toolName} 结果',
'tool.resultGeneric': '工具结果',
'tool.error': '错误',
'tool.success': '成功',
'tool.showLess': '收起',
'tool.showMore': '展开 {count} 个字符',
// ─── Tool Group Verbs ──────────────────────────────────────
'toolGroup.readOne': '读取了 1 个文件',
'toolGroup.readMany': '读取了 {count} 个文件',
'toolGroup.createdOne': '创建了一个文件',
'toolGroup.createdMany': '创建了 {count} 个文件',
'toolGroup.editedOne': '编辑了一个文件',
'toolGroup.editedMany': '编辑了 {count} 个文件',
'toolGroup.ranOne': '执行了一条命令',
'toolGroup.ranMany': '执行了 {count} 条命令',
'toolGroup.foundFiles': '查找到文件',
'toolGroup.searchedOne': '搜索了代码',
'toolGroup.searchedMany': '搜索了 {count} 个模式',
'toolGroup.agentOne': '派遣了一个代理',
'toolGroup.agentMany': '派遣了 {count} 个代理',
'toolGroup.searchedWeb': '搜索了网页',
'toolGroup.fetchedOne': '获取了一个页面',
'toolGroup.fetchedMany': '获取了 {count} 个页面',
// ─── Tasks ──────────────────────────────────────
'tasks.title': '任务',
'tasks.completed': '已完成的任务',
'tasks.newTask': '+ 新建任务',
'tasks.emptyTitle': '暂无定时任务。',
'tasks.emptyDesc': '创建定时任务来自动化你的工程工作流。',
'tasks.createNew': '创建新任务',
'tasks.totalTasks': '总任务数',
'tasks.active': '活跃',
'tasks.disabled': '已禁用',
// ─── New Task Modal ──────────────────────────────────────
'newTask.title': '新建定时任务',
'newTask.localWarning': '本地任务仅在电脑唤醒时运行。',
'newTask.name': '名称',
'newTask.namePlaceholder': 'daily-code-review',
'newTask.description': '描述',
'newTask.descPlaceholder': '审查昨天的提交并标记可疑之处',
'newTask.frequency': '频率',
'newTask.hourly': '每小时',
'newTask.daily': '每天',
'newTask.weekdays': '工作日',
'newTask.weekly': '每周',
'newTask.monthly': '每月',
'newTask.delayNote': '定时任务会使用随机延迟以优化服务器性能。',
'newTask.create': '创建任务',
// ─── Prompt Editor ──────────────────────────────────────
'promptEditor.worktree': '工作树',
'promptEditor.bypassWarning': '跳过模式将授予完整系统访问权限',
'promptEditor.within': '范围',
'promptEditor.selectFolder': '— 选择文件夹以限制范围',
// ─── Permission Mode Selector ──────────────────────────────────────
'permMode.executionPermissions': '执行权限',
'permMode.askPermissions': '询问权限',
'permMode.askPermDesc': 'CLI 请求时确认文件编辑和高风险命令',
'permMode.autoAccept': '自动接受编辑',
'permMode.autoAcceptDesc': 'Claude 无需询问即可写入磁盘',
'permMode.planMode': '计划模式',
'permMode.planModeDesc': '仅架构和推理,不操作文件',
'permMode.bypass': '跳过权限',
'permMode.bypassDesc': '对 Shell 和文件系统的完整工具访问',
'permMode.dontAsk': '不询问',
'permMode.enableBypassTitle': '启用跳过权限?',
'permMode.enableBypassSubtitle': '这将授予对系统的完全访问权限',
'permMode.enableBypassBody': 'Claude 将拥有<strong>不受限制的</strong>权限来执行 Shell 命令和修改以下目录中的文件:',
'permMode.permReadWrite': '读取、写入和删除任何文件',
'permMode.permShell': '执行任意 Shell 命令',
'permMode.permPackages': '安装或移除软件包',
'permMode.enableBypassBtn': '启用跳过',
// Mode labels (compact, for chips)
'permMode.label.default': '询问权限',
'permMode.label.acceptEdits': '自动接受',
'permMode.label.plan': '计划模式',
'permMode.label.bypassPermissions': '跳过',
'permMode.label.dontAsk': '不询问',
// ─── Model Selector ──────────────────────────────────────
'model.selectModel': '选择模型',
'model.configuration': '模型配置',
'model.effort': '推理强度',
// ─── Directory Picker ──────────────────────────────────────
'dirPicker.selectProject': '选择项目...',
'dirPicker.recent': '最近',
'dirPicker.noRecent': '暂无最近项目',
'dirPicker.chooseFolder': '选择其他文件夹',
'dirPicker.chooseProjectFolder': '选择项目文件夹',
'dirPicker.useThisFolder': '使用此文件夹',
'dirPicker.noSubdirs': '无子目录',
// ─── File Search ──────────────────────────────────────
'fileSearch.searching': '搜索中...',
'fileSearch.noMatch': '没有匹配的文件',
'fileSearch.noFiles': '此目录下无文件',
'fileSearch.navigate': '导航',
'fileSearch.attach': '附加',
'fileSearch.close': '关闭',
// ─── Teams ──────────────────────────────────────
'teams.backToLeader': '\u2190 返回主控',
'teams.viewing': '查看:',
'teams.transcript': '记录',
'teams.noMessages': '暂无消息',
'teams.team': '团队:',
'teams.members': '名成员',
// ─── Scheduled Tasks Pages ──────────────────────────────────────
'scheduledPage.title': '定时任务',
'scheduledPage.subtitle': '管理自动化运维流程和后台维护任务。',
'scheduledPage.executionMode': '执行模式',
'scheduledPage.localMode': '本地模式',
'scheduledPage.remoteMode': '远程模式',
'scheduledPage.nextRun': '下次运行',
'scheduledPage.systemHealth': '系统健康',
'scheduledPage.activeSession': '活跃会话',
'scheduledPage.colTaskName': '任务名称',
'scheduledPage.colFrequency': '频率',
'scheduledPage.colLastResult': '上次结果',
'scheduledPage.colNextExecution': '下次执行',
'scheduledPage.colActions': '操作',
'scheduledPage.endOfList': '任务列表结束',
'scheduledPage.pausedTasks': '另有 9 个任务当前已暂停或因维护而禁用。',
'scheduledPage.recentLogs': '最近输出日志',
'scheduledPage.viewArtifacts': '查看完整产物',
'scheduledPage.resourceAllocation': '资源分配',
'scheduledPage.cpuCapacity': 'CPU 容量',
'scheduledPage.memoryLoad': '内存负载',
'scheduledPage.connectedLocal': '已连接到本地节点',
'scheduledPage.thisMonth': '本月 {count}',
// ─── Error Codes ──────────────────────────────────────
'error.CLI_NOT_RUNNING': 'CLI 进程未运行。会话可能已结束或进程已崩溃。',
'error.CLI_START_FAILED': 'CLI 进程启动失败。',
'error.CLI_AUTH_REQUIRED': '需要认证。请先登录。',
'error.CLI_SESSION_CONFLICT': '会话已被其他进程占用。',
'error.CLI_SPAWN_FAILED': 'CLI 子进程启动失败。',
'error.CLI_ERROR': '处理过程中发生错误。',
'error.WORKDIR_INVALID': '工作目录无效或不存在。',
'error.PARSE_ERROR': '消息格式无效。',
'error.UNKNOWN_TYPE': '未知的消息类型。',
'error.BAD_REQUEST': '请求无效。',
'error.NOT_FOUND': '资源未找到。',
'error.INTERNAL_ERROR': '内部服务器错误。',
// ─── Server Status Verbs ──────────────────────────────────────
'serverVerb.Thinking': '思考中',
'serverVerb.Task started': '任务已启动',
'serverVerb.Task in progress': '任务进行中',
}

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../i18n'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useUIStore } from '../stores/uiStore'
@ -25,6 +26,7 @@ type Attachment = {
}
export function EmptySession() {
const t = useTranslation()
const [input, setInput] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [workDir, setWorkDir] = useState('')
@ -133,7 +135,7 @@ export function EmptySession() {
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : 'Failed to create session',
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
})
} finally {
setIsSubmitting(false)
@ -332,12 +334,12 @@ export function EmptySession() {
<div className="relative flex flex-1 flex-col overflow-hidden bg-[#FAF9F5]">
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
<div className="flex max-w-md flex-col items-center text-center">
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[18px]" />
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px] shadow-[0_2px_12px_rgba(0,0,0,0.06)]" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[#1B1C1A]" style={{ fontFamily: "'Manrope', sans-serif" }}>
New session
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[#87736D]" style={{ fontFamily: "'Inter', sans-serif" }}>
Start a fresh coding session. Claude is ready to help you build, debug, and architect your project.
{t('empty.subtitle')}
</p>
</div>
</div>
@ -409,7 +411,7 @@ export function EmptySession() {
onPaste={handlePaste}
className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[#1B1C1A] outline-none placeholder:text-[#87736D]/50"
style={{ fontFamily: "'Inter', sans-serif" }}
placeholder="Ask anything..."
placeholder={t('empty.placeholder')}
rows={2}
/>
</div>
@ -435,14 +437,14 @@ export function EmptySession() {
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[#1B1C1A] transition-colors hover:bg-[#F8F7F4]"
>
<span className="material-symbols-outlined text-[18px] text-[#87736D]">attach_file</span>
Add files or photos
{t('empty.addFiles')}
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[#1B1C1A] transition-colors hover:bg-[#F8F7F4]"
>
<span className="w-5 text-center text-[18px] font-bold text-[#87736D]">/</span>
Slash commands
{t('empty.slashCommands')}
</button>
</div>
)}
@ -458,7 +460,7 @@ export function EmptySession() {
disabled={(!input.trim() && attachments.length === 0) || isSubmitting}
className="flex w-[112px] items-center justify-center gap-1 rounded-lg bg-[var(--color-brand)] px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30"
>
Run
{t('common.run')}
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</button>
</div>

View File

@ -1,6 +1,8 @@
import { useTranslation } from '../i18n'
import { mockStatusBar } from '../mocks/data'
export function ScheduledTasksEmpty() {
const t = useTranslation()
return (
<div className="bg-[#FAF9F5] text-[#1B1C1A] flex min-h-screen font-[Inter,sans-serif]">
{/* SideNavBar */}
@ -10,44 +12,44 @@ export function ScheduledTasksEmpty() {
<span className="material-symbols-outlined text-[#87736D]">filter_list</span>
</div>
<div>
<div className="text-[#1B1C1A] font-bold">All projects</div>
<div className="text-[10px] text-[#87736D] uppercase tracking-widest">Active Session</div>
<div className="text-[#1B1C1A] font-bold">{t('sidebar.allProjects')}</div>
<div className="text-[10px] text-[#87736D] uppercase tracking-widest">{t('scheduledPage.activeSession')}</div>
</div>
</div>
<nav className="flex-1 space-y-1">
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">add</span>
<span>New session</span>
<span>{t('sidebar.newSession')}</span>
</div>
{/* Active State: Scheduled */}
<div className="px-3 py-2 bg-[#FAF9F5] text-[#1B1C1A] rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-[#8F482F] before:rounded-full cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">calendar_today</span>
<span>Scheduled</span>
<span>{t('sidebar.scheduled')}</span>
</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">history</span>
<span>Today</span>
<span>{t('sidebar.timeGroup.today')}</span>
</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">event_note</span>
<span>Previous 7 Days</span>
<span>{t('sidebar.timeGroup.last7days')}</span>
</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">archive</span>
<span>Older</span>
<span>{t('sidebar.timeGroup.older')}</span>
</div>
</nav>
<div className="mt-auto pt-4 border-t border-[#87736D]/10">
<div className="text-[10px] text-[#87736D] uppercase tracking-widest px-3 mb-2">Execution Mode</div>
<div className="text-[10px] text-[#87736D] uppercase tracking-widest px-3 mb-2">{t('scheduledPage.executionMode')}</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">computer</span>
<span>Local Mode</span>
<span>{t('scheduledPage.localMode')}</span>
</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">cloud</span>
<span>Remote Mode</span>
<span>{t('scheduledPage.remoteMode')}</span>
</div>
</div>
</aside>
@ -59,9 +61,9 @@ export function ScheduledTasksEmpty() {
<div className="flex items-center gap-6 h-full">
<div className="text-sm font-bold text-[#1B1C1A] uppercase tracking-tighter font-[Manrope,sans-serif]">Claude Code Companion</div>
<nav className="flex items-center gap-4 h-full font-[Manrope,sans-serif] font-semibold tracking-wide text-sm">
<span className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 h-full flex items-center">Code</span>
<span className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 h-full flex items-center">Terminal</span>
<span className="text-[#1B1C1A] border-b-2 border-[#8F482F] pb-1 h-full flex items-center pt-1">History</span>
<span className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 h-full flex items-center">{t('titlebar.code')}</span>
<span className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 h-full flex items-center">{t('titlebar.terminal')}</span>
<span className="text-[#1B1C1A] border-b-2 border-[#8F482F] pb-1 h-full flex items-center pt-1">{t('titlebar.history')}</span>
</nav>
</div>
<div className="flex items-center gap-4">
@ -70,14 +72,14 @@ export function ScheduledTasksEmpty() {
<span className="material-symbols-outlined text-[#87736D] text-sm cursor-pointer">arrow_forward_ios</span>
</div>
<div className="h-4 w-[1px] bg-[#87736D]/20"></div>
<div className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer text-xs font-semibold uppercase tracking-wider">Settings</div>
<div className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer text-xs font-semibold uppercase tracking-wider">{t('sidebar.settings')}</div>
</div>
</header>
{/* Content Area */}
<div className="flex-1 mt-12 mb-8 flex flex-col items-center justify-center p-8 bg-[#FAF9F5]">
<div className="max-w-2xl w-full text-center">
<h1 className="text-3xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A] tracking-tight mb-16">Scheduled tasks</h1>
<h1 className="text-3xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A] tracking-tight mb-16">{t('scheduledPage.title')}</h1>
{/* Empty State Illustration/Card */}
<div className="relative group">
@ -95,12 +97,12 @@ export function ScheduledTasksEmpty() {
</div>
</div>
<p className="text-[#54433E] font-[Inter,sans-serif] text-lg max-w-sm mx-auto leading-relaxed mb-12">
No scheduled tasks yet. Create a recurring task to automate your engineering workflows.
{t('tasks.emptyTitle')} {t('tasks.emptyDesc')}
</p>
<button className="group relative px-8 py-4 bg-[#8F482F] text-white rounded-xl font-[Manrope,sans-serif] font-bold text-sm tracking-wide shadow-lg hover:shadow-[#8F482F]/20 transition-all flex items-center gap-3 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-white/10 to-transparent opacity-30"></div>
<span className="material-symbols-outlined text-lg">add_task</span>
<span>+ New task</span>
<span>{t('tasks.newTask')}</span>
</button>
</div>
</div>

View File

@ -1,6 +1,8 @@
import { useTranslation } from '../i18n'
import { mockScheduledTasks, mockStatusBar } from '../mocks/data'
export function ScheduledTasksList() {
const t = useTranslation()
const { stats, tasks } = mockScheduledTasks
const task0 = tasks[0]!
const task1 = tasks[1]!
@ -20,14 +22,14 @@ export function ScheduledTasksList() {
</span>
</div>
<div>
<h2 className="font-[Manrope,sans-serif] text-sm font-bold text-[#1B1C1A] uppercase tracking-tighter">All projects</h2>
<p className="text-xs text-[#87736D] font-medium">Active Session</p>
<h2 className="font-[Manrope,sans-serif] text-sm font-bold text-[#1B1C1A] uppercase tracking-tighter">{t('sidebar.allProjects')}</h2>
<p className="text-xs text-[#87736D] font-medium">{t('scheduledPage.activeSession')}</p>
</div>
</div>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">add</span>
New session
{t('sidebar.newSession')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full bg-[#FAF9F5] text-[#1B1C1A] rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-[#8F482F] before:rounded-full font-medium text-sm duration-200 ease-in-out">
<span
@ -36,36 +38,36 @@ export function ScheduledTasksList() {
>
calendar_today
</span>
Scheduled
{t('sidebar.scheduled')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">history</span>
Today
{t('sidebar.timeGroup.today')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">event_note</span>
Previous 7 Days
{t('sidebar.timeGroup.last7days')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">archive</span>
Older
{t('sidebar.timeGroup.older')}
</button>
<div className="mt-auto pt-4 flex flex-col gap-2">
<div className="px-2 py-4">
<button className="w-full bg-[#E9E8E4] text-[#1B1C1A] font-[Manrope,sans-serif] text-xs font-bold py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-[#E3E2DF] transition-colors">
<span className="material-symbols-outlined text-[1rem]">search</span>
Search sessions
{t('sidebar.searchPlaceholder')}
</button>
</div>
<div className="h-[1px] bg-[#DAC1BA]/20 mx-2 mb-2"></div>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">computer</span>
Local Mode
{t('scheduledPage.localMode')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">cloud</span>
Remote Mode
{t('scheduledPage.remoteMode')}
</button>
</div>
</aside>
@ -77,9 +79,9 @@ export function ScheduledTasksList() {
<div className="flex items-center gap-8">
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] uppercase tracking-tighter text-sm">Claude Code Companion</div>
<nav className="flex items-center gap-6 font-[Manrope,sans-serif] font-semibold tracking-wide text-sm">
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">Code</a>
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">Terminal</a>
<a className="text-[#1B1C1A] border-b-2 border-[#8F482F] pb-1" href="#">History</a>
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">{t('titlebar.code')}</a>
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">{t('titlebar.terminal')}</a>
<a className="text-[#1B1C1A] border-b-2 border-[#8F482F] pb-1" href="#">{t('titlebar.history')}</a>
</nav>
</div>
<div className="flex items-center gap-4">
@ -93,7 +95,7 @@ export function ScheduledTasksList() {
</div>
<button className="font-[Manrope,sans-serif] font-semibold tracking-wide text-sm text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 flex items-center gap-1">
<span className="material-symbols-outlined text-[1.1rem]">settings</span>
Settings
{t('sidebar.settings')}
</button>
</div>
</header>
@ -107,12 +109,12 @@ export function ScheduledTasksList() {
{/* Page Header */}
<div className="flex justify-between items-end mb-12">
<div className="space-y-1">
<h1 className="font-[Manrope,sans-serif] text-3xl font-bold tracking-tight text-[#1B1C1A]">Scheduled Tasks</h1>
<p className="text-[#87736D] text-sm">Manage automated operational routines and background maintenance.</p>
<h1 className="font-[Manrope,sans-serif] text-3xl font-bold tracking-tight text-[#1B1C1A]">{t('scheduledPage.title')}</h1>
<p className="text-[#87736D] text-sm">{t('scheduledPage.subtitle')}</p>
</div>
<button className="bg-[#8F482F] hover:bg-[#AD5F45] text-white px-5 py-2.5 rounded-lg flex items-center gap-2 transition-all shadow-sm font-medium text-sm">
<span className="material-symbols-outlined text-[1.1rem]">add_task</span>
Create new task
{t('tasks.createNew')}
</button>
</div>
@ -121,20 +123,20 @@ export function ScheduledTasksList() {
{/* Total Tasks */}
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">Total Tasks</span>
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">{t('tasks.totalTasks')}</span>
<span className="material-symbols-outlined text-[#8F482F]">analytics</span>
</div>
<div className="text-4xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A]">{stats.totalTasks}</div>
<div className="mt-2 flex items-center gap-1 text-[10px] text-[#4F6237] font-bold bg-[#677B4E]/20 px-2 py-0.5 rounded-full w-fit">
<span className="material-symbols-outlined text-[10px]">trending_up</span>
+2 this month
{t('scheduledPage.thisMonth', { count: '+2' })}
</div>
</div>
{/* Next Run */}
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">Next Run</span>
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">{t('scheduledPage.nextRun')}</span>
<span className="material-symbols-outlined text-[#2D628F]">schedule</span>
</div>
<div className="text-xl font-[Manrope,sans-serif] font-bold text-[#1B1C1A]">{stats.nextRun.name}</div>
@ -144,7 +146,7 @@ export function ScheduledTasksList() {
{/* System Health */}
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">System Health</span>
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">{t('scheduledPage.systemHealth')}</span>
<span className="material-symbols-outlined text-[#4F6237]">check_circle</span>
</div>
<div className="text-4xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A]">{stats.systemHealth}%</div>
@ -157,11 +159,11 @@ export function ScheduledTasksList() {
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-[#F4F4F0]/50">
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">Task Name</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">Frequency</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">Last Result</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">Next Execution</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10 text-right">Actions</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colTaskName')}</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colFrequency')}</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colLastResult')}</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colNextExecution')}</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10 text-right">{t('scheduledPage.colActions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#DAC1BA]/5">
@ -307,8 +309,8 @@ export function ScheduledTasksList() {
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-[#F4F4F0] mb-4">
<span className="material-symbols-outlined text-[#87736D]">history_toggle_off</span>
</div>
<h3 className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-base">End of scheduled list</h3>
<p className="text-sm text-[#87736D] max-w-xs mx-auto mt-1">9 other tasks are currently paused or disabled for maintenance.</p>
<h3 className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-base">{t('scheduledPage.endOfList')}</h3>
<p className="text-sm text-[#87736D] max-w-xs mx-auto mt-1">{t('scheduledPage.pausedTasks')}</p>
</div>
</div>
@ -316,7 +318,7 @@ export function ScheduledTasksList() {
<div className="mt-12 flex flex-col md:flex-row gap-8 items-start">
{/* Recent Output Logs */}
<div className="flex-1 space-y-6">
<h2 className="font-[Manrope,sans-serif] text-lg font-bold text-[#1B1C1A]">Recent Output Logs</h2>
<h2 className="font-[Manrope,sans-serif] text-lg font-bold text-[#1B1C1A]">{t('scheduledPage.recentLogs')}</h2>
<div className="bg-[#DBDAD6] rounded-xl p-6 font-[JetBrains_Mono,monospace] text-[13px] leading-relaxed text-[#54433E] overflow-x-auto shadow-inner">
<div className="flex gap-4 opacity-50 mb-1">
<span className="w-32 shrink-0">2023-11-10 23:01</span>
@ -340,7 +342,7 @@ export function ScheduledTasksList() {
</div>
<div className="mt-4 pt-4 border-t border-[#DAC1BA]/20 flex items-center justify-between">
<span className="text-[11px] uppercase tracking-tighter opacity-50">Log stream: active</span>
<button className="text-[#8F482F] font-bold text-xs hover:underline">View Full Artifacts</button>
<button className="text-[#8F482F] font-bold text-xs hover:underline">{t('scheduledPage.viewArtifacts')}</button>
</div>
</div>
</div>
@ -348,11 +350,11 @@ export function ScheduledTasksList() {
{/* Resource Allocation Panel */}
<div className="w-full md:w-80 shrink-0">
<div className="bg-[#AD5F45]/10 p-6 rounded-xl border border-[#8F482F]/10">
<h3 className="font-[Manrope,sans-serif] font-bold text-[#8F482F] text-sm mb-3">Resource Allocation</h3>
<h3 className="font-[Manrope,sans-serif] font-bold text-[#8F482F] text-sm mb-3">{t('scheduledPage.resourceAllocation')}</h3>
<div className="space-y-4">
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-bold text-[#87736D] uppercase tracking-wider">
<span>CPU Capacity</span>
<span>{t('scheduledPage.cpuCapacity')}</span>
<span>42%</span>
</div>
<div className="w-full h-1 bg-[#DAC1BA]/30 rounded-full overflow-hidden">
@ -361,7 +363,7 @@ export function ScheduledTasksList() {
</div>
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-bold text-[#87736D] uppercase tracking-wider">
<span>Memory Load</span>
<span>{t('scheduledPage.memoryLoad')}</span>
<span>68%</span>
</div>
<div className="w-full h-1 bg-[#DAC1BA]/30 rounded-full overflow-hidden">
@ -390,7 +392,7 @@ export function ScheduledTasksList() {
>
fiber_manual_record
</span>
<span className="font-[Inter,sans-serif] text-xs tracking-tight text-[#1B1C1A]">Connected to local node</span>
<span className="font-[Inter,sans-serif] text-xs tracking-tight text-[#1B1C1A]">{t('scheduledPage.connectedLocal')}</span>
</div>
</div>
<div className="flex items-center gap-6">

View File

@ -2,10 +2,12 @@ import { useState, useEffect, useRef } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore'
import { useUIStore } from '../stores/uiStore'
import { useTranslation } from '../i18n'
import { Modal } from '../components/shared/Modal'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import type { PermissionMode, EffortLevel } from '../types/settings'
import type { Locale } from '../i18n'
import { PROVIDER_PRESETS } from '../config/providerPresets'
import type { ProviderPreset } from '../config/providerPresets'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
@ -15,6 +17,7 @@ type SettingsTab = 'providers' | 'permissions' | 'general'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
const setActiveView = useUIStore((s) => s.setActiveView)
const t = useTranslation()
return (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
@ -26,15 +29,15 @@ export function Settings() {
>
<span className="material-symbols-outlined text-[20px]">arrow_back</span>
</button>
<h1 className="text-lg font-bold text-[var(--color-text-primary)]">Settings</h1>
<h1 className="text-lg font-bold text-[var(--color-text-primary)]">{t('settings.title')}</h1>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Tab navigation */}
<div className="w-48 border-r border-[var(--color-border)] py-3 flex-shrink-0">
<TabButton icon="dns" label="Providers" active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="shield" label="Permissions" active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label="General" active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
</div>
{/* Tab content */}
@ -69,6 +72,7 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
function ProviderSettings() {
const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const t = useTranslation()
const [editingProvider, setEditingProvider] = useState<SavedProvider | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
@ -77,7 +81,7 @@ function ProviderSettings() {
const handleDelete = async (provider: SavedProvider) => {
if (activeId === provider.id) return
if (!window.confirm(`Delete provider "${provider.name}"? This cannot be undone.`)) return
if (!window.confirm(t('settings.providers.confirmDelete', { name: provider.name }))) return
await deleteProvider(provider.id).catch(console.error)
}
@ -87,7 +91,7 @@ function ProviderSettings() {
const result = await testProvider(provider.id)
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } }))
} catch {
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { success: false, latencyMs: 0, error: 'Request failed' } } }))
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } }))
}
}
@ -107,12 +111,12 @@ function ProviderSettings() {
<div className="max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">Providers</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">Manage API providers for model access.</p>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.providers.title')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.description')}</p>
</div>
<Button size="sm" onClick={() => setShowCreateModal(true)}>
<span className="material-symbols-outlined text-[16px]">add</span>
Add Provider
{t('settings.providers.addProvider')}
</Button>
</div>
@ -128,12 +132,12 @@ function ProviderSettings() {
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">Claude Official</span>
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
{isOfficialActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">ACTIVE</span>
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">{t('common.active')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">Anthropic native no API key required</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</div>
</div>
</div>
@ -165,7 +169,7 @@ function ProviderSettings() {
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
)}
{isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">ACTIVE</span>
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">{t('common.active')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
@ -173,18 +177,18 @@ function ProviderSettings() {
</div>
{test && !test.loading && test.result && (
<div className={`text-xs mt-1 ${test.result.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`}
{test.result.success ? t('settings.providers.connected', { latency: String(test.result.latencyMs) }) : t('settings.providers.failed', { error: test.result.error || '' })}
</div>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>Activate</Button>
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.activate')}</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>Test</Button>
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>Edit</Button>
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">Delete</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
)}
</div>
</div>
@ -225,6 +229,7 @@ function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {
const { createProvider, updateProvider, testConfig } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const t = useTranslation()
const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official')
const fallbackPreset = requirePreset(
@ -346,7 +351,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
}
setTestResult(result)
} catch {
setTestResult({ success: false, latencyMs: 0, error: 'Request failed' })
setTestResult({ success: false, latencyMs: 0, error: t('settings.providers.requestFailed') })
} finally {
setIsTesting(false)
}
@ -356,13 +361,13 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
<Modal
open={open}
onClose={onClose}
title={mode === 'create' ? 'Add Provider' : 'Edit Provider'}
title={mode === 'create' ? t('settings.providers.addTitle') : t('settings.providers.editTitle')}
width={720}
footer={
<>
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
{mode === 'create' ? 'Add' : 'Save'}
{mode === 'create' ? t('common.add') : t('common.save')}
</Button>
</>
}
@ -371,7 +376,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
{/* Preset chips */}
{mode === 'create' && (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">Preset</label>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.preset')}</label>
<div className="flex flex-wrap gap-2">
{availablePresets.map((preset) => (
<button
@ -390,16 +395,16 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
</div>
)}
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="Provider name" />
<Input label={t('settings.providers.name')} required value={name} onChange={(e) => setName(e.target.value)} placeholder={t('settings.providers.namePlaceholder')} />
<Input label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes..." />
<Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
{/* Base URL */}
{isCustom || mode === 'edit' ? (
<Input label="Base URL" required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/anthropic" />
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
) : (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">Base URL</label>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.baseUrl')}</label>
<div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
{baseUrl}
</div>
@ -407,7 +412,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
)}
<Input
label={mode === 'edit' ? 'API Key (leave blank to keep current)' : 'API Key'}
label={mode === 'edit' ? t('settings.providers.apiKeyKeep') : t('settings.providers.apiKey')}
required={mode === 'create'}
type="password"
value={apiKey}
@ -417,30 +422,30 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
{/* Model Mapping */}
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">Model Mapping</label>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label>
<div className="grid grid-cols-2 gap-2">
<Input label="Main Model" required value={models.main} onChange={(e) => setModels({ ...models, main: e.target.value })} placeholder="Model ID" />
<Input label="Haiku Model" value={models.haiku} onChange={(e) => setModels({ ...models, haiku: e.target.value })} placeholder="Same as main" />
<Input label="Sonnet Model" value={models.sonnet} onChange={(e) => setModels({ ...models, sonnet: e.target.value })} placeholder="Same as main" />
<Input label="Opus Model" value={models.opus} onChange={(e) => setModels({ ...models, opus: e.target.value })} placeholder="Same as main" />
<Input label={t('settings.providers.mainModel')} required value={models.main} onChange={(e) => setModels({ ...models, main: e.target.value })} placeholder="Model ID" />
<Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => setModels({ ...models, haiku: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => setModels({ ...models, sonnet: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => setModels({ ...models, opus: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
</div>
</div>
{/* Test connection */}
<div className="flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim()}>
Test Connection
{t('settings.providers.testConnection')}
</Button>
{testResult && (
<span className={`text-xs ${testResult.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`}
{testResult.success ? t('settings.providers.connected', { latency: String(testResult.latencyMs) }) : t('settings.providers.failed', { error: testResult.error || '' })}
</span>
)}
</div>
{/* Settings JSON — editable, shown for all presets including official */}
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">Settings JSON</label>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.settingsJson')}</label>
<textarea
value={settingsJson}
onChange={(e) => {
@ -489,9 +494,9 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
}`}
/>
{settingsJsonError && (
<p className="text-[11px] text-[var(--color-error)] mt-1">JSON syntax error: {settingsJsonError}</p>
<p className="text-[11px] text-[var(--color-error)] mt-1">{t('settings.providers.jsonError', { error: settingsJsonError })}</p>
)}
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">~/.claude/settings.json edit directly, will be written on save.</p>
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.settingsJsonDesc')}</p>
</div>
</div>
</Modal>
@ -503,18 +508,19 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
function PermissionSettings() {
const { permissionMode, setPermissionMode } = useSettingsStore()
const t = useTranslation()
const MODES: Array<{ mode: PermissionMode; icon: string; label: string; desc: string }> = [
{ mode: 'default', icon: 'verified_user', label: 'Ask permissions', desc: 'Ask before executing tools' },
{ mode: 'acceptEdits', icon: 'edit_note', label: 'Accept edits', desc: 'Auto-approve file edits, ask for others' },
{ mode: 'plan', icon: 'architecture', label: 'Plan mode', desc: 'Think and plan without executing' },
{ mode: 'bypassPermissions', icon: 'bolt', label: 'Bypass all', desc: 'Skip all permission checks (dangerous)' },
{ mode: 'default', icon: 'verified_user', label: t('settings.permissions.default'), desc: t('settings.permissions.defaultDesc') },
{ mode: 'acceptEdits', icon: 'edit_note', label: t('settings.permissions.acceptEdits'), desc: t('settings.permissions.acceptEditsDesc') },
{ mode: 'plan', icon: 'architecture', label: t('settings.permissions.plan'), desc: t('settings.permissions.planDesc') },
{ mode: 'bypassPermissions', icon: 'bolt', label: t('settings.permissions.bypass'), desc: t('settings.permissions.bypassDesc') },
]
return (
<div className="max-w-xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Permission Mode</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">Controls how tool execution permissions are handled.</p>
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.permissions.title')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">{t('settings.permissions.description')}</p>
<div className="flex flex-col gap-2">
{MODES.map(({ mode, icon, label, desc }) => {
@ -550,19 +556,45 @@ function PermissionSettings() {
// ─── General Settings ──────────────────────────────────────
function GeneralSettings() {
const { effortLevel, setEffort } = useSettingsStore()
const { effortLevel, setEffort, locale, setLocale } = useSettingsStore()
const t = useTranslation()
const EFFORT_LABELS: Record<EffortLevel, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
max: 'Max',
low: t('settings.general.effort.low'),
medium: t('settings.general.effort.medium'),
high: t('settings.general.effort.high'),
max: t('settings.general.effort.max'),
}
const LANGUAGES: Array<{ value: Locale; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
]
return (
<div className="max-w-xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Effort Level</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">Controls how much computation the model uses.</p>
{/* Language selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p>
<div className="flex gap-2 mb-8">
{LANGUAGES.map(({ value, label }) => (
<button
key={value}
onClick={() => setLocale(value)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
locale === value
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{label}
</button>
))}
</div>
{/* Effort Level */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.effortTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.effortDescription')}</p>
<div className="flex gap-2">
{(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => (
<button

View File

@ -2,6 +2,17 @@ import { create } from 'zustand'
import { settingsApi } from '../api/settings'
import { modelsApi } from '../api/models'
import type { PermissionMode, EffortLevel, ModelInfo } from '../types/settings'
import type { Locale } from '../i18n'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
function getStoredLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
if (stored === 'en' || stored === 'zh') return stored
} catch { /* localStorage unavailable */ }
return 'en'
}
type SettingsStore = {
permissionMode: PermissionMode
@ -9,6 +20,7 @@ type SettingsStore = {
effortLevel: EffortLevel
availableModels: ModelInfo[]
activeProviderName: string | null
locale: Locale
isLoading: boolean
error: string | null
@ -16,6 +28,7 @@ type SettingsStore = {
setPermissionMode: (mode: PermissionMode) => Promise<void>
setModel: (modelId: string) => Promise<void>
setEffort: (level: EffortLevel) => Promise<void>
setLocale: (locale: Locale) => void
}
export const useSettingsStore = create<SettingsStore>((set) => ({
@ -24,6 +37,7 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
effortLevel: 'high',
availableModels: [],
activeProviderName: null,
locale: getStoredLocale(),
isLoading: false,
error: null,
@ -68,4 +82,9 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
set({ effortLevel: level })
await modelsApi.setEffort(level)
},
setLocale: (locale) => {
set({ locale })
try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ }
},
}))