diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 4bcf76e46e6..ff0aa8fb654 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -5,6 +5,7 @@ import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { BrailleSpinner } from '@/components/ui/braille-spinner' import { FadeText } from '@/components/ui/fade-text' +import { type Translations, useI18n } from '@/i18n' import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' @@ -21,11 +22,11 @@ import { OverlayView } from '../overlays/overlay-view' // Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the // same visual vocabulary as the chat tool blocks. -function statusGlyph(status: SubagentStatus): ReactNode { +function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode { if (status === 'running' || status === 'queued') { return ( @@ -33,10 +34,10 @@ function statusGlyph(status: SubagentStatus): ReactNode { } if (status === 'failed' || status === 'interrupted') { - return + return } - return + return } const STREAM_TONE: Record = { @@ -75,6 +76,7 @@ interface AgentsViewProps { } export function AgentsView({ onClose }: AgentsViewProps) { + const { t } = useI18n() const activeSessionId = useStore($activeSessionId) const subagentsBySession = useStore($subagentsBySession) @@ -87,61 +89,61 @@ export function AgentsView({ onClose }: AgentsViewProps) { return (
-

Spawn tree

-

Live subagent activity for the current turn.

+

{t.agents.title}

+

{t.agents.subtitle}

) } -const fmtDuration = (seconds?: number) => { +const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => { if (!seconds || seconds <= 0) { return '' } if (seconds < 60) { - return `${seconds.toFixed(1)}s` + return a.durationSeconds(seconds.toFixed(1)) } const m = Math.floor(seconds / 60) const s = Math.round(seconds % 60) - return `${m}m ${s}s` + return a.durationMinutes(m, s) } -const fmtTokens = (value?: number) => { +const fmtTokens = (value: number | undefined, a: Translations['agents']) => { if (!value) { return '' } - return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok` + return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value) } -const fmtAge = (updatedAt: number, nowMs: number) => { +const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => { const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000)) if (s < 2) { - return 'now' + return a.ageNow } if (s < 60) { - return `${s}s ago` + return a.ageSeconds(s) } const m = Math.floor(s / 60) if (m < 60) { - return `${m}m ago` + return a.ageMinutes(m) } - return `${Math.floor(m / 60)}h ago` + return a.ageHours(Math.floor(m / 60)) } const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] => @@ -149,7 +151,7 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] => interface RootGroup { id: string - label: string + delegationIndex: number nodes: SubagentNode[] taskCount: number } @@ -173,18 +175,19 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] { if (node.taskCount > 1) { n += 1 - groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount }) + groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount }) continue } - groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount }) + groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount }) } return groups } function SubagentTree({ tree }: { tree: SubagentNode[] }) { + const { t } = useI18n() const flat = useMemo(() => flatten(tree), [tree]) const groups = useMemo(() => groupDelegations(tree), [tree]) const [nowMs, setNowMs] = useState(() => Date.now()) @@ -210,21 +213,19 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) { return (
-

No live subagents

-

- When a turn delegates work, child agents stream their progress here. -

+

{t.agents.emptyTitle}

+

{t.agents.emptyDesc}

) } const summary = [ - `${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`, - active > 0 ? `${active} active` : '', - failed > 0 ? `${failed} failed` : '', - tools > 0 ? `${tools} tools` : '', - files > 0 ? `${files} files` : '', - tokens > 0 ? fmtTokens(tokens) : '', + t.agents.agentsCount(flat.length), + active > 0 ? t.agents.activeCount(active) : '', + failed > 0 ? t.agents.failedCount(failed) : '', + tools > 0 ? t.agents.toolsCount(tools) : '', + files > 0 ? t.agents.filesCount(files) : '', + tokens > 0 ? fmtTokens(tokens, t.agents) : '', cost > 0 ? `$${cost.toFixed(2)}` : '' ].filter(Boolean) @@ -243,6 +244,8 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) { } function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) { + const { t } = useI18n() + if (group.nodes.length === 1 && group.taskCount <= 1) { return } @@ -252,8 +255,9 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) return (

- {group.label} · {group.nodes.length} workers - {activeWorkers > 0 ? · {activeWorkers} active : null} + {group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '} + · {t.agents.workers(group.nodes.length)} + {activeWorkers > 0 ? · {t.agents.workersActive(activeWorkers)} : null}

{group.nodes.map(node => ( @@ -275,6 +279,7 @@ function StreamLine({ parentRunning: boolean rowKey: string }) { + const { t } = useI18n() const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`) const isMono = entry.kind === 'tool' const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind] @@ -286,7 +291,7 @@ function StreamLine({ {entry.text} {active ? ( @@ -297,6 +302,7 @@ function StreamLine({ } function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) { + const { t } = useI18n() const running = node.status === 'running' || node.status === 'queued' const elapsed = useElapsedSeconds(running, `subagent:${node.id}`) @@ -317,10 +323,10 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n const subtitle = [ node.model, - fmtDuration(durationSeconds), - node.toolCount ? `${node.toolCount} tools` : '', - fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)), - `updated ${fmtAge(node.updatedAt, nowMs)}` + fmtDuration(durationSeconds, t.agents), + node.toolCount ? t.agents.toolsCount(node.toolCount) : '', + fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents), + t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents)) ].filter(Boolean) return ( @@ -331,7 +337,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n onClick={() => setOpen(v => !v)} type="button" > - {statusGlyph(node.status)} + {statusGlyph(node.status, t.agents)} 0 ? (
-

Files

+

{t.agents.files}

{fileLines.slice(0, 8).map(line => (

{line} @@ -374,7 +380,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n ))} {fileLines.length > 8 ? (

- +{fileLines.length - 8} more files + {t.agents.moreFiles(fileLines.length - 8)}

) : null}
diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 109ffba0794..fd1569d7caf 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom' import { ZoomableImage } from '@/components/chat/zoomable-image' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { Pagination, @@ -18,6 +19,7 @@ import { import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { Tip } from '@/components/ui/tooltip' import { getSessionMessages, listSessions } from '@/hermes' +import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons' @@ -311,15 +313,15 @@ function formatArtifactTime(timestamp: number): string { return ARTIFACT_TIME_FMT.format(new Date(timestamp)) } -function pageRangeLabel(total: number, page: number, pageSize: number): string { +function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string { if (total === 0) { - return '0' + return a.zero } const start = (page - 1) * pageSize + 1 const end = Math.min(total, page * pageSize) - return `${start}-${end} of ${total}` + return a.rangeOf(start, end, total) } function paginationItems(page: number, pageCount: number): Array { @@ -356,21 +358,25 @@ type CellCtx = { interface ArtifactColumn { Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement bodyClassName: string - header: (filter: ArtifactFilter) => string + header: (filter: ArtifactFilter, a: Translations['artifacts']) => string id: 'location' | 'primary' | 'session' width: (filter: ArtifactFilter) => string } -const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items') +const itemsLabel = (f: ArtifactFilter, a: Translations['artifacts']) => + f === 'link' ? a.itemsLink : f === 'file' ? a.itemsFile : a.itemsGeneric interface ArtifactsViewProps extends React.ComponentProps<'section'> { setStatusbarItemGroup?: SetStatusbarItemGroup } export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) { + const { t } = useI18n() + const a = t.artifacts const navigate = useNavigate() const [artifacts, setArtifacts] = useState(null) const [query, setQuery] = useState('') + const [refreshing, setRefreshing] = useState(false) const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all') @@ -379,6 +385,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . const [filePage, setFilePage] = useState(1) const refreshArtifacts = useCallback(async () => { + setRefreshing(true) + try { const sessions = (await listSessions(30, 1)).sessions const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) @@ -393,12 +401,14 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages)) }) - setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp)) + setArtifacts(nextArtifacts.sort((left, right) => right.timestamp - left.timestamp)) } catch (err) { - notifyError(err, 'Artifacts failed to load') + notifyError(err, a.failedLoad) setArtifacts([]) + } finally { + setRefreshing(false) } - }, []) + }, [a]) useRefreshHotkey(refreshArtifacts) @@ -479,9 +489,9 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . window.open(href, '_blank', 'noopener,noreferrer') } } catch (err) { - notifyError(err, 'Open failed') + notifyError(err, a.openFailed) } - }, []) + }, [a]) const markImageFailed = useCallback((id: string) => { setFailedImageIds(current => { @@ -503,34 +513,46 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . {...props} onSearchChange={setQuery} searchHidden={counts.all === 0} - searchPlaceholder="Search artifacts..." + searchPlaceholder={a.search} + searchTrailingAction={ + + } searchValue={query} tabs={ <> setKindFilter('all')}> - All ({counts.all}) + {a.tabAll} ({counts.all}) setKindFilter('image')}> - Images ({counts.image}) + {a.tabImages} ({counts.image}) setKindFilter('file')}> - Files ({counts.file}) + {a.tabFiles} ({counts.file}) setKindFilter('link')}> - Links ({counts.link}) + {a.tabLinks} ({counts.link}) } > {!artifacts ? ( - + ) : visibleArtifacts.length === 0 ? (
-
No artifacts found
-
- Generated images and file outputs will appear here as sessions produce them. -
+
{a.noArtifactsTitle}
+
{a.noArtifactsDesc}
) : ( @@ -547,7 +569,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . >
- {pageRangeLabel(total, page, pageSize)} {itemLabel} + {pageRangeLabel(total, page, pageSize, a)} {itemLabel}
{pageCount > 1 && ( @@ -627,7 +651,7 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz ) : ( onPageChange(item)} > @@ -657,6 +681,10 @@ interface ArtifactImageCardProps { } function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) { + const { t } = useI18n() + const a = t.artifacts + const kindLabel = artifact.kind === 'image' ? a.kindImage : artifact.kind === 'file' ? a.kindFile : a.kindLink + return (
- {artifact.kind} + {kindLabel}
{artifact.label} @@ -698,7 +726,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
@@ -768,9 +796,10 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx } function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) { + const { t } = useI18n() const isLink = artifact.kind === 'link' const value = isLink ? hostPathLabel(artifact.value) : artifact.value - const copyLabel = isLink ? 'Copy URL' : 'Copy path' + const copyLabel = isLink ? t.artifacts.copyUrl : t.artifacts.copyPath return (
@@ -814,21 +843,22 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [ { Cell: PrimaryCell, bodyClassName: 'p-0', - header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'), + header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault), id: 'primary', width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]') }, { Cell: LocationCell, bodyClassName: 'px-2.5 py-1.5', - header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'), + header: (filter, a) => + filter === 'link' ? a.colLocationLink : filter === 'file' ? a.colLocationFile : a.colLocationDefault, id: 'location', width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]') }, { Cell: SessionCell, bodyClassName: 'p-0', - header: () => 'Session', + header: (_filter, a) => a.colSession, id: 'session', width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]') } @@ -843,13 +873,15 @@ function ArtifactTable({ ctx: CellCtx filter: ArtifactFilter }) { + const { t } = useI18n() + return ( {ARTIFACT_COLUMNS.map(col => ( ))} diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index a4611233827..0c154a8a4b1 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import type { ComposerAttachment } from '@/store/composer' @@ -26,6 +27,8 @@ export function AttachmentList({ } function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) { + const { t } = useI18n() + const c = t.composer const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind] const cwd = useStore($currentCwd) const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' @@ -53,12 +56,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined) if (!preview) { - throw new Error(`Could not preview ${attachment.label}`) + throw new Error(c.couldNotPreview(attachment.label)) } setCurrentSessionPreviewTarget(preview, 'manual', target) } catch (error) { - notifyError(error, 'Preview unavailable') + notifyError(error, c.previewUnavailable) } } @@ -66,7 +69,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
{onRemove && ( - - ))} + + + ) + })} @@ -175,15 +165,8 @@ interface ContextMenuProps { state: ChatBarState } -interface PromptSnippet { - description: string - label: string - text: string -} - interface PromptSnippetsDialogProps { onInsertText: (text: string) => void onOpenChange: (open: boolean) => void open: boolean - snippets: readonly PromptSnippet[] } diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7e98456f989..5e1e3df6fb0 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,6 +1,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -55,6 +56,9 @@ export function ComposerControls({ voiceStatus: VoiceStatus onDictate: () => void }) { + const { t } = useI18n() + const c = t.composer + if (conversation.active) { return } @@ -65,9 +69,9 @@ export function ComposerControls({
{showVoicePrimary ? ( - + ) : ( - + )} {label} @@ -220,10 +228,12 @@ function DictationButton({ status: VoiceStatus onToggle: () => void }) { + const { t } = useI18n() + const c = t.composer const active = state.active || status !== 'idle' const aria = - status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation' + status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation return ( diff --git a/apps/desktop/src/app/chat/composer/help-hint.tsx b/apps/desktop/src/app/chat/composer/help-hint.tsx index c986f20f44c..897c7f0e3a7 100644 --- a/apps/desktop/src/app/chat/composer/help-hint.tsx +++ b/apps/desktop/src/app/chat/composer/help-hint.tsx @@ -1,44 +1,32 @@ import type { ReactNode } from 'react' +import { useI18n } from '@/i18n' + import { COMPLETION_DRAWER_CLASS } from './completion-drawer' -const COMMON_COMMANDS: [string, string][] = [ - ['/help', 'full list of commands + hotkeys'], - ['/clear', 'start a new session'], - ['/resume', 'resume a prior session'], - ['/details', 'control transcript detail level'], - ['/copy', 'copy selection or last assistant message'], - ['/quit', 'exit hermes'] -] - -const HOTKEYS: [string, string][] = [ - ['@', 'reference files, folders, urls, git'], - ['/', 'slash command palette'], - ['?', 'this quick help (delete to dismiss)'], - ['Enter', 'send · Shift+Enter for newline'], - ['Cmd/Ctrl+K', 'send next queued turn'], - ['Cmd/Ctrl+L', 'redraw'], - ['Esc', 'close popover · cancel run'], - ['↑ / ↓', 'cycle popover / history'] -] +const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit'] +const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓'] export function HelpHint() { + const { t } = useI18n() + const c = t.composer + return (
-
- {COMMON_COMMANDS.map(([key, desc]) => ( - +
+ {COMMON_COMMAND_KEYS.map(key => ( + ))}
-
- {HOTKEYS.map(([key, desc]) => ( - +
+ {HOTKEY_KEYS.map(key => ( + ))}

- /help opens the full panel · backspace dismisses + /help {c.helpFooter}

) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 4997014e810..2288a7b7f82 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -17,6 +17,7 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' +import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' @@ -84,29 +85,6 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36 const COMPOSER_FADE_BACKGROUND = 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' -// Resting composer placeholders. New sessions get open-ended starters; an -// existing chat gets phrasings that read as a continuation of the thread. -// One is picked at random per session (stable until the session changes). -const NEW_SESSION_PLACEHOLDERS = [ - 'What are we building?', - 'Give Hermes a task', - "What's on your mind?", - 'Describe what you need', - 'What should we tackle?', - 'Ask anything', - 'Start with a goal' -] - -const FOLLOW_UP_PLACEHOLDERS = [ - 'Send a follow-up', - 'Add more context', - 'Refine the request', - "What's next?", - 'Keep it going', - 'Push it further', - 'Adjust or continue' -] - const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] interface QueueEditState { @@ -190,7 +168,10 @@ export function ChatBar({ const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' const showHelpHint = draft === '?' + const { t } = useI18n() const gatewayState = useStore($gatewayState) + const newSessionPlaceholders = t.composer.newSessionPlaceholders + const followUpPlaceholders = t.composer.followUpPlaceholders // Resting placeholder: a starter for brand-new sessions, a continuation for // existing ones. Picked once and only re-rolled when we genuinely move to a @@ -198,7 +179,7 @@ export function ChatBar({ // started session (null → id, on the first send) is treated as the same // conversation so the placeholder doesn't visibly flip mid-stream. const [restingPlaceholder, setRestingPlaceholder] = useState(() => - pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS) + pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders) ) const prevSessionIdRef = useRef(sessionId) @@ -217,16 +198,16 @@ export function ChatBar({ return } - setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)) - }, [sessionId]) + setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)) + }, [followUpPlaceholders, newSessionPlaceholders, sessionId]) // When the bar is disabled it's because the gateway isn't open. Distinguish a // cold start ("Starting Hermes...") from a dropped connection we're trying to // restore (e.g. after the Mac slept) so the stuck state reads as recoverable. const placeholder = disabled ? gatewayState === 'closed' || gatewayState === 'error' - ? 'Reconnecting to Hermes…' - : 'Starting Hermes...' + ? t.composer.placeholderReconnecting + : t.composer.placeholderStarting : restingPlaceholder const focusInput = useCallback(() => { @@ -1213,7 +1194,7 @@ export function ChatBar({ const input = (
void } -const entryPreview = (entry: QueuedPromptEntry) => - entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn') +const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) => + entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn) export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) { + const { t } = useI18n() + const c = t.composer const [collapsed, setCollapsed] = useState(false) if (entries.length === 0) { @@ -34,7 +37,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN type="button" > - {entries.length} Queued + {c.queued(entries.length)} {!collapsed && ( @@ -57,17 +60,17 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent" />
-

{entryPreview(entry)}

+

{entryPreview(entry, c)}

{(attachmentsCount > 0 || isEditing) && (
{attachmentsCount > 0 && ( - {attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'} + {c.attachments(attachmentsCount)} )} {isEditing && ( - Editing in composer + {c.editingInComposer} )}
@@ -81,9 +84,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN : 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100' )} > - + - + - + diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx index b41e7aac816..535d1422e45 100644 --- a/apps/desktop/src/app/chat/composer/voice-activity.tsx +++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef } from 'react' import { Button } from '@/components/ui/button' +import { useI18n } from '@/i18n' import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import { stopVoicePlayback } from '@/lib/voice-playback' @@ -163,12 +164,14 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n } export function VoiceActivity({ state }: { state: VoiceActivityState }) { + const { t } = useI18n() + if (state.status === 'idle') { return null } const recording = state.status === 'recording' - const title = recording ? 'Dictating' : 'Transcribing' + const title = recording ? t.composer.dictating : t.composer.transcribing return (
() for (const session of sessions) { const path = session.cwd?.trim() || '' const id = path || '__no_workspace__' - const label = baseName(path) || path || 'No workspace' + const label = baseName(path) || path || noWorkspaceLabel const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] } group.sessions.push(session) @@ -233,6 +234,8 @@ export function ChatSidebar({ onArchiveSession, onNewSessionInWorkspace }: ChatSidebarProps) { + const { t } = useI18n() + const s = t.sidebar const sidebarOpen = useStore($sidebarOpen) const panesFlipped = useStore($panesFlipped) const agentsGrouped = useStore($sidebarAgentsGrouped) @@ -402,8 +405,8 @@ export function ChatSidebar({ ) const agentGroups = useMemo( - () => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds), - [agentSessions, workspaceOrderIds] + () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds), + [agentSessions, s.noWorkspace, workspaceOrderIds] ) const loadMoreForProfileGroup = useCallback( @@ -589,13 +592,15 @@ export function ChatSidebar({ onNavigate(item) }} - tooltip={item.label} + tooltip={s.nav[item.id] ?? item.label} type="button" > {sidebarOpen && ( <> - {item.label} + + {s.nav[item.id] ?? item.label} + {isNewSession && (
@@ -629,10 +634,10 @@ export function ChatSidebar({ contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75" emptyState={
- No sessions match “{trimmedQuery}”. + {s.noMatch(trimmedQuery)}
} - label="Results" + label={s.results} labelMeta={String(searchResults.length)} onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} @@ -653,7 +658,7 @@ export function ChatSidebar({ contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1" dndSensors={dndSensors} emptyState={} - label="Pinned" + label={s.pinned} onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} onReorder={handlePinnedDragEnd} @@ -703,9 +708,9 @@ export function ChatSidebar({ // view (always grouped by profile), so hide the button (not the slot).
{!showAllProfiles && agentSessions.length > 0 ? ( - + {(onNewSession || isProfileGroup) && ( - + diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 4c79e85b00d..0c2ed62d235 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -5,6 +5,7 @@ import { writeSessionDrag } from '@/app/chat/composer/inline-refs' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import type { SessionInfo } from '@/hermes' +import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -26,22 +27,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> { dragHandleProps?: React.HTMLAttributes } -const AGE_TICKS: ReadonlyArray<[number, string]> = [ - [86_400_000, 'd'], - [3_600_000, 'h'], - [60_000, 'm'] +const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [ + [86_400_000, 'ageDay'], + [3_600_000, 'ageHour'], + [60_000, 'ageMin'] ] -function formatAge(seconds: number): string { +function formatAge(seconds: number, r: Translations['sidebar']['row']): string { const delta = Math.max(0, Date.now() - seconds * 1000) - for (const [ms, suffix] of AGE_TICKS) { + for (const [ms, key] of AGE_TICKS) { if (delta >= ms) { - return `${Math.floor(delta / ms)}${suffix}` + return `${Math.floor(delta / ms)}${r[key]}` } } - return 'now' + return r.ageNow } export function SidebarSessionRow({ @@ -61,8 +62,10 @@ export function SidebarSessionRow({ ref, ...rest }: SidebarSessionRowProps) { + const { t } = useI18n() + const r = t.sidebar.row const title = sessionTitle(session) - const age = formatAge(session.last_active || session.started_at) + const age = formatAge(session.last_active || session.started_at, r) const handleLabel = `Reorder ${title}` // Subscribe per-row (the leaf) instead of drilling a set through the list — // the atom is tiny and rarely non-empty. True when a clarify prompt in this @@ -196,10 +199,10 @@ export function SidebarSessionRow({ title={title} > - - ) -} - -function EmptyPanel({ action, description, title }: { action?: ReactNode; description: string; title: string }) { - return ( -
-
-
{title}
-
- {description} -
- {action &&
{action}
} -
-
- ) -} - -export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) { +export function CommandCenterView({ + initialSection, + onClose, + onDeleteSession, + onNavigateRoute, + onOpenSession +}: CommandCenterViewProps) { + const { t } = useI18n() + const cc = t.commandCenter const sessions = useStore($sessions) const pinnedSessionIds = useStore($pinnedSessionIds) const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions') const [query, setQuery] = useState('') + const [searchLoading, setSearchLoading] = useState(false) + const [searchGroups, setSearchGroups] = useState([]) const [status, setStatus] = useState(null) const [logs, setLogs] = useState([]) const [systemLoading, setSystemLoading] = useState(false) @@ -139,30 +172,78 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on const [usage, setUsage] = useState(null) const [usageLoading, setUsageLoading] = useState(false) const [usageError, setUsageError] = useState('') + const searchRequestRef = useRef(0) const usageRequestRef = useRef(0) const debouncedQuery = useDebouncedValue(query.trim(), 180) - const filteredSessions = useMemo(() => { - const sorted = [...sessions].sort((a, b) => { - const left = a.last_active || a.started_at || 0 - const right = b.last_active || b.started_at || 0 + const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions]) - return right - left - }) + const filteredSessions = useMemo( + () => + [...sessions].sort((a, b) => { + const left = a.last_active || a.started_at || 0 + const right = b.last_active || b.started_at || 0 - const needle = debouncedQuery.toLowerCase() + return right - left + }), + [sessions] + ) - if (!needle) { - return sorted - } + const searchProviders = useMemo( + () => [ + { + id: 'navigation', + label: cc.providerNavigate, + search: async searchQuery => { + const routeHits: RouteSearchHit[] = NAV_ROUTES.filter(entry => + matchesSearchQuery(searchQuery, cc.nav[entry.key].title, cc.nav[entry.key].detail, entry.route) + ).map(entry => ({ + detail: cc.nav[entry.key].detail, + kind: 'route', + route: entry.route, + title: cc.nav[entry.key].title + })) - return sorted.filter(session => { - const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase() + const sectionHits: SectionSearchHit[] = SECTIONS.filter(section => + matchesSearchQuery( + searchQuery, + cc.sectionEntries[section].title, + cc.sectionEntries[section].detail, + cc.sections[section] + ) + ).map(section => ({ + detail: cc.sectionEntries[section].detail, + kind: 'section', + section, + title: cc.sectionEntries[section].title + })) - return haystack.includes(needle) - }) - }, [debouncedQuery, sessions]) + return [...routeHits, ...sectionHits] + } + }, + { + id: 'sessions', + label: cc.providerSessions, + search: async searchQuery => { + const response = await searchSessions(searchQuery) + + return response.results.map(result => { + const { detail, title } = splitSessionSearchResult(result, sessionsById) + + return { + detail, + kind: 'session', + sessionId: result.session_id, + snippet: result.snippet || '', + title + } satisfies SessionSearchHit + }) + } + } + ], + [cc, sessionsById] + ) const refreshSystem = useCallback(async () => { setSystemLoading(true) @@ -180,7 +261,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on setStatus(nextStatus) setLogs(nextLogs.lines) } catch (error) { - setSystemError(errorText(error)) + setSystemError(error instanceof Error ? error.message : String(error)) } finally { setSystemLoading(false) } @@ -200,7 +281,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } } catch (error) { if (usageRequestRef.current === requestId) { - setUsageError(errorText(error)) + setUsageError(error instanceof Error ? error.message : String(error)) } } finally { if (usageRequestRef.current === requestId) { @@ -209,6 +290,42 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } }, []) + useEffect(() => { + if (!debouncedQuery) { + setSearchGroups([]) + setSearchLoading(false) + + return + } + + const requestId = searchRequestRef.current + 1 + searchRequestRef.current = requestId + setSearchLoading(true) + + void Promise.all( + searchProviders.map(async provider => ({ + id: provider.id, + label: provider.label, + results: await provider.search(debouncedQuery) + })) + ) + .then(groups => { + if (searchRequestRef.current === requestId) { + setSearchGroups(groups.filter(group => group.results.length > 0)) + } + }) + .catch(() => { + if (searchRequestRef.current === requestId) { + setSearchGroups([]) + } + }) + .finally(() => { + if (searchRequestRef.current === requestId) { + setSearchLoading(false) + } + }) + }, [debouncedQuery, searchProviders]) + useEffect(() => { if (section === 'system' && !status && !systemLoading) { void refreshSystem() @@ -229,6 +346,8 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } }) + const showGlobalSearchResults = debouncedQuery.length > 0 + const hasGlobalSearchResults = searchGroups.length > 0 const sessionListHasResults = filteredSessions.length > 0 const runSystemAction = useCallback( @@ -254,7 +373,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on if (!nextStatus) { const pendingStatus = { exit_code: null, - lines: ['Action started, waiting for status...'], + lines: [cc.actionStartedWaiting], name: started.name, pid: started.pid, running: true @@ -264,131 +383,234 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on upsertDesktopActionTask(pendingStatus) } } catch (error) { - setSystemError(errorText(error)) + setSystemError(error instanceof Error ? error.message : String(error)) } finally { void refreshSystem() } }, - [refreshSystem] + [cc, refreshSystem] + ) + + const handleSearchSelect = useCallback( + (result: CommandCenterSearchResult) => { + if (result.kind === 'route') { + onNavigateRoute(result.route) + + return + } + + if (result.kind === 'section') { + setSection(result.section) + setQuery('') + + return + } + + onOpenSession(result.sessionId) + }, + [onNavigateRoute, onOpenSession, setSection] ) return ( - + setQuery(next)} + placeholder={cc.searchPlaceholder} + value={query} + /> + } + onClose={onClose} + > {SECTIONS.map(value => ( setSection(value)} /> ))} -
-
-

- {SECTION_LABELS[section]} -

-

- {SECTION_DESCRIPTIONS[section]} -

-
-
- {section === 'sessions' && sessions.length > 0 && ( - setQuery(next)} - placeholder="Search sessions…" - value={query} - /> - )} - {section === 'usage' && ( - setUsagePeriod(Number(id) as UsagePeriod)} - options={USAGE_PERIODS.map(value => ({ id: String(value), label: `${value}d` }))} - value={String(usagePeriod)} - /> - )} +
+
+

{cc.sections[section]}

+

{cc.sectionDescriptions[section]}

+ {section === 'system' && ( + void refreshSystem()}> + + {systemLoading ? cc.refreshing : cc.refresh} + + )} + {section === 'usage' && ( + void refreshUsage(usagePeriod)}> + + {usageLoading ? cc.refreshing : cc.refresh} + + )}
- {section === 'sessions' ? ( + {showGlobalSearchResults ? ( +
+ {!hasGlobalSearchResults ? ( + {cc.noResults} + ) : ( +
+ {searchGroups.map(group => ( +
+

+ {group.label} +

+ {group.results.map(result => { + if (result.kind === 'session') { + const pinned = pinnedSessionIds.includes(result.sessionId) + + return ( + + +
+ { + event.preventDefault() + event.stopPropagation() + pinned ? unpinSession(result.sessionId) : pinSession(result.sessionId) + }} + title={pinned ? cc.unpinSession : cc.pinSession} + > + {pinned ? ( + + ) : ( + + )} + + { + event.preventDefault() + event.stopPropagation() + void exportSession(result.sessionId, { title: result.title }) + }} + title={cc.exportSession} + > + + + { + event.preventDefault() + event.stopPropagation() + void onDeleteSession(result.sessionId) + }} + title={cc.deleteSession} + > + + +
+
+ ) + } + + return ( + + ) + })} +
+ ))} +
+ )} +
+ ) : section === 'sessions' ? (
{!sessionListHasResults ? ( - + {cc.noSessions} ) : ( -
    +
    {filteredSessions.map(session => { - const pinId = sessionPinId(session) - const pinned = pinnedSessionIds.includes(pinId) + const pinned = pinnedSessionIds.includes(session.id) return ( -
  • + -
    - (pinned ? unpinSession(pinId) : pinSession(pinId))} - title={pinned ? 'Unpin session' : 'Pin session'} - > - {pinned ? ( - - ) : ( - - )} - - void exportSession(session.id, { session, title: sessionTitle(session) })} - title="Export session" - > - - - void onDeleteSession(session.id)} - title="Delete session" - > - - -
    -
  • + (pinned ? unpinSession(session.id) : pinSession(session.id))} + title={pinned ? cc.unpinSession : cc.pinSession} + > + {pinned ? : } + + void exportSession(session.id, { session, title: sessionTitle(session) })} + title={cc.exportSession} + > + + + void onDeleteSession(session.id)} + title={cc.deleteSession} + > + + + ) })} -
+
)}
) : section === 'usage' ? ( void refreshUsage(usagePeriod)} period={usagePeriod} usage={usage} /> ) : ( -
-
+
+ {status ? (
@@ -400,51 +622,49 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500' )} /> - - {status.gateway_running ? 'Messaging gateway running' : 'Messaging gateway stopped'} + + {status.gateway_running ? cc.gatewayRunning : cc.gatewayStopped}
-
- Hermes {status.version} · Active sessions {status.active_sessions} +
+ {cc.hermesActiveSessions(status.version, status.active_sessions)}
- - + void runSystemAction('restart')}> + {cc.restartMessaging} + + void runSystemAction('update')}> + {cc.updateHermes} +
{systemAction && ( -
+
{systemAction.name} ·{' '} - {systemAction.running ? 'running' : systemAction.exit_code === 0 ? 'done' : 'failed'} + {systemAction.running ? cc.actionRunning : systemAction.exit_code === 0 ? cc.actionDone : cc.actionFailed}
)}
) : ( - +
{cc.loadingStatus}
)} -
+ -
+
- - Recent logs - + {cc.recentLogs} {systemError && ( - + {systemError} )}
-
-                  {logs.length ? logs.join('\n') : 'No logs loaded yet.'}
+                
+                  {logs.length ? logs.join('\n') : cc.noLogs}
                 
-
+
)} @@ -488,12 +708,15 @@ function formatInteger(value: null | number | undefined): string { interface UsagePanelProps { error: string loading: boolean + onPeriodChange: (period: UsagePeriod) => void onRefresh: () => void period: UsagePeriod usage: AnalyticsResponse | null } -function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProps) { +function UsagePanel({ error, loading, onPeriodChange, onRefresh, period, usage }: UsagePanelProps) { + const { t } = useI18n() + const cc = t.commandCenter const daily = useMemo(() => usage?.daily ?? [], [usage]) const totals = usage?.totals const byModel = usage?.by_model ?? [] @@ -507,82 +730,92 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp return daily.reduce((acc, entry) => Math.max(acc, (entry.input_tokens || 0) + (entry.output_tokens || 0)), 1) }, [daily]) - if (!totals) { - return ( -
- {loading ? ( - - ) : ( - - Retry - - } - description={`No token, cost, or skill activity recorded in the last ${period} days.`} - title="No usage yet" - /> - )} -
- ) - } - return ( -
- {error && ( - - - {error} - - )} - -
- - - - 0 ? `actual ${formatCost(totals.total_actual_cost)}` : undefined} - label="Est. cost" - value={formatCost(totals.total_estimated_cost)} - /> -
- -
-
- - Daily tokens - - - - input - - - output - - +
+ +
+ {USAGE_PERIODS.map(value => ( + + ))}
- {daily.length === 0 ? ( -
- No daily activity. -
- ) : ( - <> -
- {daily.map(entry => { - const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96) - const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96) + {error && ( + + + {error} + + )} + - return ( - -
+ + {totals ? ( +
+ + + + 0 ? cc.actualCost(formatCost(totals.total_actual_cost)) : undefined} + label={cc.statCost} + value={formatCost(totals.total_estimated_cost)} + /> +
+ ) : loading ? ( +
{cc.loadingUsage}
+ ) : ( +
+ {cc.noUsage(period)}{' '} + +
+ )} +
+ +
+ +
+ {cc.dailyTokens} + + + {cc.input} + + + {cc.output} + + +
+ {daily.length === 0 ? ( +
{cc.noDailyActivity}
+ ) : ( + <> +
+ {daily.map(entry => { + const total = (entry.input_tokens || 0) + (entry.output_tokens || 0) + const inputH = Math.round(((entry.input_tokens || 0) / maxTokens) * 96) + const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96) + + return ( +
0 ? 1 : 0) }} />
0 ? 1 : 0) }} />
- - ) - })} -
-
- {daily[0]?.day} - {daily[daily.length - 1]?.day} -
- - )} -
+ ) + })} +
+
+ {daily[0]?.day} + {daily[daily.length - 1]?.day} +
+ + )} + -
- ({ - key: entry.model, - label: entry.model, - value: `${formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} · ${formatCost(entry.estimated_cost)}` - }))} - title="Top models" - /> - ({ - key: entry.skill, - label: entry.skill, - value: `${entry.total_count.toLocaleString()} actions` - }))} - title="Top skills" - /> + +
+
+
+ {cc.topModels} +
+ {byModel.length === 0 ? ( +
{cc.noModelUsage}
+ ) : ( +
    + {byModel.slice(0, 6).map(entry => ( +
  • + {entry.model} + + {formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))} ·{' '} + {formatCost(entry.estimated_cost)} + +
  • + ))} +
