diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx index f9d3fc370af..ff01687aacc 100644 --- a/apps/desktop/src/app/chat/chat-drop-overlay.tsx +++ b/apps/desktop/src/app/chat/chat-drop-overlay.tsx @@ -2,11 +2,12 @@ import { useRef } from 'react' import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone' import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' -const COPY: Record<'files' | 'session', { icon: string; label: string }> = { - files: { icon: 'cloud-upload', label: 'Drop files to attach' }, - session: { icon: 'comment-discussion', label: 'Drop to link this chat' } +const ICONS: Record<'files' | 'session', string> = { + files: 'cloud-upload', + session: 'comment-discussion' } /** @@ -17,13 +18,16 @@ const COPY: Record<'files' | 'session', { icon: string; label: string }> = { * fade-out so the label doesn't blank. */ export function ChatDropOverlay({ kind }: { kind: DragKind }) { + const { t } = useI18n() const lastKind = useRef<'files' | 'session'>('files') if (kind) { lastKind.current = kind } - const { icon, label } = COPY[kind ?? lastKind.current] + const resolvedKind = kind ?? lastKind.current + const icon = ICONS[resolvedKind] + const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession return (
(profile) @@ -38,7 +40,7 @@ export function ChatSwapOverlay({ profile }: { profile: string | null }) { >
{FRAMES[frame]} - Waking up {label}… + {t.composer.wakingProfile(label ?? '')}
) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts index df74c7f4a5c..8823084a36e 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts @@ -17,39 +17,49 @@ export interface MicRecording { heardSpeech: boolean } +export interface MicRecorderErrorCopy { + microphoneAccessDenied: string + microphoneConstraintsUnsupported: string + microphoneInUse: string + microphonePermissionDenied: string + microphoneStartFailed: string + microphoneUnsupported: string + noMicrophone: string +} + interface MicRecorderHandle { start: (options?: MicRecorderOptions) => Promise stop: () => Promise cancel: () => void } -function micError(error: unknown): Error { +function micError(error: unknown, copy: MicRecorderErrorCopy): Error { const name = error instanceof DOMException ? error.name : '' if (name === 'NotAllowedError' || name === 'SecurityError') { - return new Error('Microphone permission was denied.') + return new Error(copy.microphonePermissionDenied) } if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { - return new Error('No microphone was found.') + return new Error(copy.noMicrophone) } if (name === 'NotReadableError' || name === 'TrackStartError') { - return new Error('Microphone is already in use by another app.') + return new Error(copy.microphoneInUse) } if (name === 'OverconstrainedError') { - return new Error('Microphone constraints are not supported by this device.') + return new Error(copy.microphoneConstraintsUnsupported) } if (error instanceof Error) { return error } - return new Error('Could not start microphone recording.') + return new Error(copy.microphoneStartFailed) } -export function useMicRecorder(): { handle: MicRecorderHandle; level: number; recording: boolean } { +export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorderHandle; level: number; recording: boolean } { const [level, setLevel] = useState(0) const [recording, setRecording] = useState(false) @@ -158,13 +168,13 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re } if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') { - throw new Error('This runtime does not support microphone recording.') + throw new Error(copy.microphoneUnsupported) } const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.() if (permitted === false) { - throw new Error('Microphone access denied.') + throw new Error(copy.microphoneAccessDenied) } let stream: MediaStream @@ -174,7 +184,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re audio: { echoCancellation: true, noiseSuppression: true } }) } catch (error) { - throw micError(error) + throw micError(error, copy) } const mimeType = @@ -188,7 +198,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined) } catch (error) { stream.getTracks().forEach(track => track.stop()) - throw micError(error) + throw micError(error, copy) } chunksRef.current = [] @@ -231,7 +241,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re } recorder.onerror = event => { - const error = micError((event as Event & { error?: unknown }).error) + const error = micError((event as Event & { error?: unknown }).error, copy) const resolver = stopResolverRef.current stopResolverRef.current = null cleanup() diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 3261acc3409..e4e8f3201be 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { useI18n } from '@/i18n' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' import { notify, notifyError } from '@/store/notifications' @@ -32,7 +33,9 @@ export function useVoiceConversation({ pendingResponse, consumePendingResponse }: VoiceConversationOptions) { - const { handle, level } = useMicRecorder() + const { t } = useI18n() + const voiceCopy = t.notifications.voice + const { handle, level } = useMicRecorder(voiceCopy) const [status, setStatus] = useState('idle') const [muted, setMuted] = useState(false) const turnTimeoutRef = useRef(null) @@ -168,7 +171,7 @@ export function useVoiceConversation({ await onSubmit(transcript) setStatus('thinking') } catch (error) { - notifyError(error, 'Voice transcription failed') + notifyError(error, voiceCopy.transcriptionFailed) if (enabledRef.current && !mutedRef.current && !busyRef.current) { pendingStartRef.current = true @@ -180,7 +183,7 @@ export function useVoiceConversation({ turnClosingRef.current = false } }, - [handle, onSubmit, onTranscribeAudio] + [handle, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed] ) const startListening = useCallback(async () => { @@ -201,7 +204,7 @@ export function useVoiceConversation({ silenceMs: 1_250, idleSilenceMs: 12_000, onError: error => { - notifyError(error, 'Microphone failed') + notifyError(error, voiceCopy.microphoneFailed) pendingStartRef.current = false onFatalError?.() }, @@ -210,12 +213,12 @@ export function useVoiceConversation({ setStatus('listening') turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000) } catch (error) { - notifyError(error, 'Could not start voice session') + notifyError(error, voiceCopy.couldNotStartSession) pendingStartRef.current = false setStatus('idle') onFatalError?.() } - }, [handle, handleTurn, onFatalError]) + }, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed]) const speak = useCallback(async (text: string) => { setStatus('speaking') @@ -223,7 +226,7 @@ export function useVoiceConversation({ try { await playSpeechText(text, { source: 'voice-conversation' }) } catch (error) { - notifyError(error, 'Voice playback failed') + notifyError(error, voiceCopy.playbackFailed) } finally { if (enabledRef.current) { pendingStartRef.current = true @@ -232,14 +235,14 @@ export function useVoiceConversation({ setStatus('idle') } } - }, []) + }, [voiceCopy.playbackFailed]) const start = useCallback(async () => { if (!onTranscribeAudio) { notify({ kind: 'warning', - title: 'Voice unavailable', - message: 'Configure speech-to-text to use voice mode.' + title: voiceCopy.unavailable, + message: voiceCopy.configureSpeechToText }) onFatalError?.() @@ -252,7 +255,7 @@ export function useVoiceConversation({ consumePendingResponse() pendingStartRef.current = true await startListening() - }, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening]) + }, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening, voiceCopy.configureSpeechToText, voiceCopy.unavailable]) const end = useCallback(async () => { pendingStartRef.current = false diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts index cffc2820ca7..937f2d3bc03 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' +import { useI18n } from '@/i18n' import { notify, notifyError } from '@/store/notifications' import type { VoiceActivityState, VoiceStatus } from '../types' @@ -19,7 +20,9 @@ export function useVoiceRecorder({ focusInput, onTranscript }: VoiceRecorderOptions) { - const { handle, level, recording } = useMicRecorder() + const { t } = useI18n() + const voiceCopy = t.notifications.voice + const { handle, level, recording } = useMicRecorder(voiceCopy) const [voiceStatus, setVoiceStatus] = useState('idle') const [elapsedSeconds, setElapsedSeconds] = useState(0) const startedAtRef = useRef(0) @@ -62,12 +65,12 @@ export function useVoiceRecorder({ const transcript = (await onTranscribeAudio(result.audio)).trim() if (!transcript) { - notify({ kind: 'warning', title: 'No speech detected', message: 'Try recording again.' }) + notify({ kind: 'warning', title: voiceCopy.noSpeechDetected, message: voiceCopy.tryRecordingAgain }) } else { onTranscript(transcript) } } catch (error) { - notifyError(error, 'Voice transcription failed') + notifyError(error, voiceCopy.transcriptionFailed) } finally { setVoiceStatus('idle') focusInput() @@ -76,13 +79,13 @@ export function useVoiceRecorder({ const start = async () => { if (!onTranscribeAudio) { - notify({ kind: 'warning', title: 'Voice unavailable', message: 'Voice transcription is not available yet.' }) + notify({ kind: 'warning', title: voiceCopy.unavailable, message: voiceCopy.transcriptionUnavailable }) return } try { - await handle.start({ onError: error => notifyError(error, 'Voice recording failed') }) + await handle.start({ onError: error => notifyError(error, voiceCopy.recordingFailed) }) startedAtRef.current = Date.now() setElapsedSeconds(0) setVoiceStatus('recording') @@ -91,7 +94,7 @@ export function useVoiceRecorder({ timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000) } catch (error) { setVoiceStatus('idle') - notifyError(error, 'Voice recording failed') + notifyError(error, voiceCopy.recordingFailed) } } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 973b0295854..9041fe89505 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1531,7 +1531,7 @@ export function ChatBar({ {queueEdit && editingQueuedPrompt && (
- Editing queued turn in composer + {t.composer.editingQueuedInComposer}
diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx new file mode 100644 index 00000000000..9acc43f7f19 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx @@ -0,0 +1,42 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' + +import { ComposerTriggerPopover } from './trigger-popover' + +function renderPopover(kind: '@' | '/', loading = false) { + const onHover = vi.fn() + const onPick = vi.fn() + + const rendered = render( + + + + ) + + return { ...rendered, onHover, onPick } +} + +describe('ComposerTriggerPopover i18n', () => { + afterEach(() => { + cleanup() + }) + + it('renders localized empty lookup copy for @ references', () => { + const { container } = renderPopover('@') + + expect(screen.getByText('没有匹配项。')).toBeTruthy() + expect(container.textContent).toContain('试试') + expect(container.textContent).toContain('@file:') + expect(container.textContent).toContain('或') + expect(container.textContent).toContain('@folder:') + }) + + it('renders localized loading copy for slash commands', () => { + const { container } = renderPopover('/', true) + + expect(screen.getByText('查找中…')).toBeTruthy() + expect(container.textContent).toContain('/help') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index 7cc6a3b2237..a09190dd6b3 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -1,6 +1,7 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { @@ -60,6 +61,9 @@ export function ComposerTriggerPopover({ onPick, placement = 'top' }: ComposerTriggerPopoverProps) { + const { t } = useI18n() + const copy = t.composer + return (
{items.length === 0 ? ( - + {kind === '@' ? ( <> - Try @file: or{' '} + {copy.lookupTry} @file: {copy.lookupOr}{' '} @folder:. ) : ( <> - Try /help. + {copy.lookupTry} /help. )} diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index e48ff7accff..ebfd4e58b6f 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus' import { formatRefValue } from '@/components/assistant-ui/directive-text' +import { useI18n } from '@/i18n' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' import { addComposerAttachment, @@ -193,9 +194,11 @@ const attachToMain = (attachment: ComposerAttachment) => { } export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) { + const { t } = useI18n() + const copy = t.desktop const addTextToDraft = useCallback((text: string) => { requestComposerInsert(text, { mode: 'block' }) - }, []) + }, [copy.imagePreviewFailed]) const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => { const trimmed = text.trim() @@ -300,7 +303,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway return true } catch (err) { - notifyError(err, 'Image preview failed') + notifyError(err, copy.imagePreviewFailed) return true } @@ -322,28 +325,28 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob)) if (!savedPath) { - notify({ kind: 'error', title: 'Image attach', message: 'Failed to write image to disk.' }) + notify({ kind: 'error', title: copy.imageAttach, message: copy.imageWriteFailed }) return false } return attachImagePath(savedPath) } catch (err) { - notifyError(err, 'Image attach failed') + notifyError(err, copy.imageAttachFailed) return false } }, - [attachImagePath] + [attachImagePath, copy.imageAttach, copy.imageAttachFailed, copy.imageWriteFailed] ) const pickImages = useCallback(async () => { const paths = await window.hermesDesktop?.selectPaths({ - title: 'Attach images', + title: copy.attachImages, defaultPath: currentCwd || undefined, filters: [ { - name: 'Images', + name: t.composer.images, extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff'] } ] @@ -356,7 +359,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway for (const path of paths) { await attachImagePath(path) } - }, [attachImagePath, currentCwd]) + }, [attachImagePath, copy.attachImages, currentCwd, t.composer.images]) const pasteClipboardImage = useCallback(async () => { try { @@ -365,8 +368,8 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway if (!path) { notify({ kind: 'warning', - title: 'Clipboard', - message: 'No image found in clipboard' + title: copy.clipboard, + message: copy.noClipboardImage }) return @@ -374,9 +377,9 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway await attachImagePath(path) } catch (err) { - notifyError(err, 'Clipboard paste failed') + notifyError(err, copy.clipboardPasteFailed) } - }, [attachImagePath]) + }, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage]) const attachContextFolderPath = useCallback( (folderPath: string) => { @@ -477,12 +480,12 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway } if (!attached && lastFailure) { - notify({ kind: 'warning', title: 'Drop files', message: lastFailure }) + notify({ kind: 'warning', title: copy.dropFiles, message: lastFailure }) } return attached }, - [attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath] + [attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath, copy.dropFiles] ) const removeAttachment = useCallback( diff --git a/apps/desktop/src/app/chat/right-rail/preview-console.tsx b/apps/desktop/src/app/chat/right-rail/preview-console.tsx index 70a973322b1..67df7fefc2f 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-console.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-console.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef } from 'react' import { requestComposerInsert } from '@/app/chat/composer/focus' import { CopyButton } from '@/components/ui/copy-button' import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' import { PanelBottom, Send, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify } from '@/store/notifications' @@ -74,6 +75,9 @@ interface ConsoleRowProps { } function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) { + const { t } = useI18n() + const copy = t.preview.console + return (
- + 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'} + label={visibleSelection.length > 0 ? copy.copySelected : copy.copyAll} text={() => formatConsoleEntries(sendableLogs)} > - Copy + {copy.copy}
@@ -275,7 +282,7 @@ export function PreviewConsolePanel({ ) }) ) : ( -
No console messages yet.
+
{copy.empty}
)} diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 86b8acf9c39..e3ebc4f2285 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown' import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { PageLoader } from '@/components/page-loader' +import { translateNow, useI18n } from '@/i18n' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' @@ -143,7 +144,7 @@ function filePathForTarget(target: PreviewTarget) { function formatBytes(bytes: number | undefined) { if (!bytes) { - return 'unknown size' + return translateNow('preview.unknownSize') } const units = ['B', 'KB', 'MB', 'GB'] @@ -296,6 +297,8 @@ function MarkdownPreview({ text }: { text: string }) { } function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) { + const { t } = useI18n() + return (
) @@ -330,6 +333,7 @@ function startLineDrag(event: ReactDragEvent, filePath: string, { e } function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) { + const { t } = useI18n() const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text]) const [selection, setSelection] = useState(null) const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end @@ -373,7 +377,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language: key={line} onClick={event => handleLineClick(event, line)} onDragStart={event => handleDragStart(event, line)} - title="Click to select · shift-click to extend · drag to composer" + title={t.preview.sourceLineTitle} > {line} @@ -408,6 +412,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language: } export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) { + const { t } = useI18n() const [state, setState] = useState({ loading: true }) const [forcePreview, setForcePreview] = useState(false) const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false) @@ -482,11 +487,11 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar }, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language]) if (state.loading) { - return + return } if (state.error) { - return + return } if ( @@ -501,11 +506,11 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar setForcePreview(true) }} - title={binary ? 'This looks like a binary file' : 'This file is large'} + primaryAction={{ label: t.preview.previewAnyway, onClick: () => setForcePreview(true) }} + title={binary ? t.preview.binaryTitle : t.preview.largeTitle} tone="warning" /> ) @@ -532,7 +537,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
{state.truncated && (
- Showing first 512 KB. + {t.preview.truncated}
)} {isMarkdown && setRenderMarkdownAsSource(s => !s)} />} @@ -547,8 +552,8 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar return ( ) } diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 0c8a5bb2962..21cfbeb3ced 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls' import { Tip } from '@/components/ui/tooltip' +import { type Translations, useI18n } from '@/i18n' import { Bug } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -46,18 +47,18 @@ interface PreviewLoadErrorState { const FILE_RELOAD_DEBOUNCE_MS = 200 const SERVER_RESTART_TIMEOUT_MS = 45_000 -function loadErrorTitle(error: PreviewLoadErrorState): string { +function loadErrorTitle(error: PreviewLoadErrorState, copy: Translations['preview']['web']): string { const description = error.description.toLowerCase() if (description.includes('module script') || description.includes('mime type')) { - return 'Preview app failed to boot' + return copy.appFailedToBoot } if (description.includes('connection') || description.includes('refused') || description.includes('not found')) { - return 'Server not found' + return copy.serverNotFound } - return 'Preview failed to load' + return copy.failedToLoad } function isModuleMimeError(message: string): boolean { @@ -79,6 +80,9 @@ function PreviewLoadError({ onRetry: () => void restarting?: boolean }) { + const { t } = useI18n() + const copy = t.preview.web + return ( } consoleHeight={consoleHeight} - primaryAction={{ label: 'Try again', onClick: onRetry }} + primaryAction={{ label: copy.tryAgain, onClick: onRetry }} secondaryAction={ onRestartServer ? { disabled: restarting, - label: restarting ? 'Hermes is restarting...' : 'Ask Hermes to restart the server', + label: restarting ? copy.restarting : copy.askRestart, onClick: onRestartServer } : undefined } - title={loadErrorTitle(error)} + title={loadErrorTitle(error, copy)} /> ) } @@ -122,6 +126,8 @@ export function PreviewPane({ setTitlebarToolGroup, target }: PreviewPaneProps) { + const { t } = useI18n() + const copy = t.preview.web const [consoleState] = useState(() => createPreviewConsoleState()) const consoleBodyRef = useRef(null) const consoleShouldStickRef = useRef(true) @@ -239,23 +245,23 @@ export function PreviewPane({ appendConsoleEntry({ level: 1, - message: `Hermes is looking for a preview server to restart (${taskId})` + message: copy.lookingRestart(taskId) }) notify({ kind: 'info', - title: 'Restarting preview server', - message: 'Hermes is working in the background. Watch the preview console for progress.', + title: copy.restartingTitle, + message: copy.restartingMessage, durationMs: 4000 }) } catch (error) { appendConsoleEntry({ level: 2, - message: `Could not start server restart: ${error instanceof Error ? error.message : String(error)}` + message: copy.startRestartFailed(error instanceof Error ? error.message : String(error)) }) - notifyError(error, 'Server restart failed') + notifyError(error, copy.restartFailed) } - }, [appendConsoleEntry, consoleState, currentUrl, onRestartServer]) + }, [appendConsoleEntry, consoleState, copy, currentUrl, onRestartServer]) const toggleDevTools = useCallback(() => { const webview = webviewRef.current @@ -287,14 +293,14 @@ export function PreviewPane({ active: consoleOpen, icon: , id: `${TITLEBAR_GROUP_ID}-console`, - label: consoleOpen ? 'Hide preview console' : 'Show preview console', + label: consoleOpen ? copy.hideConsole : copy.showConsole, onSelect: () => consoleState.setOpen(open => !open) }, { active: devtoolsOpen, icon: , id: `${TITLEBAR_GROUP_ID}-devtools`, - label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools', + label: devtoolsOpen ? copy.hideDevTools : copy.openDevTools, onSelect: toggleDevTools } ] @@ -304,7 +310,7 @@ export function PreviewPane({ setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools) return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, []) - }, [consoleOpen, consoleState, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools]) + }, [consoleOpen, consoleState, copy, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools]) useEffect(() => { if (!consoleOpen) { @@ -343,29 +349,27 @@ export function PreviewPane({ previewServerRestart.status === 'running' ? previewServerRestart.message : previewServerRestart.status === 'complete' - ? `Hermes finished restarting the preview server${ - previewServerRestart.message ? `: ${previewServerRestart.message}` : '' - }` - : `Server restart failed: ${previewServerRestart.message || 'unknown error'}` + ? copy.finishedRestarting(previewServerRestart.message) + : copy.failedRestarting(previewServerRestart.message || copy.unknownError) }) if (previewServerRestart.status === 'complete') { reloadPreview() notify({ kind: 'success', - title: 'Preview server restarted', - message: previewServerRestart.message?.slice(0, 160) || 'Reloading the preview now.', + title: copy.restartedTitle, + message: previewServerRestart.message?.slice(0, 160) || copy.reloadingNow, durationMs: 3500 }) } else if (previewServerRestart.status === 'error') { notify({ kind: 'warning', - title: 'Preview restart failed', - message: previewServerRestart.message?.slice(0, 200) || 'Hermes could not restart the server.', + title: copy.restartFailedTitle, + message: previewServerRestart.message?.slice(0, 200) || copy.restartFailedMessage, durationMs: 6000 }) } - }, [appendConsoleEntry, currentUrl, previewServerRestart, reloadPreview, target.url]) + }, [appendConsoleEntry, copy, currentUrl, previewServerRestart, reloadPreview, target.url]) useEffect(() => { if (!restartingServer || !previewServerRestart) { @@ -375,14 +379,11 @@ export function PreviewPane({ const taskId = previewServerRestart.taskId const timer = window.setTimeout(() => { - failPreviewServerRestart( - taskId, - 'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.' - ) + failPreviewServerRestart(taskId, copy.stillWorking) }, SERVER_RESTART_TIMEOUT_MS) return () => window.clearTimeout(timer) - }, [previewServerRestart, restartingServer]) + }, [copy.stillWorking, previewServerRestart, restartingServer]) useEffect(() => { if (reloadRequest === lastReloadRequestRef.current) { @@ -397,10 +398,10 @@ export function PreviewPane({ appendConsoleEntry({ level: 1, - message: 'Workspace changed, reloading preview' + message: copy.workspaceReloading }) reloadPreview() - }, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind]) + }, [appendConsoleEntry, copy.workspaceReloading, reloadPreview, reloadRequest, target.kind]) useEffect(() => { if ( @@ -432,8 +433,8 @@ export function PreviewPane({ level: 1, message: changedCount === 1 - ? `File changed, reloading preview: ${compactUrl(changedUrl)}` - : `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}` + ? copy.fileChanged(compactUrl(changedUrl)) + : copy.filesChanged(changedCount, compactUrl(changedUrl)) }) reloadPreview() @@ -471,7 +472,7 @@ export function PreviewPane({ .catch(error => { appendConsoleEntry({ level: 2, - message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}` + message: copy.watchFailed(error instanceof Error ? error.message : String(error)) }) }) @@ -487,7 +488,7 @@ export function PreviewPane({ void window.hermesDesktop?.stopPreviewFileWatch?.(watchId) } } - }, [appendConsoleEntry, reloadPreview, target.kind, target.url]) + }, [appendConsoleEntry, copy, reloadPreview, target.kind, target.url]) useEffect(() => { const host = hostRef.current @@ -535,8 +536,7 @@ export function PreviewPane({ if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) { setLoadError({ - description: - 'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.', + description: copy.moduleMimeDescription, url: webview.getURL?.() || target.url }) setLoading(false) @@ -567,13 +567,11 @@ export function PreviewPane({ appendConsoleEntry({ level: 3, - message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${ - detail.errorDescription || detail.validatedURL || 'unknown error' - }` + message: copy.loadFailedConsole(errorCode, detail.errorDescription || detail.validatedURL || copy.unknownError) }) setLoadError({ code: errorCode, - description: detail.errorDescription || 'The preview page could not be reached.', + description: detail.errorDescription || copy.unreachableDescription, url: detail.validatedURL || webview.getURL?.() || target.url }) setLoading(false) @@ -600,7 +598,7 @@ export function PreviewPane({ webview.removeEventListener('did-stop-loading', onStop) webview.remove() } - }, [appendConsoleEntry, consoleState, isWebPreview, target.url]) + }, [appendConsoleEntry, consoleState, copy, isWebPreview, target.url]) return (
{multiProfile && ( - navigate(PROFILES_ROUTE)} /> + navigate(PROFILES_ROUTE)} /> )} {/* Land in the new profile on a fresh chat (selectProfile triggers the @@ -328,6 +331,8 @@ const LONG_PRESS_MS = 450 // context-menu triggers via nested asChild Slots, so a single element keeps the // dnd listeners, hover tip, and right-click menu. function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) { + const { t } = useI18n() + const p = t.profiles const hue = color ?? 'var(--ui-text-quaternary)' const [pickerOpen, setPickerOpen] = useState(false) const pressTimer = useRef(null) @@ -436,27 +441,27 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on {/* The rail sits at the very bottom, so pad off the chrome (esp. the statusbar) — Radix then flips the menu up instead of squishing it. */} setPickerOpen(true)}> - Color… + {p.color} - Rename + {p.rename} - Delete + {t.common.delete} {PROFILE_SWATCHES.map(swatch => ( diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 7fd015efece..35a246ff330 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { getHermesConfigRecord, listSessions } from '@/hermes' +import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { Activity, @@ -50,6 +51,7 @@ import { SKILLS_ROUTE } from '../routes' import { FIELD_LABELS, SECTIONS } from '../settings/constants' +import { fieldCopyForSchemaKey } from '../settings/field-copy' import { prettyName } from '../settings/helpers' interface PaletteItem { @@ -92,48 +94,60 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({ title: sessionTitle(session) }) -const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [ +type NonConfigSettingsLabel = + | 'about' + | 'archivedChats' + | 'gateway' + | 'keysSettings' + | 'keysTools' + | 'mcp' + | 'providerAccounts' + | 'providerApiKeys' + +const NON_CONFIG_SETTINGS: ReadonlyArray<{ + icon: IconComponent + keywords?: string[] + labelKey: NonConfigSettingsLabel + tab: string +}> = [ { icon: Zap, keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'], - label: 'Providers', + labelKey: 'providerAccounts', tab: 'providers&pview=accounts' }, { icon: KeyRound, keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'], - label: 'Provider API keys', + labelKey: 'providerApiKeys', tab: 'providers&pview=keys' }, - { icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' }, + { icon: Globe, keywords: ['connection', 'messaging'], labelKey: 'gateway', tab: 'gateway' }, { icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'], - label: 'Tools & Keys', + labelKey: 'keysTools', tab: 'keys&kview=tools' }, { icon: Settings2, keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'], - label: 'Tools & Keys settings', + labelKey: 'keysSettings', tab: 'keys&kview=settings' }, - { icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' }, - { icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' }, - { icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' } + { icon: Wrench, keywords: ['servers', 'tools'], labelKey: 'mcp', tab: 'mcp' }, + { icon: Archive, keywords: ['history', 'archived'], labelKey: 'archivedChats', tab: 'sessions' }, + { icon: Info, keywords: ['version', 'about'], labelKey: 'about', tab: 'about' } ] -const THEME_MODES: ReadonlyArray<{ icon: IconComponent; label: string; mode: ThemeMode }> = [ - { icon: Sun, label: 'Light', mode: 'light' }, - { icon: Moon, label: 'Dark', mode: 'dark' }, - { icon: Monitor, label: 'System', mode: 'system' } +const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [ + { icon: Sun, mode: 'light' }, + { icon: Moon, mode: 'dark' }, + { icon: Monitor, mode: 'system' } ] -function fieldLabel(key: string): string { - return FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key) -} - export function CommandPalette() { + const { t } = useI18n() const open = useStore($commandPaletteOpen) const navigate = useNavigate() const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme() @@ -180,52 +194,64 @@ export function CommandPalette() { }, [open]) const go = useCallback((path: string) => () => navigate(path), [navigate]) + const settingsSectionLabel = useCallback( + (section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label, + [t.settings.sections] + ) + const configFieldLabel = useCallback( + (key: string) => + fieldCopyForSchemaKey(t.settings.fieldLabels, key) ?? + fieldCopyForSchemaKey(FIELD_LABELS, key) ?? + prettyName(key.split('.').pop() ?? key), + [t.settings.fieldLabels] + ) const baseGroups = useMemo(() => { const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}` + const cc = t.commandCenter return [ { - heading: 'Go to', + heading: cc.goTo, items: [ - { icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: 'New session', run: go(NEW_CHAT_ROUTE) }, - { icon: Settings, id: 'nav-settings', label: 'Settings', run: go(SETTINGS_ROUTE) }, + { icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) }, + { icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) }, { icon: Wrench, id: 'nav-skills', keywords: ['tools', 'toolsets'], - label: 'Skills & Tools', + label: cc.nav.skills.title, run: go(SKILLS_ROUTE) }, - { icon: MessageCircle, id: 'nav-messaging', label: 'Messaging', run: go(MESSAGING_ROUTE) }, - { icon: Package, id: 'nav-artifacts', label: 'Artifacts', run: go(ARTIFACTS_ROUTE) }, - { icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: 'Cron', run: go(CRON_ROUTE) }, - { icon: Users, id: 'nav-profiles', label: 'Profiles', run: go(PROFILES_ROUTE) }, - { icon: Cpu, id: 'nav-agents', label: 'Agents', run: go(AGENTS_ROUTE) } + { icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) }, + { icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) }, + { icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) }, + { icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) }, + { icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) } ] }, { - heading: 'Command Center', + heading: cc.commandCenter, items: [ { icon: Archive, id: 'cc-sessions', keywords: ['command center', 'sessions', 'pin'], - label: 'Sessions', + label: cc.sections.sessions, run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`) }, { icon: Activity, id: 'cc-system', keywords: ['command center', 'system', 'status', 'logs'], - label: 'System', + label: cc.sections.system, run: go(`${COMMAND_CENTER_ROUTE}?section=system`) }, { icon: BarChart3, id: 'cc-usage', keywords: ['command center', 'usage', 'tokens', 'cost'], - label: 'Usage', + label: cc.sections.usage, run: go(`${COMMAND_CENTER_ROUTE}?section=usage`) } ] @@ -234,45 +260,45 @@ export function CommandPalette() { // Declared before Settings: cmdk keeps group order, so this keeps the // theme/mode pickers on top for "theme"/"color" queries instead of // buried under a fuzzy Settings match. - heading: 'Appearance', + heading: cc.appearance, items: [ { icon: Palette, id: 'appearance-theme', keywords: ['theme', 'appearance', 'color', 'palette', 'skin', 'dark', 'light', 'look'], - label: 'Change theme…', + label: cc.changeTheme, to: 'theme' }, { icon: Sun, id: 'appearance-mode', keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'], - label: 'Change color mode…', + label: cc.changeColorMode, to: 'color-mode' } ] }, { - heading: 'Settings', + heading: cc.settings, items: [ ...SECTIONS.map(section => ({ icon: section.icon, id: `set-config-${section.id}`, - keywords: ['settings', section.label], - label: section.label, + keywords: ['settings', section.label, settingsSectionLabel(section)], + label: settingsSectionLabel(section), run: go(settingsTab(`config:${section.id}`)) })), ...NON_CONFIG_SETTINGS.map(entry => ({ icon: entry.icon, id: `set-${entry.tab}`, keywords: ['settings', ...(entry.keywords ?? [])], - label: entry.label, + label: t.settings.nav[entry.labelKey], run: go(settingsTab(entry.tab)) })) ] } ] - }, [go]) + }, [go, settingsSectionLabel, t]) // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the @@ -286,7 +312,7 @@ export function CommandPalette() { if (sessions.length > 0) { result.push({ - heading: 'Sessions', + heading: t.commandCenter.sections.sessions, items: sessions.map(session => ({ icon: MessageCircle, id: `session-${session.id}`, @@ -301,17 +327,17 @@ export function CommandPalette() { section.keys.map(key => ({ icon: section.icon, id: `field-${key}`, - keywords: ['settings', key, section.label], - label: `${section.label}: ${fieldLabel(key)}`, + keywords: ['settings', key, section.label, settingsSectionLabel(section)], + label: `${settingsSectionLabel(section)}: ${configFieldLabel(key)}`, run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`) })) ) - result.push({ heading: 'Settings fields', items: fieldItems }) + result.push({ heading: t.commandCenter.settingsFields, items: fieldItems }) if (mcpServers.length > 0) { result.push({ - heading: 'MCP servers', + heading: t.commandCenter.mcpServers, items: mcpServers.map(name => ({ icon: Wrench, id: `mcp-${name}`, @@ -324,7 +350,7 @@ export function CommandPalette() { if (archivedSessions.length > 0) { result.push({ - heading: 'Archived chats', + heading: t.commandCenter.archivedChats, items: archivedSessions.map(session => ({ icon: Archive, id: `archived-${session.id}`, @@ -336,7 +362,7 @@ export function CommandPalette() { } return result - }, [archivedSessions, go, mcpServers, search, sessions]) + }, [archivedSessions, configFieldLabel, go, mcpServers, search, sessions, settingsSectionLabel, t]) const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups]) @@ -345,13 +371,13 @@ export function CommandPalette() { const subPages = useMemo>( () => ({ theme: { - title: 'Theme', - placeholder: 'Choose a theme…', + title: t.settings.appearance.themeTitle, + placeholder: t.settings.appearance.themeDesc, // Skins aren't inherently light/dark — the same skin renders in either // mode. Group by appearance so picking an entry sets skin + mode at // once, and keep the palette open so each pick previews live. groups: (['light', 'dark'] as const).map(groupMode => ({ - heading: groupMode === 'light' ? 'Light' : 'Dark', + heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label, items: availableThemes.map(theme => ({ active: themeName === theme.name && resolvedMode === groupMode, icon: groupMode === 'light' ? Sun : Moon, @@ -367,30 +393,30 @@ export function CommandPalette() { })) }, 'color-mode': { - title: 'Color mode', - placeholder: 'Choose color mode…', + title: t.settings.appearance.colorMode, + placeholder: t.settings.appearance.colorModeDesc, groups: [ { - heading: 'Color mode', + heading: t.settings.appearance.colorMode, items: THEME_MODES.map(entry => ({ active: mode === entry.mode, icon: entry.icon, id: `mode-${entry.mode}`, keepOpen: true, - keywords: ['appearance', 'brightness', entry.label], - label: entry.label, + keywords: ['appearance', 'brightness', t.settings.modeOptions[entry.mode].label], + label: t.settings.modeOptions[entry.mode].label, run: () => setMode(entry.mode) })) } ] } }), - [availableThemes, mode, resolvedMode, setMode, setTheme, themeName] + [availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName] ) const activePage = page ? subPages[page] : null const visibleGroups = activePage ? activePage.groups : groups - const placeholder = activePage ? activePage.placeholder : 'Search commands and settings...' + const placeholder = activePage ? activePage.placeholder : t.commandCenter.searchPlaceholder const handleSelect = (item: PaletteItem) => { if (item.to) { @@ -415,7 +441,7 @@ export function CommandPalette() { aria-describedby={undefined} className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95" > - Command palette + {t.commandCenter.paletteTitle} {activePage && ( @@ -448,7 +474,7 @@ export function CommandPalette() { value={search} /> - No results found. + {t.commandCenter.noResults} {visibleGroups.map(group => ( { if ($desktopBoot.get().running || $desktopBoot.get().visible) { - failDesktopBoot('Hermes background process exited during startup.') + failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup')) } notify({ kind: 'error', - title: 'Backend stopped', - message: 'Hermes background process exited.', + title: translateNow('boot.errors.backendStopped'), + message: translateNow('boot.errors.backgroundExited'), durationMs: 0 }) }) @@ -301,7 +301,7 @@ export function useGatewayBoot({ setDesktopBootStep({ phase: 'renderer.gateway.connect', - message: 'Connecting live desktop gateway', + message: translateNow('boot.steps.connectingGateway'), progress: 95 }) publish(conn) @@ -332,7 +332,7 @@ export function useGatewayBoot({ setDesktopBootStep({ phase: 'renderer.config', - message: 'Loading Hermes settings', + message: translateNow('boot.steps.loadingSettings'), progress: 97 }) await callbacksRef.current.refreshHermesConfig() @@ -343,7 +343,7 @@ export function useGatewayBoot({ setDesktopBootStep({ phase: 'renderer.sessions', - message: 'Loading recent sessions', + message: translateNow('boot.steps.loadingSessions'), progress: 99 }) await callbacksRef.current.refreshSessions() @@ -353,7 +353,7 @@ export function useGatewayBoot({ if (!cancelled) { const message = err instanceof Error ? err.message : String(err) failDesktopBoot(message) - notifyError(err, 'Desktop boot failed') + notifyError(err, translateNow('boot.errors.desktopBootFailed')) setSessionsLoading(false) } } diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 261bf24e3d4..2b5c4da4946 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -66,141 +66,20 @@ const trimEdits = (edits: Record): Record => .filter(([, v]) => v) ) -const FIELD_COPY: Record = { - TELEGRAM_BOT_TOKEN: { - label: 'Bot token', - help: 'Create a bot with @BotFather, then paste the token it gives you.', - placeholder: 'Paste Telegram bot token' - }, - TELEGRAM_ALLOWED_USERS: { - label: 'Allowed Telegram user IDs', - help: 'Recommended. Comma-separated numeric IDs from @userinfobot. Without this, anyone can DM your bot.' - }, - TELEGRAM_PROXY: { - label: 'Proxy URL', - help: 'Only needed on networks where Telegram is blocked.', - advanced: true - }, - DISCORD_BOT_TOKEN: { - label: 'Bot token', - help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.' - }, - DISCORD_ALLOWED_USERS: { - label: 'Allowed Discord user IDs', - help: 'Recommended. Comma-separated Discord user IDs.' - }, - DISCORD_REPLY_TO_MODE: { - label: 'Reply style', - help: 'first, all, or off.', - advanced: true - }, - DISCORD_ALLOW_ALL_USERS: { - label: 'Allow all Discord users', - help: 'Development only. When true, anyone can DM the bot without an allowlist.', - advanced: true - }, - DISCORD_HOME_CHANNEL: { - label: 'Home channel ID', - help: 'Channel where the bot sends proactive messages (cron output, reminders).', - advanced: true - }, - DISCORD_HOME_CHANNEL_NAME: { - label: 'Home channel name', - help: 'Display name for the home channel in logs and status output.', - advanced: true - }, - BLUEBUBBLES_ALLOW_ALL_USERS: { - label: 'Allow all iMessage users', - help: 'When true, skip the BlueBubbles allowlist.', - advanced: true - }, - MATTERMOST_ALLOW_ALL_USERS: { - label: 'Allow all Mattermost users', - advanced: true - }, - MATTERMOST_HOME_CHANNEL: { - label: 'Home channel', - advanced: true - }, - QQ_ALLOW_ALL_USERS: { - label: 'Allow all QQ users', - advanced: true - }, - QQBOT_HOME_CHANNEL: { - label: 'QQ home channel', - help: 'Default channel or group for cron delivery.', - advanced: true - }, - QQBOT_HOME_CHANNEL_NAME: { - label: 'QQ home channel name', - advanced: true - }, - SLACK_BOT_TOKEN: { - label: 'Slack bot token', - help: 'Use the bot token from OAuth & Permissions after installing your Slack app.', - placeholder: 'Paste Slack bot token' - }, - SLACK_APP_TOKEN: { - label: 'Slack app token', - help: 'Use the app-level token required for Socket Mode.', - placeholder: 'Paste Slack app token' - }, - SLACK_ALLOWED_USERS: { - label: 'Allowed Slack user IDs', - help: 'Recommended. Comma-separated Slack user IDs.' - }, - MATTERMOST_URL: { - label: 'Server URL', - placeholder: 'https://mattermost.example.com' - }, - MATTERMOST_TOKEN: { - label: 'Bot token' - }, - MATTERMOST_ALLOWED_USERS: { - label: 'Allowed user IDs', - help: 'Recommended. Comma-separated Mattermost user IDs.' - }, - MATRIX_HOMESERVER: { - label: 'Homeserver URL', - placeholder: 'https://matrix.org' - }, - MATRIX_ACCESS_TOKEN: { - label: 'Access token' - }, - MATRIX_USER_ID: { - label: 'Bot user ID', - placeholder: '@hermes:example.org' - }, - MATRIX_ALLOWED_USERS: { - label: 'Allowed Matrix user IDs', - help: 'Recommended. Comma-separated user IDs in @user:server format.' - }, - SIGNAL_HTTP_URL: { - label: 'Signal bridge URL', - placeholder: 'http://127.0.0.1:8080', - help: 'URL of a running signal-cli REST bridge.' - }, - SIGNAL_ACCOUNT: { - label: 'Phone number', - help: 'The number registered with your signal-cli bridge.' - }, - SIGNAL_ALLOWED_USERS: { - label: 'Allowed Signal users', - help: 'Recommended. Comma-separated Signal identifiers.' - }, - WHATSAPP_ENABLED: { - label: 'Enable WhatsApp bridge', - help: 'Set automatically by the toggle below. Leave alone unless you know you need it.', - advanced: true - }, - WHATSAPP_MODE: { - label: 'Bridge mode', - advanced: true - }, - WHATSAPP_ALLOWED_USERS: { - label: 'Allowed WhatsApp users', - help: 'Recommended. Comma-separated phone numbers or WhatsApp IDs.' - } +const FIELD_COPY: Record = { + TELEGRAM_PROXY: { advanced: true }, + DISCORD_REPLY_TO_MODE: { advanced: true }, + DISCORD_ALLOW_ALL_USERS: { advanced: true }, + DISCORD_HOME_CHANNEL: { advanced: true }, + DISCORD_HOME_CHANNEL_NAME: { advanced: true }, + BLUEBUBBLES_ALLOW_ALL_USERS: { advanced: true }, + MATTERMOST_ALLOW_ALL_USERS: { advanced: true }, + MATTERMOST_HOME_CHANNEL: { advanced: true }, + QQ_ALLOW_ALL_USERS: { advanced: true }, + QQBOT_HOME_CHANNEL: { advanced: true }, + QQBOT_HOME_CHANNEL_NAME: { advanced: true }, + WHATSAPP_ENABLED: { advanced: true }, + WHATSAPP_MODE: { advanced: true } } function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) { @@ -208,9 +87,9 @@ function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) { const localized = m.fieldCopy[field.key] || {} return { - label: localized.label || copy.label || field.prompt || field.key, - help: localized.help || copy.help || field.description, - placeholder: localized.placeholder || copy.placeholder || field.prompt, + label: localized.label || field.prompt || field.key, + help: localized.help || field.description, + placeholder: localized.placeholder || field.prompt, advanced: Boolean(copy.advanced || field.advanced) } } diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx index 6fb9ab38c3d..8e429c3884a 100644 --- a/apps/desktop/src/app/overlays/overlay-view.tsx +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { translateNow } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -17,7 +18,7 @@ interface OverlayViewProps { export function OverlayView({ children, onClose, - closeLabel = 'Close', + closeLabel = translateNow('common.close'), contentClassName, headerContent, rootClassName diff --git a/apps/desktop/src/app/profiles/create-profile-dialog.tsx b/apps/desktop/src/app/profiles/create-profile-dialog.tsx index 1fc34725e3d..cd9b3fa0d5f 100644 --- a/apps/desktop/src/app/profiles/create-profile-dialog.tsx +++ b/apps/desktop/src/app/profiles/create-profile-dialog.tsx @@ -7,14 +7,12 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { createProfile, updateProfileSoul } from '@/hermes' +import { useI18n } from '@/i18n' import { AlertTriangle } from '@/lib/icons' import { cn } from '@/lib/utils' const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ -export const PROFILE_NAME_HINT = - 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.' - export function isValidProfileName(name: string): boolean { return PROFILE_NAME_RE.test(name.trim()) } @@ -31,6 +29,8 @@ export function CreateProfileDialog({ onCreated?: (name: string) => Promise | void open: boolean }) { + const { t } = useI18n() + const p = t.profiles const [name, setName] = useState('') const [cloneFromDefault, setCloneFromDefault] = useState(true) const [soul, setSoul] = useState('') @@ -57,7 +57,7 @@ export function CreateProfileDialog({ event.preventDefault() if (!trimmed || invalid) { - setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.') + setError(invalid ? p.invalidName(p.nameHint) : p.nameRequired) return } @@ -77,7 +77,7 @@ export function CreateProfileDialog({ window.setTimeout(onClose, 800) } catch (err) { setStatus('idle') - setError(err instanceof Error ? err.message : 'Failed to create profile') + setError(err instanceof Error ? err.message : p.failedCreate) } } @@ -85,16 +85,14 @@ export function CreateProfileDialog({ !value && !busy && onClose()} open={open}> - New profile - - Profiles are independent Hermes environments: separate config, skills, and SOUL.md. - + {p.newProfile} + {p.createDesc}

- {PROFILE_NAME_HINT} + {p.nameHint}

@@ -116,22 +114,20 @@ export function CreateProfileDialog({ onCheckedChange={checked => setCloneFromDefault(checked === true)} /> - Clone from default - - Copy config, skills, and SOUL.md from your default profile. - + {p.cloneFromDefault} + {p.cloneFromDefaultDesc}