mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +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:
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user