+ )} +
+ +
+
+ {cc.topSkills} +
+ {topSkills.length === 0 ? ( +
{cc.noSkillActivity}
+ ) : ( +
    + {topSkills.slice(0, 6).map(entry => ( +
  • + {entry.skill} + + {cc.actions(entry.total_count.toLocaleString())} + +
  • + ))} +
+ )} +
+
+
) } -function UsageList({ - emptyLabel, - rows, - title -}: { - emptyLabel: string - rows: Array<{ key: string; label: string; value: string }> - title: string -}) { - return ( -
-
- {title} -
- {rows.length === 0 ? ( -
- {emptyLabel} -
- ) : ( -
    - {rows.map(row => ( -
  • - {row.label} - {row.value} -
  • - ))} -
- )} -
- ) -} - function UsageStat({ hint, label, value }: { hint?: string; label: string; value: string }) { return (
-
{label}
-
{value}
- {hint &&
{hint}
} +
{label}
+
{value}
+ {hint &&
{hint}
}
) } diff --git a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx index 9e576c9ea73..2993a1c7411 100644 --- a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx +++ b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx @@ -3,6 +3,7 @@ import type * as React from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' interface CronJobActions { @@ -32,12 +33,15 @@ export function CronJobActionsMenu({ sideOffset = 6, title }: CronJobActionsMenuProps) { + const { t } = useI18n() + const c = t.cron + return ( {children} @@ -49,7 +53,7 @@ export function CronJobActionsMenu({ }} > - {isPaused ? 'Resume' : 'Pause'} + {isPaused ? c.resumeTitle : c.pauseTitle} - Trigger now + {c.triggerNow} - Edit + {c.edit} - Delete + {t.common.delete} @@ -93,12 +97,14 @@ interface CronJobActionsTriggerProps extends Omit diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 8202dd3d827..dcf852e6aa5 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -1,10 +1,9 @@ +import type * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { PageLoader } from '@/components/page-loader' -import { Badge, type BadgeProps } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Dialog, DialogContent, @@ -14,7 +13,6 @@ import { DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { SearchField } from '@/components/ui/search-field' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { @@ -27,78 +25,49 @@ import { triggerCronJob, updateCronJob } from '@/hermes' +import { type Translations, useI18n } from '@/i18n' import { AlertTriangle, Clock } from '@/lib/icons' +import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { OverlayView } from '../overlays/overlay-view' +import { PageSearchShell } from '../page-search-shell' +import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu' const DEFAULT_DELIVER = 'local' -const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ - { label: 'This desktop', value: 'local' }, - { label: 'Telegram', value: 'telegram' }, - { label: 'Discord', value: 'discord' }, - { label: 'Slack', value: 'slack' }, - { label: 'Email', value: 'email' } -] +const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email'] const SCHEDULE_OPTIONS: ReadonlyArray = [ - { - expr: '0 9 * * *', - hint: 'Every day at 9:00 AM', - label: 'Daily', - value: 'daily' - }, - { - expr: '0 9 * * 1-5', - hint: 'Monday through Friday at 9:00 AM', - label: 'Weekdays', - value: 'weekdays' - }, - { - expr: '0 9 * * 1', - hint: 'Every Monday at 9:00 AM', - label: 'Weekly', - value: 'weekly' - }, - { - expr: '0 9 1 * *', - hint: 'The first day of each month at 9:00 AM', - label: 'Monthly', - value: 'monthly' - }, - { - expr: '0 * * * *', - hint: 'At the top of every hour', - label: 'Hourly', - value: 'hourly' - }, - { - expr: '*/15 * * * *', - hint: 'Every 15 minutes', - label: 'Every 15 minutes', - value: 'every-15-minutes' - }, - { - hint: 'Cron syntax or natural language', - label: 'Custom', - value: 'custom' - } + { expr: '0 9 * * *', value: 'daily' }, + { expr: '0 9 * * 1-5', value: 'weekdays' }, + { expr: '0 9 * * 1', value: 'weekly' }, + { expr: '0 9 1 * *', value: 'monthly' }, + { expr: '0 * * * *', value: 'hourly' }, + { expr: '*/15 * * * *', value: 'every-15-minutes' }, + { value: 'custom' } ] -const STATE_VARIANT: Record = { - enabled: 'default', - scheduled: 'default', - running: 'default', +const STATE_TONE: Record = { + enabled: 'good', + scheduled: 'good', + running: 'good', paused: 'warn', disabled: 'muted', - error: 'destructive', + error: 'bad', completed: 'muted' } +const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = { + good: 'bg-primary/10 text-primary', + muted: 'bg-muted text-muted-foreground', + warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300', + bad: 'bg-destructive/10 text-destructive' +} + const asText = (value: unknown): string => (typeof value === 'string' ? value : '') const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value) @@ -155,19 +124,8 @@ function cronParts(expr: string): null | string[] { return parts.length === 5 ? parts : null } -function dayName(value: string): string { - const names: Record = { - '0': 'Sunday', - '1': 'Monday', - '2': 'Tuesday', - '3': 'Wednesday', - '4': 'Thursday', - '5': 'Friday', - '6': 'Saturday', - '7': 'Sunday' - } - - return names[value] ?? `day ${value}` +function dayName(value: string, c: Translations['cron']): string { + return c.days[value] ?? c.dayFallback(value) } function formatCronTime(minute: string, hour: string): string { @@ -243,36 +201,36 @@ function scheduleOptionForExpr(expr: string): ScheduleOption { return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1] } -function scheduleSummary(option: ScheduleOption, expr: string): string { +function scheduleSummary(option: ScheduleOption, expr: string, c: Translations['cron']): string { const parts = cronParts(expr) if (!parts) { - return option.hint + return c.scheduleHints[option.value] ?? '' } const [minute, hour, dayOfMonth, , dayOfWeek] = parts if (option.value === 'daily') { - return `Every day at ${formatCronTime(minute, hour)}` + return c.everyDayAt(formatCronTime(minute, hour)) } if (option.value === 'weekdays') { - return `Weekdays at ${formatCronTime(minute, hour)}` + return c.weekdaysAt(formatCronTime(minute, hour)) } if (option.value === 'weekly') { - return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}` + return c.everyDayOfWeekAt(dayName(dayOfWeek, c), formatCronTime(minute, hour)) } if (option.value === 'monthly') { - return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}` + return c.monthlyOnDayAt(dayOfMonth, formatCronTime(minute, hour)) } if (option.value === 'hourly') { - return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}` + return minute === '0' ? c.topOfHour : c.everyHourAt(minute.padStart(2, '0')) } - return option.hint + return c.scheduleHints[option.value] ?? '' } function formatTime(iso?: null | string): string { @@ -301,26 +259,35 @@ function matchesQuery(job: CronJob, q: string): boolean { ) } -interface CronViewProps { +interface CronViewProps extends React.ComponentProps<'section'> { onClose: () => void + setStatusbarItemGroup?: SetStatusbarItemGroup } -export function CronView({ onClose }: CronViewProps) { +export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) { + const { t } = useI18n() + const c = t.cron const [jobs, setJobs] = useState(null) const [query, setQuery] = useState('') + const [refreshing, setRefreshing] = useState(false) const [busyJobId, setBusyJobId] = useState(null) const [editor, setEditor] = useState({ mode: 'closed' }) const [pendingDelete, setPendingDelete] = useState(null) + const [deleting, setDeleting] = useState(false) const refresh = useCallback(async () => { + setRefreshing(true) + try { const result = await getCronJobs() setJobs(result) } catch (err) { - notifyError(err, 'Failed to load cron jobs') + notifyError(err, c.failedLoad) + } finally { + setRefreshing(false) } - }, []) + }, [c]) useRefreshHotkey(refresh) @@ -348,11 +315,11 @@ export function CronView({ onClose }: CronViewProps) { setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) notify({ kind: 'success', - title: isPaused ? 'Cron resumed' : 'Cron paused', + title: isPaused ? c.resumed : c.paused, message: truncate(jobTitle(job), 60) }) } catch (err) { - notifyError(err, 'Failed to update cron job') + notifyError(err, c.failedUpdate) } finally { setBusyJobId(null) } @@ -364,14 +331,33 @@ export function CronView({ onClose }: CronViewProps) { try { const updated = await triggerCronJob(job.id) setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) - notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) }) + notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) }) } catch (err) { - notifyError(err, 'Failed to trigger cron job') + notifyError(err, c.failedTrigger) } finally { setBusyJobId(null) } } + async function handleConfirmDelete() { + if (!pendingDelete) { + return + } + + setDeleting(true) + + try { + await deleteCronJob(pendingDelete.id) + setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current)) + notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) }) + setPendingDelete(null) + } catch (err) { + notifyError(err, c.failedDelete) + } finally { + setDeleting(false) + } + } + async function handleEditorSave(values: EditorValues) { if (editor.mode === 'create') { const created = await createCronJob({ @@ -382,7 +368,7 @@ export function CronView({ onClose }: CronViewProps) { }) setJobs(current => (current ? [...current, created] : [created])) - notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) }) + notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) }) } else if (editor.mode === 'edit') { const updated = await updateCronJob(editor.job.id, { prompt: values.prompt, @@ -392,61 +378,67 @@ export function CronView({ onClose }: CronViewProps) { }) setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current)) - notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) }) + notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) }) } setEditor({ mode: 'closed' }) } return ( - -
- {totalCount > 0 && ( -
- -
- )} + + void refresh()} + size="icon-xs" + title={refreshing ? c.refreshing : c.refresh} + type="button" + variant="ghost" + > + + + } + searchValue={query} + > {!jobs ? ( - + ) : visibleJobs.length === 0 ? ( // Empty state owns the primary "create" CTA — we used to also have // one in the filters bar but it was redundant. Only show the button // when there are zero jobs total; the search-empty case ("No // matches") just asks the user to broaden their query. setEditor({ mode: 'create' }) : undefined} - title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'} + title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch} /> ) : ( -
+
{/* Inline header replaces the old top-bar "New cron" button. We still need a single, always-visible affordance to add a job when the list is non-empty (rows themselves only expose edit/pause/trigger/delete). */}
- {enabledCount}/{totalCount} active + {c.active(enabledCount, totalCount)}
-
+
{visibleJobs.map(job => ( setPendingDelete(job)} @@ -458,42 +450,40 @@ export function CronView({ onClose }: CronViewProps) {
)} -
- setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> + setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> - - This will remove{' '} - {truncate(jobTitle(pendingDelete), 60)} permanently. - It will stop firing immediately. - - ) : null - } - destructive - doneLabel="Deleted" - onClose={() => setPendingDelete(null)} - onConfirm={async () => { - if (!pendingDelete) { - return - } - - await deleteCronJob(pendingDelete.id) - setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current)) - notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' }) - }} - open={pendingDelete !== null} - title="Delete cron job?" - /> + !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}> + + + {c.deleteTitle} + + {pendingDelete ? ( + <> + {c.deleteDescPrefix} + {truncate(jobTitle(pendingDelete), 60)} + {c.deleteDescSuffix} + + ) : null} + + + + + + + + + ) } function CronJobRow({ busy, + c, job, onDelete, onEdit, @@ -501,6 +491,7 @@ function CronJobRow({ onTrigger }: { busy: boolean + c: Translations['cron'] job: CronJob onDelete: () => void onEdit: () => void @@ -516,19 +507,15 @@ function CronJobRow({ return (
{job.last_error && (

@@ -569,6 +560,16 @@ function CronJobRow({ ) } +function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) { + return ( + + {children} + + ) +} + function EmptyState({ actionLabel, description, @@ -605,6 +606,8 @@ function CronEditorDialog({ onClose: () => void onSave: (values: EditorValues) => Promise }) { + const { t } = useI18n() + const c = t.cron const open = editor.mode !== 'closed' const isEdit = editor.mode === 'edit' const initial = isEdit ? editor.job : null @@ -647,7 +650,7 @@ function CronEditorDialog({ } } - const scheduleHint = scheduleSummary(selectedScheduleOption, schedule) + const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c) async function handleSubmit(event: React.FormEvent) { event.preventDefault() @@ -655,7 +658,7 @@ function CronEditorDialog({ const trimmedSchedule = schedule.trim() if (!trimmedPrompt || !trimmedSchedule) { - setError('Prompt and schedule are required.') + setError(c.promptScheduleRequired) return } @@ -671,7 +674,7 @@ function CronEditorDialog({ schedule: trimmedSchedule }) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save cron job') + setError(err instanceof Error ? err.message : c.failedSave) } finally { setSaving(false) } @@ -681,60 +684,56 @@ function CronEditorDialog({

!value && !saving && onClose()} open={open}> - {isEdit ? 'Edit cron job' : 'New cron job'} - - {isEdit - ? 'Update the schedule, prompt, or delivery target. Changes apply on next run.' - : 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".'} - + {isEdit ? c.editTitle : c.createTitle} + {isEdit ? c.editDesc : c.createDesc}
- + setName(event.target.value)} - placeholder="Morning briefing" + placeholder={c.namePlaceholder} value={name} /> - +
- {col.header(filter)} + {col.header(filter, t.artifacts)}