mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
02d6bf1c39
commit
4a1907bd10
36 changed files with 4221 additions and 1373 deletions
|
|
@ -5,6 +5,7 @@ import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
|||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -21,11 +22,11 @@ import { OverlayView } from '../overlays/overlay-view'
|
|||
|
||||
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
|
||||
// same visual vocabulary as the chat tool blocks.
|
||||
function statusGlyph(status: SubagentStatus): ReactNode {
|
||||
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
|
||||
if (status === 'running' || status === 'queued') {
|
||||
return (
|
||||
<BrailleSpinner
|
||||
ariaLabel="Running"
|
||||
ariaLabel={a.running}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
/>
|
||||
|
|
@ -33,10 +34,10 @@ function statusGlyph(status: SubagentStatus): ReactNode {
|
|||
}
|
||||
|
||||
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> = {
|
||||
|
|
@ -75,6 +76,7 @@ interface AgentsViewProps {
|
|||
}
|
||||
|
||||
export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
|
||||
|
|
@ -87,61 +89,61 @@ export function AgentsView({ onClose }: AgentsViewProps) {
|
|||
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel="Close agents"
|
||||
closeLabel={t.agents.close}
|
||||
contentClassName="px-5 pt-5 pb-4 sm:px-6"
|
||||
onClose={onClose}
|
||||
rootClassName="mx-auto max-w-3xl"
|
||||
>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">Spawn tree</h2>
|
||||
<p className="text-xs text-muted-foreground/80">Live subagent activity for the current turn.</p>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
|
||||
</header>
|
||||
<SubagentTree tree={tree} />
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
const fmtDuration = (seconds?: number) => {
|
||||
const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`
|
||||
return a.durationSeconds(seconds.toFixed(1))
|
||||
}
|
||||
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
|
||||
return `${m}m ${s}s`
|
||||
return a.durationMinutes(m, s)
|
||||
}
|
||||
|
||||
const fmtTokens = (value?: number) => {
|
||||
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok`
|
||||
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
|
||||
}
|
||||
|
||||
const fmtAge = (updatedAt: number, nowMs: number) => {
|
||||
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
|
||||
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
|
||||
|
||||
if (s < 2) {
|
||||
return 'now'
|
||||
return a.ageNow
|
||||
}
|
||||
|
||||
if (s < 60) {
|
||||
return `${s}s ago`
|
||||
return a.ageSeconds(s)
|
||||
}
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
if (m < 60) {
|
||||
return `${m}m ago`
|
||||
return a.ageMinutes(m)
|
||||
}
|
||||
|
||||
return `${Math.floor(m / 60)}h ago`
|
||||
return a.ageHours(Math.floor(m / 60))
|
||||
}
|
||||
|
||||
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
||||
|
|
@ -149,7 +151,7 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
|||
|
||||
interface RootGroup {
|
||||
id: string
|
||||
label: string
|
||||
delegationIndex: number
|
||||
nodes: SubagentNode[]
|
||||
taskCount: number
|
||||
}
|
||||
|
|
@ -173,18 +175,19 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
|
|||
|
||||
if (node.taskCount > 1) {
|
||||
n += 1
|
||||
groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount })
|
||||
groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount })
|
||||
groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
const { t } = useI18n()
|
||||
const flat = useMemo(() => flatten(tree), [tree])
|
||||
const groups = useMemo(() => groupDelegations(tree), [tree])
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
|
|
@ -210,21 +213,19 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
|||
return (
|
||||
<div className="grid place-items-center gap-3 py-12 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/60" />
|
||||
<p className="text-sm font-medium text-foreground/90">No live subagents</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">
|
||||
When a turn delegates work, child agents stream their progress here.
|
||||
</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">{t.agents.emptyDesc}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = [
|
||||
`${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`,
|
||||
active > 0 ? `${active} active` : '',
|
||||
failed > 0 ? `${failed} failed` : '',
|
||||
tools > 0 ? `${tools} tools` : '',
|
||||
files > 0 ? `${files} files` : '',
|
||||
tokens > 0 ? fmtTokens(tokens) : '',
|
||||
t.agents.agentsCount(flat.length),
|
||||
active > 0 ? t.agents.activeCount(active) : '',
|
||||
failed > 0 ? t.agents.failedCount(failed) : '',
|
||||
tools > 0 ? t.agents.toolsCount(tools) : '',
|
||||
files > 0 ? t.agents.filesCount(files) : '',
|
||||
tokens > 0 ? fmtTokens(tokens, t.agents) : '',
|
||||
cost > 0 ? `$${cost.toFixed(2)}` : ''
|
||||
].filter(Boolean)
|
||||
|
||||
|
|
@ -243,6 +244,8 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
|||
}
|
||||
|
||||
function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (group.nodes.length === 1 && group.taskCount <= 1) {
|
||||
return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
|
||||
}
|
||||
|
|
@ -252,8 +255,9 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number })
|
|||
return (
|
||||
<section className="grid min-w-0 gap-3">
|
||||
<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
|
||||
{activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null}
|
||||
{group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '}
|
||||
<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>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
{group.nodes.map(node => (
|
||||
|
|
@ -275,6 +279,7 @@ function StreamLine({
|
|||
parentRunning: boolean
|
||||
rowKey: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`)
|
||||
const isMono = entry.kind === 'tool'
|
||||
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
|
||||
|
|
@ -286,7 +291,7 @@ function StreamLine({
|
|||
{entry.text}
|
||||
{active ? (
|
||||
<BrailleSpinner
|
||||
ariaLabel="Streaming"
|
||||
ariaLabel={t.agents.streaming}
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
/>
|
||||
|
|
@ -297,6 +302,7 @@ function StreamLine({
|
|||
}
|
||||
|
||||
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
const running = node.status === 'running' || node.status === 'queued'
|
||||
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
|
||||
|
||||
|
|
@ -317,10 +323,10 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
|
||||
const subtitle = [
|
||||
node.model,
|
||||
fmtDuration(durationSeconds),
|
||||
node.toolCount ? `${node.toolCount} tools` : '',
|
||||
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)),
|
||||
`updated ${fmtAge(node.updatedAt, nowMs)}`
|
||||
fmtDuration(durationSeconds, t.agents),
|
||||
node.toolCount ? t.agents.toolsCount(node.toolCount) : '',
|
||||
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents),
|
||||
t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents))
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
|
|
@ -331,7 +337,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
onClick={() => setOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<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={cn(
|
||||
|
|
@ -366,7 +372,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<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 => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
|
|
@ -374,7 +380,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
|||
))}
|
||||
{fileLines.length > 8 ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'
|
|||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Pagination,
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
||||
|
|
@ -311,15 +313,15 @@ function formatArtifactTime(timestamp: number): string {
|
|||
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function pageRangeLabel(total: number, page: number, pageSize: number): string {
|
||||
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
|
||||
if (total === 0) {
|
||||
return '0'
|
||||
return a.zero
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(total, page * pageSize)
|
||||
|
||||
return `${start}-${end} of ${total}`
|
||||
return a.rangeOf(start, end, total)
|
||||
}
|
||||
|
||||
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
|
||||
|
|
@ -356,21 +358,25 @@ type CellCtx = {
|
|||
interface ArtifactColumn {
|
||||
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
|
||||
bodyClassName: string
|
||||
header: (filter: ArtifactFilter) => string
|
||||
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
|
||||
id: 'location' | 'primary' | 'session'
|
||||
width: (filter: ArtifactFilter) => string
|
||||
}
|
||||
|
||||
const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items')
|
||||
const itemsLabel = (f: ArtifactFilter, a: Translations['artifacts']) =>
|
||||
f === 'link' ? a.itemsLink : f === 'file' ? a.itemsFile : a.itemsGeneric
|
||||
|
||||
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const a = t.artifacts
|
||||
const navigate = useNavigate()
|
||||
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
|
||||
|
||||
|
|
@ -379,6 +385,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
const [filePage, setFilePage] = useState(1)
|
||||
|
||||
const refreshArtifacts = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
|
|
@ -393,12 +401,14 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages))
|
||||
})
|
||||
|
||||
setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp))
|
||||
setArtifacts(nextArtifacts.sort((left, right) => right.timestamp - left.timestamp))
|
||||
} catch (err) {
|
||||
notifyError(err, 'Artifacts failed to load')
|
||||
notifyError(err, a.failedLoad)
|
||||
setArtifacts([])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
}, [a])
|
||||
|
||||
useRefreshHotkey(refreshArtifacts)
|
||||
|
||||
|
|
@ -479,9 +489,9 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Open failed')
|
||||
notifyError(err, a.openFailed)
|
||||
}
|
||||
}, [])
|
||||
}, [a])
|
||||
|
||||
const markImageFailed = useCallback((id: string) => {
|
||||
setFailedImageIds(current => {
|
||||
|
|
@ -503,34 +513,46 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={counts.all === 0}
|
||||
searchPlaceholder="Search artifacts..."
|
||||
searchPlaceholder={a.search}
|
||||
searchTrailingAction={
|
||||
<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}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
|
||||
All <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
{a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
|
||||
Images <TextTabMeta>({counts.image})</TextTabMeta>
|
||||
{a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
|
||||
Files <TextTabMeta>({counts.file})</TextTabMeta>
|
||||
{a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
|
||||
Links <TextTabMeta>({counts.link})</TextTabMeta>
|
||||
{a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
<PageLoader label={a.indexing} />
|
||||
) : visibleArtifacts.length === 0 ? (
|
||||
<div className="grid h-full place-items-center px-6 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">No artifacts found</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Generated images and file outputs will appear here as sessions produce them.
|
||||
</div>
|
||||
<div className="text-sm font-medium">{a.noArtifactsTitle}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.noArtifactsDesc}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -547,7 +569,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel="images"
|
||||
itemLabel={a.itemsImage}
|
||||
onPageChange={setImagePage}
|
||||
page={currentImagePage}
|
||||
pageSize={24}
|
||||
|
|
@ -579,7 +601,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={itemsLabel(kindFilter)}
|
||||
itemLabel={itemsLabel(kindFilter, a)}
|
||||
onPageChange={setFilePage}
|
||||
page={currentFilePage}
|
||||
pageSize={100}
|
||||
|
|
@ -608,12 +630,14 @@ interface 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))
|
||||
|
||||
return (
|
||||
<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">
|
||||
{pageRangeLabel(total, page, pageSize)} {itemLabel}
|
||||
{pageRangeLabel(total, page, pageSize, a)} {itemLabel}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<Pagination className="mx-0 w-auto min-w-0 justify-end">
|
||||
|
|
@ -627,7 +651,7 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz
|
|||
<PaginationEllipsis />
|
||||
) : (
|
||||
<PaginationButton
|
||||
aria-label={`Go to ${itemLabel} page ${item}`}
|
||||
aria-label={a.goToPage(itemLabel, item)}
|
||||
isActive={page === item}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
|
|
@ -657,6 +681,10 @@ interface ArtifactImageCardProps {
|
|||
}
|
||||
|
||||
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
|
||||
const { t } = useI18n()
|
||||
const a = t.artifacts
|
||||
const kindLabel = artifact.kind === 'image' ? a.kindImage : artifact.kind === 'file' ? a.kindFile : a.kindLink
|
||||
|
||||
return (
|
||||
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
|
||||
<div
|
||||
|
|
@ -683,7 +711,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
|||
<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)">
|
||||
<FileImage className="size-3" />
|
||||
{artifact.kind}
|
||||
{kindLabel}
|
||||
</div>
|
||||
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
|
||||
{artifact.label}
|
||||
|
|
@ -698,7 +726,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
|||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
|
||||
<FolderOpen className="size-3" />
|
||||
Chat
|
||||
{a.chat}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -768,9 +796,10 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
|
|||
}
|
||||
|
||||
function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const { t } = useI18n()
|
||||
const isLink = artifact.kind === 'link'
|
||||
const value = isLink ? hostPathLabel(artifact.value) : artifact.value
|
||||
const copyLabel = isLink ? 'Copy URL' : 'Copy path'
|
||||
const copyLabel = isLink ? t.artifacts.copyUrl : t.artifacts.copyPath
|
||||
|
||||
return (
|
||||
<div className="group/location flex min-w-0 items-center gap-1.5">
|
||||
|
|
@ -814,21 +843,22 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
|
|||
{
|
||||
Cell: PrimaryCell,
|
||||
bodyClassName: 'p-0',
|
||||
header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'),
|
||||
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
|
||||
id: 'primary',
|
||||
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
|
||||
},
|
||||
{
|
||||
Cell: LocationCell,
|
||||
bodyClassName: 'px-2.5 py-1.5',
|
||||
header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'),
|
||||
header: (filter, a) =>
|
||||
filter === 'link' ? a.colLocationLink : filter === 'file' ? a.colLocationFile : a.colLocationDefault,
|
||||
id: 'location',
|
||||
width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]')
|
||||
},
|
||||
{
|
||||
Cell: SessionCell,
|
||||
bodyClassName: 'p-0',
|
||||
header: () => 'Session',
|
||||
header: (_filter, a) => a.colSession,
|
||||
id: 'session',
|
||||
width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]')
|
||||
}
|
||||
|
|
@ -843,13 +873,15 @@ function ArtifactTable({
|
|||
ctx: CellCtx
|
||||
filter: ArtifactFilter
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<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)">
|
||||
<tr>
|
||||
{ARTIFACT_COLUMNS.map(col => (
|
||||
<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>
|
||||
))}
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
|
@ -26,6 +27,8 @@ export function AttachmentList({
|
|||
}
|
||||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
|
||||
|
|
@ -53,12 +56,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not preview ${attachment.label}`)
|
||||
throw new Error(c.couldNotPreview(attachment.label))
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
notifyError(error, c.previewUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +69,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<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"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
|
|
@ -97,7 +100,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
</button>
|
||||
{onRemove && (
|
||||
<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"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -11,29 +11,14 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
|
||||
{
|
||||
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.'
|
||||
}
|
||||
]
|
||||
const SNIPPET_KEYS = ['codeReview', 'implementationPlan', 'explainThis']
|
||||
|
||||
export function ContextMenu({
|
||||
state,
|
||||
|
|
@ -44,6 +29,8 @@ export function ContextMenu({
|
|||
onPickFolders,
|
||||
onPickImages
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
// 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
|
||||
// window (composer "+" anchor), so we promoted it to a real Dialog —
|
||||
|
|
@ -71,78 +58,81 @@ export function ContextMenu({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
{c.attachLabel}
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
{c.files}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
{c.folder}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
{c.images}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
{c.pasteImage}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
{c.url}
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
|
||||
Prompt snippets…
|
||||
{c.promptSnippets}
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<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
|
||||
files inline.
|
||||
{c.tipPre}
|
||||
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
|
||||
{c.tipPost}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptSnippetsDialog
|
||||
onInsertText={onInsertText}
|
||||
onOpenChange={setSnippetsOpen}
|
||||
open={snippetsOpen}
|
||||
snippets={PROMPT_SNIPPETS}
|
||||
/>
|
||||
<PromptSnippetsDialog onInsertText={onInsertText} onOpenChange={setSnippetsOpen} open={snippetsOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippetsDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prompt snippets</DialogTitle>
|
||||
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
|
||||
<DialogTitle>{c.snippetsTitle}</DialogTitle>
|
||||
<DialogDescription>{c.snippetsDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="grid gap-1">
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<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"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<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">
|
||||
<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}
|
||||
{SNIPPET_KEYS.map(key => {
|
||||
const snippet = c.snippets[key]
|
||||
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
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={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -175,15 +165,8 @@ interface ContextMenuProps {
|
|||
state: ChatBarState
|
||||
}
|
||||
|
||||
interface PromptSnippet {
|
||||
description: string
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PromptSnippetsDialogProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
snippets: readonly PromptSnippet[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -55,6 +56,9 @@ export function ComposerControls({
|
|||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
}
|
||||
|
|
@ -65,9 +69,9 @@ export function ComposerControls({
|
|||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{showVoicePrimary ? (
|
||||
<Tip label="Start voice conversation">
|
||||
<Tip label={c.startVoice}>
|
||||
<Button
|
||||
aria-label="Start voice conversation"
|
||||
aria-label={c.startVoice}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
|
|
@ -81,9 +85,9 @@ export function ComposerControls({
|
|||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
|
||||
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
|
||||
<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}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
|
|
@ -113,25 +117,27 @@ function ConversationPill({
|
|||
onToggleMute,
|
||||
status
|
||||
}: ConversationProps & { disabled: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const speaking = status === 'speaking'
|
||||
const listening = status === 'listening' && !muted
|
||||
|
||||
const label =
|
||||
status === 'speaking'
|
||||
? 'Speaking'
|
||||
? c.speaking
|
||||
: status === 'transcribing'
|
||||
? 'Transcribing'
|
||||
? c.transcribing
|
||||
: status === 'thinking'
|
||||
? 'Thinking'
|
||||
? c.thinking
|
||||
: muted
|
||||
? 'Muted'
|
||||
: 'Listening'
|
||||
? c.muted
|
||||
: c.listening
|
||||
|
||||
return (
|
||||
<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
|
||||
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
aria-label={muted ? c.unmuteMic : c.muteMic}
|
||||
aria-pressed={muted}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
|
||||
disabled={disabled}
|
||||
|
|
@ -148,32 +154,34 @@ function ConversationPill({
|
|||
</Tip>
|
||||
{listening && (
|
||||
<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"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('submit')
|
||||
onStopTurn()
|
||||
}}
|
||||
title={c.stopListening}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Square className="fill-current" size={11} />
|
||||
<span>Stop</span>
|
||||
<span>{c.stopShort}</span>
|
||||
</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"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('close')
|
||||
onEnd()
|
||||
}}
|
||||
title={c.endConversation}
|
||||
type="button"
|
||||
>
|
||||
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
|
||||
<span>End</span>
|
||||
<span>{c.endShort}</span>
|
||||
</Button>
|
||||
<span className="sr-only" role="status">
|
||||
{label}
|
||||
|
|
@ -220,10 +228,12 @@ function DictationButton({
|
|||
status: VoiceStatus
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const active = state.active || status !== 'idle'
|
||||
|
||||
const aria =
|
||||
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
|
||||
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
|
||||
|
||||
return (
|
||||
<Tip label={aria}>
|
||||
|
|
|
|||
|
|
@ -1,44 +1,32 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
|
||||
|
||||
const COMMON_COMMANDS: [string, string][] = [
|
||||
['/help', 'full list of commands + hotkeys'],
|
||||
['/clear', 'start a new session'],
|
||||
['/resume', 'resume a prior session'],
|
||||
['/details', 'control transcript detail level'],
|
||||
['/copy', 'copy selection or last assistant message'],
|
||||
['/quit', 'exit hermes']
|
||||
]
|
||||
|
||||
const HOTKEYS: [string, string][] = [
|
||||
['@', 'reference files, folders, urls, git'],
|
||||
['/', 'slash command palette'],
|
||||
['?', 'this quick help (delete to dismiss)'],
|
||||
['Enter', 'send · Shift+Enter for newline'],
|
||||
['Cmd/Ctrl+K', 'send next queued turn'],
|
||||
['Cmd/Ctrl+L', 'redraw'],
|
||||
['Esc', 'close popover · cancel run'],
|
||||
['↑ / ↓', 'cycle popover / history']
|
||||
]
|
||||
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓']
|
||||
|
||||
export function HelpHint() {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
return (
|
||||
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
|
||||
<Section title="Common commands">
|
||||
{COMMON_COMMANDS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} mono />
|
||||
<Section title={c.commonCommands}>
|
||||
{COMMON_COMMAND_KEYS.map(key => (
|
||||
<Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Hotkeys">
|
||||
{HOTKEYS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} />
|
||||
<Section title={c.hotkeys}>
|
||||
{HOTKEY_KEYS.map(key => (
|
||||
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
|
|
@ -84,29 +85,6 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36
|
|||
const COMPOSER_FADE_BACKGROUND =
|
||||
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
||||
|
||||
// Resting composer placeholders. New sessions get open-ended starters; an
|
||||
// existing chat gets phrasings that read as a continuation of the thread.
|
||||
// One is picked at random per session (stable until the session changes).
|
||||
const NEW_SESSION_PLACEHOLDERS = [
|
||||
'What are we building?',
|
||||
'Give Hermes a task',
|
||||
"What's on your mind?",
|
||||
'Describe what you need',
|
||||
'What should we tackle?',
|
||||
'Ask anything',
|
||||
'Start with a goal'
|
||||
]
|
||||
|
||||
const FOLLOW_UP_PLACEHOLDERS = [
|
||||
'Send a follow-up',
|
||||
'Add more context',
|
||||
'Refine the request',
|
||||
"What's next?",
|
||||
'Keep it going',
|
||||
'Push it further',
|
||||
'Adjust or continue'
|
||||
]
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
interface QueueEditState {
|
||||
|
|
@ -190,7 +168,10 @@ export function ChatBar({
|
|||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
||||
const followUpPlaceholders = t.composer.followUpPlaceholders
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
|
|
@ -198,7 +179,7 @@ export function ChatBar({
|
|||
// started session (null → id, on the first send) is treated as the same
|
||||
// conversation so the placeholder doesn't visibly flip mid-stream.
|
||||
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
|
||||
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
|
||||
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
|
||||
)
|
||||
|
||||
const prevSessionIdRef = useRef(sessionId)
|
||||
|
|
@ -217,16 +198,16 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
|
||||
}, [sessionId])
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
const placeholder = disabled
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? 'Reconnecting to Hermes…'
|
||||
: 'Starting Hermes...'
|
||||
? t.composer.placeholderReconnecting
|
||||
: t.composer.placeholderStarting
|
||||
: restingPlaceholder
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
|
|
@ -1213,7 +1194,7 @@ export function ChatBar({
|
|||
const input = (
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label="Message"
|
||||
aria-label={t.composer.message}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useState } from 'react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QueuedPromptEntry } from '@/store/composer-queue'
|
||||
|
|
@ -16,10 +17,12 @@ interface QueuePanelProps {
|
|||
onSendNow: (id: string) => void
|
||||
}
|
||||
|
||||
const entryPreview = (entry: QueuedPromptEntry) =>
|
||||
entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn')
|
||||
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
|
||||
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
|
||||
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
if (entries.length === 0) {
|
||||
|
|
@ -34,7 +37,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
|||
type="button"
|
||||
>
|
||||
<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>
|
||||
|
||||
{!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"
|
||||
/>
|
||||
<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) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && (
|
||||
<span>
|
||||
{attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'}
|
||||
{c.attachments(attachmentsCount)}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
Editing in composer
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</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'
|
||||
)}
|
||||
>
|
||||
<Tip label="Edit queued turn">
|
||||
<Tip label={c.editQueued}>
|
||||
<Button
|
||||
aria-label="Edit queued turn"
|
||||
aria-label={c.editQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
|
|
@ -94,9 +97,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
|||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label="Send queued turn now">
|
||||
<Tip label={c.sendQueuedNow}>
|
||||
<Button
|
||||
aria-label="Send queued turn now"
|
||||
aria-label={c.sendQueuedNow}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
|
|
@ -107,9 +110,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
|||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label="Delete queued turn">
|
||||
<Tip label={c.deleteQueued}>
|
||||
<Button
|
||||
aria-label="Delete queued turn"
|
||||
aria-label={c.deleteQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useI18n } from '@/i18n'
|
||||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
|
@ -10,6 +11,8 @@ interface SkinSlashPopoverProps {
|
|||
}
|
||||
|
||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
||||
|
||||
|
|
@ -21,7 +24,7 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
aria-label="Desktop theme suggestions"
|
||||
aria-label={c.themeSuggestions}
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-skin-completion-drawer"
|
||||
data-state="open"
|
||||
|
|
@ -29,8 +32,10 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
|||
>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title="No matching themes.">
|
||||
Try <span className="font-mono text-foreground/80">/skin list</span>.
|
||||
<CompletionDrawerEmpty title={c.noMatchingThemes}>
|
||||
{c.themeTryPre}
|
||||
<span className="font-mono text-foreground/80">/skin list</span>
|
||||
{c.themeTryPost}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map(item => (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Globe } from '@/lib/icons'
|
||||
|
||||
const URL_HINT = /^https?:\/\//i
|
||||
|
|
@ -29,6 +30,8 @@ export function UrlDialog({
|
|||
open: boolean
|
||||
value: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const trimmed = value.trim()
|
||||
const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed)
|
||||
|
||||
|
|
@ -43,8 +46,8 @@ export function UrlDialog({
|
|||
<Globe className="size-4" />
|
||||
</span>
|
||||
<div className="grid gap-0.5 text-left">
|
||||
<DialogTitle>Attach a URL</DialogTitle>
|
||||
<DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription>
|
||||
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
|
||||
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<form
|
||||
|
|
@ -60,23 +63,24 @@ export function UrlDialog({
|
|||
autoCorrect="off"
|
||||
inputMode="url"
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="https://example.com/post"
|
||||
placeholder={c.urlPlaceholder}
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
{trimmed.length > 0 && !looksLikeUrl && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={!looksLikeUrl} type="submit">
|
||||
Attach
|
||||
{c.attach}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { stopVoicePlayback } from '@/lib/voice-playback'
|
||||
|
|
@ -163,12 +164,14 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
|
|||
}
|
||||
|
||||
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (state.status === 'idle') {
|
||||
return null
|
||||
}
|
||||
|
||||
const recording = state.status === 'recording'
|
||||
const title = recording ? 'Dictating' : 'Transcribing'
|
||||
const title = recording ? t.composer.dictating : t.composer.transcribing
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -201,6 +204,7 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) {
|
|||
}
|
||||
|
||||
export function VoicePlaybackActivity() {
|
||||
const { t } = useI18n()
|
||||
const playback = useStore($voicePlayback)
|
||||
|
||||
if (playback.status === 'idle') {
|
||||
|
|
@ -210,10 +214,10 @@ export function VoicePlaybackActivity() {
|
|||
const preparing = playback.status === 'preparing'
|
||||
|
||||
const title = preparing
|
||||
? 'Preparing audio'
|
||||
? t.composer.preparingAudio
|
||||
: playback.source === 'voice-conversation'
|
||||
? 'Speaking response'
|
||||
: 'Reading aloud'
|
||||
? t.composer.speakingResponse
|
||||
: t.composer.readingAloud
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
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>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
const id = path || '__no_workspace__'
|
||||
const label = baseName(path) || path || 'No workspace'
|
||||
const label = baseName(path) || path || noWorkspaceLabel
|
||||
|
||||
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
|
||||
group.sessions.push(session)
|
||||
|
|
@ -233,6 +234,8 @@ export function ChatSidebar({
|
|||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
|
|
@ -402,8 +405,8 @@ export function ChatSidebar({
|
|||
)
|
||||
|
||||
const agentGroups = useMemo(
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, workspaceOrderIds]
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, s.noWorkspace, workspaceOrderIds]
|
||||
)
|
||||
|
||||
const loadMoreForProfileGroup = useCallback(
|
||||
|
|
@ -589,13 +592,15 @@ export function ChatSidebar({
|
|||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={item.label}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{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 && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
|
||||
|
|
@ -615,9 +620,9 @@ export function ChatSidebar({
|
|||
{sidebarOpen && showSessionSections && (
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label="Search sessions"
|
||||
aria-label={s.searchAria}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search sessions…"
|
||||
placeholder={s.searchPlaceholder}
|
||||
value={searchQuery}
|
||||
/>
|
||||
</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"
|
||||
emptyState={
|
||||
<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>
|
||||
}
|
||||
label="Results"
|
||||
label={s.results}
|
||||
labelMeta={String(searchResults.length)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
|
|
@ -653,7 +658,7 @@ export function ChatSidebar({
|
|||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label="Pinned"
|
||||
label={s.pinned}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
|
|
@ -703,9 +708,9 @@ export function ChatSidebar({
|
|||
// view (always grouped by profile), so hide the button (not the slot).
|
||||
<div className="grid size-6 shrink-0 place-items-center">
|
||||
{!showAllProfiles && agentSessions.length > 0 ? (
|
||||
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
|
||||
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
|
||||
className={cn(
|
||||
'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'
|
||||
|
|
@ -724,7 +729,7 @@ export function ChatSidebar({
|
|||
) : null}
|
||||
</div>
|
||||
}
|
||||
label="Sessions"
|
||||
label={s.sessions}
|
||||
labelMeta={recentsMeta}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
|
|
@ -795,19 +800,25 @@ function SidebarSessionSkeletons() {
|
|||
)
|
||||
}
|
||||
|
||||
const SidebarAllPinnedState = () => (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
|
||||
Everything here is pinned. Unpin a chat to show it in recents.
|
||||
</div>
|
||||
)
|
||||
function SidebarAllPinnedState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
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() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<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)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift-click a chat to pin</span>
|
||||
<span>{t.sidebar.shiftClickHint}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1006,6 +1017,8 @@ function SidebarWorkspaceGroup({
|
|||
ref,
|
||||
...rest
|
||||
}: SidebarWorkspaceGroupProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const isProfileGroup = group.mode === 'profile'
|
||||
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
|
||||
const [open, setOpen] = useState(true)
|
||||
|
|
@ -1052,9 +1065,9 @@ function SidebarWorkspaceGroup({
|
|||
/>
|
||||
</button>
|
||||
{(onNewSession || isProfileGroup) && (
|
||||
<Tip label={`New session in ${group.label}`}>
|
||||
<Tip label={s.newSessionIn(group.label)}>
|
||||
<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"
|
||||
// Profile groups start a fresh session in that profile but keep the
|
||||
// all-profiles browse view (newSessionInProfile leaves the scope
|
||||
|
|
@ -1069,7 +1082,7 @@ function SidebarWorkspaceGroup({
|
|||
{reorderable && (
|
||||
<span
|
||||
{...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"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
|
|
@ -1091,9 +1104,9 @@ function SidebarWorkspaceGroup({
|
|||
(isProfileGroup ? (
|
||||
<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
|
||||
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"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
type="button"
|
||||
|
|
@ -1144,7 +1157,8 @@ interface 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 (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -43,13 +44,15 @@ interface ItemSpec {
|
|||
}
|
||||
|
||||
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 items: ItemSpec[] = [
|
||||
{
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? 'Unpin' : 'Pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
|
|
@ -58,17 +61,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
|||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'copy',
|
||||
label: 'Copy ID',
|
||||
label: r.copyId,
|
||||
onSelect: event => {
|
||||
event.preventDefault()
|
||||
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,
|
||||
icon: 'cloud-download',
|
||||
label: 'Export',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
|
|
@ -77,7 +80,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
|||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
label: 'Rename',
|
||||
label: r.rename,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
|
|
@ -86,7 +89,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
|||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: 'Archive',
|
||||
label: r.archive,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
|
|
@ -96,7 +99,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
|||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
icon: 'trash',
|
||||
label: 'Delete',
|
||||
label: t.common.delete,
|
||||
onSelect: () => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
|
|
@ -132,6 +135,7 @@ interface SessionActionsMenuProps
|
|||
}
|
||||
|
||||
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
|
||||
return (
|
||||
|
|
@ -140,7 +144,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
|
|||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={`Actions for ${actions.title}`}
|
||||
aria-label={t.sidebar.row.actionsFor(actions.title)}
|
||||
className="w-40"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
|
|
@ -157,13 +161,14 @@ interface SessionContextMenuProps extends SessionActions {
|
|||
}
|
||||
|
||||
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<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)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
|
@ -181,6 +186,8 @@ interface RenameSessionDialogProps {
|
|||
}
|
||||
|
||||
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [value, setValue] = useState(currentTitle)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
|
@ -211,10 +218,10 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
|||
const result = await renameSession(sessionId, next, profile)
|
||||
const finalTitle = result.title || next || ''
|
||||
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)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Rename failed')
|
||||
notifyError(err, r.renameFailed)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
|
@ -224,8 +231,8 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
|||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename session</DialogTitle>
|
||||
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
|
||||
<DialogTitle>{r.renameTitle}</DialogTitle>
|
||||
<DialogDescription>{r.renameDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
|
|
@ -239,16 +246,16 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
|||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Untitled session"
|
||||
placeholder={r.untitledPlaceholder}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={submitting} onClick={() => void submit()} type="button">
|
||||
Save
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -26,22 +27,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
|||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
const AGE_TICKS: ReadonlyArray<[number, string]> = [
|
||||
[86_400_000, 'd'],
|
||||
[3_600_000, 'h'],
|
||||
[60_000, 'm']
|
||||
const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
|
||||
[86_400_000, 'ageDay'],
|
||||
[3_600_000, 'ageHour'],
|
||||
[60_000, 'ageMin']
|
||||
]
|
||||
|
||||
function formatAge(seconds: number): string {
|
||||
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
|
||||
const delta = Math.max(0, Date.now() - seconds * 1000)
|
||||
|
||||
for (const [ms, suffix] of AGE_TICKS) {
|
||||
for (const [ms, key] of AGE_TICKS) {
|
||||
if (delta >= ms) {
|
||||
return `${Math.floor(delta / ms)}${suffix}`
|
||||
return `${Math.floor(delta / ms)}${r[key]}`
|
||||
}
|
||||
}
|
||||
|
||||
return 'now'
|
||||
return r.ageNow
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
|
|
@ -61,8 +62,10 @@ export function SidebarSessionRow({
|
|||
ref,
|
||||
...rest
|
||||
}: SidebarSessionRowProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at)
|
||||
const age = formatAge(session.last_active || session.started_at, r)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
|
|
@ -196,10 +199,10 @@ export function SidebarSessionRow({
|
|||
title={title}
|
||||
>
|
||||
<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!"
|
||||
size="icon"
|
||||
title="Session actions"
|
||||
title={r.sessionActions}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
|
|
@ -220,6 +223,9 @@ function SidebarRowDot({
|
|||
needsInput?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// "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.
|
||||
// Amber + steady (no ping) reads as "your turn", distinct from the accent
|
||||
|
|
@ -227,17 +233,17 @@ function SidebarRowDot({
|
|||
if (needsInput) {
|
||||
return (
|
||||
<span
|
||||
aria-label="Needs your input"
|
||||
aria-label={r.needsInput}
|
||||
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
|
||||
role="status"
|
||||
title="Waiting for your answer"
|
||||
title={r.waitingForAnswer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? 'Session running' : undefined}
|
||||
aria-label={isWorking ? r.sessionRunning : undefined}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isWorking
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@ import type * as React from 'react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
interface CronJobActions {
|
||||
|
|
@ -32,12 +33,15 @@ export function CronJobActionsMenu({
|
|||
sideOffset = 6,
|
||||
title
|
||||
}: CronJobActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={`Actions for ${title}`}
|
||||
aria-label={c.actionsFor(title)}
|
||||
className="w-44"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
|
|
@ -49,7 +53,7 @@ export function CronJobActionsMenu({
|
|||
}}
|
||||
>
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
<span>{isPaused ? 'Resume' : 'Pause'}</span>
|
||||
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
|
|
@ -60,7 +64,7 @@ export function CronJobActionsMenu({
|
|||
}}
|
||||
>
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
<span>Trigger now</span>
|
||||
<span>{c.triggerNow}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
|
|
@ -70,7 +74,7 @@ export function CronJobActionsMenu({
|
|||
}}
|
||||
>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>Edit</span>
|
||||
<span>{c.edit}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
|
|
@ -81,7 +85,7 @@ export function CronJobActionsMenu({
|
|||
variant="destructive"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>Delete</span>
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -93,12 +97,14 @@ interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Bu
|
|||
}
|
||||
|
||||
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
aria-label={t.cron.actionsFor(title)}
|
||||
className={className}
|
||||
size="icon-sm"
|
||||
title="Cron job actions"
|
||||
title={t.cron.actionsTitle}
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -14,7 +13,6 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
|
|
@ -27,78 +25,49 @@ import {
|
|||
triggerCronJob,
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
|
||||
{ label: 'This desktop', value: 'local' },
|
||||
{ label: 'Telegram', value: 'telegram' },
|
||||
{ label: 'Discord', value: 'discord' },
|
||||
{ label: 'Slack', value: 'slack' },
|
||||
{ label: 'Email', value: 'email' }
|
||||
]
|
||||
const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
|
||||
|
||||
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
{
|
||||
expr: '0 9 * * *',
|
||||
hint: 'Every day at 9:00 AM',
|
||||
label: 'Daily',
|
||||
value: 'daily'
|
||||
},
|
||||
{
|
||||
expr: '0 9 * * 1-5',
|
||||
hint: 'Monday through Friday at 9:00 AM',
|
||||
label: 'Weekdays',
|
||||
value: 'weekdays'
|
||||
},
|
||||
{
|
||||
expr: '0 9 * * 1',
|
||||
hint: 'Every Monday at 9:00 AM',
|
||||
label: 'Weekly',
|
||||
value: 'weekly'
|
||||
},
|
||||
{
|
||||
expr: '0 9 1 * *',
|
||||
hint: 'The first day of each month at 9:00 AM',
|
||||
label: 'Monthly',
|
||||
value: 'monthly'
|
||||
},
|
||||
{
|
||||
expr: '0 * * * *',
|
||||
hint: 'At the top of every hour',
|
||||
label: 'Hourly',
|
||||
value: 'hourly'
|
||||
},
|
||||
{
|
||||
expr: '*/15 * * * *',
|
||||
hint: 'Every 15 minutes',
|
||||
label: 'Every 15 minutes',
|
||||
value: 'every-15-minutes'
|
||||
},
|
||||
{
|
||||
hint: 'Cron syntax or natural language',
|
||||
label: 'Custom',
|
||||
value: 'custom'
|
||||
}
|
||||
{ expr: '0 9 * * *', value: 'daily' },
|
||||
{ expr: '0 9 * * 1-5', value: 'weekdays' },
|
||||
{ expr: '0 9 * * 1', value: 'weekly' },
|
||||
{ expr: '0 9 1 * *', value: 'monthly' },
|
||||
{ expr: '0 * * * *', value: 'hourly' },
|
||||
{ expr: '*/15 * * * *', value: 'every-15-minutes' },
|
||||
{ value: 'custom' }
|
||||
]
|
||||
|
||||
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
|
||||
enabled: 'default',
|
||||
scheduled: 'default',
|
||||
running: 'default',
|
||||
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
|
||||
enabled: 'good',
|
||||
scheduled: 'good',
|
||||
running: 'good',
|
||||
paused: 'warn',
|
||||
disabled: 'muted',
|
||||
error: 'destructive',
|
||||
error: 'bad',
|
||||
completed: 'muted'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
|
||||
|
||||
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value)
|
||||
|
|
@ -155,19 +124,8 @@ function cronParts(expr: string): null | string[] {
|
|||
return parts.length === 5 ? parts : null
|
||||
}
|
||||
|
||||
function dayName(value: string): string {
|
||||
const names: Record<string, string> = {
|
||||
'0': 'Sunday',
|
||||
'1': 'Monday',
|
||||
'2': 'Tuesday',
|
||||
'3': 'Wednesday',
|
||||
'4': 'Thursday',
|
||||
'5': 'Friday',
|
||||
'6': 'Saturday',
|
||||
'7': 'Sunday'
|
||||
}
|
||||
|
||||
return names[value] ?? `day ${value}`
|
||||
function dayName(value: string, c: Translations['cron']): string {
|
||||
return c.days[value] ?? c.dayFallback(value)
|
||||
}
|
||||
|
||||
function formatCronTime(minute: string, hour: string): string {
|
||||
|
|
@ -243,36 +201,36 @@ function scheduleOptionForExpr(expr: string): ScheduleOption {
|
|||
return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1]
|
||||
}
|
||||
|
||||
function scheduleSummary(option: ScheduleOption, expr: string): string {
|
||||
function scheduleSummary(option: ScheduleOption, expr: string, c: Translations['cron']): string {
|
||||
const parts = cronParts(expr)
|
||||
|
||||
if (!parts) {
|
||||
return option.hint
|
||||
return c.scheduleHints[option.value] ?? ''
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, , dayOfWeek] = parts
|
||||
|
||||
if (option.value === 'daily') {
|
||||
return `Every day at ${formatCronTime(minute, hour)}`
|
||||
return c.everyDayAt(formatCronTime(minute, hour))
|
||||
}
|
||||
|
||||
if (option.value === 'weekdays') {
|
||||
return `Weekdays at ${formatCronTime(minute, hour)}`
|
||||
return c.weekdaysAt(formatCronTime(minute, hour))
|
||||
}
|
||||
|
||||
if (option.value === 'weekly') {
|
||||
return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}`
|
||||
return c.everyDayOfWeekAt(dayName(dayOfWeek, c), formatCronTime(minute, hour))
|
||||
}
|
||||
|
||||
if (option.value === 'monthly') {
|
||||
return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}`
|
||||
return c.monthlyOnDayAt(dayOfMonth, formatCronTime(minute, hour))
|
||||
}
|
||||
|
||||
if (option.value === 'hourly') {
|
||||
return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}`
|
||||
return minute === '0' ? c.topOfHour : c.everyHourAt(minute.padStart(2, '0'))
|
||||
}
|
||||
|
||||
return option.hint
|
||||
return c.scheduleHints[option.value] ?? ''
|
||||
}
|
||||
|
||||
function formatTime(iso?: null | string): string {
|
||||
|
|
@ -301,26 +259,35 @@ function matchesQuery(job: CronJob, q: string): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
interface CronViewProps {
|
||||
interface CronViewProps extends React.ComponentProps<'section'> {
|
||||
onClose: () => void
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function CronView({ onClose }: CronViewProps) {
|
||||
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const [jobs, setJobs] = useState<CronJob[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyJobId, setBusyJobId] = useState<null | string>(null)
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const result = await getCronJobs()
|
||||
setJobs(result)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to load cron jobs')
|
||||
notifyError(err, c.failedLoad)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
}, [c])
|
||||
|
||||
useRefreshHotkey(refresh)
|
||||
|
||||
|
|
@ -348,11 +315,11 @@ export function CronView({ onClose }: CronViewProps) {
|
|||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: isPaused ? 'Cron resumed' : 'Cron paused',
|
||||
title: isPaused ? c.resumed : c.paused,
|
||||
message: truncate(jobTitle(job), 60)
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to update cron job')
|
||||
notifyError(err, c.failedUpdate)
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
|
|
@ -364,14 +331,33 @@ export function CronView({ onClose }: CronViewProps) {
|
|||
try {
|
||||
const updated = await triggerCronJob(job.id)
|
||||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) })
|
||||
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to trigger cron job')
|
||||
notifyError(err, c.failedTrigger)
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(true)
|
||||
|
||||
try {
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
|
||||
setPendingDelete(null)
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedDelete)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditorSave(values: EditorValues) {
|
||||
if (editor.mode === 'create') {
|
||||
const created = await createCronJob({
|
||||
|
|
@ -382,7 +368,7 @@ export function CronView({ onClose }: CronViewProps) {
|
|||
})
|
||||
|
||||
setJobs(current => (current ? [...current, created] : [created]))
|
||||
notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) })
|
||||
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
|
||||
} else if (editor.mode === 'edit') {
|
||||
const updated = await updateCronJob(editor.job.id, {
|
||||
prompt: values.prompt,
|
||||
|
|
@ -392,61 +378,67 @@ export function CronView({ onClose }: CronViewProps) {
|
|||
})
|
||||
|
||||
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
|
||||
notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) })
|
||||
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
|
||||
}
|
||||
|
||||
setEditor({ mode: 'closed' })
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close cron" onClose={onClose}>
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{totalCount > 0 && (
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-4 pb-2">
|
||||
<SearchField
|
||||
containerClassName="max-w-[60vw]"
|
||||
onChange={setQuery}
|
||||
placeholder="Search cron jobs…"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OverlayView closeLabel={c.close} onClose={onClose}>
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder={c.search}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? c.refreshing : c.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
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 ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
<PageLoader label={c.loading} />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
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.'
|
||||
}
|
||||
actionLabel={totalCount === 0 ? c.createFirst : undefined}
|
||||
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
|
||||
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
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{enabledCount}/{totalCount} active
|
||||
{c.active(enabledCount, totalCount)}
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
{c.newCron}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
c={c}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
|
|
@ -458,42 +450,40 @@ export function CronView({ onClose }: CronViewProps) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<ConfirmDialog
|
||||
busyLabel="Deleting…"
|
||||
confirmLabel="Delete"
|
||||
description={
|
||||
pendingDelete ? (
|
||||
<>
|
||||
This will remove{' '}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently.
|
||||
It will stop firing immediately.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
destructive
|
||||
doneLabel="Deleted"
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' })
|
||||
}}
|
||||
open={pendingDelete !== null}
|
||||
title="Delete cron job?"
|
||||
/>
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{c.deleteTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
{c.deleteDescPrefix}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
|
||||
{c.deleteDescSuffix}
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? c.deleting : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageSearchShell>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobRow({
|
||||
busy,
|
||||
c,
|
||||
job,
|
||||
onDelete,
|
||||
onEdit,
|
||||
|
|
@ -501,6 +491,7 @@ function CronJobRow({
|
|||
onTrigger
|
||||
}: {
|
||||
busy: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
|
|
@ -516,19 +507,15 @@ function CronJobRow({
|
|||
return (
|
||||
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
<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}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
|
||||
{state}
|
||||
</Badge>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<Badge className="capitalize" variant="muted">
|
||||
{deliver}
|
||||
</Badge>
|
||||
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
|
||||
)}
|
||||
</div>
|
||||
{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" />
|
||||
{jobScheduleDisplay(job)}
|
||||
</span>
|
||||
<span>Last: {formatTime(job.last_run_at)}</span>
|
||||
<span>Next: {formatTime(job.next_run_at)}</span>
|
||||
<span>
|
||||
{c.last} {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{c.next} {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<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({
|
||||
actionLabel,
|
||||
description,
|
||||
|
|
@ -605,6 +606,8 @@ function CronEditorDialog({
|
|||
onClose: () => void
|
||||
onSave: (values: EditorValues) => Promise<void>
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const open = editor.mode !== 'closed'
|
||||
const isEdit = editor.mode === 'edit'
|
||||
const initial = isEdit ? editor.job : null
|
||||
|
|
@ -647,7 +650,7 @@ function CronEditorDialog({
|
|||
}
|
||||
}
|
||||
|
||||
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule)
|
||||
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c)
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
|
@ -655,7 +658,7 @@ function CronEditorDialog({
|
|||
const trimmedSchedule = schedule.trim()
|
||||
|
||||
if (!trimmedPrompt || !trimmedSchedule) {
|
||||
setError('Prompt and schedule are required.')
|
||||
setError(c.promptScheduleRequired)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -671,7 +674,7 @@ function CronEditorDialog({
|
|||
schedule: trimmedSchedule
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save cron job')
|
||||
setError(err instanceof Error ? err.message : c.failedSave)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
|
@ -681,60 +684,56 @@ function CronEditorDialog({
|
|||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit cron job' : 'New cron job'}</DialogTitle>
|
||||
<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>
|
||||
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
|
||||
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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
|
||||
autoFocus
|
||||
id="cron-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
placeholder="Morning briefing"
|
||||
placeholder={c.namePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field htmlFor="cron-prompt" label="Prompt">
|
||||
<Field htmlFor="cron-prompt" label={c.promptLabel}>
|
||||
<Textarea
|
||||
className="min-h-24 font-mono"
|
||||
id="cron-prompt"
|
||||
onChange={event => setPrompt(event.target.value)}
|
||||
placeholder="Summarize my unread Slack threads and email me the top 5..."
|
||||
placeholder={c.promptPlaceholder}
|
||||
value={prompt}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<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}>
|
||||
<SelectTrigger id="cron-frequency">
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULE_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{c.scheduleLabels[option.value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field htmlFor="cron-deliver" label="Deliver to">
|
||||
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
|
||||
<Select onValueChange={setDeliver} value={deliver}>
|
||||
<SelectTrigger id="cron-deliver">
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{DELIVERY_VALUES.map(value => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{c.deliveryLabels[value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -743,15 +742,15 @@ function CronEditorDialog({
|
|||
</div>
|
||||
|
||||
{schedulePreset === 'custom' ? (
|
||||
<Field htmlFor="cron-schedule" label="Custom schedule">
|
||||
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
|
||||
<Input
|
||||
className="font-mono"
|
||||
id="cron-schedule"
|
||||
onChange={event => setSchedule(event.target.value)}
|
||||
placeholder="0 9 * * * or weekdays at 9am"
|
||||
placeholder={c.customPlaceholder}
|
||||
value={schedule}
|
||||
/>
|
||||
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint>
|
||||
<FieldHint>{c.customHint}</FieldHint>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
|
||||
|
|
@ -771,10 +770,10 @@ function CronEditorDialog({
|
|||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={saving} type="submit">
|
||||
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Create cron'}
|
||||
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
|
@ -787,18 +786,20 @@ function Field({
|
|||
children,
|
||||
htmlFor,
|
||||
label,
|
||||
optional
|
||||
optional,
|
||||
optionalLabel
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
htmlFor: string
|
||||
label: string
|
||||
optional?: boolean
|
||||
optionalLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
|
||||
{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>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -820,7 +821,5 @@ interface EditorValues {
|
|||
|
||||
interface ScheduleOption {
|
||||
expr?: string
|
||||
hint: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -700,6 +700,7 @@ export function DesktopController() {
|
|||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
type MessagingPlatformInfo,
|
||||
updateMessagingPlatform
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -33,31 +33,15 @@ interface MessagingViewProps extends React.ComponentProps<'section'> {
|
|||
|
||||
type EditMap = Record<string, Record<string, string>>
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
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'
|
||||
const PILL_TONE: Record<StatusTone, 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 TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
|
||||
good: 'default',
|
||||
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')
|
||||
const stateLabel = (state: null | string | undefined, m: Translations['messaging']) =>
|
||||
state ? m.states[state] || state.replace(/_/g, ' ') : m.unknown
|
||||
|
||||
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
|
||||
if (!enabled) {
|
||||
|
|
@ -86,7 +70,7 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
|||
TELEGRAM_BOT_TOKEN: {
|
||||
label: 'Bot token',
|
||||
help: 'Create a bot with @BotFather, then paste the token it gives you.',
|
||||
placeholder: '123456:ABC...'
|
||||
placeholder: 'Paste Telegram bot token'
|
||||
},
|
||||
TELEGRAM_ALLOWED_USERS: {
|
||||
label: 'Allowed Telegram user IDs',
|
||||
|
|
@ -153,13 +137,13 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
|||
},
|
||||
SLACK_BOT_TOKEN: {
|
||||
label: 'Slack bot token',
|
||||
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
|
||||
placeholder: 'xoxb-...'
|
||||
help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
|
||||
placeholder: 'Paste Slack bot token'
|
||||
},
|
||||
SLACK_APP_TOKEN: {
|
||||
label: 'Slack app token',
|
||||
help: 'Starts with xapp-. Required for Socket Mode.',
|
||||
placeholder: 'xapp-...'
|
||||
help: 'Use the app-level token required for Socket Mode.',
|
||||
placeholder: 'Paste Slack app token'
|
||||
},
|
||||
SLACK_ALLOWED_USERS: {
|
||||
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 localized = m.fieldCopy[field.key] || {}
|
||||
|
||||
return {
|
||||
label: copy.label || field.prompt || field.key,
|
||||
help: copy.help || field.description,
|
||||
placeholder: copy.placeholder || field.prompt,
|
||||
label: localized.label || copy.label || field.prompt || field.key,
|
||||
help: localized.help || copy.help || field.description,
|
||||
placeholder: localized.placeholder || copy.placeholder || field.prompt,
|
||||
advanced: Boolean(copy.advanced || field.advanced)
|
||||
}
|
||||
}
|
||||
|
||||
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
|
||||
const [edits, setEdits] = useState<EditMap>({})
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -249,14 +236,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
setPlatforms(result.platforms)
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
notifyError(err, 'Messaging platforms failed to load')
|
||||
notifyError(err, m.loadFailed)
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [m])
|
||||
|
||||
useRefreshHotkey(() => void refreshPlatforms())
|
||||
|
||||
|
|
@ -330,11 +317,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`,
|
||||
message: 'Restart the gateway for this change to take effect.'
|
||||
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
|
||||
message: m.restartToApply
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${platform.name}`)
|
||||
notifyError(err, m.failedUpdate(platform.name))
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
|
|
@ -355,11 +342,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
await refreshPlatforms()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: `${platform.name} setup saved`,
|
||||
message: 'Restart the gateway to reconnect with the new credentials.'
|
||||
title: m.setupSaved(platform.name),
|
||||
message: m.restartToReconnect
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${platform.name}`)
|
||||
notifyError(err, m.failedSave(platform.name))
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
|
|
@ -378,9 +365,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
}
|
||||
}))
|
||||
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) {
|
||||
notifyError(err, `Failed to clear ${key}`)
|
||||
notifyError(err, m.failedClear(key))
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
|
|
@ -391,11 +378,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={(platforms?.length ?? 0) === 0}
|
||||
searchPlaceholder="Search messaging..."
|
||||
searchPlaceholder={m.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{!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)]">
|
||||
<aside className="min-h-0 overflow-y-auto p-2">
|
||||
|
|
@ -485,12 +472,14 @@ function PlatformDetail({
|
|||
platform: MessagingPlatformInfo
|
||||
saving: string | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const hasEdits = Object.keys(trimEdits(edits)).length > 0
|
||||
const requiredFields = platform.env_vars.filter(field => field.required)
|
||||
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field).advanced)
|
||||
const advancedFields = 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, m).advanced)
|
||||
const hiddenCount = advancedFields.length
|
||||
const isSavingEnv = saving === `env:${platform.id}`
|
||||
|
||||
|
|
@ -506,11 +495,11 @@ function PlatformDetail({
|
|||
{platform.description}
|
||||
</p>
|
||||
<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}>
|
||||
{platform.configured ? 'Credentials set' : 'Needs setup'}
|
||||
{platform.configured ? m.credentialsSet : m.needsSetup}
|
||||
</SetupPill>
|
||||
{!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>}
|
||||
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
|
||||
</div>
|
||||
<PlatformHint platform={platform} />
|
||||
</div>
|
||||
|
|
@ -524,14 +513,14 @@ function PlatformDetail({
|
|||
)}
|
||||
|
||||
<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)">
|
||||
{introCopy(platform)}
|
||||
{introCopy(platform, m)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
Open setup guide
|
||||
{m.openSetupGuide}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
|
|
@ -539,7 +528,7 @@ function PlatformDetail({
|
|||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Required</SectionTitle>
|
||||
<SectionTitle>{m.required}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{requiredFields.length > 0 ? (
|
||||
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)">
|
||||
This platform does not need a token here. Use the setup guide above, then enable it below.
|
||||
{m.noTokenNeeded}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -562,7 +551,7 @@ function PlatformDetail({
|
|||
|
||||
{optionalFields.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>Recommended</SectionTitle>
|
||||
<SectionTitle>{m.recommended}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{optionalFields.map(field => (
|
||||
<MessagingField
|
||||
|
|
@ -585,7 +574,7 @@ function PlatformDetail({
|
|||
onClick={() => setShowAdvanced(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>Advanced ({hiddenCount})</span>
|
||||
<span>{m.advanced(hiddenCount)}</span>
|
||||
<DisclosureCaret open={showAdvanced} size="0.875rem" />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
|
|
@ -609,19 +598,23 @@ function PlatformDetail({
|
|||
|
||||
<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">
|
||||
<Switch
|
||||
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
size="xs"
|
||||
/>
|
||||
<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)]">
|
||||
<Switch
|
||||
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
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">
|
||||
{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">
|
||||
<Save />
|
||||
{isSavingEnv ? 'Saving...' : 'Save changes'}
|
||||
{isSavingEnv ? m.saving : m.saveChanges}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -636,7 +629,7 @@ const PLATFORM_INTRO: Record<string, string> = {
|
|||
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.',
|
||||
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:
|
||||
'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.',
|
||||
|
|
@ -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.'
|
||||
}
|
||||
|
||||
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({
|
||||
edits,
|
||||
|
|
@ -682,7 +676,9 @@ function MessagingField({
|
|||
onEdit: (key: string, value: string) => void
|
||||
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}`
|
||||
|
||||
return (
|
||||
|
|
@ -693,12 +689,12 @@ function MessagingField({
|
|||
className={CREDENTIAL_CONTROL_CLASS}
|
||||
id={fieldId}
|
||||
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'}
|
||||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{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">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
|
|
@ -709,7 +705,7 @@ function MessagingField({
|
|||
className="size-8 shrink-0"
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
title={`Clear ${field.key}`}
|
||||
title={m.clearField(field.key)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
|
|
@ -721,7 +717,7 @@ function MessagingField({
|
|||
title={
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
|
@ -733,24 +729,45 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!platform.enabled || platform.state === 'connected') {
|
||||
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
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
|
||||
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} />
|
||||
{children}
|
||||
</Badge>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
37
apps/desktop/src/app/overlays/overlay-search-input.tsx
Normal file
37
apps/desktop/src/app/overlays/overlay-search-input.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ interface PageSearchShellProps extends React.ComponentProps<'section'> {
|
|||
filters?: ReactNode
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder: string
|
||||
searchTrailingAction?: ReactNode
|
||||
searchValue: string
|
||||
/** Hide the search field when there's nothing to search (empty dataset). */
|
||||
searchHidden?: boolean
|
||||
|
|
@ -23,6 +24,7 @@ export function PageSearchShell({
|
|||
filters,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchTrailingAction,
|
||||
searchValue,
|
||||
searchHidden = false,
|
||||
...props
|
||||
|
|
@ -58,6 +60,7 @@ export function PageSearchShell({
|
|||
containerClassName="max-w-[45vw]"
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
trailingAction={searchTrailingAction}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,8 @@ import { useStore } from '@nanostores/react'
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
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 {
|
||||
$desktopVersion,
|
||||
|
|
@ -18,29 +19,31 @@ import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
|||
|
||||
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) {
|
||||
return 'never'
|
||||
return a.never
|
||||
}
|
||||
|
||||
const diff = Date.now() - ms
|
||||
|
||||
if (diff < 60_000) {
|
||||
return 'just now'
|
||||
return a.justNow
|
||||
}
|
||||
|
||||
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) {
|
||||
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() {
|
||||
const { t } = useI18n()
|
||||
const a = t.settings.about
|
||||
const version = useStore($desktopVersion)
|
||||
const status = useStore($updateStatus)
|
||||
const apply = useStore($updateApply)
|
||||
|
|
@ -69,21 +72,21 @@ export function AboutSettings() {
|
|||
let statusTone: 'idle' | 'available' | 'error' = 'idle'
|
||||
|
||||
if (!supported) {
|
||||
statusLine = status?.message ?? "This build can't update itself from inside the app."
|
||||
statusLine = status?.message ?? a.cantUpdate
|
||||
statusTone = 'error'
|
||||
} else if (status?.error) {
|
||||
statusLine = "We couldn't reach the update server."
|
||||
statusLine = a.cantReach
|
||||
statusTone = 'error'
|
||||
} else if (applying) {
|
||||
statusLine = 'An update is currently installing.'
|
||||
statusLine = a.installing
|
||||
statusTone = 'available'
|
||||
} else if (behind > 0) {
|
||||
statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).`
|
||||
statusLine = a.updateReady(behind)
|
||||
statusTone = 'available'
|
||||
} else if (status) {
|
||||
statusLine = "You're on the latest version."
|
||||
statusLine = a.onLatest
|
||||
} else {
|
||||
statusLine = 'Tap "Check now" to look for updates.'
|
||||
statusLine = a.tapCheck
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -93,15 +96,15 @@ export function AboutSettings() {
|
|||
<Sparkles className="size-8" />
|
||||
</span>
|
||||
<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">
|
||||
{version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'}
|
||||
{version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 w-full max-w-2xl">
|
||||
<SectionHeading icon={RefreshCw} title="Updates" />
|
||||
<SectionHeading icon={RefreshCw} title={a.updates} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -111,12 +114,19 @@ export function AboutSettings() {
|
|||
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Last checked {relativeTime(status?.fetchedAt)}
|
||||
{justChecked && !checking ? ' · just now' : ''}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
{statusTone === 'available' ? (
|
||||
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
) : statusTone === 'error' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<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 className="mt-3 flex flex-wrap items-center gap-4">
|
||||
|
|
@ -126,13 +136,13 @@ export function AboutSettings() {
|
|||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{checking && <Loader2 className="size-3 animate-spin" />}
|
||||
{checking ? 'Checking…' : 'Check now'}
|
||||
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{checking ? a.checking : a.checkNow}
|
||||
</Button>
|
||||
|
||||
{behind > 0 && supported && !applying && (
|
||||
<Button onClick={() => openUpdatesWindow()} size="sm">
|
||||
See what's new
|
||||
{a.seeWhatsNew}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -146,16 +156,17 @@ export function AboutSettings() {
|
|||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Release notes
|
||||
<ExternalLink className="size-3" />
|
||||
{a.releaseNotes}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListRow
|
||||
description="Hermes checks for updates automatically in the background and lets you know when one is ready."
|
||||
hint={`Branch ${status?.branch ?? 'unknown'} · Commit ${status?.currentSha?.slice(0, 7) ?? 'unknown'}`}
|
||||
title="Automatic updates"
|
||||
description={a.automaticUpdatesDesc}
|
||||
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
|
||||
title={a.automaticUpdates}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
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 { Check } from '@/lib/icons'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { SettingsContent } from './primitives'
|
||||
import { Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
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() {
|
||||
const { t, locale, setLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
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 (
|
||||
<SettingsContent>
|
||||
<div className="grid gap-8">
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
|
||||
chat surface styling.
|
||||
</p>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<SectionHeading icon={Palette} title={a.title} />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={MODE_OPTIONS}
|
||||
value={mode}
|
||||
/>
|
||||
}
|
||||
description="Pick a fixed mode or let Hermes follow your system setting."
|
||||
title="Color Mode"
|
||||
/>
|
||||
<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">{t.language.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
|
||||
</div>
|
||||
<Pill>{LOCALE_META[locale].name}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{locales.map(code => {
|
||||
const active = locale === code
|
||||
|
||||
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={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>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setToolViewMode(id)
|
||||
}}
|
||||
options={
|
||||
[
|
||||
{ id: 'product', label: 'Product' },
|
||||
{ id: 'technical', label: 'Technical' }
|
||||
] as const
|
||||
}
|
||||
value={toolViewMode}
|
||||
/>
|
||||
}
|
||||
description="Product hides raw tool payloads; Technical shows full input/output."
|
||||
title="Tool Call Display"
|
||||
/>
|
||||
<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.colorMode}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
|
||||
</div>
|
||||
<Pill>{t.settings.modeOptions[mode].label}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
|
||||
const active = mode === id
|
||||
const copy = t.settings.modeOptions[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={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 className="grid gap-3">
|
||||
<SectionHead description="Desktop palettes only. The selected mode is applied on top." title="Theme" />
|
||||
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<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.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 => {
|
||||
const active = themeName === theme.name
|
||||
|
||||
return (
|
||||
<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}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
|
|
@ -133,17 +231,8 @@ export function AppearanceSettings() {
|
|||
}}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'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">
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
|
|
@ -152,7 +241,11 @@ export function AppearanceSettings() {
|
|||
{theme.description}
|
||||
</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>
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
getHermesConfigSchema,
|
||||
saveHermesConfig
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
|
@ -37,9 +38,20 @@ function ConfigField({
|
|||
optionLabels?: Record<string, string>
|
||||
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 rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim()
|
||||
|
||||
const rawDescription = (
|
||||
t.settings.fieldDescriptions[schemaKey] ??
|
||||
FIELD_DESCRIPTIONS[schemaKey] ??
|
||||
schema.description ??
|
||||
''
|
||||
).trim()
|
||||
|
||||
const normalizedDesc = normalize(rawDescription)
|
||||
|
||||
const description =
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useRef } from 'react'
|
|||
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
|
@ -34,6 +35,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||
]
|
||||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
||||
// sub-view is deep-linkable and survives a refresh.
|
||||
|
|
@ -64,12 +66,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
URL.revokeObjectURL(url)
|
||||
triggerHaptic('success')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Export failed')
|
||||
notifyError(err, t.settings.exportFailed)
|
||||
}
|
||||
}
|
||||
|
||||
const resetConfig = async () => {
|
||||
if (!window.confirm('Reset all settings to Hermes defaults?')) {
|
||||
if (!window.confirm(t.settings.resetConfirm)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -78,12 +80,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
triggerHaptic('success')
|
||||
onConfigSaved?.()
|
||||
} catch (err) {
|
||||
notifyError(err, 'Reset failed')
|
||||
notifyError(err, t.settings.resetFailed)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close settings" onClose={onClose}>
|
||||
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => {
|
||||
|
|
@ -94,7 +96,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
active={activeView === view}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
label={t.settings.sections[s.id] ?? s.label}
|
||||
onClick={() => setActiveView(view)}
|
||||
/>
|
||||
)
|
||||
|
|
@ -127,13 +129,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
<OverlayNavItem
|
||||
active={activeView === 'gateway'}
|
||||
icon={Globe}
|
||||
label="Gateway"
|
||||
label={t.settings.nav.gateway}
|
||||
onClick={() => setActiveView('gateway')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="Tools & Keys"
|
||||
label={t.settings.nav.apiKeys}
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
{activeView === 'keys' && (
|
||||
|
|
@ -157,29 +159,29 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
<OverlayNavItem
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
label="MCP"
|
||||
label={t.settings.nav.mcp}
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label="Archived Chats"
|
||||
label={t.settings.nav.archivedChats}
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
icon={Info}
|
||||
label="About"
|
||||
label={t.settings.nav.about}
|
||||
onClick={() => setActiveView('about')}
|
||||
/>
|
||||
<div className="mt-auto flex items-center gap-1 pt-2">
|
||||
<Tip label="Export config">
|
||||
<Tip label={t.settings.exportConfig}>
|
||||
<OverlayIconButton onClick={() => void exportConfig()}>
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label="Import config">
|
||||
<Tip label={t.settings.importConfig}>
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
|
|
@ -189,7 +191,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
<IconUpload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label="Reset to defaults">
|
||||
<Tip label={t.settings.resetToDefaults}>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
|
|
@ -44,6 +45,7 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
|
|||
}
|
||||
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
|
|
@ -76,7 +78,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
{
|
||||
icon: <Codicon name="layout-sidebar-left" />,
|
||||
id: 'sidebar',
|
||||
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
|
||||
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
leftEdge.toggle()
|
||||
|
|
@ -85,12 +87,12 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
{
|
||||
icon: <Codicon name="arrow-swap" />,
|
||||
id: 'flip-panes',
|
||||
label: 'Swap sidebar sides',
|
||||
label: t.titlebar.swapSidebarSides,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
togglePanesFlipped()
|
||||
},
|
||||
title: 'Swap the sessions and file browser sides'
|
||||
title: t.titlebar.swapSidebarSidesTitle
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
|
|
@ -98,7 +100,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
const rightSidebarTool: TitlebarTool = {
|
||||
icon: <Codicon name="layout-sidebar-right" />,
|
||||
id: 'right-sidebar',
|
||||
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
|
||||
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
rightEdge.toggle()
|
||||
|
|
@ -111,13 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
active: hapticsMuted,
|
||||
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
||||
id: 'haptics',
|
||||
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
|
||||
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
|
||||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
label: 'Open settings',
|
||||
label: t.titlebar.openSettings,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
onOpenSettings()
|
||||
|
|
@ -199,6 +201,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
|||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={tool.title ?? tool.label}
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
|
|
@ -221,6 +224,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
|||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
title={tool.title ?? tool.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ describe('SkillsView toolset management', () => {
|
|||
|
||||
await renderSkills()
|
||||
|
||||
expect(screen.getByText('Cron Jobs')).toBeTruthy()
|
||||
expect(await screen.findByText('Cron Jobs')).toBeTruthy()
|
||||
expect(screen.queryByText(/⏰/)).toBeNull()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
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 { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
|
|
@ -70,33 +72,39 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
|
|||
}
|
||||
|
||||
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
const refreshCapabilities = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
|
||||
setSkills(nextSkills)
|
||||
setToolsets(nextToolsets)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Skills failed to load')
|
||||
notifyError(err, t.skills.skillsLoadFailed)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refreshCapabilities)
|
||||
}, [t])
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
|
||||
}, [])
|
||||
.catch(err => notifyError(err, t.skills.toolsetsRefreshFailed))
|
||||
}, [t])
|
||||
|
||||
useRefreshHotkey(refreshCapabilities)
|
||||
|
||||
useEffect(() => {
|
||||
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)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Skill enabled' : 'Skill disabled',
|
||||
message: `${skill.name} applies to new sessions.`
|
||||
title: enabled ? t.skills.skillEnabled : t.skills.skillDisabled,
|
||||
message: t.skills.appliesToNewSessions(skill.name)
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${skill.name}`)
|
||||
notifyError(err, t.skills.failedToUpdate(skill.name))
|
||||
} finally {
|
||||
setSavingSkill(null)
|
||||
}
|
||||
|
|
@ -169,11 +177,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
|||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
|
||||
message: `${toolsetDisplayLabel(toolset)} applies to new sessions.`
|
||||
title: enabled ? t.skills.toolsetEnabled : t.skills.toolsetDisabled,
|
||||
message: t.skills.appliesToNewSessions(toolsetDisplayLabel(toolset))
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`)
|
||||
notifyError(err, t.skills.failedToUpdate(toolsetDisplayLabel(toolset)))
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
|
|
@ -183,54 +191,66 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
|||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
mode === 'skills' && categories.length > 0 ? (
|
||||
<>
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
All <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
{t.skills.tabSkills}
|
||||
</TextTab>
|
||||
{categories.map(category => (
|
||||
<TextTab
|
||||
active={activeCategory === category.key}
|
||||
key={category.key}
|
||||
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
|
||||
>
|
||||
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
{t.skills.tabToolsets}
|
||||
</TextTab>
|
||||
</div>
|
||||
{mode === 'skills' && categories.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
</TextTab>
|
||||
))}
|
||||
</>
|
||||
) : undefined
|
||||
{categories.map(category => (
|
||||
<TextTab
|
||||
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}
|
||||
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
|
||||
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
Skills
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
Toolsets
|
||||
</TextTab>
|
||||
</>
|
||||
searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? t.skills.refreshing : t.skills.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshCapabilities()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? t.skills.refreshing : t.skills.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label="Loading capabilities..." />
|
||||
<PageLoader label={t.skills.loading} />
|
||||
) : 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 ? (
|
||||
<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">
|
||||
{skillGroups.map(([category, list]) => (
|
||||
<div className="space-y-1.5" key={category}>
|
||||
{activeCategory === null && (
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{list.map(skill => (
|
||||
<div
|
||||
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="truncate text-sm font-medium">{skill.name}</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{asText(skill.description) || 'No description.'}
|
||||
{asText(skill.description) || t.skills.noDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -256,15 +276,15 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
|||
)}
|
||||
</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 ? (
|
||||
<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="text-xs text-muted-foreground">
|
||||
{enabledToolsets}/{toolsets.length} toolsets enabled
|
||||
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(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">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label={t.skills.configureToolset(label)}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() =>
|
||||
setExpandedToolset(current => (current === toolset.name ? null : toolset.name))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
{toolset.configured ? t.skills.configured : t.skills.needsKeys}
|
||||
</StatusPill>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${label} toolset`}
|
||||
aria-label={t.skills.toggleToolset(label)}
|
||||
checked={toolset.enabled}
|
||||
disabled={savingToolset === toolset.name}
|
||||
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
|
||||
|
|
@ -297,7 +317,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
|||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{asText(toolset.description) || 'No description.'}
|
||||
{asText(toolset.description) || t.skills.noDescription}
|
||||
</p>
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
|
|
|
|||
57
apps/desktop/src/i18n/context.test.tsx
Normal file
57
apps/desktop/src/i18n/context.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
76
apps/desktop/src/i18n/context.tsx
Normal file
76
apps/desktop/src/i18n/context.tsx
Normal 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
669
apps/desktop/src/i18n/en.ts
Normal 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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
apps/desktop/src/i18n/index.ts
Normal file
2
apps/desktop/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { I18nProvider, LOCALE_META, useI18n } from './context'
|
||||
export type { Locale, Translations } from './types'
|
||||
539
apps/desktop/src/i18n/types.ts
Normal file
539
apps/desktop/src/i18n/types.ts
Normal 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
802
apps/desktop/src/i18n/zh.ts
Normal 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: '请解释这是如何工作的,并指给我关键文件。'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { HashRouter } from 'react-router-dom'
|
|||
import App from './app'
|
||||
import { ErrorBoundary } from './components/error-boundary'
|
||||
import { HapticsProvider } from './components/haptics-provider'
|
||||
import { I18nProvider } from './i18n'
|
||||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { queryClient } from './lib/query-client'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
|
|
@ -27,13 +28,15 @@ createRoot(document.getElementById('root')!).render(
|
|||
<StrictMode>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue