diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx index f9d3fc370a..ff01687aac 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/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index e19128add3..0cea51661c 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1532,7 +1532,7 @@ export function ChatBar({ {queueEdit && editingQueuedPrompt && (
- Editing queued turn in composer + {t.composer.editingQueuedInComposer}
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 e48ff7accf..ebfd4e58b6 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 70a973322b..67df7fefc2 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 86b8acf9c3..e3ebc4f228 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 0c8a5bb296..21cfbeb3ce 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 7fd015efec..236aac244b 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, @@ -92,48 +93,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 +193,61 @@ 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) => t.settings.fieldLabels[key] ?? 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 +256,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 +308,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 +323,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 +346,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 +358,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 +367,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 +389,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 +437,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 +470,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 261bf24e3d..2b5c4da494 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 6fb9ab38c3..8e429c3884 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 1fc34725e3..cd9b3fa0d5 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}