mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
602c61ad4c
commit
a2bf92079b
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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`
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() || ''
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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
54
desktop/src/i18n/index.ts
Normal 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 }
|
||||
324
desktop/src/i18n/locales/en.ts
Normal file
324
desktop/src/i18n/locales/en.ts
Normal 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
|
||||
324
desktop/src/i18n/locales/zh.ts
Normal file
324
desktop/src/i18n/locales/zh.ts
Normal 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': '任务进行中',
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 */ }
|
||||
},
|
||||
}))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user