feat(desktop): add i18n with Simplified Chinese (zh-Hans) support

Introduce a lightweight React context-based i18n layer for the desktop
app and translate the UI into Simplified Chinese.

- New apps/desktop/src/i18n module: typed Translations interface, en + zh
  locale tables, I18nProvider/useI18n, localStorage-persisted locale
  (defaults to English), and language endonym metadata for the picker.
- Wire I18nProvider at the app root in main.tsx.
- Refactor 24 desktop screens/components to read strings from the `t`
  object instead of hard-coded English.
- Add a unit test for the i18n context.
This commit is contained in:
Jim Liu 宝玉 2026-06-03 08:48:01 -05:00 committed by Teknium
parent 02d6bf1c39
commit 4a1907bd10
36 changed files with 4221 additions and 1373 deletions

View file

@ -5,6 +5,7 @@ import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { BrailleSpinner } from '@/components/ui/braille-spinner' import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { FadeText } from '@/components/ui/fade-text' import { FadeText } from '@/components/ui/fade-text'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation' import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils' 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 // Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
// same visual vocabulary as the chat tool blocks. // 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') { if (status === 'running' || status === 'queued') {
return ( return (
<BrailleSpinner <BrailleSpinner
ariaLabel="Running" ariaLabel={a.running}
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80" className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
spinner="breathe" spinner="breathe"
/> />
@ -33,10 +34,10 @@ function statusGlyph(status: SubagentStatus): ReactNode {
} }
if (status === 'failed' || status === 'interrupted') { if (status === 'failed' || status === 'interrupted') {
return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" /> return <AlertCircle aria-label={a.failed} className="size-3.5 shrink-0 text-destructive" />
} }
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" /> return <CheckCircle2 aria-label={a.done} className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
} }
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = { const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
@ -75,6 +76,7 @@ interface AgentsViewProps {
} }
export function AgentsView({ onClose }: AgentsViewProps) { export function AgentsView({ onClose }: AgentsViewProps) {
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId) const activeSessionId = useStore($activeSessionId)
const subagentsBySession = useStore($subagentsBySession) const subagentsBySession = useStore($subagentsBySession)
@ -87,61 +89,61 @@ export function AgentsView({ onClose }: AgentsViewProps) {
return ( return (
<OverlayView <OverlayView
closeLabel="Close agents" closeLabel={t.agents.close}
contentClassName="px-5 pt-5 pb-4 sm:px-6" contentClassName="px-5 pt-5 pb-4 sm:px-6"
onClose={onClose} onClose={onClose}
rootClassName="mx-auto max-w-3xl" rootClassName="mx-auto max-w-3xl"
> >
<header className="mb-3 shrink-0"> <header className="mb-3 shrink-0">
<h2 className="text-sm font-semibold text-foreground">Spawn tree</h2> <h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
<p className="text-xs text-muted-foreground/80">Live subagent activity for the current turn.</p> <p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
</header> </header>
<SubagentTree tree={tree} /> <SubagentTree tree={tree} />
</OverlayView> </OverlayView>
) )
} }
const fmtDuration = (seconds?: number) => { const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => {
if (!seconds || seconds <= 0) { if (!seconds || seconds <= 0) {
return '' return ''
} }
if (seconds < 60) { if (seconds < 60) {
return `${seconds.toFixed(1)}s` return a.durationSeconds(seconds.toFixed(1))
} }
const m = Math.floor(seconds / 60) const m = Math.floor(seconds / 60)
const s = Math.round(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) { if (!value) {
return '' 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)) const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
if (s < 2) { if (s < 2) {
return 'now' return a.ageNow
} }
if (s < 60) { if (s < 60) {
return `${s}s ago` return a.ageSeconds(s)
} }
const m = Math.floor(s / 60) const m = Math.floor(s / 60)
if (m < 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[] => const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
@ -149,7 +151,7 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
interface RootGroup { interface RootGroup {
id: string id: string
label: string delegationIndex: number
nodes: SubagentNode[] nodes: SubagentNode[]
taskCount: number taskCount: number
} }
@ -173,18 +175,19 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
if (node.taskCount > 1) { if (node.taskCount > 1) {
n += 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 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 return groups
} }
function SubagentTree({ tree }: { tree: SubagentNode[] }) { function SubagentTree({ tree }: { tree: SubagentNode[] }) {
const { t } = useI18n()
const flat = useMemo(() => flatten(tree), [tree]) const flat = useMemo(() => flatten(tree), [tree])
const groups = useMemo(() => groupDelegations(tree), [tree]) const groups = useMemo(() => groupDelegations(tree), [tree])
const [nowMs, setNowMs] = useState(() => Date.now()) const [nowMs, setNowMs] = useState(() => Date.now())
@ -210,21 +213,19 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
return ( return (
<div className="grid place-items-center gap-3 py-12 text-center"> <div className="grid place-items-center gap-3 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/60" /> <Sparkles className="size-6 text-muted-foreground/60" />
<p className="text-sm font-medium text-foreground/90">No live subagents</p> <p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75"> <p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
When a turn delegates work, child agents stream their progress here.
</p>
</div> </div>
) )
} }
const summary = [ const summary = [
`${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`, t.agents.agentsCount(flat.length),
active > 0 ? `${active} active` : '', active > 0 ? t.agents.activeCount(active) : '',
failed > 0 ? `${failed} failed` : '', failed > 0 ? t.agents.failedCount(failed) : '',
tools > 0 ? `${tools} tools` : '', tools > 0 ? t.agents.toolsCount(tools) : '',
files > 0 ? `${files} files` : '', files > 0 ? t.agents.filesCount(files) : '',
tokens > 0 ? fmtTokens(tokens) : '', tokens > 0 ? fmtTokens(tokens, t.agents) : '',
cost > 0 ? `$${cost.toFixed(2)}` : '' cost > 0 ? `$${cost.toFixed(2)}` : ''
].filter(Boolean) ].filter(Boolean)
@ -243,6 +244,8 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
} }
function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) { function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) {
const { t } = useI18n()
if (group.nodes.length === 1 && group.taskCount <= 1) { if (group.nodes.length === 1 && group.taskCount <= 1) {
return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} /> return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
} }
@ -252,8 +255,9 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number })
return ( return (
<section className="grid min-w-0 gap-3"> <section className="grid min-w-0 gap-3">
<p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70"> <p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70">
{group.label} <span className="text-muted-foreground/50">·</span> {group.nodes.length} workers {group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '}
{activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null} <span className="text-muted-foreground/50">·</span> {t.agents.workers(group.nodes.length)}
{activeWorkers > 0 ? <span className="text-primary/85"> · {t.agents.workersActive(activeWorkers)}</span> : null}
</p> </p>
<div className="grid min-w-0 gap-4"> <div className="grid min-w-0 gap-4">
{group.nodes.map(node => ( {group.nodes.map(node => (
@ -275,6 +279,7 @@ function StreamLine({
parentRunning: boolean parentRunning: boolean
rowKey: string rowKey: string
}) { }) {
const { t } = useI18n()
const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`) const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`)
const isMono = entry.kind === 'tool' const isMono = entry.kind === 'tool'
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind] const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
@ -286,7 +291,7 @@ function StreamLine({
{entry.text} {entry.text}
{active ? ( {active ? (
<BrailleSpinner <BrailleSpinner
ariaLabel="Streaming" ariaLabel={t.agents.streaming}
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70" className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
spinner="breathe" spinner="breathe"
/> />
@ -297,6 +302,7 @@ function StreamLine({
} }
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) { function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
const { t } = useI18n()
const running = node.status === 'running' || node.status === 'queued' const running = node.status === 'running' || node.status === 'queued'
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`) const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
@ -317,10 +323,10 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
const subtitle = [ const subtitle = [
node.model, node.model,
fmtDuration(durationSeconds), fmtDuration(durationSeconds, t.agents),
node.toolCount ? `${node.toolCount} tools` : '', node.toolCount ? t.agents.toolsCount(node.toolCount) : '',
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)), fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents),
`updated ${fmtAge(node.updatedAt, nowMs)}` t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents))
].filter(Boolean) ].filter(Boolean)
return ( return (
@ -331,7 +337,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
onClick={() => setOpen(v => !v)} onClick={() => setOpen(v => !v)}
type="button" type="button"
> >
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status)}</span> <span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status, t.agents)}</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5"> <span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span <span
className={cn( className={cn(
@ -366,7 +372,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
{open && fileLines.length > 0 ? ( {open && fileLines.length > 0 ? (
<div className="grid min-w-0 gap-0.5 pl-6"> <div className="grid min-w-0 gap-0.5 pl-6">
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">Files</p> <p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
{fileLines.slice(0, 8).map(line => ( {fileLines.slice(0, 8).map(line => (
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}> <p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
{line} {line}
@ -374,7 +380,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
))} ))}
{fileLines.length > 8 ? ( {fileLines.length > 8 ? (
<p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65"> <p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65">
+{fileLines.length - 8} more files {t.agents.moreFiles(fileLines.length - 8)}
</p> </p>
) : null} ) : null}
</div> </div>

View file

@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/chat/zoomable-image' import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader' import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button' import { CopyButton } from '@/components/ui/copy-button'
import { import {
Pagination, Pagination,
@ -18,6 +19,7 @@ import {
import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip' import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listSessions } from '@/hermes' import { getSessionMessages, listSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime' import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons' 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)) 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) { if (total === 0) {
return '0' return a.zero
} }
const start = (page - 1) * pageSize + 1 const start = (page - 1) * pageSize + 1
const end = Math.min(total, page * pageSize) 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<number | 'ellipsis'> { function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
@ -356,21 +358,25 @@ type CellCtx = {
interface ArtifactColumn { interface ArtifactColumn {
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
bodyClassName: string bodyClassName: string
header: (filter: ArtifactFilter) => string header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
id: 'location' | 'primary' | 'session' id: 'location' | 'primary' | 'session'
width: (filter: ArtifactFilter) => string 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'> { interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup setStatusbarItemGroup?: SetStatusbarItemGroup
} }
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) { export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
const { t } = useI18n()
const a = t.artifacts
const navigate = useNavigate() const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null) const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all') const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
@ -379,6 +385,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const [filePage, setFilePage] = useState(1) const [filePage, setFilePage] = useState(1)
const refreshArtifacts = useCallback(async () => { const refreshArtifacts = useCallback(async () => {
setRefreshing(true)
try { try {
const sessions = (await listSessions(30, 1)).sessions const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) 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)) 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) { } catch (err) {
notifyError(err, 'Artifacts failed to load') notifyError(err, a.failedLoad)
setArtifacts([]) setArtifacts([])
} finally {
setRefreshing(false)
} }
}, []) }, [a])
useRefreshHotkey(refreshArtifacts) useRefreshHotkey(refreshArtifacts)
@ -479,9 +489,9 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
window.open(href, '_blank', 'noopener,noreferrer') window.open(href, '_blank', 'noopener,noreferrer')
} }
} catch (err) { } catch (err) {
notifyError(err, 'Open failed') notifyError(err, a.openFailed)
} }
}, []) }, [a])
const markImageFailed = useCallback((id: string) => { const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => { setFailedImageIds(current => {
@ -503,34 +513,46 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props} {...props}
onSearchChange={setQuery} onSearchChange={setQuery}
searchHidden={counts.all === 0} searchHidden={counts.all === 0}
searchPlaceholder="Search artifacts..." searchPlaceholder={a.search}
searchTrailingAction={
<Button
aria-label={refreshing ? a.refreshing : a.refresh}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
title={refreshing ? a.refreshing : a.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query} searchValue={query}
tabs={ tabs={
<> <>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}> <TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
All <TextTabMeta>({counts.all})</TextTabMeta> {a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
</TextTab> </TextTab>
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}> <TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
Images <TextTabMeta>({counts.image})</TextTabMeta> {a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
</TextTab> </TextTab>
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}> <TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
Files <TextTabMeta>({counts.file})</TextTabMeta> {a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
</TextTab> </TextTab>
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}> <TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
Links <TextTabMeta>({counts.link})</TextTabMeta> {a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
</TextTab> </TextTab>
</> </>
} }
> >
{!artifacts ? ( {!artifacts ? (
<PageLoader label="Indexing recent session artifacts" /> <PageLoader label={a.indexing} />
) : visibleArtifacts.length === 0 ? ( ) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center"> <div className="grid h-full place-items-center px-6 text-center">
<div> <div>
<div className="text-sm font-medium">No artifacts found</div> <div className="text-sm font-medium">{a.noArtifactsTitle}</div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">{a.noArtifactsDesc}</div>
Generated images and file outputs will appear here as sessions produce them.
</div>
</div> </div>
</div> </div>
) : ( ) : (
@ -547,7 +569,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
> >
<ArtifactsPagination <ArtifactsPagination
className="ml-auto justify-end px-0" className="ml-auto justify-end px-0"
itemLabel="images" itemLabel={a.itemsImage}
onPageChange={setImagePage} onPageChange={setImagePage}
page={currentImagePage} page={currentImagePage}
pageSize={24} pageSize={24}
@ -579,7 +601,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
> >
<ArtifactsPagination <ArtifactsPagination
className="ml-auto justify-end px-0" className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)} itemLabel={itemsLabel(kindFilter, a)}
onPageChange={setFilePage} onPageChange={setFilePage}
page={currentFilePage} page={currentFilePage}
pageSize={100} pageSize={100}
@ -608,12 +630,14 @@ interface ArtifactsPaginationProps {
} }
function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) { function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) {
const { t } = useI18n()
const a = t.artifacts
const pageCount = Math.max(1, Math.ceil(total / pageSize)) const pageCount = Math.max(1, Math.ceil(total / pageSize))
return ( return (
<div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}> <div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}>
<div className="shrink-0 text-[0.62rem] text-muted-foreground"> <div className="shrink-0 text-[0.62rem] text-muted-foreground">
{pageRangeLabel(total, page, pageSize)} {itemLabel} {pageRangeLabel(total, page, pageSize, a)} {itemLabel}
</div> </div>
{pageCount > 1 && ( {pageCount > 1 && (
<Pagination className="mx-0 w-auto min-w-0 justify-end"> <Pagination className="mx-0 w-auto min-w-0 justify-end">
@ -627,7 +651,7 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz
<PaginationEllipsis /> <PaginationEllipsis />
) : ( ) : (
<PaginationButton <PaginationButton
aria-label={`Go to ${itemLabel} page ${item}`} aria-label={a.goToPage(itemLabel, item)}
isActive={page === item} isActive={page === item}
onClick={() => onPageChange(item)} onClick={() => onPageChange(item)}
> >
@ -657,6 +681,10 @@ interface ArtifactImageCardProps {
} }
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: 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 ( return (
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)"> <article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<div <div
@ -683,7 +711,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)"> <div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<FileImage className="size-3" /> <FileImage className="size-3" />
{artifact.kind} {kindLabel}
</div> </div>
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium"> <div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
{artifact.label} {artifact.label}
@ -698,7 +726,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong"> <Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
<FolderOpen className="size-3" /> <FolderOpen className="size-3" />
Chat {a.chat}
</Button> </Button>
</div> </div>
</div> </div>
@ -768,9 +796,10 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
} }
function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) { function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const { t } = useI18n()
const isLink = artifact.kind === 'link' const isLink = artifact.kind === 'link'
const value = isLink ? hostPathLabel(artifact.value) : artifact.value 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 ( return (
<div className="group/location flex min-w-0 items-center gap-1.5"> <div className="group/location flex min-w-0 items-center gap-1.5">
@ -814,21 +843,22 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{ {
Cell: PrimaryCell, Cell: PrimaryCell,
bodyClassName: 'p-0', 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', id: 'primary',
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]') width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
}, },
{ {
Cell: LocationCell, Cell: LocationCell,
bodyClassName: 'px-2.5 py-1.5', 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', id: 'location',
width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]') width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]')
}, },
{ {
Cell: SessionCell, Cell: SessionCell,
bodyClassName: 'p-0', bodyClassName: 'p-0',
header: () => 'Session', header: (_filter, a) => a.colSession,
id: 'session', id: 'session',
width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]') width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]')
} }
@ -843,13 +873,15 @@ function ArtifactTable({
ctx: CellCtx ctx: CellCtx
filter: ArtifactFilter filter: ArtifactFilter
}) { }) {
const { t } = useI18n()
return ( return (
<table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]"> <table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]">
<thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)"> <thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
<tr> <tr>
{ARTIFACT_COLUMNS.map(col => ( {ARTIFACT_COLUMNS.map(col => (
<th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}> <th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}>
{col.header(filter)} {col.header(filter, t.artifacts)}
</th> </th>
))} ))}
</tr> </tr>

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip' import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons' import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer' import type { ComposerAttachment } from '@/store/composer'
@ -26,6 +27,8 @@ export function AttachmentList({
} }
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) { 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 Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd) const cwd = useStore($currentCwd)
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' 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) const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
if (!preview) { if (!preview) {
throw new Error(`Could not preview ${attachment.label}`) throw new Error(c.couldNotPreview(attachment.label))
} }
setCurrentSessionPreviewTarget(preview, 'manual', target) setCurrentSessionPreviewTarget(preview, 'manual', target)
} catch (error) { } catch (error) {
notifyError(error, 'Preview unavailable') notifyError(error, c.previewUnavailable)
} }
} }
@ -66,7 +69,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
<Tip label={attachment.path || attachment.detail || attachment.label}> <Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0"> <div className="group/attachment relative min-w-0 shrink-0">
<button <button
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label} aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default" className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview} disabled={!canPreview}
onClick={() => void openPreview()} onClick={() => void openPreview()}
@ -97,7 +100,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
</button> </button>
{onRemove && ( {onRemove && (
<button <button
aria-label={`Remove ${attachment.label}`} aria-label={c.removeAttachment(attachment.label)}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100" className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)} onClick={() => onRemove(attachment.id)}
type="button" type="button"

View file

@ -11,29 +11,14 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons' import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN } from './controls' import { GHOST_ICON_BTN } from './controls'
import type { ChatBarState } from './types' import type { ChatBarState } from './types'
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [ const SNIPPET_KEYS = ['codeReview', 'implementationPlan', 'explainThis']
{
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
label: 'Code review',
text: 'Please review this for bugs, regressions, and missing tests.'
},
{
description: 'Outline an approach before touching code so the diff stays focused.',
label: 'Implementation plan',
text: 'Please make a concise implementation plan before changing code.'
},
{
description: 'Walk through how the selected code works and link to the key files.',
label: 'Explain this',
text: 'Please explain how this works and point me to the key files.'
}
]
export function ContextMenu({ export function ContextMenu({
state, state,
@ -44,6 +29,8 @@ export function ContextMenu({
onPickFolders, onPickFolders,
onPickImages onPickImages
}: ContextMenuProps) { }: ContextMenuProps) {
const { t } = useI18n()
const c = t.composer
// Prompt snippets used to be a Radix submenu. That submenu didn't open // Prompt snippets used to be a Radix submenu. That submenu didn't open
// reliably when the parent menu was positioned at the bottom of the // reliably when the parent menu was positioned at the bottom of the
// window (composer "+" anchor), so we promoted it to a real Dialog — // window (composer "+" anchor), so we promoted it to a real Dialog —
@ -71,78 +58,81 @@ export function ContextMenu({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}> <DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85"> <DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Attach {c.attachLabel}
</DropdownMenuLabel> </DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}> <ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
Files {c.files}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}> <ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
Folder {c.folder}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}> <ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
Images {c.images}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}> <ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
Paste image {c.pasteImage}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}> <ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
URL {c.url}
</ContextMenuItem> </ContextMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}> <ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
Prompt snippets {c.promptSnippets}
</ContextMenuItem> </ContextMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80"> <div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference {c.tipPre}
files inline. <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
{c.tipPost}
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<PromptSnippetsDialog <PromptSnippetsDialog onInsertText={onInsertText} onOpenChange={setSnippetsOpen} open={snippetsOpen} />
onInsertText={onInsertText}
onOpenChange={setSnippetsOpen}
open={snippetsOpen}
snippets={PROMPT_SNIPPETS}
/>
</> </>
) )
} }
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) { function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippetsDialogProps) {
const { t } = useI18n()
const c = t.composer
return ( return (
<Dialog onOpenChange={onOpenChange} open={open}> <Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3"> <DialogContent className="max-w-md gap-3">
<DialogHeader> <DialogHeader>
<DialogTitle>Prompt snippets</DialogTitle> <DialogTitle>{c.snippetsTitle}</DialogTitle>
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription> <DialogDescription>{c.snippetsDesc}</DialogDescription>
</DialogHeader> </DialogHeader>
<ul className="grid gap-1"> <ul className="grid gap-1">
{snippets.map(snippet => ( {SNIPPET_KEYS.map(key => {
<li key={snippet.label}> const snippet = c.snippets[key]
<button
className="group/snippet flex w-full items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none" return (
onClick={() => { <li key={key}>
onInsertText(snippet.text) <button
onOpenChange(false) className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
}} onClick={() => {
type="button" onInsertText(snippet.text)
> onOpenChange(false)
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" /> }}
<span className="grid min-w-0 gap-0.5"> type="button"
<span className="text-sm font-medium text-foreground">{snippet.label}</span> >
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> <MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
{snippet.description} <span className="grid min-w-0 gap-0.5">
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{snippet.description}
</span>
</span> </span>
</span> </button>
</button> </li>
</li> )
))} })}
</ul> </ul>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -175,15 +165,8 @@ interface ContextMenuProps {
state: ChatBarState state: ChatBarState
} }
interface PromptSnippet {
description: string
label: string
text: string
}
interface PromptSnippetsDialogProps { interface PromptSnippetsDialogProps {
onInsertText: (text: string) => void onInsertText: (text: string) => void
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
open: boolean open: boolean
snippets: readonly PromptSnippet[]
} }

View file

@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip' import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons' import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -55,6 +56,9 @@ export function ComposerControls({
voiceStatus: VoiceStatus voiceStatus: VoiceStatus
onDictate: () => void onDictate: () => void
}) { }) {
const { t } = useI18n()
const c = t.composer
if (conversation.active) { if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} /> return <ConversationPill {...conversation} disabled={disabled} />
} }
@ -65,9 +69,9 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)"> <div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} /> <DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{showVoicePrimary ? ( {showVoicePrimary ? (
<Tip label="Start voice conversation"> <Tip label={c.startVoice}>
<Button <Button
aria-label="Start voice conversation" aria-label={c.startVoice}
className={PRIMARY_ICON_BTN} className={PRIMARY_ICON_BTN}
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
@ -81,9 +85,9 @@ export function ComposerControls({
</Button> </Button>
</Tip> </Tip>
) : ( ) : (
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}> <Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
<Button <Button
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'} aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
className={PRIMARY_ICON_BTN} className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit} disabled={disabled || !canSubmit}
type="submit" type="submit"
@ -113,25 +117,27 @@ function ConversationPill({
onToggleMute, onToggleMute,
status status
}: ConversationProps & { disabled: boolean }) { }: ConversationProps & { disabled: boolean }) {
const { t } = useI18n()
const c = t.composer
const speaking = status === 'speaking' const speaking = status === 'speaking'
const listening = status === 'listening' && !muted const listening = status === 'listening' && !muted
const label = const label =
status === 'speaking' status === 'speaking'
? 'Speaking' ? c.speaking
: status === 'transcribing' : status === 'transcribing'
? 'Transcribing' ? c.transcribing
: status === 'thinking' : status === 'thinking'
? 'Thinking' ? c.thinking
: muted : muted
? 'Muted' ? c.muted
: 'Listening' : c.listening
return ( return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)"> <div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}> <Tip label={muted ? c.unmuteMic : c.muteMic}>
<Button <Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'} aria-label={muted ? c.unmuteMic : c.muteMic}
aria-pressed={muted} aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')} className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled} disabled={disabled}
@ -148,32 +154,34 @@ function ConversationPill({
</Tip> </Tip>
{listening && ( {listening && (
<Button <Button
aria-label="Stop listening and send" aria-label={c.stopListening}
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
triggerHaptic('submit') triggerHaptic('submit')
onStopTurn() onStopTurn()
}} }}
title={c.stopListening}
type="button" type="button"
variant="ghost" variant="ghost"
> >
<Square className="fill-current" size={11} /> <Square className="fill-current" size={11} />
<span>Stop</span> <span>{c.stopShort}</span>
</Button> </Button>
)} )}
<Button <Button
aria-label="End voice conversation" aria-label={c.endConversation}
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90" className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
triggerHaptic('close') triggerHaptic('close')
onEnd() onEnd()
}} }}
title={c.endConversation}
type="button" type="button"
> >
<ConversationIndicator level={level} listening={listening} speaking={speaking} /> <ConversationIndicator level={level} listening={listening} speaking={speaking} />
<span>End</span> <span>{c.endShort}</span>
</Button> </Button>
<span className="sr-only" role="status"> <span className="sr-only" role="status">
{label} {label}
@ -220,10 +228,12 @@ function DictationButton({
status: VoiceStatus status: VoiceStatus
onToggle: () => void onToggle: () => void
}) { }) {
const { t } = useI18n()
const c = t.composer
const active = state.active || status !== 'idle' const active = state.active || status !== 'idle'
const aria = const aria =
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation' status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
return ( return (
<Tip label={aria}> <Tip label={aria}>

View file

@ -1,44 +1,32 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useI18n } from '@/i18n'
import { COMPLETION_DRAWER_CLASS } from './completion-drawer' import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
const COMMON_COMMANDS: [string, string][] = [ const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
['/help', 'full list of commands + hotkeys'], const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓']
['/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']
]
export function HelpHint() { export function HelpHint() {
const { t } = useI18n()
const c = t.composer
return ( return (
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog"> <div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
<Section title="Common commands"> <Section title={c.commonCommands}>
{COMMON_COMMANDS.map(([key, desc]) => ( {COMMON_COMMAND_KEYS.map(key => (
<Row description={desc} key={key} keyLabel={key} mono /> <Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
))} ))}
</Section> </Section>
<Section title="Hotkeys"> <Section title={c.hotkeys}>
{HOTKEYS.map(([key, desc]) => ( {HOTKEY_KEYS.map(key => (
<Row description={desc} key={key} keyLabel={key} /> <Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
))} ))}
</Section> </Section>
<p className="px-2.5 py-1 text-xs text-muted-foreground/80"> <p className="px-2.5 py-1 text-xs text-muted-foreground/80">
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses <span className="font-mono text-foreground/80">/help</span> {c.helpFooter}
</p> </p>
</div> </div>
) )

View file

@ -17,6 +17,7 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query' import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer' import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages' import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
@ -84,29 +85,6 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36
const COMPOSER_FADE_BACKGROUND = const COMPOSER_FADE_BACKGROUND =
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' '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)] const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
interface QueueEditState { interface QueueEditState {
@ -190,7 +168,10 @@ export function ChatBar({
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
const showHelpHint = draft === '?' const showHelpHint = draft === '?'
const { t } = useI18n()
const gatewayState = useStore($gatewayState) 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 // 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 // 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 // started session (null → id, on the first send) is treated as the same
// conversation so the placeholder doesn't visibly flip mid-stream. // conversation so the placeholder doesn't visibly flip mid-stream.
const [restingPlaceholder, setRestingPlaceholder] = useState(() => const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS) pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
) )
const prevSessionIdRef = useRef(sessionId) const prevSessionIdRef = useRef(sessionId)
@ -217,16 +198,16 @@ export function ChatBar({
return return
} }
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)) setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
}, [sessionId]) }, [followUpPlaceholders, newSessionPlaceholders, sessionId])
// When the bar is disabled it's because the gateway isn't open. Distinguish a // 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 // 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. // restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
const placeholder = disabled const placeholder = disabled
? gatewayState === 'closed' || gatewayState === 'error' ? gatewayState === 'closed' || gatewayState === 'error'
? 'Reconnecting to Hermes…' ? t.composer.placeholderReconnecting
: 'Starting Hermes...' : t.composer.placeholderStarting
: restingPlaceholder : restingPlaceholder
const focusInput = useCallback(() => { const focusInput = useCallback(() => {
@ -1213,7 +1194,7 @@ export function ChatBar({
const input = ( const input = (
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}> <div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div <div
aria-label="Message" aria-label={t.composer.message}
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
className={cn( className={cn(

View file

@ -3,6 +3,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Tip } from '@/components/ui/tooltip' import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons' import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue' import type { QueuedPromptEntry } from '@/store/composer-queue'
@ -16,10 +17,12 @@ interface QueuePanelProps {
onSendNow: (id: string) => void onSendNow: (id: string) => void
} }
const entryPreview = (entry: QueuedPromptEntry) => const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn') entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) { export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
if (entries.length === 0) { if (entries.length === 0) {
@ -34,7 +37,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
type="button" type="button"
> >
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" /> <DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
<span className="truncate">{entries.length} Queued</span> <span className="truncate">{c.queued(entries.length)}</span>
</button> </button>
{!collapsed && ( {!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" className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry)}</p> <p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
{(attachmentsCount > 0 || isEditing) && ( {(attachmentsCount > 0 || isEditing) && (
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75"> <div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
{attachmentsCount > 0 && ( {attachmentsCount > 0 && (
<span> <span>
{attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'} {c.attachments(attachmentsCount)}
</span> </span>
)} )}
{isEditing && ( {isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]"> <span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
Editing in composer {c.editingInComposer}
</span> </span>
)} )}
</div> </div>
@ -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' : 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
)} )}
> >
<Tip label="Edit queued turn"> <Tip label={c.editQueued}>
<Button <Button
aria-label="Edit queued turn" aria-label={c.editQueued}
className="h-5 w-5 rounded-md" className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing} disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)} onClick={() => onEdit(entry)}
@ -94,9 +97,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<Pencil size={11} /> <Pencil size={11} />
</Button> </Button>
</Tip> </Tip>
<Tip label="Send queued turn now"> <Tip label={c.sendQueuedNow}>
<Button <Button
aria-label="Send queued turn now" aria-label={c.sendQueuedNow}
className="h-5 w-5 rounded-md" className="h-5 w-5 rounded-md"
disabled={busy || isEditing} disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)} onClick={() => onSendNow(entry.id)}
@ -107,9 +110,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<ArrowUp size={11} /> <ArrowUp size={11} />
</Button> </Button>
</Tip> </Tip>
<Tip label="Delete queued turn"> <Tip label={c.deleteQueued}>
<Button <Button
aria-label="Delete queued turn" aria-label={c.deleteQueued}
className="h-5 w-5 rounded-md" className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)} onClick={() => onDelete(entry.id)}
size="icon-xs" size="icon-xs"

View file

@ -1,3 +1,4 @@
import { useI18n } from '@/i18n'
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands' import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context' import { useTheme } from '@/themes/context'
@ -10,6 +11,8 @@ interface SkinSlashPopoverProps {
} }
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) { export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
const { t } = useI18n()
const c = t.composer
const { availableThemes, themeName } = useTheme() const { availableThemes, themeName } = useTheme()
const match = draft.match(/^\/skin\s+(\S*)$/i) const match = draft.match(/^\/skin\s+(\S*)$/i)
@ -21,7 +24,7 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
return ( return (
<div <div
aria-label="Desktop theme suggestions" aria-label={c.themeSuggestions}
className={COMPLETION_DRAWER_CLASS} className={COMPLETION_DRAWER_CLASS}
data-slot="composer-skin-completion-drawer" data-slot="composer-skin-completion-drawer"
data-state="open" data-state="open"
@ -29,8 +32,10 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
> >
<div className="grid gap-0.5 pt-0.5"> <div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? ( {items.length === 0 ? (
<CompletionDrawerEmpty title="No matching themes."> <CompletionDrawerEmpty title={c.noMatchingThemes}>
Try <span className="font-mono text-foreground/80">/skin list</span>. {c.themeTryPre}
<span className="font-mono text-foreground/80">/skin list</span>
{c.themeTryPost}
</CompletionDrawerEmpty> </CompletionDrawerEmpty>
) : ( ) : (
items.map(item => ( items.map(item => (

View file

@ -10,6 +10,7 @@ import {
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { Globe } from '@/lib/icons' import { Globe } from '@/lib/icons'
const URL_HINT = /^https?:\/\//i const URL_HINT = /^https?:\/\//i
@ -29,6 +30,8 @@ export function UrlDialog({
open: boolean open: boolean
value: string value: string
}) { }) {
const { t } = useI18n()
const c = t.composer
const trimmed = value.trim() const trimmed = value.trim()
const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed) const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed)
@ -43,8 +46,8 @@ export function UrlDialog({
<Globe className="size-4" /> <Globe className="size-4" />
</span> </span>
<div className="grid gap-0.5 text-left"> <div className="grid gap-0.5 text-left">
<DialogTitle>Attach a URL</DialogTitle> <DialogTitle>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription> <DialogDescription>{c.attachUrlDesc}</DialogDescription>
</div> </div>
</DialogHeader> </DialogHeader>
<form <form
@ -60,23 +63,24 @@ export function UrlDialog({
autoCorrect="off" autoCorrect="off"
inputMode="url" inputMode="url"
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
placeholder="https://example.com/post" placeholder={c.urlPlaceholder}
ref={inputRef} ref={inputRef}
spellCheck={false} spellCheck={false}
value={value} value={value}
/> />
{trimmed.length > 0 && !looksLikeUrl && ( {trimmed.length > 0 && !looksLikeUrl && (
<p className="text-xs text-muted-foreground/85"> <p className="text-xs text-muted-foreground/85">
Include the full URL, e.g. <span className="font-mono">https://…</span> {c.urlHintPre}
<span className="font-mono">https://…</span>
</p> </p>
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost"> <Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel {t.common.cancel}
</Button> </Button>
<Button disabled={!looksLikeUrl} type="submit"> <Button disabled={!looksLikeUrl} type="submit">
Attach {c.attach}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons' import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { stopVoicePlayback } from '@/lib/voice-playback' import { stopVoicePlayback } from '@/lib/voice-playback'
@ -163,12 +164,14 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
} }
export function VoiceActivity({ state }: { state: VoiceActivityState }) { export function VoiceActivity({ state }: { state: VoiceActivityState }) {
const { t } = useI18n()
if (state.status === 'idle') { if (state.status === 'idle') {
return null return null
} }
const recording = state.status === 'recording' const recording = state.status === 'recording'
const title = recording ? 'Dictating' : 'Transcribing' const title = recording ? t.composer.dictating : t.composer.transcribing
return ( return (
<div <div
@ -201,6 +204,7 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) {
} }
export function VoicePlaybackActivity() { export function VoicePlaybackActivity() {
const { t } = useI18n()
const playback = useStore($voicePlayback) const playback = useStore($voicePlayback)
if (playback.status === 'idle') { if (playback.status === 'idle') {
@ -210,10 +214,10 @@ export function VoicePlaybackActivity() {
const preparing = playback.status === 'preparing' const preparing = playback.status === 'preparing'
const title = preparing const title = preparing
? 'Preparing audio' ? t.composer.preparingAudio
: playback.source === 'voice-conversation' : playback.source === 'voice-conversation'
? 'Speaking response' ? t.composer.speakingResponse
: 'Reading aloud' : t.composer.readingAloud
return ( return (
<div <div

View file

@ -36,6 +36,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip' import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color' import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search' import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -176,13 +177,13 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
} }
} }
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] { function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>() const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) { for (const session of sessions) {
const path = session.cwd?.trim() || '' const path = session.cwd?.trim() || ''
const id = path || '__no_workspace__' 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: [] } const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
group.sessions.push(session) group.sessions.push(session)
@ -233,6 +234,8 @@ export function ChatSidebar({
onArchiveSession, onArchiveSession,
onNewSessionInWorkspace onNewSessionInWorkspace
}: ChatSidebarProps) { }: ChatSidebarProps) {
const { t } = useI18n()
const s = t.sidebar
const sidebarOpen = useStore($sidebarOpen) const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped) const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped) const agentsGrouped = useStore($sidebarAgentsGrouped)
@ -402,8 +405,8 @@ export function ChatSidebar({
) )
const agentGroups = useMemo( const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds), () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, workspaceOrderIds] [agentSessions, s.noWorkspace, workspaceOrderIds]
) )
const loadMoreForProfileGroup = useCallback( const loadMoreForProfileGroup = useCallback(
@ -589,13 +592,15 @@ export function ChatSidebar({
onNavigate(item) onNavigate(item)
}} }}
tooltip={item.label} tooltip={s.nav[item.id] ?? item.label}
type="button" type="button"
> >
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" /> <item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{sidebarOpen && ( {sidebarOpen && (
<> <>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span> <span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
{s.nav[item.id] ?? item.label}
</span>
{isNewSession && ( {isNewSession && (
<KbdGroup <KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')} className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
@ -615,9 +620,9 @@ export function ChatSidebar({
{sidebarOpen && showSessionSections && ( {sidebarOpen && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1"> <div className="shrink-0 px-2 pb-1 pt-1">
<SearchField <SearchField
aria-label="Search sessions" aria-label={s.searchAria}
onChange={setSearchQuery} onChange={setSearchQuery}
placeholder="Search sessions…" placeholder={s.searchPlaceholder}
value={searchQuery} value={searchQuery}
/> />
</div> </div>
@ -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" contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={ emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)"> <div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
No sessions match {trimmedQuery}. {s.noMatch(trimmedQuery)}
</div> </div>
} }
label="Results" label={s.results}
labelMeta={String(searchResults.length)} labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession} onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession} 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" contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors} dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />} emptyState={<SidebarPinnedEmptyState />}
label="Pinned" label={s.pinned}
onArchiveSession={onArchiveSession} onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession} onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd} onReorder={handlePinnedDragEnd}
@ -703,9 +708,9 @@ export function ChatSidebar({
// view (always grouped by profile), so hide the button (not the slot). // view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center"> <div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? ( {!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}> <Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button <Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'} aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
className={cn( className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100', 'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100' agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
@ -724,7 +729,7 @@ export function ChatSidebar({
) : null} ) : null}
</div> </div>
} }
label="Sessions" label={s.sessions}
labelMeta={recentsMeta} labelMeta={recentsMeta}
onArchiveSession={onArchiveSession} onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession} onDeleteSession={onDeleteSession}
@ -795,19 +800,25 @@ function SidebarSessionSkeletons() {
) )
} }
const SidebarAllPinnedState = () => ( function SidebarAllPinnedState() {
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)"> const { t } = useI18n()
Everything here is pinned. Unpin a chat to show it in recents.
</div> return (
) <div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
{t.sidebar.allPinned}
</div>
)
}
function SidebarPinnedEmptyState() { function SidebarPinnedEmptyState() {
const { t } = useI18n()
return ( return (
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)"> <div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)"> <span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
<Codicon name="pin" size="0.75rem" /> <Codicon name="pin" size="0.75rem" />
</span> </span>
<span>Shift-click a chat to pin</span> <span>{t.sidebar.shiftClickHint}</span>
</div> </div>
) )
} }
@ -1006,6 +1017,8 @@ function SidebarWorkspaceGroup({
ref, ref,
...rest ...rest
}: SidebarWorkspaceGroupProps) { }: SidebarWorkspaceGroupProps) {
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile' const isProfileGroup = group.mode === 'profile'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
@ -1052,9 +1065,9 @@ function SidebarWorkspaceGroup({
/> />
</button> </button>
{(onNewSession || isProfileGroup) && ( {(onNewSession || isProfileGroup) && (
<Tip label={`New session in ${group.label}`}> <Tip label={s.newSessionIn(group.label)}>
<button <button
aria-label={`New session in ${group.label}`} aria-label={s.newSessionIn(group.label)}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100" className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
// Profile groups start a fresh session in that profile but keep the // Profile groups start a fresh session in that profile but keep the
// all-profiles browse view (newSessionInProfile leaves the scope // all-profiles browse view (newSessionInProfile leaves the scope
@ -1069,7 +1082,7 @@ function SidebarWorkspaceGroup({
{reorderable && ( {reorderable && (
<span <span
{...dragHandleProps} {...dragHandleProps}
aria-label={`Reorder workspace ${group.label}`} aria-label={s.reorderWorkspace(group.label)}
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing" className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()} onClick={event => event.stopPropagation()}
> >
@ -1091,9 +1104,9 @@ function SidebarWorkspaceGroup({
(isProfileGroup ? ( (isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} /> <SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : ( ) : (
<Tip label={`Show ${nextCount} more in ${group.label}`}> <Tip label={s.showMoreIn(nextCount, group.label)}>
<button <button
aria-label={`Show ${nextCount} more in ${group.label}`} aria-label={s.showMoreIn(nextCount, group.label)}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground" className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)} onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
type="button" type="button"
@ -1144,7 +1157,8 @@ interface SidebarLoadMoreRowProps {
} }
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) { function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more' const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return ( return (
<button <button

View file

@ -16,6 +16,7 @@ import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes' import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export' import { exportSession } from '@/lib/session-export'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
@ -43,13 +44,15 @@ interface ItemSpec {
} }
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) { function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
const { t } = useI18n()
const r = t.sidebar.row
const [renameOpen, setRenameOpen] = useState(false) const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [ const items: ItemSpec[] = [
{ {
disabled: !onPin, disabled: !onPin,
icon: 'pin', icon: 'pin',
label: pinned ? 'Unpin' : 'Pin', label: pinned ? r.unpin : r.pin,
onSelect: () => { onSelect: () => {
triggerHaptic('selection') triggerHaptic('selection')
onPin?.() onPin?.()
@ -58,17 +61,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{ {
disabled: !sessionId, disabled: !sessionId,
icon: 'copy', icon: 'copy',
label: 'Copy ID', label: r.copyId,
onSelect: event => { onSelect: event => {
event.preventDefault() event.preventDefault()
triggerHaptic('selection') triggerHaptic('selection')
void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID')) void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
} }
}, },
{ {
disabled: !sessionId, disabled: !sessionId,
icon: 'cloud-download', icon: 'cloud-download',
label: 'Export', label: r.export,
onSelect: () => { onSelect: () => {
triggerHaptic('selection') triggerHaptic('selection')
void exportSession(sessionId, { title }) void exportSession(sessionId, { title })
@ -77,7 +80,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{ {
disabled: !sessionId, disabled: !sessionId,
icon: 'edit', icon: 'edit',
label: 'Rename', label: r.rename,
onSelect: () => { onSelect: () => {
triggerHaptic('selection') triggerHaptic('selection')
setRenameOpen(true) setRenameOpen(true)
@ -86,7 +89,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
{ {
disabled: !onArchive, disabled: !onArchive,
icon: 'archive', icon: 'archive',
label: 'Archive', label: r.archive,
onSelect: () => { onSelect: () => {
triggerHaptic('selection') triggerHaptic('selection')
onArchive?.() onArchive?.()
@ -96,7 +99,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
className: 'text-destructive focus:text-destructive', className: 'text-destructive focus:text-destructive',
disabled: !onDelete, disabled: !onDelete,
icon: 'trash', icon: 'trash',
label: 'Delete', label: t.common.delete,
onSelect: () => { onSelect: () => {
triggerHaptic('warning') triggerHaptic('warning')
onDelete?.() onDelete?.()
@ -132,6 +135,7 @@ interface SessionActionsMenuProps
} }
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) { export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions) const { renameDialog, renderItems } = useSessionActions(actions)
return ( return (
@ -140,7 +144,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align={align} align={align}
aria-label={`Actions for ${actions.title}`} aria-label={t.sidebar.row.actionsFor(actions.title)}
className="w-40" className="w-40"
sideOffset={sideOffset} sideOffset={sideOffset}
> >
@ -157,13 +161,14 @@ interface SessionContextMenuProps extends SessionActions {
} }
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) { export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions) const { renameDialog, renderItems } = useSessionActions(actions)
return ( return (
<> <>
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40"> <ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
{renderItems(ContextMenuItem)} {renderItems(ContextMenuItem)}
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
@ -181,6 +186,8 @@ interface RenameSessionDialogProps {
} }
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) { function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
const { t } = useI18n()
const r = t.sidebar.row
const [value, setValue] = useState(currentTitle) const [value, setValue] = useState(currentTitle)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@ -211,10 +218,10 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
const result = await renameSession(sessionId, next, profile) const result = await renameSession(sessionId, next, profile)
const finalTitle = result.title || next || '' const finalTitle = result.title || next || ''
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' }) notify({ durationMs: 2_000, kind: 'success', message: r.renamed })
onOpenChange(false) onOpenChange(false)
} catch (err) { } catch (err) {
notifyError(err, 'Rename failed') notifyError(err, r.renameFailed)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@ -224,8 +231,8 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
<Dialog onOpenChange={onOpenChange} open={open}> <Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Rename session</DialogTitle> <DialogTitle>{r.renameTitle}</DialogTitle>
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription> <DialogDescription>{r.renameDesc}</DialogDescription>
</DialogHeader> </DialogHeader>
<Input <Input
autoFocus autoFocus
@ -239,16 +246,16 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
onOpenChange(false) onOpenChange(false)
} }
}} }}
placeholder="Untitled session" placeholder={r.untitledPlaceholder}
ref={inputRef} ref={inputRef}
value={value} value={value}
/> />
<DialogFooter> <DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost"> <Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel {t.common.cancel}
</Button> </Button>
<Button disabled={submitting} onClick={() => void submit()} type="button"> <Button disabled={submitting} onClick={() => void submit()} type="button">
Save {t.common.save}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View file

@ -5,6 +5,7 @@ import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes' import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime' import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -26,22 +27,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
dragHandleProps?: React.HTMLAttributes<HTMLElement> dragHandleProps?: React.HTMLAttributes<HTMLElement>
} }
const AGE_TICKS: ReadonlyArray<[number, string]> = [ const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
[86_400_000, 'd'], [86_400_000, 'ageDay'],
[3_600_000, 'h'], [3_600_000, 'ageHour'],
[60_000, 'm'] [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) 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) { if (delta >= ms) {
return `${Math.floor(delta / ms)}${suffix}` return `${Math.floor(delta / ms)}${r[key]}`
} }
} }
return 'now' return r.ageNow
} }
export function SidebarSessionRow({ export function SidebarSessionRow({
@ -61,8 +62,10 @@ export function SidebarSessionRow({
ref, ref,
...rest ...rest
}: SidebarSessionRowProps) { }: SidebarSessionRowProps) {
const { t } = useI18n()
const r = t.sidebar.row
const title = sessionTitle(session) 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}` const handleLabel = `Reorder ${title}`
// Subscribe per-row (the leaf) instead of drilling a set through the list — // 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 // the atom is tiny and rarely non-empty. True when a clarify prompt in this
@ -196,10 +199,10 @@ export function SidebarSessionRow({
title={title} title={title}
> >
<Button <Button
aria-label={`Actions for ${title}`} aria-label={r.actionsFor(title)}
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!" className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
size="icon" size="icon"
title="Session actions" title={r.sessionActions}
variant="ghost" variant="ghost"
> >
<Codicon name="ellipsis" size="0.875rem" /> <Codicon name="ellipsis" size="0.875rem" />
@ -220,6 +223,9 @@ function SidebarRowDot({
needsInput?: boolean needsInput?: boolean
className?: string className?: string
}) { }) {
const { t } = useI18n()
const r = t.sidebar.row
// "Needs input" wins over "working": a clarify-blocked session is technically // "Needs input" wins over "working": a clarify-blocked session is technically
// still running, but the actionable state is that it's waiting on the user. // still running, but the actionable state is that it's waiting on the user.
// Amber + steady (no ping) reads as "your turn", distinct from the accent // Amber + steady (no ping) reads as "your turn", distinct from the accent
@ -227,17 +233,17 @@ function SidebarRowDot({
if (needsInput) { if (needsInput) {
return ( return (
<span <span
aria-label="Needs your input" aria-label={r.needsInput}
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)} className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
role="status" role="status"
title="Waiting for your answer" title={r.waitingForAnswer}
/> />
) )
} }
return ( return (
<span <span
aria-label={isWorking ? 'Session running' : undefined} aria-label={isWorking ? r.sessionRunning : undefined}
className={cn( className={cn(
'rounded-full', 'rounded-full',
isWorking isWorking

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ import type * as React from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
interface CronJobActions { interface CronJobActions {
@ -32,12 +33,15 @@ export function CronJobActionsMenu({
sideOffset = 6, sideOffset = 6,
title title
}: CronJobActionsMenuProps) { }: CronJobActionsMenuProps) {
const { t } = useI18n()
const c = t.cron
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align={align} align={align}
aria-label={`Actions for ${title}`} aria-label={c.actionsFor(title)}
className="w-44" className="w-44"
sideOffset={sideOffset} sideOffset={sideOffset}
> >
@ -49,7 +53,7 @@ export function CronJobActionsMenu({
}} }}
> >
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" /> <Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? 'Resume' : 'Pause'}</span> <span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
@ -60,7 +64,7 @@ export function CronJobActionsMenu({
}} }}
> >
<Codicon name="zap" size="0.875rem" /> <Codicon name="zap" size="0.875rem" />
<span>Trigger now</span> <span>{c.triggerNow}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
@ -70,7 +74,7 @@ export function CronJobActionsMenu({
}} }}
> >
<Codicon name="edit" size="0.875rem" /> <Codicon name="edit" size="0.875rem" />
<span>Edit</span> <span>{c.edit}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
@ -81,7 +85,7 @@ export function CronJobActionsMenu({
variant="destructive" variant="destructive"
> >
<Codicon name="trash" size="0.875rem" /> <Codicon name="trash" size="0.875rem" />
<span>Delete</span> <span>{t.common.delete}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -93,12 +97,14 @@ interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Bu
} }
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) { export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
const { t } = useI18n()
return ( return (
<Button <Button
aria-label={`Actions for ${title}`} aria-label={t.cron.actionsFor(title)}
className={className} className={className}
size="icon-sm" size="icon-sm"
title="Cron job actions" title={t.cron.actionsTitle}
variant="ghost" variant="ghost"
{...props} {...props}
> >

View file

@ -1,10 +1,9 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader' import { PageLoader } from '@/components/page-loader'
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -14,7 +13,6 @@ import {
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { SearchField } from '@/components/ui/search-field'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
@ -27,78 +25,49 @@ import {
triggerCronJob, triggerCronJob,
updateCronJob updateCronJob
} from '@/hermes' } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, Clock } from '@/lib/icons' import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayView } from '../overlays/overlay-view' 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' import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
const DEFAULT_DELIVER = 'local' const DEFAULT_DELIVER = 'local'
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
{ label: 'This desktop', value: 'local' },
{ label: 'Telegram', value: 'telegram' },
{ label: 'Discord', value: 'discord' },
{ label: 'Slack', value: 'slack' },
{ label: 'Email', value: 'email' }
]
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [ const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{ { expr: '0 9 * * *', value: 'daily' },
expr: '0 9 * * *', { expr: '0 9 * * 1-5', value: 'weekdays' },
hint: 'Every day at 9:00 AM', { expr: '0 9 * * 1', value: 'weekly' },
label: 'Daily', { expr: '0 9 1 * *', value: 'monthly' },
value: 'daily' { expr: '0 * * * *', value: 'hourly' },
}, { expr: '*/15 * * * *', value: 'every-15-minutes' },
{ { value: 'custom' }
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'
}
] ]
const STATE_VARIANT: Record<string, BadgeProps['variant']> = { const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
enabled: 'default', enabled: 'good',
scheduled: 'default', scheduled: 'good',
running: 'default', running: 'good',
paused: 'warn', paused: 'warn',
disabled: 'muted', disabled: 'muted',
error: 'destructive', error: 'bad',
completed: 'muted' 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 asText = (value: unknown): string => (typeof value === 'string' ? value : '')
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}` : 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 return parts.length === 5 ? parts : null
} }
function dayName(value: string): string { function dayName(value: string, c: Translations['cron']): string {
const names: Record<string, string> = { return c.days[value] ?? c.dayFallback(value)
'0': 'Sunday',
'1': 'Monday',
'2': 'Tuesday',
'3': 'Wednesday',
'4': 'Thursday',
'5': 'Friday',
'6': 'Saturday',
'7': 'Sunday'
}
return names[value] ?? `day ${value}`
} }
function formatCronTime(minute: string, hour: string): string { function formatCronTime(minute: string, hour: string): string {
@ -243,36 +201,36 @@ function scheduleOptionForExpr(expr: string): ScheduleOption {
return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1] 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) const parts = cronParts(expr)
if (!parts) { if (!parts) {
return option.hint return c.scheduleHints[option.value] ?? ''
} }
const [minute, hour, dayOfMonth, , dayOfWeek] = parts const [minute, hour, dayOfMonth, , dayOfWeek] = parts
if (option.value === 'daily') { if (option.value === 'daily') {
return `Every day at ${formatCronTime(minute, hour)}` return c.everyDayAt(formatCronTime(minute, hour))
} }
if (option.value === 'weekdays') { if (option.value === 'weekdays') {
return `Weekdays at ${formatCronTime(minute, hour)}` return c.weekdaysAt(formatCronTime(minute, hour))
} }
if (option.value === 'weekly') { 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') { if (option.value === 'monthly') {
return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}` return c.monthlyOnDayAt(dayOfMonth, formatCronTime(minute, hour))
} }
if (option.value === 'hourly') { 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 { 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 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<CronJob[] | null>(null) const [jobs, setJobs] = useState<CronJob[] | null>(null)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [busyJobId, setBusyJobId] = useState<null | string>(null) const [busyJobId, setBusyJobId] = useState<null | string>(null)
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' }) const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null) const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setRefreshing(true)
try { try {
const result = await getCronJobs() const result = await getCronJobs()
setJobs(result) setJobs(result)
} catch (err) { } catch (err) {
notifyError(err, 'Failed to load cron jobs') notifyError(err, c.failedLoad)
} finally {
setRefreshing(false)
} }
}, []) }, [c])
useRefreshHotkey(refresh) useRefreshHotkey(refresh)
@ -348,11 +315,11 @@ export function CronView({ onClose }: CronViewProps) {
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({ notify({
kind: 'success', kind: 'success',
title: isPaused ? 'Cron resumed' : 'Cron paused', title: isPaused ? c.resumed : c.paused,
message: truncate(jobTitle(job), 60) message: truncate(jobTitle(job), 60)
}) })
} catch (err) { } catch (err) {
notifyError(err, 'Failed to update cron job') notifyError(err, c.failedUpdate)
} finally { } finally {
setBusyJobId(null) setBusyJobId(null)
} }
@ -364,14 +331,33 @@ export function CronView({ onClose }: CronViewProps) {
try { try {
const updated = await triggerCronJob(job.id) const updated = await triggerCronJob(job.id)
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current)) 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) { } catch (err) {
notifyError(err, 'Failed to trigger cron job') notifyError(err, c.failedTrigger)
} finally { } finally {
setBusyJobId(null) 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) { async function handleEditorSave(values: EditorValues) {
if (editor.mode === 'create') { if (editor.mode === 'create') {
const created = await createCronJob({ const created = await createCronJob({
@ -382,7 +368,7 @@ export function CronView({ onClose }: CronViewProps) {
}) })
setJobs(current => (current ? [...current, created] : [created])) 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') { } else if (editor.mode === 'edit') {
const updated = await updateCronJob(editor.job.id, { const updated = await updateCronJob(editor.job.id, {
prompt: values.prompt, 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)) 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' }) setEditor({ mode: 'closed' })
} }
return ( return (
<OverlayView closeLabel="Close cron" onClose={onClose}> <OverlayView closeLabel={c.close} onClose={onClose}>
<div className="flex min-h-0 flex-1 flex-col pt-[calc(var(--titlebar-height)+0.5rem)]"> <PageSearchShell
{totalCount > 0 && ( {...props}
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-4 pb-2"> onSearchChange={setQuery}
<SearchField searchPlaceholder={c.search}
containerClassName="max-w-[60vw]" searchTrailingAction={
onChange={setQuery} <Button
placeholder="Search cron jobs…" aria-label={refreshing ? c.refreshing : c.refresh}
value={query} className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
/> disabled={refreshing}
</div> onClick={() => void refresh()}
)} size="icon-xs"
title={refreshing ? c.refreshing : c.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!jobs ? ( {!jobs ? (
<PageLoader label="Loading cron jobs..." /> <PageLoader label={c.loading} />
) : visibleJobs.length === 0 ? ( ) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have // 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 // one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No // when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query. // matches") just asks the user to broaden their query.
<EmptyState <EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined} actionLabel={totalCount === 0 ? c.createFirst : undefined}
description={ description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined} onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'} title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
/> />
) : ( ) : (
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3"> <div className="h-full overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We {/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */} edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground"> <span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active {c.active(enabledCount, totalCount)}
</span> </span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm"> <Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" /> <Codicon name="add" />
New cron {c.newCron}
</Button> </Button>
</div> </div>
<div> <div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => ( {visibleJobs.map(job => (
<CronJobRow <CronJobRow
busy={busyJobId === job.id} busy={busyJobId === job.id}
c={c}
job={job} job={job}
key={job.id} key={job.id}
onDelete={() => setPendingDelete(job)} onDelete={() => setPendingDelete(job)}
@ -458,42 +450,40 @@ export function CronView({ onClose }: CronViewProps) {
</div> </div>
</div> </div>
)} )}
</div> <CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<ConfirmDialog <Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
busyLabel="Deleting…" <DialogContent className="max-w-md">
confirmLabel="Delete" <DialogHeader>
description={ <DialogTitle>{c.deleteTitle}</DialogTitle>
pendingDelete ? ( <DialogDescription>
<> {pendingDelete ? (
This will remove{' '} <>
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently. {c.deleteDescPrefix}
It will stop firing immediately. <span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
</> {c.deleteDescSuffix}
) : null </>
} ) : null}
destructive </DialogDescription>
doneLabel="Deleted" </DialogHeader>
onClose={() => setPendingDelete(null)} <DialogFooter>
onConfirm={async () => { <Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
if (!pendingDelete) { {t.common.cancel}
return </Button>
} <Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? c.deleting : t.common.delete}
await deleteCronJob(pendingDelete.id) </Button>
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current)) </DialogFooter>
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' }) </DialogContent>
}} </Dialog>
open={pendingDelete !== null} </PageSearchShell>
title="Delete cron job?"
/>
</OverlayView> </OverlayView>
) )
} }
function CronJobRow({ function CronJobRow({
busy, busy,
c,
job, job,
onDelete, onDelete,
onEdit, onEdit,
@ -501,6 +491,7 @@ function CronJobRow({
onTrigger onTrigger
}: { }: {
busy: boolean busy: boolean
c: Translations['cron']
job: CronJob job: CronJob
onDelete: () => void onDelete: () => void
onEdit: () => void onEdit: () => void
@ -516,19 +507,15 @@ function CronJobRow({
return ( return (
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"> <div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<button <button
className="min-w-0 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40" className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onEdit} onClick={onEdit}
type="button" type="button"
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{jobTitle(job)}</span> <span className="truncate text-sm font-medium">{jobTitle(job)}</span>
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}> <StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
{state}
</Badge>
{deliver && deliver !== DEFAULT_DELIVER && ( {deliver && deliver !== DEFAULT_DELIVER && (
<Badge className="capitalize" variant="muted"> <StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
{deliver}
</Badge>
)} )}
</div> </div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>} {hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
@ -537,8 +524,12 @@ function CronJobRow({
<Clock className="size-3" /> <Clock className="size-3" />
{jobScheduleDisplay(job)} {jobScheduleDisplay(job)}
</span> </span>
<span>Last: {formatTime(job.last_run_at)}</span> <span>
<span>Next: {formatTime(job.next_run_at)}</span> {c.last} {formatTime(job.last_run_at)}
</span>
<span>
{c.next} {formatTime(job.next_run_at)}
</span>
</div> </div>
{job.last_error && ( {job.last_error && (
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive"> <p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
@ -569,6 +560,16 @@ function CronJobRow({
) )
} }
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
return (
<span
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
>
{children}
</span>
)
}
function EmptyState({ function EmptyState({
actionLabel, actionLabel,
description, description,
@ -605,6 +606,8 @@ function CronEditorDialog({
onClose: () => void onClose: () => void
onSave: (values: EditorValues) => Promise<void> onSave: (values: EditorValues) => Promise<void>
}) { }) {
const { t } = useI18n()
const c = t.cron
const open = editor.mode !== 'closed' const open = editor.mode !== 'closed'
const isEdit = editor.mode === 'edit' const isEdit = editor.mode === 'edit'
const initial = isEdit ? editor.job : null 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) { async function handleSubmit(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
@ -655,7 +658,7 @@ function CronEditorDialog({
const trimmedSchedule = schedule.trim() const trimmedSchedule = schedule.trim()
if (!trimmedPrompt || !trimmedSchedule) { if (!trimmedPrompt || !trimmedSchedule) {
setError('Prompt and schedule are required.') setError(c.promptScheduleRequired)
return return
} }
@ -671,7 +674,7 @@ function CronEditorDialog({
schedule: trimmedSchedule schedule: trimmedSchedule
}) })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save cron job') setError(err instanceof Error ? err.message : c.failedSave)
} finally { } finally {
setSaving(false) setSaving(false)
} }
@ -681,60 +684,56 @@ function CronEditorDialog({
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}> <Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{isEdit ? 'Edit cron job' : 'New cron job'}</DialogTitle> <DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription> <DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
{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".'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}> <form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="cron-name" label="Name" optional> <Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
<Input <Input
autoFocus autoFocus
id="cron-name" id="cron-name"
onChange={event => setName(event.target.value)} onChange={event => setName(event.target.value)}
placeholder="Morning briefing" placeholder={c.namePlaceholder}
value={name} value={name}
/> />
</Field> </Field>
<Field htmlFor="cron-prompt" label="Prompt"> <Field htmlFor="cron-prompt" label={c.promptLabel}>
<Textarea <Textarea
className="min-h-24 font-mono" className="min-h-24 font-mono"
id="cron-prompt" id="cron-prompt"
onChange={event => setPrompt(event.target.value)} onChange={event => setPrompt(event.target.value)}
placeholder="Summarize my unread Slack threads and email me the top 5..." placeholder={c.promptPlaceholder}
value={prompt} value={prompt}
/> />
</Field> </Field>
<div className="grid items-start gap-4 sm:grid-cols-2"> <div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label="Frequency"> <Field htmlFor="cron-frequency" label={c.frequencyLabel}>
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}> <Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger id="cron-frequency"> <SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{SCHEDULE_OPTIONS.map(option => ( {SCHEDULE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {c.scheduleLabels[option.value]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<Field htmlFor="cron-deliver" label="Deliver to"> <Field htmlFor="cron-deliver" label={c.deliverLabel}>
<Select onValueChange={setDeliver} value={deliver}> <Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger id="cron-deliver"> <SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{DELIVERY_OPTIONS.map(option => ( {DELIVERY_VALUES.map(value => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={value} value={value}>
{option.label} {c.deliveryLabels[value]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -743,15 +742,15 @@ function CronEditorDialog({
</div> </div>
{schedulePreset === 'custom' ? ( {schedulePreset === 'custom' ? (
<Field htmlFor="cron-schedule" label="Custom schedule"> <Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
<Input <Input
className="font-mono" className="font-mono"
id="cron-schedule" id="cron-schedule"
onChange={event => setSchedule(event.target.value)} onChange={event => setSchedule(event.target.value)}
placeholder="0 9 * * * or weekdays at 9am" placeholder={c.customPlaceholder}
value={schedule} value={schedule}
/> />
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint> <FieldHint>{c.customHint}</FieldHint>
</Field> </Field>
) : ( ) : (
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2"> <div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
@ -771,10 +770,10 @@ function CronEditorDialog({
<DialogFooter> <DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline"> <Button disabled={saving} onClick={onClose} type="button" variant="outline">
Cancel {t.common.cancel}
</Button> </Button>
<Button disabled={saving} type="submit"> <Button disabled={saving} type="submit">
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Create cron'} {saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@ -787,18 +786,20 @@ function Field({
children, children,
htmlFor, htmlFor,
label, label,
optional optional,
optionalLabel
}: { }: {
children: React.ReactNode children: React.ReactNode
htmlFor: string htmlFor: string
label: string label: string
optional?: boolean optional?: boolean
optionalLabel?: string
}) { }) {
return ( return (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}> <label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label} {label}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">Optional</span>} {optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
</label> </label>
{children} {children}
</div> </div>
@ -820,7 +821,5 @@ interface EditorValues {
interface ScheduleOption { interface ScheduleOption {
expr?: string expr?: string
hint: string
label: string
value: string value: string
} }

View file

@ -700,6 +700,7 @@ export function DesktopController() {
initialSection={commandCenterInitialSection} initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute} onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession} onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))} onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/> />
</Suspense> </Suspense>

View file

@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader' import { PageLoader } from '@/components/page-loader'
import { StatusDot, type StatusTone } from '@/components/status-dot' import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@ -14,6 +13,7 @@ import {
type MessagingPlatformInfo, type MessagingPlatformInfo,
updateMessagingPlatform updateMessagingPlatform
} from '@/hermes' } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons' import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
@ -33,31 +33,15 @@ interface MessagingViewProps extends React.ComponentProps<'section'> {
type EditMap = Record<string, Record<string, string>> type EditMap = Record<string, Record<string, string>>
const STATE_LABELS: Record<string, string> = { const PILL_TONE: Record<StatusTone, string> = {
connected: 'Connected', good: 'bg-primary/10 text-primary',
connecting: 'Connecting', muted: 'bg-muted text-muted-foreground',
disabled: 'Disabled', warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
fatal: 'Error', bad: 'bg-destructive/10 text-destructive'
gateway_stopped: 'Messaging gateway stopped',
not_configured: 'Needs setup',
pending_restart: 'Restart needed',
retrying: 'Retrying',
startup_failed: 'Startup failed'
} }
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = { const stateLabel = (state: null | string | undefined, m: Translations['messaging']) =>
good: 'default', state ? m.states[state] || state.replace(/_/g, ' ') : m.unknown
muted: 'muted',
warn: 'warn',
bad: 'destructive'
}
const HINT_BY_STATE: Record<string, string> = {
pending_restart: 'Restart the gateway from the status bar to apply this change.',
gateway_stopped: 'Start the gateway from the status bar to connect.'
}
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone { function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
if (!enabled) { if (!enabled) {
@ -86,7 +70,7 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
TELEGRAM_BOT_TOKEN: { TELEGRAM_BOT_TOKEN: {
label: 'Bot token', label: 'Bot token',
help: 'Create a bot with @BotFather, then paste the token it gives you.', help: 'Create a bot with @BotFather, then paste the token it gives you.',
placeholder: '123456:ABC...' placeholder: 'Paste Telegram bot token'
}, },
TELEGRAM_ALLOWED_USERS: { TELEGRAM_ALLOWED_USERS: {
label: 'Allowed Telegram user IDs', label: 'Allowed Telegram user IDs',
@ -153,13 +137,13 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
}, },
SLACK_BOT_TOKEN: { SLACK_BOT_TOKEN: {
label: 'Slack bot token', label: 'Slack bot token',
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.', help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
placeholder: 'xoxb-...' placeholder: 'Paste Slack bot token'
}, },
SLACK_APP_TOKEN: { SLACK_APP_TOKEN: {
label: 'Slack app token', label: 'Slack app token',
help: 'Starts with xapp-. Required for Socket Mode.', help: 'Use the app-level token required for Socket Mode.',
placeholder: 'xapp-...' placeholder: 'Paste Slack app token'
}, },
SLACK_ALLOWED_USERS: { SLACK_ALLOWED_USERS: {
label: 'Allowed Slack user IDs', label: 'Allowed Slack user IDs',
@ -219,18 +203,21 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
} }
} }
function fieldCopy(field: MessagingEnvVarInfo) { function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
const copy = FIELD_COPY[field.key] || {} const copy = FIELD_COPY[field.key] || {}
const localized = m.fieldCopy[field.key] || {}
return { return {
label: copy.label || field.prompt || field.key, label: localized.label || copy.label || field.prompt || field.key,
help: copy.help || field.description, help: localized.help || copy.help || field.description,
placeholder: copy.placeholder || field.prompt, placeholder: localized.placeholder || copy.placeholder || field.prompt,
advanced: Boolean(copy.advanced || field.advanced) advanced: Boolean(copy.advanced || field.advanced)
} }
} }
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) { export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
const { t } = useI18n()
const m = t.messaging
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null) const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [edits, setEdits] = useState<EditMap>({}) const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
@ -249,14 +236,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setPlatforms(result.platforms) setPlatforms(result.platforms)
} catch (err) { } catch (err) {
if (!silent) { if (!silent) {
notifyError(err, 'Messaging platforms failed to load') notifyError(err, m.loadFailed)
} }
} finally { } finally {
if (!silent) { if (!silent) {
setRefreshing(false) setRefreshing(false)
} }
} }
}, []) }, [m])
useRefreshHotkey(() => void refreshPlatforms()) useRefreshHotkey(() => void refreshPlatforms())
@ -330,11 +317,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
) )
notify({ notify({
kind: 'success', kind: 'success',
title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`, title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
message: 'Restart the gateway for this change to take effect.' message: m.restartToApply
}) })
} catch (err) { } catch (err) {
notifyError(err, `Failed to update ${platform.name}`) notifyError(err, m.failedUpdate(platform.name))
} finally { } finally {
setSaving(null) setSaving(null)
} }
@ -355,11 +342,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
await refreshPlatforms() await refreshPlatforms()
notify({ notify({
kind: 'success', kind: 'success',
title: `${platform.name} setup saved`, title: m.setupSaved(platform.name),
message: 'Restart the gateway to reconnect with the new credentials.' message: m.restartToReconnect
}) })
} catch (err) { } catch (err) {
notifyError(err, `Failed to save ${platform.name}`) notifyError(err, m.failedSave(platform.name))
} finally { } finally {
setSaving(null) setSaving(null)
} }
@ -378,9 +365,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
} }
})) }))
await refreshPlatforms() await refreshPlatforms()
notify({ kind: 'success', title: `${key} cleared`, message: `${platform.name} setup was updated.` }) notify({ kind: 'success', title: m.keyCleared(key), message: m.setupUpdated(platform.name) })
} catch (err) { } catch (err) {
notifyError(err, `Failed to clear ${key}`) notifyError(err, m.failedClear(key))
} finally { } finally {
setSaving(null) setSaving(null)
} }
@ -391,11 +378,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{...props} {...props}
onSearchChange={setQuery} onSearchChange={setQuery}
searchHidden={(platforms?.length ?? 0) === 0} searchHidden={(platforms?.length ?? 0) === 0}
searchPlaceholder="Search messaging..." searchPlaceholder={m.search}
searchValue={query} searchValue={query}
> >
{!platforms ? ( {!platforms ? (
<PageLoader label="Loading messaging platforms..." /> <PageLoader label={m.loading} />
) : ( ) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]"> <div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto p-2"> <aside className="min-h-0 overflow-y-auto p-2">
@ -485,12 +472,14 @@ function PlatformDetail({
platform: MessagingPlatformInfo platform: MessagingPlatformInfo
saving: string | null saving: string | null
}) { }) {
const { t } = useI18n()
const m = t.messaging
const [showAdvanced, setShowAdvanced] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false)
const hasEdits = Object.keys(trimEdits(edits)).length > 0 const hasEdits = Object.keys(trimEdits(edits)).length > 0
const requiredFields = platform.env_vars.filter(field => field.required) const requiredFields = platform.env_vars.filter(field => field.required)
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field).advanced) const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field).advanced) const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
const hiddenCount = advancedFields.length const hiddenCount = advancedFields.length
const isSavingEnv = saving === `env:${platform.id}` const isSavingEnv = saving === `env:${platform.id}`
@ -506,11 +495,11 @@ function PlatformDetail({
{platform.description} {platform.description}
</p> </p>
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill> <StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
<SetupPill active={platform.configured}> <SetupPill active={platform.configured}>
{platform.configured ? 'Credentials set' : 'Needs setup'} {platform.configured ? m.credentialsSet : m.needsSetup}
</SetupPill> </SetupPill>
{!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>} {!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
</div> </div>
<PlatformHint platform={platform} /> <PlatformHint platform={platform} />
</div> </div>
@ -524,14 +513,14 @@ function PlatformDetail({
)} )}
<section> <section>
<SectionTitle>Get your credentials</SectionTitle> <SectionTitle>{m.getCredentials}</SectionTitle>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> <p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{introCopy(platform)} {introCopy(platform, m)}
</p> </p>
<div className="mt-3"> <div className="mt-3">
<Button asChild size="sm" variant="textStrong"> <Button asChild size="sm" variant="textStrong">
<a href={platform.docs_url} rel="noreferrer" target="_blank"> <a href={platform.docs_url} rel="noreferrer" target="_blank">
Open setup guide {m.openSetupGuide}
<ExternalLink className="size-3.5" /> <ExternalLink className="size-3.5" />
</a> </a>
</Button> </Button>
@ -539,7 +528,7 @@ function PlatformDetail({
</section> </section>
<section> <section>
<SectionTitle>Required</SectionTitle> <SectionTitle>{m.required}</SectionTitle>
<div className="mt-3 grid gap-1"> <div className="mt-3 grid gap-1">
{requiredFields.length > 0 ? ( {requiredFields.length > 0 ? (
requiredFields.map(field => ( requiredFields.map(field => (
@ -554,7 +543,7 @@ function PlatformDetail({
)) ))
) : ( ) : (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> <p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
This platform does not need a token here. Use the setup guide above, then enable it below. {m.noTokenNeeded}
</p> </p>
)} )}
</div> </div>
@ -562,7 +551,7 @@ function PlatformDetail({
{optionalFields.length > 0 && ( {optionalFields.length > 0 && (
<section> <section>
<SectionTitle>Recommended</SectionTitle> <SectionTitle>{m.recommended}</SectionTitle>
<div className="mt-3 grid gap-1"> <div className="mt-3 grid gap-1">
{optionalFields.map(field => ( {optionalFields.map(field => (
<MessagingField <MessagingField
@ -585,7 +574,7 @@ function PlatformDetail({
onClick={() => setShowAdvanced(value => !value)} onClick={() => setShowAdvanced(value => !value)}
type="button" type="button"
> >
<span>Advanced ({hiddenCount})</span> <span>{m.advanced(hiddenCount)}</span>
<DisclosureCaret open={showAdvanced} size="0.875rem" /> <DisclosureCaret open={showAdvanced} size="0.875rem" />
</button> </button>
{showAdvanced && ( {showAdvanced && (
@ -609,19 +598,23 @@ function PlatformDetail({
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5"> <footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2"> <div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<Switch <label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`} <Switch
checked={platform.enabled} aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
disabled={saving === `enabled:${platform.id}`} checked={platform.enabled}
onCheckedChange={onToggle} disabled={saving === `enabled:${platform.id}`}
size="xs" onCheckedChange={onToggle}
/> />
<span className="text-xs font-medium text-muted-foreground">
{platform.enabled ? m.enabled : m.disabled}
</span>
</label>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>} {hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm"> <Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
<Save /> <Save />
{isSavingEnv ? 'Saving...' : 'Save changes'} {isSavingEnv ? m.saving : m.saveChanges}
</Button> </Button>
</div> </div>
</div> </div>
@ -636,7 +629,7 @@ const PLATFORM_INTRO: Record<string, string> = {
discord: discord:
'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.', 'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.',
slack: slack:
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the Bot token (xoxb-) and App-level token (xapp-).', 'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the bot token and app-level token.',
mattermost: mattermost:
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.', 'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.', matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
@ -667,7 +660,8 @@ const PLATFORM_INTRO: Record<string, string> = {
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.' 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
} }
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description const introCopy = (platform: MessagingPlatformInfo, m: Translations['messaging']) =>
m.platformIntro[platform.id] || PLATFORM_INTRO[platform.id] || platform.description
function MessagingField({ function MessagingField({
edits, edits,
@ -682,7 +676,9 @@ function MessagingField({
onEdit: (key: string, value: string) => void onEdit: (key: string, value: string) => void
saving: string | null saving: string | null
}) { }) {
const copy = fieldCopy(field) const { t } = useI18n()
const m = t.messaging
const copy = fieldCopy(field, m)
const fieldId = `messaging-field-${field.key}` const fieldId = `messaging-field-${field.key}`
return ( return (
@ -693,12 +689,12 @@ function MessagingField({
className={CREDENTIAL_CONTROL_CLASS} className={CREDENTIAL_CONTROL_CLASS}
id={fieldId} id={fieldId}
onChange={event => onEdit(field.key, event.target.value)} onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder} placeholder={field.is_set ? field.redacted_value || m.replaceValue : copy.placeholder}
type={field.is_password ? 'password' : 'text'} type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''} value={edits[field.key] || ''}
/> />
{field.url && ( {field.url && (
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost"> <Button asChild className="size-8 shrink-0" title={m.openDocs} variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank"> <a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" /> <ExternalLink className="size-3.5" />
</a> </a>
@ -709,7 +705,7 @@ function MessagingField({
className="size-8 shrink-0" className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`} disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)} onClick={() => onClear(field.key)}
title={`Clear ${field.key}`} title={m.clearField(field.key)}
variant="ghost" variant="ghost"
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
@ -721,7 +717,7 @@ function MessagingField({
title={ title={
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
<label htmlFor={fieldId}>{copy.label}</label> <label htmlFor={fieldId}>{copy.label}</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>} {field.is_set && <span className="text-[0.66rem] font-medium text-primary">{m.saved}</span>}
</span> </span>
} }
/> />
@ -733,24 +729,45 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
} }
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) { function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
const { t } = useI18n()
if (!platform.enabled || platform.state === 'connected') { if (!platform.enabled || platform.state === 'connected') {
return null return null
} }
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped) const hint =
platform.state === 'pending_restart'
? t.messaging.hintPendingRestart
: platform.gateway_running
? null
: t.messaging.hintGatewayStopped
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
} }
function StatePill({ children, tone }: { children: string; tone: StatusTone }) { function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
return ( return (
<Badge variant={TONE_VARIANT[tone]}> <span
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
PILL_TONE[tone]
)}
>
<StatusDot tone={tone} /> <StatusDot tone={tone} />
{children} {children}
</Badge> </span>
) )
} }
function SetupPill({ active, children }: { active: boolean; children: string }) { function SetupPill({ active, children }: { active: boolean; children: string }) {
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge> return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
PILL_TONE[active ? 'good' : 'muted']
)}
>
{children}
</span>
)
} }

View file

@ -0,0 +1,37 @@
import type { RefObject } from 'react'
import { SearchField } from '@/components/ui/search-field'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
containerClassName?: string
inputRef?: RefObject<HTMLInputElement | null>
loading?: boolean
onChange: (value: string) => void
placeholder: string
value: string
}
export function OverlaySearchInput({
containerClassName,
inputRef,
loading = false,
onChange,
placeholder,
value
}: OverlaySearchInputProps) {
return (
<SearchField
containerClassName={cn(
'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 shadow-sm focus-within:border-(--ui-stroke-secondary)',
containerClassName
)}
inputClassName="h-8 text-[0.8125rem]"
inputRef={inputRef}
loading={loading}
onChange={onChange}
placeholder={placeholder}
value={value}
/>
)
}

View file

@ -11,6 +11,7 @@ interface PageSearchShellProps extends React.ComponentProps<'section'> {
filters?: ReactNode filters?: ReactNode
onSearchChange: (value: string) => void onSearchChange: (value: string) => void
searchPlaceholder: string searchPlaceholder: string
searchTrailingAction?: ReactNode
searchValue: string searchValue: string
/** Hide the search field when there's nothing to search (empty dataset). */ /** Hide the search field when there's nothing to search (empty dataset). */
searchHidden?: boolean searchHidden?: boolean
@ -23,6 +24,7 @@ export function PageSearchShell({
filters, filters,
onSearchChange, onSearchChange,
searchPlaceholder, searchPlaceholder,
searchTrailingAction,
searchValue, searchValue,
searchHidden = false, searchHidden = false,
...props ...props
@ -58,6 +60,7 @@ export function PageSearchShell({
containerClassName="max-w-[45vw]" containerClassName="max-w-[45vw]"
onChange={onSearchChange} onChange={onSearchChange}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
trailingAction={searchTrailingAction}
value={searchValue} value={searchValue}
/> />
</div> </div>

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,8 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons' import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
$desktopVersion, $desktopVersion,
@ -18,29 +19,31 @@ import { ListRow, SectionHeading, SettingsContent } from './primitives'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases' const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
function relativeTime(ms: number | undefined) { function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
if (!ms) { if (!ms) {
return 'never' return a.never
} }
const diff = Date.now() - ms const diff = Date.now() - ms
if (diff < 60_000) { if (diff < 60_000) {
return 'just now' return a.justNow
} }
if (diff < 3_600_000) { if (diff < 3_600_000) {
return `${Math.round(diff / 60_000)} min ago` return a.minAgo(Math.round(diff / 60_000))
} }
if (diff < 86_400_000) { if (diff < 86_400_000) {
return `${Math.round(diff / 3_600_000)} hours ago` return a.hoursAgo(Math.round(diff / 3_600_000))
} }
return `${Math.round(diff / 86_400_000)} days ago` return a.daysAgo(Math.round(diff / 86_400_000))
} }
export function AboutSettings() { export function AboutSettings() {
const { t } = useI18n()
const a = t.settings.about
const version = useStore($desktopVersion) const version = useStore($desktopVersion)
const status = useStore($updateStatus) const status = useStore($updateStatus)
const apply = useStore($updateApply) const apply = useStore($updateApply)
@ -69,21 +72,21 @@ export function AboutSettings() {
let statusTone: 'idle' | 'available' | 'error' = 'idle' let statusTone: 'idle' | 'available' | 'error' = 'idle'
if (!supported) { if (!supported) {
statusLine = status?.message ?? "This build can't update itself from inside the app." statusLine = status?.message ?? a.cantUpdate
statusTone = 'error' statusTone = 'error'
} else if (status?.error) { } else if (status?.error) {
statusLine = "We couldn't reach the update server." statusLine = a.cantReach
statusTone = 'error' statusTone = 'error'
} else if (applying) { } else if (applying) {
statusLine = 'An update is currently installing.' statusLine = a.installing
statusTone = 'available' statusTone = 'available'
} else if (behind > 0) { } else if (behind > 0) {
statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).` statusLine = a.updateReady(behind)
statusTone = 'available' statusTone = 'available'
} else if (status) { } else if (status) {
statusLine = "You're on the latest version." statusLine = a.onLatest
} else { } else {
statusLine = 'Tap "Check now" to look for updates.' statusLine = a.tapCheck
} }
return ( return (
@ -93,15 +96,15 @@ export function AboutSettings() {
<Sparkles className="size-8" /> <Sparkles className="size-8" />
</span> </span>
<div> <div>
<h2 className="text-lg font-semibold tracking-tight">Hermes Desktop</h2> <h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'} {version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
</p> </p>
</div> </div>
</div> </div>
<div className="mx-auto mt-4 w-full max-w-2xl"> <div className="mx-auto mt-4 w-full max-w-2xl">
<SectionHeading icon={RefreshCw} title="Updates" /> <SectionHeading icon={RefreshCw} title={a.updates} />
<div <div
className={cn( className={cn(
@ -111,12 +114,19 @@ export function AboutSettings() {
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground' statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
)} )}
> >
<div className="min-w-0"> <div className="flex items-start gap-2">
<p className="font-medium">{statusLine}</p> {statusTone === 'available' ? (
<p className="mt-1 text-xs text-muted-foreground"> <Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
Last checked {relativeTime(status?.fetchedAt)} ) : statusTone === 'error' ? null : (
{justChecked && !checking ? ' · just now' : ''} <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
</p> )}
<div className="min-w-0">
<p className="font-medium">{statusLine}</p>
<p className="mt-1 text-xs text-muted-foreground">
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
{justChecked && !checking ? a.justNowSuffix : ''}
</p>
</div>
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-4"> <div className="mt-3 flex flex-wrap items-center gap-4">
@ -126,13 +136,13 @@ export function AboutSettings() {
size="sm" size="sm"
variant="textStrong" variant="textStrong"
> >
{checking && <Loader2 className="size-3 animate-spin" />} {checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? 'Checking…' : 'Check now'} {checking ? a.checking : a.checkNow}
</Button> </Button>
{behind > 0 && supported && !applying && ( {behind > 0 && supported && !applying && (
<Button onClick={() => openUpdatesWindow()} size="sm"> <Button onClick={() => openUpdatesWindow()} size="sm">
See what&apos;s new {a.seeWhatsNew}
</Button> </Button>
)} )}
@ -146,16 +156,17 @@ export function AboutSettings() {
rel="noreferrer" rel="noreferrer"
target="_blank" target="_blank"
> >
Release notes <ExternalLink className="size-3" />
{a.releaseNotes}
</a> </a>
</Button> </Button>
</div> </div>
</div> </div>
<ListRow <ListRow
description="Hermes checks for updates automatically in the background and lets you know when one is ready." description={a.automaticUpdatesDesc}
hint={`Branch ${status?.branch ?? 'unknown'} · Commit ${status?.currentSha?.slice(0, 7) ?? 'unknown'}`} hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title="Automatic updates" title={a.automaticUpdates}
/> />
</div> </div>
</SettingsContent> </SettingsContent>

View file

@ -1,16 +1,15 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { SegmentedControl } from '@/components/ui/segmented-control' import { type Locale, LOCALE_META, useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { Check } from '@/lib/icons' import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context' import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets' import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants' import { MODE_OPTIONS } from './constants'
import { SettingsContent } from './primitives' import { Pill, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) { function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name] const t = BUILTIN_THEMES[name]
@ -52,80 +51,179 @@ function ThemePreview({ name }: { name: string }) {
) )
} }
function SectionHead({ title, description, control }: { title: string; description: string; control?: ReactNode }) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{title}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</div>
</div>
{control && <div className="shrink-0">{control}</div>}
</div>
)
}
export function AppearanceSettings() { export function AppearanceSettings() {
const { t, locale, setLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode) const toolViewMode = useStore($toolViewMode)
const activeTheme = availableThemes.find(theme => theme.name === themeName)
const a = t.settings.appearance
const locales = Object.keys(LOCALE_META) as Locale[]
return ( return (
<SettingsContent> <SettingsContent>
<div className="grid gap-8"> <div className="space-y-5">
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)"> <div>
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and <SectionHeading icon={Palette} title={a.title} />
chat surface styling. <p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
</p> {a.intro}
</p>
</div>
<section> <section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<SectionHead <div className="mb-3 flex items-center justify-between gap-3">
control={ <div>
<SegmentedControl <div className="text-sm font-medium">{t.language.label}</div>
onChange={id => { <div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
triggerHaptic('crisp') </div>
setMode(id) <Pill>{LOCALE_META[locale].name}</Pill>
}} </div>
options={MODE_OPTIONS} <div className="grid gap-2 sm:grid-cols-3">
value={mode} {locales.map(code => {
/> const active = locale === code
}
description="Pick a fixed mode or let Hermes follow your system setting." return (
title="Color Mode" <button
/> className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={code}
onClick={() => {
triggerHaptic('crisp')
setLocale(code)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">
{LOCALE_META[code].name}
</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] uppercase tracking-wide text-(--ui-text-tertiary)">
{code}
</div>
</button>
)
})}
</div>
</section> </section>
<section> <section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<SectionHead <div className="mb-3 flex items-center justify-between gap-3">
control={ <div>
<SegmentedControl <div className="text-sm font-medium">{a.colorMode}</div>
onChange={id => { <div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
triggerHaptic('selection') </div>
setToolViewMode(id) <Pill>{t.settings.modeOptions[mode].label}</Pill>
}} </div>
options={ <div className="grid gap-2 sm:grid-cols-3">
[ {MODE_OPTIONS.map(({ id, icon: Icon }) => {
{ id: 'product', label: 'Product' }, const active = mode === id
{ id: 'technical', label: 'Technical' } const copy = t.settings.modeOptions[id]
] as const
} return (
value={toolViewMode} <button
/> className={cn(
} 'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
description="Product hides raw tool payloads; Technical shows full input/output." active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
title="Tool Call Display" )}
/> key={id}
onClick={() => {
triggerHaptic('crisp')
setMode(id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
<Icon className="size-4" />
</span>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{copy.label}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.description}
</div>
</button>
)
})}
</div>
</section> </section>
<section className="grid gap-3"> <section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<SectionHead description="Desktop palettes only. The selected mode is applied on top." title="Theme" /> <div className="mb-3 flex items-center justify-between gap-3">
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2 xl:grid-cols-3"> <div>
<div className="text-sm font-medium">{a.toolViewTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
</div>
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{ id: 'product', label: a.product, description: a.productDesc },
{ id: 'technical', label: a.technical, description: a.technicalDesc }
] as const
).map(option => {
const active = toolViewMode === option.id
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={option.id}
onClick={() => {
triggerHaptic('selection')
setToolViewMode(option.id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{option.description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.themeTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
</div>
{activeTheme && <Pill>{activeTheme.label}</Pill>}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => { {availableThemes.map(theme => {
const active = themeName === theme.name const active = themeName === theme.name
return ( return (
<button <button
className="group text-left" className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name} key={theme.name}
onClick={() => { onClick={() => {
triggerHaptic('crisp') triggerHaptic('crisp')
@ -133,17 +231,8 @@ export function AppearanceSettings() {
}} }}
type="button" type="button"
> >
<div <ThemePreview name={theme.name} />
className={cn( <div className="mt-3 flex items-start justify-between gap-3 px-1">
'rounded-xl transition',
active
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'opacity-90 group-hover:opacity-100'
)}
>
<ThemePreview name={theme.name} />
</div>
<div className="mt-2.5 flex items-start justify-between gap-2 px-0.5">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium"> <div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label} {theme.label}
@ -152,7 +241,11 @@ export function AppearanceSettings() {
{theme.description} {theme.description}
</div> </div>
</div> </div>
{active && <Check className="mt-0.5 size-4 shrink-0 text-primary" />} {active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div> </div>
</button> </button>
) )

View file

@ -13,6 +13,7 @@ import {
getHermesConfigSchema, getHermesConfigSchema,
saveHermesConfig saveHermesConfig
} from '@/hermes' } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes' import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
@ -37,9 +38,20 @@ function ConfigField({
optionLabels?: Record<string, string> optionLabels?: Record<string, string>
onChange: (value: unknown) => void onChange: (value: unknown) => void
}) { }) {
const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey) const { t } = useI18n()
const label =
t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '') const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim()
const rawDescription = (
t.settings.fieldDescriptions[schemaKey] ??
FIELD_DESCRIPTIONS[schemaKey] ??
schema.description ??
''
).trim()
const normalizedDesc = normalize(rawDescription) const normalizedDesc = normalize(rawDescription)
const description = const description =

View file

@ -3,6 +3,7 @@ import { useRef } from 'react'
import { Tip } from '@/components/ui/tooltip' import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons' import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications' import { notifyError } from '@/store/notifications'
@ -34,6 +35,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
] ]
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) { export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const { t } = useI18n()
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId) const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Providers subnav (Accounts vs API keys) lives in its own param so each // Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh. // sub-view is deep-linkable and survives a refresh.
@ -64,12 +66,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
triggerHaptic('success') triggerHaptic('success')
} catch (err) { } catch (err) {
notifyError(err, 'Export failed') notifyError(err, t.settings.exportFailed)
} }
} }
const resetConfig = async () => { const resetConfig = async () => {
if (!window.confirm('Reset all settings to Hermes defaults?')) { if (!window.confirm(t.settings.resetConfirm)) {
return return
} }
@ -78,12 +80,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
triggerHaptic('success') triggerHaptic('success')
onConfigSaved?.() onConfigSaved?.()
} catch (err) { } catch (err) {
notifyError(err, 'Reset failed') notifyError(err, t.settings.resetFailed)
} }
} }
return ( return (
<OverlayView closeLabel="Close settings" onClose={onClose}> <OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
<OverlaySplitLayout> <OverlaySplitLayout>
<OverlaySidebar> <OverlaySidebar>
{SECTIONS.map(s => { {SECTIONS.map(s => {
@ -94,7 +96,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
active={activeView === view} active={activeView === view}
icon={s.icon} icon={s.icon}
key={s.id} key={s.id}
label={s.label} label={t.settings.sections[s.id] ?? s.label}
onClick={() => setActiveView(view)} onClick={() => setActiveView(view)}
/> />
) )
@ -127,13 +129,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem <OverlayNavItem
active={activeView === 'gateway'} active={activeView === 'gateway'}
icon={Globe} icon={Globe}
label="Gateway" label={t.settings.nav.gateway}
onClick={() => setActiveView('gateway')} onClick={() => setActiveView('gateway')}
/> />
<OverlayNavItem <OverlayNavItem
active={activeView === 'keys'} active={activeView === 'keys'}
icon={KeyRound} icon={KeyRound}
label="Tools & Keys" label={t.settings.nav.apiKeys}
onClick={() => setActiveView('keys')} onClick={() => setActiveView('keys')}
/> />
{activeView === 'keys' && ( {activeView === 'keys' && (
@ -157,29 +159,29 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem <OverlayNavItem
active={activeView === 'mcp'} active={activeView === 'mcp'}
icon={Wrench} icon={Wrench}
label="MCP" label={t.settings.nav.mcp}
onClick={() => setActiveView('mcp')} onClick={() => setActiveView('mcp')}
/> />
<OverlayNavItem <OverlayNavItem
active={activeView === 'sessions'} active={activeView === 'sessions'}
icon={Archive} icon={Archive}
label="Archived Chats" label={t.settings.nav.archivedChats}
onClick={() => setActiveView('sessions')} onClick={() => setActiveView('sessions')}
/> />
<div className="my-2 h-px bg-border/30" /> <div className="my-2 h-px bg-border/30" />
<OverlayNavItem <OverlayNavItem
active={activeView === 'about'} active={activeView === 'about'}
icon={Info} icon={Info}
label="About" label={t.settings.nav.about}
onClick={() => setActiveView('about')} onClick={() => setActiveView('about')}
/> />
<div className="mt-auto flex items-center gap-1 pt-2"> <div className="mt-auto flex items-center gap-1 pt-2">
<Tip label="Export config"> <Tip label={t.settings.exportConfig}>
<OverlayIconButton onClick={() => void exportConfig()}> <OverlayIconButton onClick={() => void exportConfig()}>
<IconDownload className="size-3.5" /> <IconDownload className="size-3.5" />
</OverlayIconButton> </OverlayIconButton>
</Tip> </Tip>
<Tip label="Import config"> <Tip label={t.settings.importConfig}>
<OverlayIconButton <OverlayIconButton
onClick={() => { onClick={() => {
triggerHaptic('open') triggerHaptic('open')
@ -189,7 +191,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<IconUpload className="size-3.5" /> <IconUpload className="size-3.5" />
</OverlayIconButton> </OverlayIconButton>
</Tip> </Tip>
<Tip label="Reset to defaults"> <Tip label={t.settings.resetToDefaults}>
<OverlayIconButton <OverlayIconButton
className="hover:text-destructive" className="hover:text-destructive"
onClick={() => { onClick={() => {

View file

@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
@ -44,6 +45,7 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
} }
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) { export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
const { t } = useI18n()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const hapticsMuted = useStore($hapticsMuted) const hapticsMuted = useStore($hapticsMuted)
@ -76,7 +78,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{ {
icon: <Codicon name="layout-sidebar-left" />, icon: <Codicon name="layout-sidebar-left" />,
id: 'sidebar', id: 'sidebar',
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`, label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
onSelect: () => { onSelect: () => {
triggerHaptic('tap') triggerHaptic('tap')
leftEdge.toggle() leftEdge.toggle()
@ -85,12 +87,12 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{ {
icon: <Codicon name="arrow-swap" />, icon: <Codicon name="arrow-swap" />,
id: 'flip-panes', id: 'flip-panes',
label: 'Swap sidebar sides', label: t.titlebar.swapSidebarSides,
onSelect: () => { onSelect: () => {
triggerHaptic('tap') triggerHaptic('tap')
togglePanesFlipped() togglePanesFlipped()
}, },
title: 'Swap the sessions and file browser sides' title: t.titlebar.swapSidebarSidesTitle
}, },
...leftTools ...leftTools
] ]
@ -98,7 +100,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const rightSidebarTool: TitlebarTool = { const rightSidebarTool: TitlebarTool = {
icon: <Codicon name="layout-sidebar-right" />, icon: <Codicon name="layout-sidebar-right" />,
id: 'right-sidebar', id: 'right-sidebar',
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`, label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
onSelect: () => { onSelect: () => {
triggerHaptic('tap') triggerHaptic('tap')
rightEdge.toggle() rightEdge.toggle()
@ -111,13 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
active: hapticsMuted, active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />, icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
id: 'haptics', id: 'haptics',
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics', label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics onSelect: toggleHaptics
}, },
{ {
icon: <Codicon name="settings-gear" />, icon: <Codicon name="settings-gear" />,
id: 'settings', id: 'settings',
label: 'Open settings', label: t.titlebar.openSettings,
onSelect: () => { onSelect: () => {
triggerHaptic('open') triggerHaptic('open')
onOpenSettings() onOpenSettings()
@ -199,6 +201,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
onPointerDown={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()}
rel="noreferrer" rel="noreferrer"
target="_blank" target="_blank"
title={tool.title ?? tool.label}
> >
{tool.icon} {tool.icon}
</a> </a>
@ -221,6 +224,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
}} }}
onPointerDown={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()}
size="icon-titlebar" size="icon-titlebar"
title={tool.title ?? tool.label}
type="button" type="button"
variant="ghost" variant="ghost"
> >

View file

@ -81,7 +81,7 @@ describe('SkillsView toolset management', () => {
await renderSkills() await renderSkills()
expect(screen.getByText('Cron Jobs')).toBeTruthy() expect(await screen.findByText('Cron Jobs')).toBeTruthy()
expect(screen.queryByText(/⏰/)).toBeNull() expect(screen.queryByText(/⏰/)).toBeNull()
}) })

View file

@ -3,16 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader' import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes' import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications' import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes' import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell' import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers' import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel' import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
@ -70,33 +72,39 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
} }
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) { export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
const { t } = useI18n()
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills') const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [skills, setSkills] = useState<SkillInfo[] | null>(null) const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null) const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
const [activeCategory, setActiveCategory] = useState<string | null>(null) const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [savingSkill, setSavingSkill] = useState<string | null>(null) const [savingSkill, setSavingSkill] = useState<string | null>(null)
const [savingToolset, setSavingToolset] = useState<string | null>(null) const [savingToolset, setSavingToolset] = useState<string | null>(null)
const [expandedToolset, setExpandedToolset] = useState<string | null>(null) const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
const refreshCapabilities = useCallback(async () => { const refreshCapabilities = useCallback(async () => {
setRefreshing(true)
try { try {
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()]) const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
setSkills(nextSkills) setSkills(nextSkills)
setToolsets(nextToolsets) setToolsets(nextToolsets)
} catch (err) { } catch (err) {
notifyError(err, 'Skills failed to load') notifyError(err, t.skills.skillsLoadFailed)
} finally {
setRefreshing(false)
} }
}, []) }, [t])
useRefreshHotkey(refreshCapabilities)
const refreshToolsets = useCallback(() => { const refreshToolsets = useCallback(() => {
getToolsets() getToolsets()
.then(setToolsets) .then(setToolsets)
.catch(err => notifyError(err, 'Toolsets failed to refresh')) .catch(err => notifyError(err, t.skills.toolsetsRefreshFailed))
}, []) }, [t])
useRefreshHotkey(refreshCapabilities)
useEffect(() => { useEffect(() => {
void refreshCapabilities() void refreshCapabilities()
@ -148,11 +156,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current) setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current)
notify({ notify({
kind: 'success', kind: 'success',
title: enabled ? 'Skill enabled' : 'Skill disabled', title: enabled ? t.skills.skillEnabled : t.skills.skillDisabled,
message: `${skill.name} applies to new sessions.` message: t.skills.appliesToNewSessions(skill.name)
}) })
} catch (err) { } catch (err) {
notifyError(err, `Failed to update ${skill.name}`) notifyError(err, t.skills.failedToUpdate(skill.name))
} finally { } finally {
setSavingSkill(null) setSavingSkill(null)
} }
@ -169,11 +177,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
) )
notify({ notify({
kind: 'success', kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled', title: enabled ? t.skills.toolsetEnabled : t.skills.toolsetDisabled,
message: `${toolsetDisplayLabel(toolset)} applies to new sessions.` message: t.skills.appliesToNewSessions(toolsetDisplayLabel(toolset))
}) })
} catch (err) { } catch (err) {
notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`) notifyError(err, t.skills.failedToUpdate(toolsetDisplayLabel(toolset)))
} finally { } finally {
setSavingToolset(null) setSavingToolset(null)
} }
@ -183,54 +191,66 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<PageSearchShell <PageSearchShell
{...props} {...props}
filters={ filters={
mode === 'skills' && categories.length > 0 ? ( <>
<> <div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}> <TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
All <TextTabMeta>{totalSkills}</TextTabMeta> {t.skills.tabSkills}
</TextTab> </TextTab>
{categories.map(category => ( <TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
<TextTab {t.skills.tabToolsets}
active={activeCategory === category.key} </TextTab>
key={category.key} </div>
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)} {mode === 'skills' && categories.length > 0 && (
> <div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta> <TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab> </TextTab>
))} {categories.map(category => (
</> <TextTab
) : undefined active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
))}
</div>
)}
</>
} }
onSearchChange={setQuery} onSearchChange={setQuery}
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0} searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'} searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets}
searchValue={query} searchTrailingAction={
tabs={ <Button
<> aria-label={refreshing ? t.skills.refreshing : t.skills.refresh}
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}> className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
Skills disabled={refreshing}
</TextTab> onClick={() => void refreshCapabilities()}
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}> size="icon-xs"
Toolsets title={refreshing ? t.skills.refreshing : t.skills.refresh}
</TextTab> type="button"
</> variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
} }
searchValue={query}
> >
{!skills || !toolsets ? ( {!skills || !toolsets ? (
<PageLoader label="Loading capabilities..." /> <PageLoader label={t.skills.loading} />
) : mode === 'skills' ? ( ) : mode === 'skills' ? (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}> <div className="h-full overflow-y-auto px-4 py-3">
{visibleSkills.length === 0 ? ( {visibleSkills.length === 0 ? (
<EmptyState description="Try a broader search or different category." title="No skills found" /> <EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{skillGroups.map(([category, list]) => ( {skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}> <div className="space-y-1.5" key={category}>
{activeCategory === null && ( <div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> {prettyName(category)}
{prettyName(category)} </div>
</div> <div className="divide-y divide-(--ui-stroke-quaternary)">
)}
<div>
{list.map(skill => ( {list.map(skill => (
<div <div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center" className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@ -239,7 +259,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-medium">{skill.name}</div> <div className="truncate text-sm font-medium">{skill.name}</div>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
{asText(skill.description) || 'No description.'} {asText(skill.description) || t.skills.noDescription}
</p> </p>
</div> </div>
<Switch <Switch
@ -256,15 +276,15 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)} )}
</div> </div>
) : ( ) : (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}> <div className="h-full overflow-y-auto px-4 py-3">
{visibleToolsets.length === 0 ? ( {visibleToolsets.length === 0 ? (
<EmptyState description="Try a broader search query." title="No toolsets found" /> <EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{enabledToolsets}/{toolsets.length} toolsets enabled {t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
</div> </div>
<div> <div className="divide-y divide-(--ui-stroke-quaternary)">
{visibleToolsets.map(toolset => { {visibleToolsets.map(toolset => {
const tools = toolNames(toolset) const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset) const label = toolsetDisplayLabel(toolset)
@ -277,19 +297,19 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
<button <button
aria-expanded={expanded} aria-expanded={expanded}
aria-label={`Configure ${label}`} aria-label={t.skills.configureToolset(label)}
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => onClick={() =>
setExpandedToolset(current => (current === toolset.name ? null : toolset.name)) setExpandedToolset(current => (current === toolset.name ? null : toolset.name))
} }
type="button" type="button"
> >
<StatusPill active={toolset.configured}> <StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'} {toolset.configured ? t.skills.configured : t.skills.needsKeys}
</StatusPill> </StatusPill>
</button> </button>
<Switch <Switch
aria-label={`Toggle ${label} toolset`} aria-label={t.skills.toggleToolset(label)}
checked={toolset.enabled} checked={toolset.enabled}
disabled={savingToolset === toolset.name} disabled={savingToolset === toolset.name}
onCheckedChange={checked => void handleToggleToolset(toolset, checked)} onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
@ -297,7 +317,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
</div> </div>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{asText(toolset.description) || 'No description.'} {asText(toolset.description) || t.skills.noDescription}
</p> </p>
{tools.length > 0 && ( {tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">

View file

@ -0,0 +1,57 @@
import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it } from 'vitest'
import { I18nProvider, useI18n } from './context'
const STORAGE_KEY = 'hermes-desktop-locale'
// This jsdom build ships a partial localStorage (missing removeItem/clear), so
// back it with a Map for a deterministic, self-contained test.
function installStorageMock() {
const store = new Map<string, string>()
const mock: Storage = {
get length() {
return store.size
},
clear: () => store.clear(),
getItem: key => store.get(key) ?? null,
key: index => Array.from(store.keys())[index] ?? null,
removeItem: key => void store.delete(key),
setItem: (key, value) => void store.set(key, String(value))
}
Object.defineProperty(window, 'localStorage', { configurable: true, value: mock })
}
const wrapper = ({ children }: { children: ReactNode }) => <I18nProvider>{children}</I18nProvider>
describe('I18nProvider', () => {
beforeEach(installStorageMock)
it('defaults to English', () => {
const { result } = renderHook(() => useI18n(), { wrapper })
expect(result.current.locale).toBe('en')
expect(result.current.t.language.label).toBe('Language')
})
it('switches translations and persists the locale', () => {
const { result } = renderHook(() => useI18n(), { wrapper })
act(() => result.current.setLocale('zh'))
expect(result.current.locale).toBe('zh')
expect(result.current.t.language.label).toBe('语言')
expect(window.localStorage.getItem(STORAGE_KEY)).toBe('zh')
})
it('restores a persisted locale on mount', () => {
window.localStorage.setItem(STORAGE_KEY, 'zh')
const { result } = renderHook(() => useI18n(), { wrapper })
expect(result.current.locale).toBe('zh')
})
})

View file

@ -0,0 +1,76 @@
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react'
import { en } from './en'
import type { Locale, Translations } from './types'
import { zh } from './zh'
const TRANSLATIONS: Record<Locale, Translations> = {
en,
zh
}
// Endonyms (native names) for the language picker so users recognize their
// language regardless of the current UI language. No country flags —
// languages are not countries.
export const LOCALE_META: Record<Locale, { name: string }> = {
en: { name: 'English' },
zh: { name: '简体中文' }
}
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[]
const STORAGE_KEY = 'hermes-desktop-locale'
function isLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as string[]).includes(value)
}
function getInitialLocale(): Locale {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored && isLocale(stored)) {
return stored
}
} catch {
// localStorage unavailable (privacy mode / SSR) — fall back to English.
}
return 'en'
}
interface I18nContextValue {
locale: Locale
setLocale: (next: Locale) => void
t: Translations
}
const I18nContext = createContext<I18nContextValue>({
locale: 'en',
setLocale: () => {},
t: en
})
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale)
const setLocale = useCallback((next: Locale) => {
setLocaleState(next)
try {
window.localStorage.setItem(STORAGE_KEY, next)
} catch {
// ignore persistence failures
}
}, [])
const value = useMemo<I18nContextValue>(
() => ({ locale, setLocale, t: TRANSLATIONS[locale] }),
[locale, setLocale]
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n(): I18nContextValue {
return useContext(I18nContext)
}

669
apps/desktop/src/i18n/en.ts Normal file
View file

@ -0,0 +1,669 @@
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import type { Translations } from './types'
export const en: Translations = {
common: {
save: 'Save',
saving: 'Saving…',
cancel: 'Cancel',
close: 'Close',
confirm: 'Confirm',
delete: 'Delete',
refresh: 'Refresh',
retry: 'Retry',
on: 'On',
off: 'Off'
},
titlebar: {
hideSidebar: 'Hide sidebar',
showSidebar: 'Show sidebar',
search: 'Search',
searchTitle: 'Search sessions, views, and actions',
swapSidebarSides: 'Swap sidebar sides',
swapSidebarSidesTitle: 'Swap the sessions and file browser sides',
hideRightSidebar: 'Hide right sidebar',
showRightSidebar: 'Show right sidebar',
muteHaptics: 'Mute haptics',
unmuteHaptics: 'Unmute haptics',
openSettings: 'Open settings'
},
language: {
label: 'Language',
description: 'Choose the language for the desktop interface.'
},
settings: {
closeSettings: 'Close settings',
exportConfig: 'Export config',
importConfig: 'Import config',
resetToDefaults: 'Reset to defaults',
resetConfirm: 'Reset all settings to Hermes defaults?',
exportFailed: 'Export failed',
resetFailed: 'Reset failed',
nav: {
gateway: 'Gateway',
apiKeys: 'Tools & Keys',
mcp: 'MCP',
archivedChats: 'Archived Chats',
about: 'About'
},
sections: {
model: 'Model',
chat: 'Chat',
appearance: 'Appearance',
workspace: 'Workspace',
safety: 'Safety',
memory: 'Memory & Context',
voice: 'Voice',
advanced: 'Advanced'
},
searchPlaceholder: {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
sessions: 'Search archived sessions...'
},
modeOptions: {
light: { label: 'Light', description: 'Bright desktop surfaces' },
dark: { label: 'Dark', description: 'Low-glare workspace' },
system: { label: 'System', description: 'Follow OS appearance' }
},
appearance: {
title: 'Appearance',
intro:
'These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and chat surface styling.',
colorMode: 'Color Mode',
colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',
toolViewTitle: 'Tool Call Display',
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
product: 'Product',
productDesc: 'Human-friendly tool activity with concise summaries.',
technical: 'Technical',
technicalDesc: 'Include raw tool args/results and low-level details.',
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
about: {
heading: 'Hermes Desktop',
version: value => `Version ${value}`,
versionUnavailable: 'Version unavailable',
updates: 'Updates',
checkNow: 'Check now',
checking: 'Checking…',
seeWhatsNew: "See what's new",
releaseNotes: 'Release notes',
onLatest: "You're on the latest version.",
installing: 'An update is currently installing.',
cantUpdate: "This build can't update itself from inside the app.",
cantReach: "We couldn't reach the update server.",
tapCheck: 'Tap "Check now" to look for updates.',
updateReady: count => `A new update is ready (${count} change${count === 1 ? '' : 's'} included).`,
lastChecked: age => `Last checked ${age}`,
justNowSuffix: ' · just now',
automaticUpdates: 'Automatic updates',
automaticUpdatesDesc:
'Hermes checks for updates automatically in the background and lets you know when one is ready.',
branchCommit: (branch, commit) => `Branch ${branch} · Commit ${commit}`,
never: 'never',
justNow: 'just now',
minAgo: count => `${count} min ago`,
hoursAgo: count => `${count} hours ago`,
daysAgo: count => `${count} days ago`
}
},
skills: {
tabSkills: 'Skills',
tabToolsets: 'Toolsets',
all: 'All',
searchSkills: 'Search skills...',
searchToolsets: 'Search toolsets...',
refresh: 'Refresh skills',
refreshing: 'Refreshing skills',
loading: 'Loading capabilities...',
noSkillsTitle: 'No skills found',
noSkillsDesc: 'Try a broader search or different category.',
noToolsetsTitle: 'No toolsets found',
noToolsetsDesc: 'Try a broader search query.',
noDescription: 'No description.',
configured: 'Configured',
needsKeys: 'Needs keys',
toolsetsEnabled: (enabled, total) => `${enabled}/${total} toolsets enabled`,
configureToolset: label => `Configure ${label}`,
toggleToolset: label => `Toggle ${label} toolset`,
skillsLoadFailed: 'Skills failed to load',
toolsetsRefreshFailed: 'Toolsets failed to refresh',
skillEnabled: 'Skill enabled',
skillDisabled: 'Skill disabled',
toolsetEnabled: 'Toolset enabled',
toolsetDisabled: 'Toolset disabled',
appliesToNewSessions: name => `${name} applies to new sessions.`,
failedToUpdate: name => `Failed to update ${name}`
},
agents: {
close: 'Close agents',
title: 'Spawn tree',
subtitle: 'Live subagent activity for the current turn.',
emptyTitle: 'No live subagents',
emptyDesc: 'When a turn delegates work, child agents stream their progress here.',
running: 'Running',
failed: 'Failed',
done: 'Done',
streaming: 'Streaming',
files: 'Files',
moreFiles: count => `+${count} more files`,
delegation: index => `Delegation ${index}`,
workers: count => `${count} workers`,
workersActive: count => `${count} active`,
agentsCount: count => `${count} ${count === 1 ? 'agent' : 'agents'}`,
activeCount: count => `${count} active`,
failedCount: count => `${count} failed`,
toolsCount: count => `${count} tools`,
filesCount: count => `${count} files`,
updatedAgo: age => `updated ${age}`,
ageNow: 'now',
ageSeconds: seconds => `${seconds}s ago`,
ageMinutes: minutes => `${minutes}m ago`,
ageHours: hours => `${hours}h ago`,
durationSeconds: seconds => `${seconds}s`,
durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
tokensK: k => `${k}k tok`,
tokens: value => `${value} tok`
},
commandCenter: {
close: 'Close command center',
searchPlaceholder: 'Search sessions, views, and actions',
sections: { sessions: 'Sessions', system: 'System', usage: 'Usage' },
sectionDescriptions: {
sessions: 'Search and manage sessions',
system: 'Status, logs, and system actions',
usage: 'Token, cost, and skill activity over time'
},
nav: {
newChat: { title: 'New session', detail: 'Start a fresh session' },
settings: { title: 'Settings', detail: 'Configure Hermes desktop' },
skills: { title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
messaging: { title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
artifacts: { title: 'Artifacts', detail: 'Browse generated outputs' }
},
sectionEntries: {
sessions: { title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
system: { title: 'System panel', detail: 'Gateway status, logs, restart/update' },
usage: { title: 'Usage panel', detail: 'Token, cost, and skill activity' }
},
providerNavigate: 'Navigate',
providerSessions: 'Sessions',
refresh: 'Refresh',
refreshing: 'Refreshing...',
noResults: 'No matching results found.',
pinSession: 'Pin session',
unpinSession: 'Unpin session',
exportSession: 'Export session',
deleteSession: 'Delete session',
noSessions: 'No sessions yet.',
gatewayRunning: 'Messaging gateway running',
gatewayStopped: 'Messaging gateway stopped',
hermesActiveSessions: (version, count) => `Hermes ${version} · Active sessions ${count}`,
restartMessaging: 'Restart messaging',
updateHermes: 'Update Hermes',
actionRunning: 'running',
actionDone: 'done',
actionFailed: 'failed',
actionStartedWaiting: 'Action started, waiting for status...',
loadingStatus: 'Loading status...',
recentLogs: 'Recent logs',
noLogs: 'No logs loaded yet.',
days: count => `${count}d`,
statSessions: 'Sessions',
statApiCalls: 'API calls',
statTokens: 'Tokens in/out',
statCost: 'Est. cost',
actualCost: cost => `actual ${cost}`,
loadingUsage: 'Loading usage...',
noUsage: period => `No usage in the last ${period} days.`,
retry: 'Retry',
dailyTokens: 'Daily tokens',
input: 'input',
output: 'output',
noDailyActivity: 'No daily activity.',
topModels: 'Top models',
noModelUsage: 'No model usage yet.',
topSkills: 'Top skills',
noSkillActivity: 'No skill activity yet.',
actions: count => `${count} actions`
},
messaging: {
search: 'Search messaging...',
loading: 'Loading messaging platforms...',
loadFailed: 'Messaging platforms failed to load',
states: {
connected: 'Connected',
connecting: 'Connecting',
disabled: 'Disabled',
fatal: 'Error',
gateway_stopped: 'Messaging gateway stopped',
not_configured: 'Needs setup',
pending_restart: 'Restart needed',
retrying: 'Retrying',
startup_failed: 'Startup failed'
},
unknown: 'Unknown',
hintPendingRestart: 'Restart the gateway from the status bar to apply this change.',
hintGatewayStopped: 'Start the gateway from the status bar to connect.',
credentialsSet: 'Credentials set',
needsSetup: 'Needs setup',
gatewayStopped: 'Messaging gateway stopped',
getCredentials: 'Get your credentials',
openSetupGuide: 'Open setup guide',
required: 'Required',
recommended: 'Recommended',
advanced: count => `Advanced (${count})`,
noTokenNeeded: 'This platform does not need a token here. Use the setup guide above, then enable it below.',
enabled: 'Enabled',
disabled: 'Disabled',
unsavedChanges: 'Unsaved changes',
saving: 'Saving...',
saveChanges: 'Save changes',
saved: 'Saved',
replaceValue: 'Replace current value',
openDocs: 'Open docs',
clearField: key => `Clear ${key}`,
enableAria: name => `Enable ${name}`,
disableAria: name => `Disable ${name}`,
platformEnabled: name => `${name} enabled`,
platformDisabled: name => `${name} disabled`,
restartToApply: 'Restart the gateway for this change to take effect.',
setupSaved: name => `${name} setup saved`,
restartToReconnect: 'Restart the gateway to reconnect with the new credentials.',
keyCleared: key => `${key} cleared`,
setupUpdated: name => `${name} setup was updated.`,
failedUpdate: name => `Failed to update ${name}`,
failedSave: name => `Failed to save ${name}`,
failedClear: key => `Failed to clear ${key}`,
fieldCopy: {},
platformIntro: {}
},
profiles: {
close: 'Close profiles',
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
title: 'Profiles',
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
loading: 'Loading profiles...',
newProfile: 'New profile',
noProfiles: 'No profiles yet.',
selectPrompt: 'Select a profile to view its details.',
refresh: 'Refresh profiles',
refreshing: 'Refreshing profiles',
default: 'default',
skills: count => `${count} ${count === 1 ? 'skill' : 'skills'}`,
env: 'env',
defaultBadge: 'Default',
rename: 'Rename',
copySetup: 'Copy setup',
copying: 'Copying...',
modelLabel: 'Model',
skillsLabel: 'Skills',
notSet: 'Not set',
soulDesc: 'The system prompt and persona instructions baked into this profile.',
unsavedChanges: 'Unsaved changes',
loadingSoul: 'Loading SOUL.md...',
emptySoul: 'Empty SOUL.md — start writing the persona...',
saving: 'Saving...',
saveSoul: 'Save SOUL.md',
deleteTitle: 'Delete profile?',
deleteDescPrefix: 'This will delete ',
deleteDescMid: ' and remove its ',
deleteDescSuffix: ' directory. This cannot be undone.',
deleting: 'Deleting...',
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
nameLabel: 'Name',
cloneFromDefault: 'Clone from default',
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
invalidName: hint => `Invalid name. ${hint}`,
nameRequired: 'Name is required.',
creating: 'Creating...',
createAction: 'Create profile',
renameTitle: 'Rename profile',
renameDescPrefix: 'Renaming updates the profile directory and any wrapper scripts in ',
renameDescSuffix: '.',
newNameLabel: 'New name',
renaming: 'Renaming...',
created: 'Profile created',
renamed: 'Profile renamed',
deleted: 'Profile deleted',
setupCopied: 'Setup command copied',
soulSaved: 'SOUL.md saved',
failedLoad: 'Failed to load profiles',
failedDelete: 'Failed to delete profile',
failedCopy: 'Failed to copy setup command',
failedLoadSoul: 'Failed to load SOUL.md',
failedSaveSoul: 'Failed to save SOUL.md',
failedCreate: 'Failed to create profile',
failedRename: 'Failed to rename profile'
},
cron: {
close: 'Close cron',
search: 'Search cron jobs...',
refresh: 'Refresh cron jobs',
refreshing: 'Refreshing cron jobs',
loading: 'Loading cron jobs...',
states: {
enabled: 'enabled',
scheduled: 'scheduled',
running: 'running',
paused: 'paused',
disabled: 'disabled',
error: 'error',
completed: 'completed'
},
deliveryLabels: {
local: 'This desktop',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: 'Email'
},
scheduleLabels: {
daily: 'Daily',
weekdays: 'Weekdays',
weekly: 'Weekly',
monthly: 'Monthly',
hourly: 'Hourly',
'every-15-minutes': 'Every 15 minutes',
custom: 'Custom'
},
scheduleHints: {
daily: 'Every day at 9:00 AM',
weekdays: 'Monday through Friday at 9:00 AM',
weekly: 'Every Monday at 9:00 AM',
monthly: 'The first day of each month at 9:00 AM',
hourly: 'At the top of every hour',
'every-15-minutes': 'Every 15 minutes',
custom: 'Cron syntax or natural language'
},
days: {
'0': 'Sunday',
'1': 'Monday',
'2': 'Tuesday',
'3': 'Wednesday',
'4': 'Thursday',
'5': 'Friday',
'6': 'Saturday',
'7': 'Sunday'
},
dayFallback: value => `day ${value}`,
everyDayAt: time => `Every day at ${time}`,
weekdaysAt: time => `Weekdays at ${time}`,
everyDayOfWeekAt: (day, time) => `Every ${day} at ${time}`,
monthlyOnDayAt: (dayOfMonth, time) => `Monthly on day ${dayOfMonth} at ${time}`,
topOfHour: 'At the top of every hour',
everyHourAt: minute => `Every hour at :${minute}`,
active: (enabled, total) => `${enabled}/${total} active`,
newCron: 'New cron',
createFirst: 'Create first cron',
emptyDescNew:
'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.',
emptyDescSearch: 'Try a broader search query.',
emptyTitleNew: 'No scheduled jobs yet',
emptyTitleSearch: 'No matches',
last: 'Last:',
next: 'Next:',
actionsFor: title => `Actions for ${title}`,
actionsTitle: 'Cron job actions',
resume: 'Resume cron',
pause: 'Pause cron',
resumeTitle: 'Resume',
pauseTitle: 'Pause',
triggerNow: 'Trigger now',
edit: 'Edit cron',
deleteTitle: 'Delete cron job?',
deleteDescPrefix: 'This will remove ',
deleteDescSuffix: ' permanently. It will stop firing immediately.',
deleting: 'Deleting...',
resumed: 'Cron resumed',
paused: 'Cron paused',
triggered: 'Cron triggered',
deleted: 'Cron deleted',
created: 'Cron created',
updated: 'Cron updated',
failedLoad: 'Failed to load cron jobs',
failedUpdate: 'Failed to update cron job',
failedTrigger: 'Failed to trigger cron job',
failedDelete: 'Failed to delete cron job',
failedSave: 'Failed to save cron job',
editTitle: 'Edit cron job',
createTitle: 'New cron job',
editDesc: 'Update the schedule, prompt, or delivery target. Changes apply on next run.',
createDesc:
'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
nameLabel: 'Name',
namePlaceholder: 'Morning briefing',
promptLabel: 'Prompt',
promptPlaceholder: 'Summarize my unread Slack threads and email me the top 5...',
frequencyLabel: 'Frequency',
deliverLabel: 'Deliver to',
customScheduleLabel: 'Custom schedule',
customPlaceholder: '0 9 * * * or weekdays at 9am',
customHint: 'Cron expression, or phrases like "every hour" or "weekdays at 9am".',
optional: 'Optional',
promptScheduleRequired: 'Prompt and schedule are required.',
saveChanges: 'Save changes',
createAction: 'Create cron'
},
artifacts: {
search: 'Search artifacts...',
refresh: 'Refresh artifacts',
refreshing: 'Refreshing artifacts',
indexing: 'Indexing recent session artifacts',
tabAll: 'All',
tabImages: 'Images',
tabFiles: 'Files',
tabLinks: 'Links',
noArtifactsTitle: 'No artifacts found',
noArtifactsDesc: 'Generated images and file outputs will appear here as sessions produce them.',
failedLoad: 'Artifacts failed to load',
openFailed: 'Open failed',
itemsImage: 'images',
itemsLink: 'links',
itemsFile: 'files',
itemsGeneric: 'items',
zero: '0',
rangeOf: (start, end, total) => `${start}-${end} of ${total}`,
goToPage: (itemLabel, page) => `Go to ${itemLabel} page ${page}`,
colTitleLink: 'Link title',
colTitleFile: 'Name',
colTitleDefault: 'Title / name',
colLocationLink: 'URL',
colLocationFile: 'Path',
colLocationDefault: 'Location',
colSession: 'Session',
kindImage: 'image',
kindFile: 'file',
kindLink: 'link',
chat: 'Chat',
copyUrl: 'Copy URL',
copyPath: 'Copy path'
},
sidebar: {
nav: {
'new-session': 'New session',
skills: 'Skills & Tools',
messaging: 'Messaging',
artifacts: 'Artifacts'
},
searchAria: 'Search sessions',
searchPlaceholder: 'Search sessions…',
clearSearch: 'Clear search',
noMatch: query => `No sessions match “${query}”.`,
results: 'Results',
pinned: 'Pinned',
sessions: 'Sessions',
groupAriaGrouped: 'Show sessions as a single list',
groupAriaUngrouped: 'Group sessions by workspace',
groupTitleGrouped: 'Ungroup sessions',
groupTitleUngrouped: 'Group by workspace',
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
shiftClickHint: 'Shift-click a chat to pin · drag to reorder',
noWorkspace: 'No workspace',
newSessionIn: label => `New session in ${label}`,
reorderWorkspace: label => `Reorder workspace ${label}`,
showMoreIn: (count, label) => `Show ${count} more in ${label}`,
loading: 'Loading…',
loadMore: 'Load more',
loadCount: step => `Load ${step} more`,
row: {
pin: 'Pin',
unpin: 'Unpin',
copyId: 'Copy ID',
export: 'Export',
rename: 'Rename',
archive: 'Archive',
copyIdFailed: 'Could not copy session ID',
actionsFor: title => `Actions for ${title}`,
sessionActions: 'Session actions',
sessionRunning: 'Session running',
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
renameDesc: 'Give this chat a memorable title. Leave empty to clear.',
untitledPlaceholder: 'Untitled session',
ageNow: 'now',
ageDay: 'd',
ageHour: 'h',
ageMin: 'm'
}
},
composer: {
message: 'Message',
placeholderStarting: 'Starting Hermes...',
placeholderReconnecting: 'Reconnecting to Hermes…',
placeholderFollowUp: 'Send follow-up',
newSessionPlaceholders: [
'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'
],
followUpPlaceholders: [
'Send a follow-up',
'Add more context',
'Refine the request',
"What's next?",
'Keep it going',
'Push it further',
'Adjust or continue'
],
startVoice: 'Start voice conversation',
queueMessage: 'Queue message',
stop: 'Stop',
send: 'Send',
speaking: 'Speaking',
transcribing: 'Transcribing',
thinking: 'Thinking',
muted: 'Muted',
listening: 'Listening',
muteMic: 'Mute microphone',
unmuteMic: 'Unmute microphone',
stopListening: 'Stop listening and send',
stopShort: 'Stop',
endConversation: 'End voice conversation',
endShort: 'End',
stopDictation: 'Stop dictation',
transcribingDictation: 'Transcribing dictation',
voiceDictation: 'Voice dictation',
commonCommands: 'Common commands',
hotkeys: 'Hotkeys',
helpFooter: 'opens the full panel · backspace dismisses',
commandDescs: {
'/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'
},
hotkeyDescs: {
'@': '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'
},
attachUrlTitle: 'Attach a URL',
attachUrlDesc: 'Hermes will fetch the page and include it as context for this turn.',
urlPlaceholder: 'https://example.com/post',
urlHintPre: 'Include the full URL, e.g. ',
attach: 'Attach',
queued: count => `${count} Queued`,
attachmentOnly: 'Attachment-only turn',
emptyTurn: 'Empty turn',
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
editingInComposer: 'Editing in composer',
editQueued: 'Edit queued turn',
sendQueuedNow: 'Send queued turn now',
deleteQueued: 'Delete queued turn',
previewUnavailable: 'Preview unavailable',
previewLabel: label => `Preview ${label}`,
couldNotPreview: label => `Could not preview ${label}`,
removeAttachment: label => `Remove ${label}`,
dictating: 'Dictating',
preparingAudio: 'Preparing audio',
speakingResponse: 'Speaking response',
readingAloud: 'Reading aloud',
themeSuggestions: 'Desktop theme suggestions',
noMatchingThemes: 'No matching themes.',
themeTryPre: 'Try ',
themeTryPost: '.',
attachLabel: 'Attach',
files: 'Files…',
folder: 'Folder…',
images: 'Images…',
pasteImage: 'Paste image',
url: 'URL…',
promptSnippets: 'Prompt snippets…',
tipPre: 'Tip: type ',
tipPost: ' to reference files inline.',
snippetsTitle: 'Prompt snippets',
snippetsDesc: 'Pick a starter prompt to drop into the composer.',
snippets: {
codeReview: {
label: 'Code review',
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
text: 'Please review this for bugs, regressions, and missing tests.'
},
implementationPlan: {
label: 'Implementation plan',
description: 'Outline an approach before touching code so the diff stays focused.',
text: 'Please make a concise implementation plan before changing code.'
},
explainThis: {
label: 'Explain this',
description: 'Walk through how the selected code works and link to the key files.',
text: 'Please explain how this works and point me to the key files.'
}
}
}
}

View file

@ -0,0 +1,2 @@
export { I18nProvider, LOCALE_META, useI18n } from './context'
export type { Locale, Translations } from './types'

View file

@ -0,0 +1,539 @@
// Desktop i18n type contract.
//
// `Translations` is the single source of truth for every translatable string
// surface. Each locale file (`en.ts`, `zh.ts`, …) must satisfy this interface,
// so a missing key is a compile error — that's the completeness guard for
// "full" coverage as more surfaces are migrated off hardcoded English.
export type Locale = 'en' | 'zh'
interface ModeOptionCopy {
label: string
description: string
}
export interface Translations {
common: {
save: string
saving: string
cancel: string
close: string
confirm: string
delete: string
refresh: string
retry: string
on: string
off: string
}
titlebar: {
hideSidebar: string
showSidebar: string
search: string
searchTitle: string
swapSidebarSides: string
swapSidebarSidesTitle: string
hideRightSidebar: string
showRightSidebar: string
muteHaptics: string
unmuteHaptics: string
openSettings: string
}
language: {
label: string
description: string
}
settings: {
closeSettings: string
exportConfig: string
importConfig: string
resetToDefaults: string
resetConfirm: string
exportFailed: string
resetFailed: string
nav: {
gateway: string
apiKeys: string
mcp: string
archivedChats: string
about: string
}
sections: Record<string, string>
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>
modeOptions: Record<'light' | 'dark' | 'system', ModeOptionCopy>
appearance: {
title: string
intro: string
colorMode: string
colorModeDesc: string
toolViewTitle: string
toolViewDesc: string
product: string
productDesc: string
technical: string
technicalDesc: string
themeTitle: string
themeDesc: string
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
about: {
heading: string
version: (value: string) => string
versionUnavailable: string
updates: string
checkNow: string
checking: string
seeWhatsNew: string
releaseNotes: string
onLatest: string
installing: string
cantUpdate: string
cantReach: string
tapCheck: string
updateReady: (count: number) => string
lastChecked: (age: string) => string
justNowSuffix: string
automaticUpdates: string
automaticUpdatesDesc: string
branchCommit: (branch: string, commit: string) => string
never: string
justNow: string
minAgo: (count: number) => string
hoursAgo: (count: number) => string
daysAgo: (count: number) => string
}
}
skills: {
tabSkills: string
tabToolsets: string
all: string
searchSkills: string
searchToolsets: string
refresh: string
refreshing: string
loading: string
noSkillsTitle: string
noSkillsDesc: string
noToolsetsTitle: string
noToolsetsDesc: string
noDescription: string
configured: string
needsKeys: string
toolsetsEnabled: (enabled: number, total: number) => string
configureToolset: (label: string) => string
toggleToolset: (label: string) => string
skillsLoadFailed: string
toolsetsRefreshFailed: string
skillEnabled: string
skillDisabled: string
toolsetEnabled: string
toolsetDisabled: string
appliesToNewSessions: (name: string) => string
failedToUpdate: (name: string) => string
}
agents: {
close: string
title: string
subtitle: string
emptyTitle: string
emptyDesc: string
running: string
failed: string
done: string
streaming: string
files: string
moreFiles: (count: number) => string
delegation: (index: number) => string
workers: (count: number) => string
workersActive: (count: number) => string
agentsCount: (count: number) => string
activeCount: (count: number) => string
failedCount: (count: number) => string
toolsCount: (count: number) => string
filesCount: (count: number) => string
updatedAgo: (age: string) => string
ageNow: string
ageSeconds: (seconds: number) => string
ageMinutes: (minutes: number) => string
ageHours: (hours: number) => string
durationSeconds: (seconds: string) => string
durationMinutes: (minutes: number, seconds: number) => string
tokensK: (k: string) => string
tokens: (value: number) => string
}
commandCenter: {
close: string
searchPlaceholder: string
sections: Record<'sessions' | 'system' | 'usage', string>
sectionDescriptions: Record<'sessions' | 'system' | 'usage', string>
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
sectionEntries: Record<'sessions' | 'system' | 'usage', { title: string; detail: string }>
providerNavigate: string
providerSessions: string
refresh: string
refreshing: string
noResults: string
pinSession: string
unpinSession: string
exportSession: string
deleteSession: string
noSessions: string
gatewayRunning: string
gatewayStopped: string
hermesActiveSessions: (version: string, count: number) => string
restartMessaging: string
updateHermes: string
actionRunning: string
actionDone: string
actionFailed: string
actionStartedWaiting: string
loadingStatus: string
recentLogs: string
noLogs: string
days: (count: number) => string
statSessions: string
statApiCalls: string
statTokens: string
statCost: string
actualCost: (cost: string) => string
loadingUsage: string
noUsage: (period: number) => string
retry: string
dailyTokens: string
input: string
output: string
noDailyActivity: string
topModels: string
noModelUsage: string
topSkills: string
noSkillActivity: string
actions: (count: string) => string
}
messaging: {
search: string
loading: string
loadFailed: string
states: Record<string, string>
unknown: string
hintPendingRestart: string
hintGatewayStopped: string
credentialsSet: string
needsSetup: string
gatewayStopped: string
getCredentials: string
openSetupGuide: string
required: string
recommended: string
advanced: (count: number) => string
noTokenNeeded: string
enabled: string
disabled: string
unsavedChanges: string
saving: string
saveChanges: string
saved: string
replaceValue: string
openDocs: string
clearField: (key: string) => string
enableAria: (name: string) => string
disableAria: (name: string) => string
platformEnabled: (name: string) => string
platformDisabled: (name: string) => string
restartToApply: string
setupSaved: (name: string) => string
restartToReconnect: string
keyCleared: (key: string) => string
setupUpdated: (name: string) => string
failedUpdate: (name: string) => string
failedSave: (name: string) => string
failedClear: (key: string) => string
fieldCopy: Record<string, { label?: string; help?: string; placeholder?: string }>
platformIntro: Record<string, string>
}
profiles: {
close: string
nameHint: string
title: string
count: (count: number) => string
loading: string
newProfile: string
noProfiles: string
selectPrompt: string
refresh: string
refreshing: string
default: string
skills: (count: number) => string
env: string
defaultBadge: string
rename: string
copySetup: string
copying: string
modelLabel: string
skillsLabel: string
notSet: string
soulDesc: string
unsavedChanges: string
loadingSoul: string
emptySoul: string
saving: string
saveSoul: string
deleteTitle: string
deleteDescPrefix: string
deleteDescMid: string
deleteDescSuffix: string
deleting: string
createDesc: string
nameLabel: string
cloneFromDefault: string
cloneFromDefaultDesc: string
invalidName: (hint: string) => string
nameRequired: string
creating: string
createAction: string
renameTitle: string
renameDescPrefix: string
renameDescSuffix: string
newNameLabel: string
renaming: string
created: string
renamed: string
deleted: string
setupCopied: string
soulSaved: string
failedLoad: string
failedDelete: string
failedCopy: string
failedLoadSoul: string
failedSaveSoul: string
failedCreate: string
failedRename: string
}
cron: {
close: string
search: string
refresh: string
refreshing: string
loading: string
states: Record<string, string>
deliveryLabels: Record<string, string>
scheduleLabels: Record<string, string>
scheduleHints: Record<string, string>
days: Record<string, string>
dayFallback: (value: string) => string
everyDayAt: (time: string) => string
weekdaysAt: (time: string) => string
everyDayOfWeekAt: (day: string, time: string) => string
monthlyOnDayAt: (dayOfMonth: string, time: string) => string
topOfHour: string
everyHourAt: (minute: string) => string
active: (enabled: number, total: number) => string
newCron: string
createFirst: string
emptyDescNew: string
emptyDescSearch: string
emptyTitleNew: string
emptyTitleSearch: string
last: string
next: string
actionsFor: (title: string) => string
actionsTitle: string
resume: string
pause: string
resumeTitle: string
pauseTitle: string
triggerNow: string
edit: string
deleteTitle: string
deleteDescPrefix: string
deleteDescSuffix: string
deleting: string
resumed: string
paused: string
triggered: string
deleted: string
created: string
updated: string
failedLoad: string
failedUpdate: string
failedTrigger: string
failedDelete: string
failedSave: string
editTitle: string
createTitle: string
editDesc: string
createDesc: string
nameLabel: string
namePlaceholder: string
promptLabel: string
promptPlaceholder: string
frequencyLabel: string
deliverLabel: string
customScheduleLabel: string
customPlaceholder: string
customHint: string
optional: string
promptScheduleRequired: string
saveChanges: string
createAction: string
}
artifacts: {
search: string
refresh: string
refreshing: string
indexing: string
tabAll: string
tabImages: string
tabFiles: string
tabLinks: string
noArtifactsTitle: string
noArtifactsDesc: string
failedLoad: string
openFailed: string
itemsImage: string
itemsLink: string
itemsFile: string
itemsGeneric: string
zero: string
rangeOf: (start: number, end: number, total: number) => string
goToPage: (itemLabel: string, page: number) => string
colTitleLink: string
colTitleFile: string
colTitleDefault: string
colLocationLink: string
colLocationFile: string
colLocationDefault: string
colSession: string
kindImage: string
kindFile: string
kindLink: string
chat: string
copyUrl: string
copyPath: string
}
sidebar: {
nav: Record<string, string>
searchAria: string
searchPlaceholder: string
clearSearch: string
noMatch: (query: string) => string
results: string
pinned: string
sessions: string
groupAriaGrouped: string
groupAriaUngrouped: string
groupTitleGrouped: string
groupTitleUngrouped: string
allPinned: string
shiftClickHint: string
noWorkspace: string
newSessionIn: (label: string) => string
reorderWorkspace: (label: string) => string
showMoreIn: (count: number, label: string) => string
loading: string
loadMore: string
loadCount: (step: number) => string
row: {
pin: string
unpin: string
copyId: string
export: string
rename: string
archive: string
copyIdFailed: string
actionsFor: (title: string) => string
sessionActions: string
sessionRunning: string
needsInput: string
waitingForAnswer: string
renamed: string
renameFailed: string
renameTitle: string
renameDesc: string
untitledPlaceholder: string
ageNow: string
ageDay: string
ageHour: string
ageMin: string
}
}
composer: {
message: string
placeholderStarting: string
placeholderReconnecting: string
placeholderFollowUp: string
newSessionPlaceholders: readonly string[]
followUpPlaceholders: readonly string[]
startVoice: string
queueMessage: string
stop: string
send: string
speaking: string
transcribing: string
thinking: string
muted: string
listening: string
muteMic: string
unmuteMic: string
stopListening: string
stopShort: string
endConversation: string
endShort: string
stopDictation: string
transcribingDictation: string
voiceDictation: string
commonCommands: string
hotkeys: string
helpFooter: string
commandDescs: Record<string, string>
hotkeyDescs: Record<string, string>
attachUrlTitle: string
attachUrlDesc: string
urlPlaceholder: string
urlHintPre: string
attach: string
queued: (count: number) => string
attachmentOnly: string
emptyTurn: string
attachments: (count: number) => string
editingInComposer: string
editQueued: string
sendQueuedNow: string
deleteQueued: string
previewUnavailable: string
previewLabel: (label: string) => string
couldNotPreview: (label: string) => string
removeAttachment: (label: string) => string
dictating: string
preparingAudio: string
speakingResponse: string
readingAloud: string
themeSuggestions: string
noMatchingThemes: string
themeTryPre: string
themeTryPost: string
attachLabel: string
files: string
folder: string
images: string
pasteImage: string
url: string
promptSnippets: string
tipPre: string
tipPost: string
snippetsTitle: string
snippetsDesc: string
snippets: Record<string, { label: string; description: string; text: string }>
}
}

802
apps/desktop/src/i18n/zh.ts Normal file
View file

@ -0,0 +1,802 @@
import type { Translations } from './types'
export const zh: Translations = {
common: {
save: '保存',
saving: '保存中…',
cancel: '取消',
close: '关闭',
confirm: '确认',
delete: '删除',
refresh: '刷新',
retry: '重试',
on: '开',
off: '关'
},
titlebar: {
hideSidebar: '隐藏侧边栏',
showSidebar: '显示侧边栏',
search: '搜索',
searchTitle: '搜索会话、视图与操作',
swapSidebarSides: '交换侧边栏位置',
swapSidebarSidesTitle: '交换会话栏和文件浏览器的位置',
hideRightSidebar: '隐藏右侧栏',
showRightSidebar: '显示右侧栏',
muteHaptics: '关闭触感反馈',
unmuteHaptics: '开启触感反馈',
openSettings: '打开设置'
},
language: {
label: '语言',
description: '选择桌面界面的语言。'
},
settings: {
closeSettings: '关闭设置',
exportConfig: '导出配置',
importConfig: '导入配置',
resetToDefaults: '恢复默认',
resetConfirm: '将所有设置恢复为 Hermes 默认值?',
exportFailed: '导出失败',
resetFailed: '重置失败',
nav: {
gateway: '网关',
apiKeys: '工具与密钥',
mcp: 'MCP',
archivedChats: '已归档对话',
about: '关于'
},
sections: {
model: '模型',
chat: '对话',
appearance: '外观',
workspace: '工作区',
safety: '安全',
memory: '记忆与上下文',
voice: '语音',
advanced: '高级'
},
searchPlaceholder: {
about: '关于 Hermes Desktop',
config: '搜索设置…',
gateway: '网关连接…',
keys: '搜索 API 密钥…',
mcp: '搜索 MCP 服务器…',
sessions: '搜索已归档会话…'
},
modeOptions: {
light: { label: '明亮', description: '明亮的桌面界面' },
dark: { label: '暗色', description: '低眩光工作区' },
system: { label: '跟随系统', description: '跟随系统外观' }
},
appearance: {
title: '外观',
intro: '这些是仅桌面端的显示偏好。模式控制明暗;主题控制强调色与对话界面样式。',
colorMode: '颜色模式',
colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。',
toolViewTitle: '工具调用显示',
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
product: '产品',
productDesc: '易读的工具活动与简洁摘要。',
technical: '技术',
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
},
fieldLabels: {
model: '默认模型',
model_context_length: '上下文窗口',
fallback_providers: '备用模型',
toolsets: '启用的工具集',
timezone: '时区',
'display.personality': '人格',
'display.show_reasoning': '推理过程块',
'agent.max_turns': '最大智能体步数',
'agent.image_input_mode': '图片附件',
'terminal.cwd': '工作目录',
'terminal.backend': '执行后端',
'terminal.timeout': '命令超时',
'terminal.persistent_shell': '持久化 Shell',
'terminal.env_passthrough': '环境变量透传',
file_read_max_chars: '文件读取上限',
'tool_output.max_bytes': '终端输出上限',
'tool_output.max_lines': '文件分页上限',
'tool_output.max_line_length': '行长度上限',
'code_execution.mode': '代码执行模式',
'approvals.mode': '审批模式',
'approvals.timeout': '审批超时',
'approvals.mcp_reload_confirm': '确认 MCP 重载',
command_allowlist: '命令白名单',
'security.redact_secrets': '隐去密钥',
'security.allow_private_urls': '允许私有 URL',
'browser.allow_private_urls': '浏览器私有 URL',
'browser.auto_local_for_private_urls': '私有 URL 使用本地浏览器',
'checkpoints.enabled': '文件检查点',
'checkpoints.max_snapshots': '检查点上限',
'voice.record_key': '语音快捷键',
'voice.max_recording_seconds': '最长录音时长',
'voice.auto_tts': '朗读回复',
'stt.enabled': '语音转文字',
'stt.provider': '语音转文字提供方',
'stt.local.model': '本地转写模型',
'stt.local.language': '转写语言',
'stt.elevenlabs.model_id': 'ElevenLabs STT 模型',
'stt.elevenlabs.language_code': 'ElevenLabs 语言',
'stt.elevenlabs.tag_audio_events': '标记音频事件',
'stt.elevenlabs.diarize': '说话人区分',
'tts.provider': '文字转语音提供方',
'tts.edge.voice': 'Edge 语音',
'tts.openai.model': 'OpenAI TTS 模型',
'tts.openai.voice': 'OpenAI 语音',
'tts.elevenlabs.voice_id': 'ElevenLabs 语音',
'tts.elevenlabs.model_id': 'ElevenLabs 模型',
'memory.memory_enabled': '持久记忆',
'memory.user_profile_enabled': '用户画像',
'memory.memory_char_limit': '记忆预算',
'memory.user_char_limit': '画像预算',
'memory.provider': '记忆提供方',
'context.engine': '上下文引擎',
'compression.enabled': '自动压缩',
'compression.threshold': '压缩阈值',
'compression.target_ratio': '压缩目标',
'compression.protect_last_n': '保护最近消息',
'agent.api_max_retries': 'API 重试次数',
'agent.service_tier': '服务等级',
'agent.tool_use_enforcement': '工具调用强制',
'delegation.model': '子智能体模型',
'delegation.provider': '子智能体提供方',
'delegation.max_iterations': '子智能体轮次上限',
'delegation.max_concurrent_children': '并行子智能体',
'delegation.child_timeout_seconds': '子智能体超时',
'delegation.reasoning_effort': '子智能体推理强度'
},
fieldDescriptions: {
model: '用于新对话,除非你在输入框中选择其他模型。',
model_context_length: '保持为 0 则使用所选模型检测到的上下文窗口。',
fallback_providers: '默认模型失败时尝试的备用 provider:model 条目。',
'display.personality': '新会话的默认助手风格。',
timezone: '当 Hermes 需要本地时间上下文时使用。留空则使用系统时区。',
'display.show_reasoning': '当后端提供推理内容时予以显示。',
'agent.image_input_mode': '控制图片附件如何发送给模型。',
'terminal.cwd': '工具与终端操作的默认项目目录。',
'code_execution.mode': '代码执行被限定到当前项目的严格程度。',
'terminal.persistent_shell': '当后端支持时,在命令之间保留 Shell 状态。',
'terminal.env_passthrough': '传入工具执行的环境变量。',
file_read_max_chars: 'Hermes 单次文件读取可读取的最大字符数。',
'approvals.mode': 'Hermes 如何处理需要显式审批的命令。',
'approvals.timeout': '审批提示在超时前等待的时长。',
'security.redact_secrets': '尽可能从模型可见内容中隐藏检测到的密钥。',
'checkpoints.enabled': '在文件编辑前创建可回滚的快照。',
'memory.memory_enabled': '保存有助于未来会话的持久记忆。',
'memory.user_profile_enabled': '维护一份精简的用户偏好画像。',
'context.engine': '在接近上下文上限时管理长对话的策略。',
'compression.enabled': '当对话变大时对较早的上下文进行摘要。',
'voice.auto_tts': '自动朗读助手回复。',
'stt.enabled': '启用本地或提供方支持的语音转写。',
'stt.elevenlabs.language_code': '可选的 ISO-639-3 语言代码。留空让 ElevenLabs 自动检测。',
'agent.max_turns': 'Hermes 停止一次运行前工具调用轮次的上限。'
},
about: {
heading: 'Hermes Desktop',
version: value => `版本 ${value}`,
versionUnavailable: '版本不可用',
updates: '更新',
checkNow: '立即检查',
checking: '检查中…',
seeWhatsNew: '查看新增内容',
releaseNotes: '发行说明',
onLatest: '你已是最新版本。',
installing: '正在安装更新。',
cantUpdate: '此版本无法在应用内自我更新。',
cantReach: '无法连接更新服务器。',
tapCheck: '点击"立即检查"以查找更新。',
updateReady: count => `已准备好新更新(包含 ${count} 项更改)。`,
lastChecked: age => `上次检查:${age}`,
justNowSuffix: ' · 刚刚',
automaticUpdates: '自动更新',
automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。',
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
never: '从未',
justNow: '刚刚',
minAgo: count => `${count} 分钟前`,
hoursAgo: count => `${count} 小时前`,
daysAgo: count => `${count} 天前`
}
},
skills: {
tabSkills: '技能',
tabToolsets: '工具集',
all: '全部',
searchSkills: '搜索技能…',
searchToolsets: '搜索工具集…',
refresh: '刷新技能',
refreshing: '正在刷新技能',
loading: '正在加载能力…',
noSkillsTitle: '未找到技能',
noSkillsDesc: '尝试更宽泛的搜索或其他分类。',
noToolsetsTitle: '未找到工具集',
noToolsetsDesc: '尝试更宽泛的搜索词。',
noDescription: '暂无描述。',
configured: '已配置',
needsKeys: '需要密钥',
toolsetsEnabled: (enabled, total) => `已启用 ${enabled}/${total} 个工具集`,
configureToolset: label => `配置 ${label}`,
toggleToolset: label => `切换 ${label} 工具集`,
skillsLoadFailed: '技能加载失败',
toolsetsRefreshFailed: '工具集刷新失败',
skillEnabled: '技能已启用',
skillDisabled: '技能已禁用',
toolsetEnabled: '工具集已启用',
toolsetDisabled: '工具集已禁用',
appliesToNewSessions: name => `${name} 将应用于新会话。`,
failedToUpdate: name => `更新 ${name} 失败`
},
agents: {
close: '关闭代理',
title: '派生树',
subtitle: '当前回合的子代理实时活动。',
emptyTitle: '暂无活跃子代理',
emptyDesc: '当某个回合派发任务时,子代理会在此实时显示进度。',
running: '运行中',
failed: '失败',
done: '完成',
streaming: '流式传输',
files: '文件',
moreFiles: count => `还有 ${count} 个文件`,
delegation: index => `派发 ${index}`,
workers: count => `${count} 个工作单元`,
workersActive: count => `${count} 个活跃`,
agentsCount: count => `${count} 个代理`,
activeCount: count => `${count} 个活跃`,
failedCount: count => `${count} 个失败`,
toolsCount: count => `${count} 个工具`,
filesCount: count => `${count} 个文件`,
updatedAgo: age => `更新于 ${age}`,
ageNow: '刚刚',
ageSeconds: seconds => `${seconds} 秒前`,
ageMinutes: minutes => `${minutes} 分钟前`,
ageHours: hours => `${hours} 小时前`,
durationSeconds: seconds => `${seconds}`,
durationMinutes: (minutes, seconds) => `${minutes}${seconds}`,
tokensK: k => `${k}k 词元`,
tokens: value => `${value} 词元`
},
commandCenter: {
close: '关闭命令中心',
searchPlaceholder: '搜索会话、视图与操作',
sections: { sessions: '会话', system: '系统', usage: '用量' },
sectionDescriptions: {
sessions: '搜索与管理会话',
system: '状态、日志与系统操作',
usage: '一段时间内的词元、成本与技能活动'
},
nav: {
newChat: { title: '新建会话', detail: '开始一个新会话' },
settings: { title: '设置', detail: '配置 Hermes 桌面端' },
skills: { title: '技能与工具', detail: '启用技能、工具集与提供方' },
messaging: { title: '消息平台', detail: '配置 Telegram、Slack、Discord 等' },
artifacts: { title: '产物', detail: '浏览生成的输出' }
},
sectionEntries: {
sessions: { title: '会话面板', detail: '搜索、置顶与管理会话' },
system: { title: '系统面板', detail: '网关状态、日志、重启/更新' },
usage: { title: '用量面板', detail: '词元、成本与技能活动' }
},
providerNavigate: '导航',
providerSessions: '会话',
refresh: '刷新',
refreshing: '刷新中…',
noResults: '未找到匹配结果。',
pinSession: '置顶会话',
unpinSession: '取消置顶',
exportSession: '导出会话',
deleteSession: '删除会话',
noSessions: '暂无会话。',
gatewayRunning: '消息网关运行中',
gatewayStopped: '消息网关已停止',
hermesActiveSessions: (version, count) => `Hermes ${version} · 活跃会话 ${count}`,
restartMessaging: '重启消息服务',
updateHermes: '更新 Hermes',
actionRunning: '运行中',
actionDone: '完成',
actionFailed: '失败',
actionStartedWaiting: '操作已启动,等待状态…',
loadingStatus: '正在加载状态…',
recentLogs: '最近日志',
noLogs: '尚未加载日志。',
days: count => `${count}`,
statSessions: '会话',
statApiCalls: 'API 调用',
statTokens: '输入/输出词元',
statCost: '预估成本',
actualCost: cost => `实际 ${cost}`,
loadingUsage: '正在加载用量…',
noUsage: period => `最近 ${period} 天暂无用量。`,
retry: '重试',
dailyTokens: '每日词元',
input: '输入',
output: '输出',
noDailyActivity: '暂无每日活动。',
topModels: '常用模型',
noModelUsage: '暂无模型用量。',
topSkills: '常用技能',
noSkillActivity: '暂无技能活动。',
actions: count => `${count} 次操作`
},
messaging: {
search: '搜索消息平台…',
loading: '正在加载消息平台…',
loadFailed: '消息平台加载失败',
states: {
connected: '已连接',
connecting: '连接中',
disabled: '已禁用',
fatal: '错误',
gateway_stopped: '消息网关已停止',
not_configured: '需要设置',
pending_restart: '需要重启',
retrying: '重试中',
startup_failed: '启动失败'
},
unknown: '未知',
hintPendingRestart: '在状态栏重启网关以应用此更改。',
hintGatewayStopped: '在状态栏启动网关以建立连接。',
credentialsSet: '凭据已设置',
needsSetup: '需要设置',
gatewayStopped: '消息网关已停止',
getCredentials: '获取你的凭据',
openSetupGuide: '打开设置指南',
required: '必填',
recommended: '推荐',
advanced: count => `高级 (${count})`,
noTokenNeeded: '此平台无需在此填写令牌。请按上方设置指南操作,然后在下方启用。',
enabled: '已启用',
disabled: '已禁用',
unsavedChanges: '有未保存的更改',
saving: '保存中…',
saveChanges: '保存更改',
saved: '已保存',
replaceValue: '替换当前值',
openDocs: '打开文档',
clearField: key => `清除 ${key}`,
enableAria: name => `启用 ${name}`,
disableAria: name => `禁用 ${name}`,
platformEnabled: name => `${name} 已启用`,
platformDisabled: name => `${name} 已禁用`,
restartToApply: '重启网关后此更改才会生效。',
setupSaved: name => `${name} 设置已保存`,
restartToReconnect: '重启网关以使用新凭据重新连接。',
keyCleared: key => `${key} 已清除`,
setupUpdated: name => `${name} 设置已更新。`,
failedUpdate: name => `更新 ${name} 失败`,
failedSave: name => `保存 ${name} 失败`,
failedClear: key => `清除 ${key} 失败`,
fieldCopy: {
TELEGRAM_BOT_TOKEN: { label: 'Bot 令牌', help: '用 @BotFather 创建一个机器人,然后粘贴它给你的令牌。' },
TELEGRAM_ALLOWED_USERS: {
label: '允许的 Telegram 用户 ID',
help: '推荐。来自 @userinfobot 的逗号分隔数字 ID。不设置则任何人都能私信你的机器人。'
},
TELEGRAM_PROXY: { label: '代理 URL', help: '仅在 Telegram 被屏蔽的网络中需要。' },
DISCORD_BOT_TOKEN: { label: 'Bot 令牌', help: '在 Discord 开发者门户创建应用,添加机器人,然后粘贴其令牌。' },
DISCORD_ALLOWED_USERS: { label: '允许的 Discord 用户 ID', help: '推荐。逗号分隔的 Discord 用户 ID。' },
DISCORD_REPLY_TO_MODE: { label: '回复方式', help: 'first、all 或 off。' },
SLACK_BOT_TOKEN: { label: 'Slack bot 令牌', help: '安装 Slack 应用后,在 OAuth & Permissions 中找到 bot 令牌。' },
SLACK_APP_TOKEN: { label: 'Slack app 令牌', help: 'Socket Mode 需要 app 级令牌。' },
SLACK_ALLOWED_USERS: { label: '允许的 Slack 用户 ID', help: '推荐。逗号分隔的 Slack 用户 ID。' },
MATTERMOST_URL: { label: '服务器 URL' },
MATTERMOST_TOKEN: { label: 'Bot 令牌' },
MATTERMOST_ALLOWED_USERS: { label: '允许的用户 ID', help: '推荐。逗号分隔的 Mattermost 用户 ID。' },
MATRIX_HOMESERVER: { label: 'Homeserver URL' },
MATRIX_ACCESS_TOKEN: { label: '访问令牌' },
MATRIX_USER_ID: { label: 'Bot 用户 ID' },
MATRIX_ALLOWED_USERS: { label: '允许的 Matrix 用户 ID', help: '推荐。@user:server 格式的逗号分隔用户 ID。' },
SIGNAL_HTTP_URL: { label: 'Signal 桥接 URL', help: '运行中的 signal-cli REST 桥接的 URL。' },
SIGNAL_ACCOUNT: { label: '电话号码', help: '在 signal-cli 桥接中注册的号码。' },
SIGNAL_ALLOWED_USERS: { label: '允许的 Signal 用户', help: '推荐。逗号分隔的 Signal 标识符。' },
WHATSAPP_ENABLED: { label: '启用 WhatsApp 桥接', help: '由下方开关自动设置。除非确知需要,否则请勿改动。' },
WHATSAPP_MODE: { label: '桥接模式' },
WHATSAPP_ALLOWED_USERS: { label: '允许的 WhatsApp 用户', help: '推荐。逗号分隔的电话号码或 WhatsApp ID。' }
},
platformIntro: {
telegram:
'在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
discord:
'打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
signal: '在可访问的位置运行 signal-cli REST 桥接,然后把 Hermes 指向该 URL 和已注册的电话号码。',
whatsapp: '启动 Hermes 自带的 WhatsApp 桥接,首次运行时扫描二维码,然后启用该平台。',
bluebubbles: '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。',
homeassistant: '在 Home Assistant 中打开你的个人资料并创建长期访问令牌。把它连同你的 HA URL 一起粘贴到这里。',
email: '使用专用邮箱。对于 Gmail/Workspace,创建应用专用密码并使用 imap.gmail.com / smtp.gmail.com。',
sms: '从 Twilio 控制台获取你的 Account SID 和 Auth Token,以及一个可发送短信的电话号码。',
dingtalk: '在开发者控制台创建钉钉应用,然后在此复制 Client ID(App key)和 Client Secret。',
feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信(应用)选项。',
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
qqbot: '在 QQ 开放平台(q.qq.com)注册一个应用,复制 App ID 和 Client Secret。',
api_server: '把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
webhook: '运行一个 HTTP 服务器,供其他工具(GitHub、GitLab、自定义应用)POST。用 secret 验证签名。'
}
},
profiles: {
close: '关闭配置档案',
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
title: '配置档案',
count: count => `${count} 个配置档案`,
loading: '正在加载配置档案…',
newProfile: '新建配置档案',
noProfiles: '暂无配置档案。',
selectPrompt: '选择一个配置档案以查看其详情。',
refresh: '刷新配置档案',
refreshing: '正在刷新配置档案',
default: '默认',
skills: count => `${count} 个技能`,
env: 'env',
defaultBadge: '默认',
rename: '重命名',
copySetup: '复制安装命令',
copying: '复制中…',
modelLabel: '模型',
skillsLabel: '技能',
notSet: '未设置',
soulDesc: '内置于此配置档案的系统提示词与人格指令。',
unsavedChanges: '有未保存的更改',
loadingSoul: '正在加载 SOUL.md…',
emptySoul: '空的 SOUL.md —— 开始撰写人格设定…',
saving: '保存中…',
saveSoul: '保存 SOUL.md',
deleteTitle: '删除配置档案?',
deleteDescPrefix: '这将删除 ',
deleteDescMid: ' 并移除其 ',
deleteDescSuffix: ' 目录。此操作无法撤销。',
deleting: '删除中…',
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
nameLabel: '名称',
cloneFromDefault: '从默认档案克隆',
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
invalidName: hint => `名称无效。${hint}`,
nameRequired: '名称为必填项。',
creating: '创建中…',
createAction: '创建配置档案',
renameTitle: '重命名配置档案',
renameDescPrefix: '重命名会更新配置档案目录以及 ',
renameDescSuffix: ' 中的所有包装脚本。',
newNameLabel: '新名称',
renaming: '重命名中…',
created: '配置档案已创建',
renamed: '配置档案已重命名',
deleted: '配置档案已删除',
setupCopied: '安装命令已复制',
soulSaved: 'SOUL.md 已保存',
failedLoad: '加载配置档案失败',
failedDelete: '删除配置档案失败',
failedCopy: '复制安装命令失败',
failedLoadSoul: '加载 SOUL.md 失败',
failedSaveSoul: '保存 SOUL.md 失败',
failedCreate: '创建配置档案失败',
failedRename: '重命名配置档案失败'
},
cron: {
close: '关闭定时任务',
search: '搜索定时任务…',
refresh: '刷新定时任务',
refreshing: '正在刷新定时任务',
loading: '正在加载定时任务…',
states: {
enabled: '已启用',
scheduled: '已排程',
running: '运行中',
paused: '已暂停',
disabled: '已禁用',
error: '错误',
completed: '已完成'
},
deliveryLabels: {
local: '此桌面',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: '电子邮件'
},
scheduleLabels: {
daily: '每天',
weekdays: '工作日',
weekly: '每周',
monthly: '每月',
hourly: '每小时',
'every-15-minutes': '每 15 分钟',
custom: '自定义'
},
scheduleHints: {
daily: '每天上午 9:00',
weekdays: '周一至周五上午 9:00',
weekly: '每周一上午 9:00',
monthly: '每月第一天上午 9:00',
hourly: '每个整点',
'every-15-minutes': '每 15 分钟',
custom: 'Cron 语法或自然语言'
},
days: {
'0': '周日',
'1': '周一',
'2': '周二',
'3': '周三',
'4': '周四',
'5': '周五',
'6': '周六',
'7': '周日'
},
dayFallback: value => `${value}`,
everyDayAt: time => `每天 ${time}`,
weekdaysAt: time => `工作日 ${time}`,
everyDayOfWeekAt: (day, time) => `${day} ${time}`,
monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth}${time}`,
topOfHour: '每个整点',
everyHourAt: minute => `每小时的 :${minute}`,
active: (enabled, total) => `${enabled}/${total} 个启用`,
newCron: '新建定时任务',
createFirst: '创建第一个定时任务',
emptyDescNew: '按 cron 表达式排程一个提示词。Hermes 会运行它,并把结果发送到你选择的目的地。',
emptyDescSearch: '尝试更宽泛的搜索词。',
emptyTitleNew: '暂无排程任务',
emptyTitleSearch: '无匹配项',
last: '上次:',
next: '下次:',
actionsFor: title => `${title} 的操作`,
actionsTitle: '定时任务操作',
resume: '恢复定时任务',
pause: '暂停定时任务',
resumeTitle: '恢复',
pauseTitle: '暂停',
triggerNow: '立即触发',
edit: '编辑定时任务',
deleteTitle: '删除定时任务?',
deleteDescPrefix: '这将永久移除 ',
deleteDescSuffix: '。它会立即停止触发。',
deleting: '删除中…',
resumed: '定时任务已恢复',
paused: '定时任务已暂停',
triggered: '定时任务已触发',
deleted: '定时任务已删除',
created: '定时任务已创建',
updated: '定时任务已更新',
failedLoad: '加载定时任务失败',
failedUpdate: '更新定时任务失败',
failedTrigger: '触发定时任务失败',
failedDelete: '删除定时任务失败',
failedSave: '保存定时任务失败',
editTitle: '编辑定时任务',
createTitle: '新建定时任务',
editDesc: '更新排程、提示词或投递目标。更改将在下次运行时生效。',
createDesc: '排程一个提示词以自动运行。使用 cron 语法或类似"每 15 分钟"的自然语言。',
nameLabel: '名称',
namePlaceholder: '晨间简报',
promptLabel: '提示词',
promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
frequencyLabel: '频率',
deliverLabel: '投递至',
customScheduleLabel: '自定义排程',
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。',
optional: '可选',
promptScheduleRequired: '提示词和排程为必填项。',
saveChanges: '保存更改',
createAction: '创建定时任务'
},
artifacts: {
search: '搜索产物…',
refresh: '刷新产物',
refreshing: '正在刷新产物',
indexing: '正在索引最近会话的产物',
tabAll: '全部',
tabImages: '图片',
tabFiles: '文件',
tabLinks: '链接',
noArtifactsTitle: '未找到产物',
noArtifactsDesc: '当会话生成图片和文件输出时,它们会显示在这里。',
failedLoad: '产物加载失败',
openFailed: '打开失败',
itemsImage: '张图片',
itemsLink: '个链接',
itemsFile: '个文件',
itemsGeneric: '项',
zero: '0',
rangeOf: (start, end, total) => `${start}-${end},共 ${total}`,
goToPage: (itemLabel, page) => `前往${itemLabel}${page}`,
colTitleLink: '链接标题',
colTitleFile: '名称',
colTitleDefault: '标题 / 名称',
colLocationLink: 'URL',
colLocationFile: '路径',
colLocationDefault: '位置',
colSession: '会话',
kindImage: '图片',
kindFile: '文件',
kindLink: '链接',
chat: '对话',
copyUrl: '复制 URL',
copyPath: '复制路径'
},
sidebar: {
nav: {
'new-session': '新建会话',
skills: '技能与工具',
messaging: '消息平台',
artifacts: '产物'
},
searchAria: '搜索会话',
searchPlaceholder: '搜索会话…',
clearSearch: '清除搜索',
noMatch: query => `没有会话匹配"${query}"。`,
results: '结果',
pinned: '已置顶',
sessions: '会话',
groupAriaGrouped: '以单一列表显示会话',
groupAriaUngrouped: '按工作区分组会话',
groupTitleGrouped: '取消分组',
groupTitleUngrouped: '按工作区分组',
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
shiftClickHint: 'Shift+单击对话以置顶 · 拖动以重新排序',
noWorkspace: '无工作区',
newSessionIn: label => `${label} 中新建会话`,
reorderWorkspace: label => `重新排序工作区 ${label}`,
showMoreIn: (count, label) => `${label} 中再显示 ${count}`,
loading: '加载中…',
loadMore: '加载更多',
loadCount: step => `再加载 ${step}`,
row: {
pin: '置顶',
unpin: '取消置顶',
copyId: '复制 ID',
export: '导出',
rename: '重命名',
archive: '归档',
copyIdFailed: '无法复制会话 ID',
actionsFor: title => `${title} 的操作`,
sessionActions: '会话操作',
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
renameDesc: '给这个对话起一个好记的标题。留空则清除。',
untitledPlaceholder: '无标题会话',
ageNow: '刚刚',
ageDay: '天',
ageHour: '时',
ageMin: '分'
}
},
composer: {
message: '消息',
placeholderStarting: '正在启动 Hermes…',
placeholderReconnecting: '正在重新连接 Hermes…',
placeholderFollowUp: '发送后续消息',
newSessionPlaceholders: [
'我们要构建什么?',
'给 Hermes 一个任务',
'你在想什么?',
'描述你需要什么',
'我们该处理什么?',
'随便问点什么',
'从一个目标开始'
],
followUpPlaceholders: [
'发送后续消息',
'补充更多上下文',
'细化这个请求',
'下一步是什么?',
'继续推进',
'再深入一点',
'调整或继续'
],
startVoice: '开始语音对话',
queueMessage: '排队消息',
stop: '停止',
send: '发送',
speaking: '讲话中',
transcribing: '转写中',
thinking: '思考中',
muted: '已静音',
listening: '聆听中',
muteMic: '麦克风静音',
unmuteMic: '取消麦克风静音',
stopListening: '停止聆听并发送',
stopShort: '停止',
endConversation: '结束语音对话',
endShort: '结束',
stopDictation: '停止听写',
transcribingDictation: '正在转写听写',
voiceDictation: '语音听写',
commonCommands: '常用命令',
hotkeys: '快捷键',
helpFooter: '打开完整面板 · 退格键关闭',
commandDescs: {
'/help': '命令与快捷键的完整列表',
'/clear': '开始新会话',
'/resume': '恢复之前的会话',
'/details': '控制对话记录的详细程度',
'/copy': '复制所选内容或最后一条助手消息',
'/quit': '退出 hermes'
},
hotkeyDescs: {
'@': '引用文件、文件夹、URL、git',
'/': '斜杠命令面板',
'?': '此快速帮助(删除以关闭)',
Enter: '发送 · Shift+Enter 换行',
'Cmd/Ctrl+K': '发送下一条排队的回合',
'Cmd/Ctrl+L': '重绘',
Esc: '关闭弹窗 · 取消运行',
'↑ / ↓': '循环弹窗 / 历史'
},
attachUrlTitle: '附加 URL',
attachUrlDesc: 'Hermes 将抓取该页面并作为本回合的上下文。',
urlPlaceholder: 'https://example.com/post',
urlHintPre: '请包含完整 URL,例如 ',
attach: '附加',
queued: count => `${count} 条排队`,
attachmentOnly: '仅附件回合',
emptyTurn: '空回合',
attachments: count => `${count} 个附件`,
editingInComposer: '正在输入框中编辑',
editQueued: '编辑排队回合',
sendQueuedNow: '立即发送排队回合',
deleteQueued: '删除排队回合',
previewUnavailable: '预览不可用',
previewLabel: label => `预览 ${label}`,
couldNotPreview: label => `无法预览 ${label}`,
removeAttachment: label => `移除 ${label}`,
dictating: '听写中',
preparingAudio: '正在准备音频',
speakingResponse: '正在朗读回复',
readingAloud: '朗读中',
themeSuggestions: '桌面主题建议',
noMatchingThemes: '没有匹配的主题。',
themeTryPre: '试试 ',
themeTryPost: '。',
attachLabel: '附加',
files: '文件…',
folder: '文件夹…',
images: '图片…',
pasteImage: '粘贴图片',
url: 'URL…',
promptSnippets: '提示词片段…',
tipPre: '提示:输入 ',
tipPost: ' 以内联引用文件。',
snippetsTitle: '提示词片段',
snippetsDesc: '选择一个起始提示词放入输入框。',
snippets: {
codeReview: {
label: '代码审查',
description: '审查当前更改是否存在回归、遗漏的边界情况和缺失的测试。',
text: '请审查这部分是否存在缺陷、回归和缺失的测试。'
},
implementationPlan: {
label: '实现计划',
description: '在动代码之前先勾勒方案,让 diff 保持聚焦。',
text: '请在修改代码前制定一个简洁的实现计划。'
},
explainThis: {
label: '解释这段',
description: '讲解所选代码的工作方式,并链接到关键文件。',
text: '请解释这是如何工作的,并指给我关键文件。'
}
}
}
}

View file

@ -8,6 +8,7 @@ import { HashRouter } from 'react-router-dom'
import App from './app' import App from './app'
import { ErrorBoundary } from './components/error-boundary' import { ErrorBoundary } from './components/error-boundary'
import { HapticsProvider } from './components/haptics-provider' import { HapticsProvider } from './components/haptics-provider'
import { I18nProvider } from './i18n'
import { installClipboardShim } from './lib/clipboard' import { installClipboardShim } from './lib/clipboard'
import { queryClient } from './lib/query-client' import { queryClient } from './lib/query-client'
import { ThemeProvider } from './themes/context' import { ThemeProvider } from './themes/context'
@ -27,13 +28,15 @@ createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ErrorBoundary label="root"> <ErrorBoundary label="root">
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider> <I18nProvider>
<HapticsProvider> <ThemeProvider>
<HashRouter> <HapticsProvider>
<App /> <HashRouter>
</HashRouter> <App />
</HapticsProvider> </HashRouter>
</ThemeProvider> </HapticsProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider> </QueryClientProvider>
</ErrorBoundary> </ErrorBoundary>
</StrictMode> </StrictMode>