Merge remote-tracking branch 'origin/main' into bb/remove-composer-message-shadows

# Conflicts:
#	apps/desktop/src/components/assistant-ui/tool-fallback.tsx
This commit is contained in:
Brooklyn Nicholson
2026-06-06 10:47:42 -05:00
95 changed files with 9454 additions and 1437 deletions
@@ -7,6 +7,7 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -63,6 +64,8 @@ export const ClarifyTool = (props: ToolCallMessagePartProps) => {
}
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const { t } = useI18n()
const copy = t.assistant.clarify
const request = useStore($clarifyRequest)
const gateway = useStore($gateway)
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
@@ -102,13 +105,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const respond = useCallback(
async (answer: string) => {
if (!ready || !matchingRequest) {
notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response')
notifyError(new Error(copy.notReady), copy.sendFailed)
return
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response')
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
return
}
@@ -125,7 +128,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
// The matching tool.complete will land shortly after, swapping this
// panel for the ToolFallback view above.
} catch (error) {
notifyError(error, 'Could not send clarify response')
notifyError(error, copy.sendFailed)
setSubmitting(false)
}
},
@@ -172,7 +175,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
<HelpCircle className="size-3.5" />
</span>
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
{question || <em className="font-normal text-muted-foreground/70">Loading question</em>}
{question || <em className="font-normal text-muted-foreground/70">{copy.loadingQuestion}</em>}
</span>
</div>
@@ -209,7 +212,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
type="button"
>
<RadioDot selected={false} />
<span className="flex-1">Other (type your answer)</span>
<span className="flex-1">{copy.other}</span>
</button>
</div>
)}
@@ -221,12 +224,12 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
disabled={submitting}
onChange={event => setDraft(event.target.value)}
onKeyDown={handleTextareaKey}
placeholder="Type your answer…"
placeholder={copy.placeholder}
ref={textareaRef}
value={draft}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-[0.6875rem] text-muted-foreground/85">/Ctrl + Enter to send</span>
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
<div className="flex items-center gap-1.5">
{hasChoices && (
<Button
@@ -239,7 +242,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
type="button"
variant="ghost"
>
Back
{copy.back}
</Button>
)}
<Button
@@ -249,10 +252,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
type="button"
variant="ghost"
>
Skip
{copy.skip}
</Button>
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
</Button>
</div>
</div>
@@ -75,6 +75,7 @@ import {
import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
@@ -183,22 +184,26 @@ function pickPrimaryPreviewTarget(targets: string[]): string[] {
return [localUrl || targets[targets.length - 1]]
}
const CenteredThreadSpinner: FC = () => (
<div
aria-label="Loading session"
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
role="status"
>
<Loader
aria-hidden="true"
className="size-12 text-midground/70"
pathSteps={220}
role="presentation"
strokeScale={0.72}
type="rose-curve"
/>
</div>
)
const CenteredThreadSpinner: FC = () => {
const { t } = useI18n()
return (
<div
aria-label={t.assistant.thread.loadingSession}
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
role="status"
>
<Loader
aria-hidden="true"
className="size-12 text-midground/70"
pathSteps={220}
role="presentation"
strokeScale={0.72}
type="rose-curve"
/>
</div>
)
}
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
@@ -278,10 +283,11 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
)
const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const elapsed = useElapsedSeconds()
return (
<StatusRow data-slot="aui_response-loading" label="Hermes is loading a response">
<StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
<ActivityTimerText seconds={elapsed} />
</StatusRow>
@@ -363,6 +369,7 @@ const ThinkingDisclosure: FC<{
pending?: boolean
timerKey?: string
}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
const { t } = useI18n()
// `null` = no explicit user toggle yet, defer to the streaming default.
// The default is "auto-open while streaming, auto-collapse when done" so
// reasoning surfaces a live preview without manual interaction. The first
@@ -419,7 +426,7 @@ const ThinkingDisclosure: FC<{
pending && 'shimmer text-foreground/55'
)}
>
Thinking
{t.assistant.thread.thinking}
</span>
{pending && (
<ActivityTimerText
@@ -537,7 +544,10 @@ function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
function formatMessageTimestamp(value: Date | string | number | undefined): string {
function formatMessageTimestamp(
value: Date | string | number | undefined,
labels: { today: (time: string) => string; yesterday: (time: string) => string }
): string {
if (!value) {
return ''
}
@@ -551,17 +561,19 @@ function formatMessageTimestamp(value: Date | string | number | undefined): stri
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
if (dayDelta === 0) {
return `Today, ${TIME_FMT.format(date)}`
return labels.today(TIME_FMT.format(date))
}
if (dayDelta === 1) {
return `Yesterday, ${TIME_FMT.format(date)}`
return labels.yesterday(TIME_FMT.format(date))
}
return SHORT_FMT.format(date)
}
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [menuOpen, setMenuOpen] = useState(false)
return (
@@ -580,15 +592,15 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
)}
data-slot="aui_msg-actions"
>
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label="Copy" text={messageText} />
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
<Codicon name="refresh" />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<DropdownMenuTrigger asChild>
<TooltipIconButton tooltip="More actions">
<TooltipIconButton tooltip={copy.moreActions}>
<Codicon name="ellipsis" />
</TooltipIconButton>
</DropdownMenuTrigger>
@@ -596,7 +608,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
<MessageTimestamp />
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
<GitBranchIcon />
Branch in new chat
{copy.branchNewChat}
</DropdownMenuItem>
<ReadAloudItem messageId={messageId} text={messageText} />
</DropdownMenuContent>
@@ -607,6 +619,8 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
}
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const voicePlayback = useStore($voicePlayback)
const readAloudStatus =
@@ -625,9 +639,9 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
try {
await playSpeechText(text, { messageId, source: 'read-aloud' })
} catch (error) {
notifyError(error, 'Read aloud failed')
notifyError(error, copy.readAloudFailed)
}
}, [messageId, text])
}, [copy.readAloudFailed, messageId, text])
return (
<DropdownMenuItem
@@ -638,14 +652,15 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
}}
>
<Icon className={isPreparing ? 'animate-spin' : undefined} />
{isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'}
{isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
</DropdownMenuItem>
)
}
const MessageTimestamp: FC = () => {
const { t } = useI18n()
const createdAt = useAuiState(s => s.message.createdAt)
const label = formatMessageTimestamp(createdAt)
const label = formatMessageTimestamp(createdAt, t.assistant.thread)
if (!label) {
return null
@@ -712,6 +727,8 @@ const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -transla
const UserMessage: FC<{
onCancel?: () => Promise<void> | void
}> = ({ onCancel }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
@@ -803,10 +820,10 @@ const UserMessage: FC<{
) : (
<ActionBarPrimitive.Edit asChild>
<button
aria-label="Edit message"
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
title="Edit message"
title={copy.editMessage}
type="button"
>
{bubbleContent}
@@ -817,14 +834,14 @@ const UserMessage: FC<{
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
<button
aria-label="Stop"
aria-label={copy.stop}
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onCancel?.()
}}
title="Stop"
title={copy.stop}
type="button"
>
{StopGlyph}
@@ -833,7 +850,7 @@ const UserMessage: FC<{
<span
aria-hidden="true"
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
title="Editable checkpoint"
title={copy.editableCheckpoint}
>
<Codicon name="discard" size="0.875rem" />
</span>
@@ -848,18 +865,18 @@ const UserMessage: FC<{
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
<BranchPickerPrimitive.Previous
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title="Restore previous checkpoint"
title={copy.restorePrevious}
>
Restore checkpoint
{copy.restoreCheckpoint}
</BranchPickerPrimitive.Previous>
<span className="checkpoint-divider opacity-55">
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title="Restore next checkpoint"
title={copy.restoreNext}
>
Go forward
{copy.goForward}
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
</div>
@@ -930,6 +947,8 @@ interface UserEditComposerProps {
}
const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const aui = useAui()
const draft = useAuiState(s => s.composer.text)
const rootRef = useRef<HTMLDivElement | null>(null)
@@ -1406,7 +1425,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
data-expanded={expanded ? 'true' : undefined}
>
<div
aria-label="Edit message"
aria-label={copy.editMessage}
autoFocus
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
@@ -1415,7 +1434,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
)}
contentEditable
data-placeholder="Edit message"
data-placeholder={copy.editMessage}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onDragOver={handleDragOver}
@@ -1432,7 +1451,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
/>
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
<button
aria-label="Send edited message"
aria-label={copy.sendEdited}
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
disabled={!canSubmit || submitting}
onClick={() => {
@@ -1442,7 +1461,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
submitEdit(editor)
}
}}
title="Send edited message"
title={copy.sendEdited}
type="button"
>
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
@@ -13,6 +13,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
@@ -52,6 +53,8 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const { t } = useI18n()
const copy = t.assistant.approval
const gateway = useStore($gateway)
const [submitting, setSubmitting] = useState<ApprovalChoice | null>(null)
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
@@ -68,7 +71,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send approval response')
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
return
}
@@ -83,7 +86,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
clearApprovalRequest(request.sessionId)
} catch (error) {
notifyError(error, 'Could not send approval response')
notifyError(error, copy.sendFailed)
setSubmitting(null)
}
},
@@ -123,14 +126,14 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : 'Run'}
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More approval options"
aria-label={copy.moreOptions}
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
@@ -140,7 +143,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>Allow this session</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
@@ -149,10 +152,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
setTimeout(() => setConfirmAlways(true), 0)
}}
>
Always allow
{copy.alwaysAllowMenu}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
Reject
{copy.reject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -165,18 +168,16 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : 'Reject'}
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Always allow this command?</DialogTitle>
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
<DialogDescription>
This adds the {request.description} pattern to your permanent allowlist (
<code className="font-mono text-xs">~/.hermes/config.yaml</code>). Hermes wont ask again for commands
like this in this session or any future one.
{copy.alwaysDescription(request.description)}
</DialogDescription>
</DialogHeader>
@@ -188,7 +189,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
<DialogFooter>
<Button onClick={() => setConfirmAlways(false)} size="sm" variant="ghost">
Cancel
{t.common.cancel}
</Button>
<Button
onClick={() => {
@@ -198,7 +199,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
size="sm"
variant="destructive"
>
Always allow
{copy.alwaysAllow}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,6 @@
import { normalizeExternalUrl } from '@/lib/external-link'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
import { translateNow } from '@/i18n'
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
@@ -1095,6 +1096,17 @@ function toolDetailText(
}
export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } {
const copy = {
command: translateNow('assistant.tool.copyCommand'),
content: translateNow('assistant.tool.copyContent'),
file: translateNow('assistant.tool.copyFile'),
output: translateNow('assistant.tool.copyOutput'),
path: translateNow('assistant.tool.copyPath'),
query: translateNow('assistant.tool.copyQuery'),
results: translateNow('assistant.tool.copyResults'),
url: translateNow('assistant.tool.copyUrl'),
generic: translateNow('common.copy')
}
const args = parseMaybeObject(part.args)
const result = parseMaybeObject(part.result)
const detail = view.detail.trim()
@@ -1102,25 +1114,25 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
if (hasSubstantialOutput) {
return { label: 'Copy output', text: detail }
return { label: copy.output, text: detail }
}
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
if (command) {
return { label: 'Copy command', text: command }
return { label: copy.command, text: command }
}
}
if (part.toolName === 'web_extract') {
if (hasSubstantialOutput) {
return { label: 'Copy content', text: detail }
return { label: copy.content, text: detail }
}
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
if (url) {
return { label: 'Copy URL', text: url }
return { label: copy.url, text: url }
}
}
@@ -1128,7 +1140,7 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
if (url) {
return { label: 'Copy URL', text: url }
return { label: copy.url, text: url }
}
}
@@ -1136,25 +1148,25 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
if (view.searchHits?.length) {
const text = view.searchHits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n')
return { label: 'Copy results', text }
return { label: copy.results, text }
}
const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
if (query) {
return { label: 'Copy query', text: query }
return { label: copy.query, text: query }
}
}
if (part.toolName === 'read_file') {
if (hasSubstantialOutput) {
return { label: 'Copy file', text: detail }
return { label: copy.file, text: detail }
}
const path = firstStringField(args, ['path', 'file', 'filepath'])
if (path) {
return { label: 'Copy path', text: path }
return { label: copy.path, text: path }
}
}
@@ -1162,15 +1174,15 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
const path = firstStringField(args, ['path', 'file', 'filepath'])
if (path) {
return { label: 'Copy path', text: path }
return { label: copy.path, text: path }
}
}
if (detail) {
return { label: 'Copy output', text: detail }
return { label: copy.output, text: detail }
}
return { label: 'Copy', text: view.title }
return { label: copy.generic, text: view.title }
}
function dynamicTitle(
@@ -16,6 +16,7 @@ import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
@@ -70,6 +71,13 @@ const TOOL_SECTION_SURFACE_CLASS =
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
interface ToolStatusCopy {
statusDone: string
statusError: string
statusRecovered: string
statusRunning: string
}
function rawTechnicalTrace(args: unknown, result: unknown): string {
const parts = [args, result]
.filter(value => value !== undefined && value !== null)
@@ -89,11 +97,11 @@ function rawTechnicalTrace(args: unknown, result: unknown): string {
return parts.join('\n')
}
function statusGlyph(status: ToolStatus): ReactNode {
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
if (status === 'running') {
return (
<BrailleSpinner
ariaLabel="Running"
ariaLabel={copy.statusRunning}
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
spinner="breathe"
/>
@@ -101,22 +109,32 @@ function statusGlyph(status: ToolStatus): ReactNode {
}
if (status === 'error') {
return <AlertCircle aria-label="Error" className="size-3.5 shrink-0 text-destructive" />
return <AlertCircle aria-label={copy.statusError} className="size-3.5 shrink-0 text-destructive" />
}
if (status === 'warning') {
return <AlertCircle aria-label="Recovered" className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
return (
<AlertCircle
aria-label={copy.statusRecovered}
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
)
}
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
return (
<CheckCircle2
aria-label={copy.statusDone}
className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85"
/>
)
}
// Leading glyph for any tool-row header. Status (running/error/warning)
// takes precedence; otherwise falls back to the tool's codicon. Returns
// null when neither applies so callers can render unconditionally.
function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) {
function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string; status?: ToolStatus }) {
const node = status ? (
statusGlyph(status)
statusGlyph(status, copy)
) : icon ? (
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
@@ -176,6 +194,8 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean
}
function ToolEntry({ part }: ToolEntryProps) {
const { t } = useI18n()
const copy = t.assistant.tool
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
const embedded = useContext(ToolEmbedContext)
@@ -282,7 +302,7 @@ function ToolEntry({ part }: ToolEntryProps) {
trailing={trailing}
>
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph icon={view.icon} status={leadingStatus(isPending, view.status)} />
<ToolGlyph copy={copy} icon={view.icon} status={leadingStatus(isPending, view.status)} />
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
@@ -308,7 +328,7 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt="Tool output" className="h-auto w-full object-cover" src={view.imageUrl} />
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
</div>
)}
{hasSearchHits && view.searchHits && (
@@ -379,7 +399,7 @@ function ToolEntry({ part }: ToolEntryProps) {
))}
{showRawSearchDrilldown && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>Raw response</summary>
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>{copy.rawResponse}</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{view.rawResult}
</pre>
@@ -1,6 +1,7 @@
import { type FC, useCallback, useEffect, useRef } from 'react'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
type Rgb = { r: number; g: number; b: number }
@@ -266,8 +267,10 @@ const DiffusionCanvas: FC = () => {
}
export const ImageGenerationPlaceholder: FC = () => {
const { t } = useI18n()
return (
<div aria-label="Rendering image" aria-live="polite" className="w-full max-w-136 self-start" role="status">
<div aria-label={t.assistant.tool.renderingImage} aria-live="polite" className="w-full max-w-136 self-start" role="status">
<div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]">
<DiffusionCanvas />
</div>
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { MonitorPlay } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { previewName } from '@/lib/preview-targets'
@@ -14,6 +15,7 @@ import {
import { $currentCwd } from '@/store/session'
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
const { t } = useI18n()
const cwd = useStore($currentCwd)
const activePreview = useStore($previewTarget)
const [opening, setOpening] = useState(false)
@@ -93,7 +95,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
return
}
notifyError(error, 'Preview unavailable')
notifyError(error, t.preview.unavailable)
} finally {
if (mountedRef.current && requestTokenRef.current === requestToken) {
setOpening(false)
@@ -116,7 +118,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
onClick={() => void togglePreview()}
type="button"
>
{opening ? 'Opening…' : isActive ? 'Hide' : 'Open preview'}
{opening ? t.preview.opening : isActive ? t.preview.hide : t.preview.openPreview}
</button>
</div>
)
@@ -13,6 +13,7 @@ import {
CodeCardTitle
} from '@/components/chat/code-card'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
/**
@@ -48,6 +49,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
code,
defer = false
}) => {
const { t } = useI18n()
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
// Streaming may hand us empty/incomplete fences — render nothing rather
@@ -68,14 +70,14 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
<CodeCardHeader>
<CodeCardTitle>
<CodeCardIcon name={codiconForLanguage(label)} />
Code
{t.assistant.tool.code}
{label && <CodeCardSubtitle> · {label}</CodeCardSubtitle>}
</CodeCardTitle>
<CopyButton
appearance="inline"
className="-my-1 -mr-1 h-5 px-1 opacity-55 hover:opacity-100"
iconClassName="size-2.5"
label="Copy code"
label={t.assistant.tool.copyCode}
showLabel={false}
text={trimmed}
/>
@@ -3,6 +3,7 @@
import { type ComponentProps, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { Download } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -50,7 +51,14 @@ export interface ZoomableImageProps extends ComponentProps<'img'> {
slot?: string
}
interface ImageActionCopy {
downloadImage: string
savingImage: string
}
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
const { t } = useI18n()
const copy = t.desktop
const [saving, setSaving] = useState(false)
const [lightboxOpen, setLightboxOpen] = useState(false)
const canOpen = Boolean(src)
@@ -67,7 +75,7 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
const saved = await window.hermesDesktop.saveImageFromUrl(src)
if (saved) {
notify({ kind: 'success', title: 'Image saved', message: imageFilename(src) })
notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
}
return
@@ -80,17 +88,17 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
await startBrowserDownload(src)
notify({
kind: 'info',
title: 'Download started',
message: 'Restart Hermes Desktop to use Save Image.'
title: copy.downloadStarted,
message: copy.restartToUseSaveImage
})
} catch (fallbackError) {
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
notifyError(fallbackError, copy.restartToSaveImages)
}
return
}
notifyError(error, 'Image download failed')
notifyError(error, copy.imageDownloadFailed)
} finally {
setSaving(false)
}
@@ -109,7 +117,7 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
onClick={() => setLightboxOpen(false)}
src={src}
/>
<ImageActionButton onClick={handleDownload} saving={saving} variant="lightbox" />
<ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="lightbox" />
</div>
</DialogContent>
</Dialog>
@@ -125,12 +133,12 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
className="contents"
disabled={!canOpen}
onClick={() => canOpen && setLightboxOpen(true)}
title={canOpen ? 'Open image' : undefined}
title={canOpen ? copy.openImage : undefined}
type="button"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
{src && <ImageActionButton onClick={handleDownload} saving={saving} variant="inline" />}
{src && <ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="inline" />}
</span>
{lightbox}
</>
@@ -138,17 +146,19 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
}
function ImageActionButton({
copy,
onClick,
saving,
variant
}: {
copy: ImageActionCopy
onClick: () => void
saving: boolean
variant: 'inline' | 'lightbox'
}) {
return (
<button
aria-label={saving ? 'Saving image' : 'Download image'}
aria-label={saving ? copy.savingImage : copy.downloadImage}
className={cn(
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50',
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
@@ -158,7 +168,7 @@ function ImageActionButton({
event.stopPropagation()
void onClick()
}}
title={saving ? 'Saving image' : 'Download image'}
title={saving ? copy.savingImage : copy.downloadImage}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
@@ -8,6 +8,7 @@ import type {
DesktopBootstrapStageState,
DesktopBootstrapState
} from '@/global'
import { useI18n } from '@/i18n'
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -49,14 +50,6 @@ interface StageRowProps {
now: number
}
const STATE_LABEL: Record<DesktopBootstrapStageState, string> = {
pending: 'Pending',
running: 'Installing',
succeeded: 'Done',
skipped: 'Skipped',
failed: 'Failed'
}
function formatStageName(name: string): string {
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
if (name.length <= 3) {
@@ -104,6 +97,8 @@ function formatElapsed(ms: number): string {
}
function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
const { t } = useI18n()
const copy = t.install
const state: DesktopBootstrapStageState = result?.state || 'pending'
const elapsed =
@@ -147,9 +142,13 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
{formatStageName(descriptor.name)}
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{state === 'running' ? (elapsed ? `${STATE_LABEL[state]} · ${elapsed}` : STATE_LABEL[state]) : null}
{state === 'running'
? elapsed
? `${copy.stageStates[state]} · ${elapsed}`
: copy.stageStates[state]
: null}
{state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null}
{state === 'failed' ? STATE_LABEL[state] : null}
{state === 'failed' ? copy.stageStates[state] : null}
</span>
</div>
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
@@ -242,6 +241,8 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
}
export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) {
const { t } = useI18n()
const copy = t.install
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
@@ -350,14 +351,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
<h2 className="text-2xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
<p className="mt-2 text-sm text-muted-foreground">
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and run the
command below, then relaunch this app. Subsequent launches will skip this step.
{copy.unsupportedDesc(platformLabel)}
</p>
<div className="mt-4">
<div className="mb-1.5 text-xs font-medium text-muted-foreground">Install command</div>
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{copy.installCommand}</div>
<pre className="overflow-x-auto rounded-md border bg-muted/50 px-3 py-2.5 font-mono text-[12px]">
<code>{ups.installCommand}</code>
</pre>
@@ -369,7 +369,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
size="sm"
variant="secondary"
>
Copy command
{copy.copyCommand}
</Button>
<Button
onClick={() => {
@@ -378,17 +378,17 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
size="sm"
variant="ghost"
>
View install docs
{copy.viewDocs}
</Button>
</div>
</div>
<div className="mt-6 flex items-center justify-between border-t pt-4">
<span className="text-xs text-muted-foreground">
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
{copy.installTo} <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
</span>
<Button onClick={() => window.location.reload()} size="sm" variant="default">
I{'\u2019'}ve run it -- retry
{copy.retryAfterRun}
</Button>
</div>
</div>
@@ -415,13 +415,10 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
{/* Header -- always visible, never scrolls */}
<div className="flex-shrink-0 p-8 pb-4">
<h2 className="text-2xl font-semibold tracking-tight">
{failed ? 'Installation failed' : state.active ? 'Setting up Hermes Agent' : 'Finishing up'}
{failed ? copy.failedTitle : state.active ? copy.settingUpTitle : copy.finishingTitle}
</h2>
<p className="mt-1.5 text-sm text-muted-foreground">
{failed
? 'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.'
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
'Subsequent launches will skip this step.'}
{failed ? copy.failedDesc : copy.activeDesc}
</p>
</div>
@@ -431,8 +428,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<div className="mb-4">
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span>
{completedCount} of {totalCount} steps complete
{currentStage && ` -- now: ${formatStageName(currentStage)}`}
{copy.progress(completedCount, totalCount)}
{currentStage && copy.currentStage(formatStageName(currentStage))}
{currentElapsed && ` (${currentElapsed})`}
</span>
<span className="tabular-nums">{progressPct}%</span>
@@ -449,7 +446,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
{totalCount === 0 && state.active && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Fetching installer manifest...</span>
<span>{copy.fetchingManifest}</span>
</div>
)}
@@ -457,7 +454,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm">
<div className="mb-1 flex items-center gap-1.5 font-medium text-destructive">
<AlertTriangle className="h-4 w-4" />
<span>Error</span>
<span>{copy.error}</span>
</div>
<p className="whitespace-pre-wrap break-words text-foreground/90">{state.error}</p>
</div>
@@ -484,9 +481,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
type="button"
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
<span>{logOpen ? copy.hideOutput : copy.showOutput}</span>
<span className="ml-1 tabular-nums">
({state.log.length} line{state.log.length === 1 ? '' : 's'})
({copy.lines(state.log.length)})
</span>
</button>
@@ -498,7 +495,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
)}
>
{state.log.length === 0 ? (
<div className="text-muted-foreground">No output yet.</div>
<div className="text-muted-foreground">{copy.noOutput}</div>
) : (
<>
{state.log.map((entry, i) => (
@@ -540,7 +537,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
variant="ghost"
>
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{cancelling ? 'Cancelling...' : 'Cancel install'}
{cancelling ? copy.cancelling : copy.cancelInstall}
</Button>
</div>
</div>
@@ -551,7 +548,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
Full transcript saved to{' '}
{copy.transcriptSaved}{' '}
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
</span>
<div className="flex gap-2">
@@ -574,7 +571,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
size="sm"
variant="secondary"
>
{copied ? 'Copied!' : 'Copy output'}
{copied ? copy.copiedOutput : copy.copyOutput}
</Button>
<Button
onClick={async () => {
@@ -593,7 +590,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
size="sm"
variant="default"
>
Reload and retry
{copy.reloadRetry}
</Button>
</div>
</div>
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import {
Check,
ChevronDown,
@@ -51,7 +52,7 @@ interface DesktopOnboardingOverlayProps {
}
export interface ApiKeyOption {
description: string
description?: string
docsUrl: string
envKey: string
id: string
@@ -64,41 +65,31 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [
{
id: 'openrouter',
name: 'OpenRouter',
short: 'one key, many models',
envKey: 'OPENROUTER_API_KEY',
description: 'Hosts hundreds of models behind a single key. Good default for new installs.',
docsUrl: 'https://openrouter.ai/keys'
},
{
id: 'openai',
name: 'OpenAI',
short: 'GPT-class models',
envKey: 'OPENAI_API_KEY',
description: 'Direct access to OpenAI models.',
docsUrl: 'https://platform.openai.com/api-keys'
},
{
id: 'gemini',
name: 'Google Gemini',
short: 'Gemini models',
envKey: 'GEMINI_API_KEY',
description: 'Direct access to Google Gemini models.',
docsUrl: 'https://aistudio.google.com/app/apikey'
},
{
id: 'xai',
name: 'xAI Grok',
short: 'Grok models',
envKey: 'XAI_API_KEY',
description: 'Direct access to xAI Grok models.',
docsUrl: 'https://console.x.ai/'
},
{
id: 'local',
name: 'Local / custom endpoint',
short: 'self-hosted',
envKey: 'OPENAI_BASE_URL',
description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).',
docsUrl: 'https://github.com/NousResearch/hermes-agent#bring-your-own-endpoint',
placeholder: 'http://127.0.0.1:8000/v1'
}
@@ -118,13 +109,6 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
pkce: 'Opens your browser to sign in, then continues here',
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
loopback: 'Opens your browser to sign in — Hermes connects automatically',
external: 'Sign in once in your terminal, then come back to chat'
}
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
@@ -132,6 +116,7 @@ export const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
const { t } = useI18n()
const onboarding = useStore($desktopOnboarding)
const boot = useStore($desktopBoot)
const ctxRef = useRef<OnboardingContext>({ requestGateway, onCompleted })
@@ -212,7 +197,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
<Header />
{onboarding.manual ? (
<Button
aria-label="Close"
aria-label={t.common.close}
className="absolute right-3 top-3 z-10 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={() => closeManualOnboarding()}
size="icon-sm"
@@ -242,6 +227,7 @@ function ReasonNotice({ reason }: { reason: string }) {
}
function Preparing({ boot }: { boot: DesktopBootState }) {
const { t } = useI18n()
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
const hasError = Boolean(boot.error)
const installing = boot.phase.startsWith('runtime.')
@@ -250,8 +236,8 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
<div className="grid gap-3" role="status">
<p className="text-sm text-muted-foreground">
{installing
? 'Hermes is finishing install. This usually takes under a minute on first run.'
: 'Starting Hermes…'}
? t.onboarding.preparingInstall
: t.onboarding.starting}
</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
@@ -272,6 +258,8 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
}
function Header() {
const { t } = useI18n()
return (
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-5 py-4">
<div className="flex items-start gap-3">
@@ -279,9 +267,9 @@ function Header() {
<Sparkles className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Let's get you setup with Hermes Agent</h2>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">{t.onboarding.headerTitle}</h2>
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
Connect a model provider to start chatting. Most options take one click.
{t.onboarding.headerDesc}
</p>
</div>
</div>
@@ -290,7 +278,6 @@ function Header() {
}
export const FEATURED_ID = 'nous'
const FEATURED_PITCH = 'One subscription, 300+ frontier models the recommended way to run Hermes'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
const readShowAll = () => {
@@ -312,6 +299,7 @@ const persistShowAll = (value: boolean) => {
}
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { t } = useI18n()
const { manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
@@ -335,7 +323,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
}
if (providers === null) {
return <Status>Looking up providers...</Status>
return <Status>{t.onboarding.lookingUpProviders}</Status>
}
const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx)
@@ -363,7 +351,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
onClick={() => setShowAll(persistShowAll(!showAll))}
type="button"
>
{showAll ? 'Collapse' : 'Other providers'}
{showAll ? t.onboarding.collapse : t.onboarding.otherProviders}
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</button>
) : null}
@@ -377,7 +365,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
onClick={() => setOnboardingMode('apikey')}
type="button"
>
I have an API key
{t.onboarding.haveApiKey}
</button>
</div>
</div>
@@ -388,13 +376,15 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
// the skip so it never re-nags. The user connects a provider any time from
// Settings → Providers. Rendered only on the unconfigured first-run flow.
function ChooseLaterLink() {
const { t } = useI18n()
return (
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => dismissFirstRunOnboarding()}
type="button"
>
I'll choose a provider later
{t.onboarding.chooseLater}
</button>
)
}
@@ -406,6 +396,7 @@ export function FeaturedProviderRow({
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const { t } = useI18n()
const loggedIn = provider.status?.logged_in
return (
@@ -426,11 +417,11 @@ export function FeaturedProviderRow({
) : (
<span className="inline-flex items-center gap-1.5 bg-primary px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-primary-foreground">
<span aria-hidden="true" className="dither inline-block size-2 shrink-0" />
Recommended
{t.onboarding.recommended}
</span>
)}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.featuredPitch}</p>
</div>
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
</button>
@@ -438,15 +429,19 @@ export function FeaturedProviderRow({
}
function ConnectedTag() {
const { t } = useI18n()
return (
<span className="inline-flex items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
Connected
{t.onboarding.connected}
</span>
)
}
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
const { t } = useI18n()
return (
<button
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
@@ -455,7 +450,7 @@ export function KeyProviderRow({ onClick }: { onClick: () => void }) {
>
<div className="min-w-0">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</button>
@@ -469,6 +464,7 @@ export function ProviderRow({
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const { t } = useI18n()
const loggedIn = provider.status?.logged_in
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
@@ -485,7 +481,9 @@ export function ProviderRow({
</span>
{loggedIn ? <ConnectedTag /> : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{t.onboarding.flowSubtitles[provider.flow]}
</p>
</div>
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</button>
@@ -514,6 +512,7 @@ export function ApiKeyForm({
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const { t } = useI18n()
const [option, setOption] = useState<ApiKeyOption>(options[0])
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
@@ -551,6 +550,8 @@ export function ApiKeyForm({
// Only require a non-empty value — no length/format validation, so a short
// or unusual key can't block the user from continuing.
const canSave = value.trim().length >= 1
const optionCopy = t.onboarding.apiKeyOptions[option.id]
const optionDescription = optionCopy?.description ?? option.description
const submit = async () => {
if (!canSave || saving) {
@@ -564,7 +565,7 @@ export function ApiKeyForm({
if (result.ok) {
setValue('')
} else {
setError(result.message ?? 'Could not save credential.')
setError(result.message ?? t.onboarding.couldNotSave)
}
setSaving(false)
@@ -579,7 +580,7 @@ export function ApiKeyForm({
type="button"
>
<ChevronLeft className="size-3" />
Back to sign in
{t.onboarding.backToSignIn}
</button>
) : null}
@@ -602,15 +603,19 @@ export function ApiKeyForm({
<Check className="size-3.5 text-muted-foreground" />
) : null}
</div>
{o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null}
{(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? (
<p className="mt-1 text-xs text-muted-foreground">
{t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}
</p>
) : null}
</button>
))}
</div>
<div className="grid scroll-mt-4 gap-2" ref={entryRef}>
<div className="flex items-center justify-between gap-3">
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
{option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null}
<p className="text-sm leading-6 text-muted-foreground">{optionDescription}</p>
{option.docsUrl ? <DocsLink href={option.docsUrl}>{t.onboarding.getKey}</DocsLink> : null}
</div>
<Input
autoComplete="off"
@@ -619,7 +624,7 @@ export function ApiKeyForm({
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={
currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')
currentRedacted ?? (alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey)
}
type={isLocal ? 'text' : 'password'}
value={value}
@@ -631,13 +636,13 @@ export function ApiKeyForm({
<div>
{alreadySet && onClear ? (
<Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
Remove
{t.common.remove}
</Button>
) : null}
</div>
<Button disabled={!canSave || saving} onClick={() => void submit()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
{saving ? t.onboarding.connecting : alreadySet ? t.onboarding.update : t.common.connect}
</Button>
</div>
</div>
@@ -645,21 +650,22 @@ export function ApiKeyForm({
}
function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) {
const { t } = useI18n()
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
if (flow.status === 'starting') {
return <Status>Starting sign-in for {title}...</Status>
return <Status>{t.onboarding.startingSignIn(title)}</Status>
}
if (flow.status === 'submitting') {
return <Status>Verifying your code with {title}...</Status>
return <Status>{t.onboarding.verifyingCode(title)}</Status>
}
if (flow.status === 'success') {
return (
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<Check className="size-4" />
{title} connected. Picking a default model...
{t.onboarding.connectedPicking(title)}
</div>
)
}
@@ -672,11 +678,11 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
return (
<div className="grid gap-3">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{flow.message || 'Sign-in failed. Try again.'}
{flow.message || t.onboarding.signInFailed}
</div>
<div className="flex justify-end">
<Button onClick={cancelOnboardingFlow} variant="outline">
Pick a different provider
{t.onboarding.pickDifferentProvider}
</Button>
</div>
</div>
@@ -685,23 +691,23 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
if (flow.status === 'awaiting_user') {
return (
<Step title={`Sign in with ${title}`}>
<Step title={t.onboarding.signInWith(title)}>
<ol className="list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>We opened {title} in your browser.</li>
<li>Authorize Hermes there.</li>
<li>Copy the authorization code and paste it below.</li>
<li>{t.onboarding.openedBrowser(title)}</li>
<li>{t.onboarding.authorizeThere}</li>
<li>{t.onboarding.copyAuthCode}</li>
</ol>
<Input
autoFocus
onChange={e => setOnboardingCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)}
placeholder="Paste authorization code"
placeholder={t.onboarding.pasteAuthCode}
value={flow.code}
/>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open authorization page</DocsLink>}>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenAuthPage}</DocsLink>}>
<CancelBtn />
<Button disabled={!flow.code.trim()} onClick={() => void submitOnboardingCode(ctx)}>
Continue
{t.common.continue}
</Button>
</FlowFooter>
</Step>
@@ -710,15 +716,14 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
if (flow.status === 'awaiting_browser') {
return (
<Step title={`Sign in with ${title}`}>
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">
We opened {title} in your browser. Authorize Hermes there and you'll be connected automatically — nothing to
copy or paste.
{t.onboarding.autoBrowser(title)}
</p>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenSignInPage}</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for you to authorize...
{t.onboarding.waitingAuthorize}
</span>
<CancelBtn size="sm" />
</FlowFooter>
@@ -728,19 +733,18 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
if (flow.status === 'external_pending') {
return (
<Step title={`Sign in with ${title}`}>
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">
{title} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed
in":
{t.onboarding.externalPending(title)}
</p>
<CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
<FlowFooter
left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{title} docs</DocsLink> : null}
left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink> : null}
>
<CancelBtn />
<Button onClick={() => void recheckExternalSignin(ctx)}>
<Check className="size-4" />
I've signed in
{t.onboarding.signedIn}
</Button>
</FlowFooter>
</Step>
@@ -752,13 +756,13 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
}
return (
<Step title={`Sign in with ${title}`}>
<p className="text-sm text-muted-foreground">We opened {title} in your browser. Enter this code there:</p>
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">{t.onboarding.deviceCodeOpened(title)}</p>
<CodeBlock copied={flow.copied} large onCopy={() => void copyDeviceCode()} text={flow.start.user_code} />
<FlowFooter left={<DocsLink href={flow.start.verification_url}>Re-open verification page</DocsLink>}>
<FlowFooter left={<DocsLink href={flow.start.verification_url}>{t.onboarding.reopenVerification}</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for you to authorize...
{t.onboarding.waitingAuthorize}
</span>
<CancelBtn size="sm" />
</FlowFooter>
@@ -786,11 +790,13 @@ function CodeBlock({
onCopy: () => void
text: string
}) {
const { t } = useI18n()
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
<code className={cn('font-mono', large ? 'text-2xl tracking-[0.4em]' : 'text-sm')}>{text}</code>
<Button onClick={onCopy} size="sm" variant="outline">
{copied ? <Check className="size-4" /> : 'Copy'}
{copied ? <Check className="size-4" /> : t.onboarding.copy}
</Button>
</div>
)
@@ -806,9 +812,11 @@ function FlowFooter({ children, left }: { children: React.ReactNode; left?: Reac
}
function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
const { t } = useI18n()
return (
<Button onClick={cancelOnboardingFlow} size={size} variant="ghost">
Cancel
{t.common.cancel}
</Button>
)
}
@@ -820,6 +828,7 @@ function ConfirmingModelPanel({
ctx: OnboardingContext
flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
}) {
const { t } = useI18n()
// Local state controls whether the model picker dialog is open.
// We reuse the existing ModelPickerDialog component (the same picker
// available from the chat shell) rather than building an inline
@@ -845,34 +854,34 @@ function ConfirmingModelPanel({
<div className="grid gap-4">
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<Check className="size-4 shrink-0" />
<span>{flow.label} connected.</span>
<span>{t.onboarding.connectedProvider(flow.label)}</span>
</div>
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t.onboarding.defaultModel}</p>
{freeTier === true && (
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
Free tier
{t.onboarding.freeTier}
</span>
)}
{freeTier === false && (
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
Pro
{t.onboarding.pro}
</span>
)}
</div>
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
{price && (price.input || price.output) && (
<p className="mt-1 font-mono text-xs text-muted-foreground">
{price.free ? 'Free' : `${price.input || '?'} in / ${price.output || '?'} out per Mtok`}
{price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
</p>
)}
</div>
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
Change
{t.onboarding.change}
</Button>
</div>
</div>
@@ -880,7 +889,7 @@ function ConfirmingModelPanel({
<div className="flex justify-end">
<Button disabled={flow.saving} onClick={() => confirmOnboardingModel(ctx)}>
{flow.saving ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
Start chatting
{t.onboarding.startChatting}
</Button>
</div>
@@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { ErrorState } from '@/components/ui/error-state'
import { useI18n } from '@/i18n'
export interface ErrorBoundaryFallbackProps {
error: Error
@@ -52,21 +53,23 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
const { t } = useI18n()
return (
<div className="fixed inset-0 z-[1500] grid place-items-center bg-(--ui-chat-surface-background) p-6">
<ErrorState
className="w-full max-w-[28rem]"
description={error.message || 'The view hit an unexpected error. Your chats and settings are safe.'}
title="Something broke in the interface"
description={error.message || t.errors.boundaryDesc}
title={t.errors.boundaryTitle}
>
<Button className="font-semibold" onClick={reset} size="lg">
Try again
{t.common.retry}
</Button>
<Button onClick={() => window.location.reload()} variant="text">
Reload window
{t.errors.reloadWindow}
</Button>
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="text">
Open logs
{t.errors.openLogs}
</Button>
</ErrorState>
</div>
@@ -0,0 +1,53 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesConfigRecord } from '@/hermes'
import { type I18nConfigClient, I18nProvider } from '@/i18n'
import { LanguageSwitcher } from './language-switcher'
// cmdk (the searchable list) wires a ResizeObserver and scrolls the active
// item into view — neither exists in jsdom. Stub them, matching the polyfill
// idiom in tool-approval-group.test.tsx.
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
Element.prototype.scrollIntoView = function scrollIntoView() {}
describe('LanguageSwitcher', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('persists language changes through display.language config', async () => {
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
const latestConfig: HermesConfigRecord = { display: { language: 'en', skin: 'slate' } }
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue(latestConfig),
saveConfig
}
render(
<I18nProvider configClient={configClient}>
<LanguageSwitcher />
</I18nProvider>
)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Switch language' }).hasAttribute('disabled')).toBe(false)
})
fireEvent.click(screen.getByRole('button', { name: 'Switch language' }))
fireEvent.click(screen.getByRole('option', { name: /日本語/i }))
await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
expect(saveConfig).toHaveBeenCalledWith({ display: { language: 'ja', skin: 'slate' } })
})
})
@@ -0,0 +1,175 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { useIsMobile } from '@/hooks/use-mobile'
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, ChevronDown, Globe } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
export interface LanguageSwitcherProps {
className?: string
collapsed?: boolean
dropUp?: boolean
}
interface LanguageCommandProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>
autoFocus?: boolean
disabled?: boolean
locale: Locale
noResults: string
onSelect: (code: Locale) => void
searchPlaceholder: string
}
export function LanguageSwitcher({ className, collapsed = false, dropUp = false }: LanguageSwitcherProps) {
const { isSavingLocale, locale, setLocale, t } = useI18n()
const [open, setOpen] = useState(false)
const isMobile = useIsMobile()
const useMobileSheet = Boolean(dropUp && isMobile)
const current = LOCALE_META[locale]
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>
const title = t.language.switchTo
const selectLocale = async (code: Locale) => {
if (code === locale || isSavingLocale) {
setOpen(false)
return
}
triggerHaptic('selection')
try {
await setLocale(code)
setOpen(false)
triggerHaptic('success')
} catch (error) {
notifyError(error, t.language.saveError)
}
}
const trigger = (
<Button
aria-expanded={open}
aria-label={title}
className={cn(
'min-w-32 justify-between gap-2 border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 text-left text-muted-foreground hover:text-foreground',
collapsed && 'min-w-0 px-2',
className
)}
disabled={isSavingLocale}
size="sm"
title={title}
type="button"
variant="outline"
>
<span className="inline-flex min-w-0 items-center gap-2">
<Globe className="size-3.5 shrink-0" />
{!collapsed && <span className="truncate">{current.name}</span>}
</span>
{!collapsed && <ChevronDown className="size-3 shrink-0 opacity-70" />}
</Button>
)
if (useMobileSheet) {
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent className="max-h-[min(28rem,80vh)] rounded-t-xl" side="bottom">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{t.language.description}</SheetDescription>
</SheetHeader>
<LanguageCommand
allLocales={allLocales}
disabled={isSavingLocale}
locale={locale}
noResults={t.language.noResults}
onSelect={code => void selectLocale(code)}
searchPlaceholder={t.language.searchPlaceholder}
/>
</SheetContent>
</Sheet>
)
}
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-0" side={dropUp ? 'top' : 'bottom'}>
<LanguageCommand
allLocales={allLocales}
autoFocus
disabled={isSavingLocale}
locale={locale}
noResults={t.language.noResults}
onSelect={code => void selectLocale(code)}
searchPlaceholder={t.language.searchPlaceholder}
/>
</PopoverContent>
</Popover>
)
}
function LanguageCommand({
allLocales,
autoFocus,
disabled,
locale,
noResults,
onSelect,
searchPlaceholder
}: LanguageCommandProps) {
const [search, setSearch] = useState('')
// Own the search term and filter manually. cmdk's built-in shouldFilter
// reorders items by its fuzzy-match score (≈alphabetical with an empty
// query), which destroys the curated en→zh→zh-hant→ja order. We disable it
// and do a plain substring filter that preserves array order — matching
// model-picker.tsx. Match against the endonym, the (hidden) English name,
// and the locale code so "日本"/"japanese"/"ja" all find Japanese.
const q = search.trim().toLowerCase()
const filtered = allLocales.filter(
([code, meta]) =>
!q ||
meta.name.toLowerCase().includes(q) ||
meta.englishName.toLowerCase().includes(q) ||
code.toLowerCase().includes(q)
)
return (
<Command className="bg-transparent" shouldFilter={false}>
<CommandInput autoFocus={autoFocus} onValueChange={setSearch} placeholder={searchPlaceholder} value={search} />
<CommandList className="max-h-80 p-1">
{filtered.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">{noResults}</div>
) : (
filtered.map(([code, meta]) => {
const selected = code === locale
return (
<CommandItem
className={cn(selected ? 'font-medium text-foreground' : 'text-muted-foreground')}
disabled={disabled}
key={code}
onSelect={() => onSelect(code)}
value={code}
>
<Check className={cn('size-3.5 shrink-0 text-primary', !selected && 'invisible')} />
<span className="min-w-0 flex-1 truncate">{meta.name}</span>
<span className="font-mono text-[0.65rem] uppercase text-(--ui-text-tertiary)">{code}</span>
</CommandItem>
)
})
)}
</CommandList>
</Command>
)
}
+27 -15
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useI18n } from '@/i18n'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
@@ -42,6 +43,8 @@ export function ModelPickerDialog({
onSelect,
contentClassName
}: ModelPickerDialogProps) {
const { t } = useI18n()
const copy = t.modelPicker
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
// Own the search term so we can filter manually. cmdk's built-in
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
@@ -97,9 +100,9 @@ export function ModelPickerDialog({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className={cn('max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0', contentClassName)}>
<DialogHeader className="border-b border-border px-4 py-3">
<DialogTitle>Switch model</DialogTitle>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription className="font-mono text-xs leading-relaxed">
current: {optionsModel || currentModel || '(unknown)'}
{copy.current} {optionsModel || currentModel || copy.unknown}
{optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''}
</DialogDescription>
</DialogHeader>
@@ -108,11 +111,11 @@ export function ModelPickerDialog({
<CommandInput
autoFocus
onValueChange={setSearch}
placeholder="Filter providers and models..."
placeholder={copy.search}
value={search}
/>
<CommandList className="max-h-96">
{!loading && !error && <CommandEmpty>No models found.</CommandEmpty>}
{!loading && !error && <CommandEmpty>{copy.noModels}</CommandEmpty>}
<ModelResults
currentModel={optionsModel || currentModel}
currentProvider={optionsProvider || currentProvider}
@@ -132,15 +135,15 @@ export function ModelPickerDialog({
disabled={!sessionId}
onCheckedChange={checked => setPersistGlobal(checked === true)}
/>
{sessionId ? 'Persist globally (otherwise this session only)' : 'Persist globally'}
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
</label>
<div className="flex items-center gap-2">
<Button onClick={addProvider} variant="ghost">
Add provider
{copy.addProvider}
</Button>
<Button onClick={() => onOpenChange(false)} variant="outline">
Cancel
{t.common.cancel}
</Button>
</div>
</DialogFooter>
@@ -166,6 +169,9 @@ function ModelResults({
onSelectModel: (provider: ModelOptionProvider, model: string) => void
search: string
}) {
const { t } = useI18n()
const copy = t.modelPicker
if (loading) {
return <LoadingResults />
}
@@ -173,7 +179,7 @@ function ModelResults({
if (error) {
return (
<div className="px-3 py-3">
<InlineNotice kind="error" title="Could not load models">
<InlineNotice kind="error" title={copy.loadFailed}>
{error}
</InlineNotice>
</div>
@@ -181,7 +187,7 @@ function ModelResults({
}
if (providers.length === 0) {
return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div>
return <div className="px-4 py-6 text-sm text-muted-foreground">{copy.noAuthenticatedProviders}</div>
}
const q = search.trim().toLowerCase()
@@ -241,14 +247,14 @@ function ModelResults({
value={`${provider.slug}:${model}`}
>
<span className="min-w-0 flex-1 truncate">{model}</span>
{locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">Pro</span>}
{locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">{copy.pro}</span>}
<ModelPrice isCurrent={isCurrent} price={price} />
</CommandItem>
)
})}
{unavailable.size > 0 && (
<div className="px-6 pb-2 pt-1 text-[0.62rem] leading-relaxed text-muted-foreground">
Pro models need a paid Nous subscription.
{copy.proNeedsSubscription}
</div>
)}
</CommandGroup>
@@ -261,6 +267,9 @@ function ModelResults({
// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns.
// Renders nothing when pricing is unavailable for the model.
function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) {
const { t } = useI18n()
const copy = t.modelPicker
if (!price || (!price.input && !price.output)) {
return null
}
@@ -273,7 +282,7 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
)}
>
Free
{copy.free}
</span>
)
}
@@ -284,7 +293,7 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo
'shrink-0 text-[0.66rem] tabular-nums',
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
)}
title="Input / Output price per million tokens"
title={copy.priceTitle}
>
{price.input || '?'} / {price.output || '?'}
</span>
@@ -304,15 +313,18 @@ function LoadingResults() {
}
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
const { t } = useI18n()
const copy = t.modelPicker
// free_tier is only set for Nous. true → "Free tier", false → "Pro".
const tierBadge =
provider.free_tier === true ? (
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
Free tier
{copy.freeTier}
</span>
) : provider.free_tier === false ? (
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
Pro
{copy.pro}
</span>
) : null
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import {
$visibleModels,
@@ -32,6 +33,8 @@ export function ModelVisibilityDialog({
open,
sessionId
}: ModelVisibilityDialogProps) {
const { t } = useI18n()
const copy = t.modelVisibility
const [search, setSearch] = useState('')
const stored = useStore($visibleModels)
@@ -76,7 +79,7 @@ export function ModelVisibilityDialog({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xs gap-0 overflow-hidden p-0">
<DialogHeader className="px-3 pb-1 pt-3">
<DialogTitle className="text-[0.8125rem]">Models</DialogTitle>
<DialogTitle className="text-[0.8125rem]">{copy.title}</DialogTitle>
</DialogHeader>
<div className="px-3 py-1.5">
@@ -84,7 +87,7 @@ export function ModelVisibilityDialog({
autoFocus
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearch(event.target.value)}
placeholder="Search models"
placeholder={copy.search}
type="text"
value={search}
/>
@@ -93,7 +96,7 @@ export function ModelVisibilityDialog({
<div className="max-h-[55vh] overflow-y-auto pb-1">
{providers.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : 'No authenticated providers.'}
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
</div>
) : (
providers.map(provider => {
@@ -140,7 +143,7 @@ export function ModelVisibilityDialog({
}}
type="button"
>
Add provider
{copy.addProvider}
</button>
</div>
</DialogContent>
+21 -18
View File
@@ -13,6 +13,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
@@ -34,6 +35,8 @@ import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } fr
// backdrop-dismiss path.
function SudoDialog() {
const { t } = useI18n()
const copy = t.prompts
const request = useStore($sudoRequest)
const gateway = useStore($gateway)
const [password, setPassword] = useState('')
@@ -51,7 +54,7 @@ function SudoDialog() {
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send sudo password')
notifyError(new Error(copy.gatewayDisconnected), copy.sudoSendFailed)
return
}
@@ -66,11 +69,11 @@ function SudoDialog() {
triggerHaptic('submit')
clearSudoRequest(request.sessionId, request.requestId)
} catch (error) {
notifyError(error, 'Could not send sudo password')
notifyError(error, copy.sudoSendFailed)
setSubmitting(false)
}
},
[gateway, request]
[copy.gatewayDisconnected, copy.sudoSendFailed, gateway, request]
)
// Cancel → empty password. The backend treats an empty sudo response as a
@@ -102,11 +105,9 @@ function SudoDialog() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="size-4 text-primary" />
Administrator password
{copy.sudoTitle}
</DialogTitle>
<DialogDescription>
Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.
</DialogDescription>
<DialogDescription>{copy.sudoDesc}</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
@@ -114,16 +115,16 @@ function SudoDialog() {
autoFocus
disabled={submitting}
onChange={event => setPassword(event.target.value)}
placeholder="sudo password"
placeholder={copy.sudoPlaceholder}
type="password"
value={password}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
{t.common.cancel}
</Button>
<Button disabled={submitting} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : t.common.send}
</Button>
</DialogFooter>
</form>
@@ -133,6 +134,8 @@ function SudoDialog() {
}
function SecretDialog() {
const { t } = useI18n()
const copy = t.prompts
const request = useStore($secretRequest)
const gateway = useStore($gateway)
const [value, setValue] = useState('')
@@ -150,7 +153,7 @@ function SecretDialog() {
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send secret')
notifyError(new Error(copy.gatewayDisconnected), copy.secretSendFailed)
return
}
@@ -165,11 +168,11 @@ function SecretDialog() {
triggerHaptic('submit')
clearSecretRequest(request.sessionId, request.requestId)
} catch (error) {
notifyError(error, 'Could not send secret')
notifyError(error, copy.secretSendFailed)
setSubmitting(false)
}
},
[gateway, request]
[copy.gatewayDisconnected, copy.secretSendFailed, gateway, request]
)
const onOpenChange = useCallback(
@@ -199,9 +202,9 @@ function SecretDialog() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-4 text-primary" />
{request.envVar || 'Secret required'}
{request.envVar || copy.secretTitle}
</DialogTitle>
<DialogDescription>{request.prompt || 'Hermes needs a credential to continue.'}</DialogDescription>
<DialogDescription>{request.prompt || copy.secretDesc}</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
@@ -209,16 +212,16 @@ function SecretDialog() {
autoFocus
disabled={submitting}
onChange={event => setValue(event.target.value)}
placeholder={request.envVar || 'secret value'}
placeholder={request.envVar || copy.secretPlaceholder}
type="password"
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
{t.common.cancel}
</Button>
<Button disabled={submitting || !value} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : t.common.send}
</Button>
</DialogFooter>
</form>
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
interface ConfirmDialogProps {
@@ -29,15 +30,20 @@ export function ConfirmDialog({
onConfirm,
title,
description,
confirmLabel = 'Confirm',
busyLabel = 'Working…',
doneLabel = 'Done',
cancelLabel = 'Cancel',
confirmLabel,
busyLabel,
doneLabel,
cancelLabel,
destructive = false
}: ConfirmDialogProps) {
const { t } = useI18n()
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
const busy = status === 'saving' || status === 'done'
const resolvedConfirmLabel = confirmLabel ?? t.common.confirm
const resolvedBusyLabel = busyLabel ?? t.common.loading
const resolvedDoneLabel = doneLabel ?? t.common.done
const resolvedCancelLabel = cancelLabel ?? t.common.cancel
useEffect(() => {
if (open) {
@@ -60,7 +66,7 @@ export function ConfirmDialog({
window.setTimeout(onClose, 600)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Something went wrong')
setError(err instanceof Error ? err.message : t.errors.genericFailure)
}
}
@@ -91,10 +97,10 @@ export function ConfirmDialog({
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
{cancelLabel}
{resolvedCancelLabel}
</Button>
<Button disabled={busy} onClick={() => void run()} variant={destructive ? 'destructive' : 'default'}>
<ActionStatus busy={busyLabel} done={doneLabel} idle={confirmLabel} state={status} />
<ActionStatus busy={resolvedBusyLabel} done={resolvedDoneLabel} idle={resolvedConfirmLabel} state={status} />
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,36 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { CopyButton } from './copy-button'
describe('CopyButton i18n', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('uses localized default labels and copied feedback', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
})
render(
<I18nProvider configClient={null} initialLocale="zh">
<CopyButton text="hello" />
</I18nProvider>
)
const button = screen.getByRole('button', { name: '复制' })
expect(button.textContent).toContain('复制')
fireEvent.click(button)
await waitFor(() => expect(writeText).toHaveBeenCalledWith('hello'))
await waitFor(() => expect(screen.getByRole('button', { name: '已复制' })).toBeTruthy())
expect(screen.getByRole('button', { name: '已复制' }).textContent).toContain('已复制')
})
})
+12 -7
View File
@@ -3,6 +3,7 @@ import * as React from 'react'
import { Button } from '@/components/ui/button'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Copy, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -59,10 +60,10 @@ export function CopyButton({
children,
className,
disabled = false,
errorMessage = 'Copy failed',
errorMessage,
haptic = true,
iconClassName,
label = 'Copy',
label,
onCopied,
onCopyError,
preventDefault = false,
@@ -71,6 +72,9 @@ export function CopyButton({
text,
title
}: CopyButtonProps) {
const { t } = useI18n()
const resolvedErrorMessage = errorMessage ?? t.common.copyFailed
const resolvedLabel = label ?? t.common.copy
const [status, setStatus] = React.useState<CopyStatus>('idle')
const resetRef = React.useRef<number | null>(null)
@@ -138,10 +142,10 @@ export function CopyButton({
const visibleChildren =
(showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row'))
? status === 'copied'
? 'Copied'
? t.common.copied
: status === 'error'
? 'Failed'
: (children ?? label)
? t.common.failed
: (children ?? resolvedLabel)
: null
const content = (
@@ -151,8 +155,9 @@ export function CopyButton({
</>
)
const feedbackLabel = status === 'copied' ? 'Copied' : status === 'error' ? errorMessage : (title ?? label)
const ariaLabel = status === 'idle' ? label : feedbackLabel
const feedbackLabel =
status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
if (appearance === 'menu-item') {
return (
+5 -2
View File
@@ -3,6 +3,7 @@ import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
@@ -42,6 +43,8 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
const { t } = useI18n()
return (
<DialogPortal>
<DialogOverlay />
@@ -60,13 +63,13 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label="Close"
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="1rem" />
<span className="sr-only">Close</span>
<span className="sr-only">{t.common.close}</span>
</Button>
</DialogPrimitive.Close>
)}
+12 -5
View File
@@ -1,12 +1,15 @@
import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
const { t } = useI18n()
return (
<nav
aria-label="pagination"
aria-label={t.ui.pagination.label}
className={cn('mx-auto flex w-full justify-center', className)}
data-slot="pagination"
{...props}
@@ -48,9 +51,11 @@ function PaginationButton({ className, isActive, ...props }: PaginationButtonPro
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<'button'>) {
const { t } = useI18n()
return (
<button
aria-label="Go to previous page"
aria-label={t.ui.pagination.previousAria}
className={cn(
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
className
@@ -60,15 +65,17 @@ function PaginationPrevious({ className, ...props }: React.ComponentProps<'butto
{...props}
>
<Codicon name="chevron-left" size="0.75rem" />
<span>Prev</span>
<span>{t.ui.pagination.previous}</span>
</button>
)
}
function PaginationNext({ className, ...props }: React.ComponentProps<'button'>) {
const { t } = useI18n()
return (
<button
aria-label="Go to next page"
aria-label={t.ui.pagination.nextAria}
className={cn(
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
className
@@ -77,7 +84,7 @@ function PaginationNext({ className, ...props }: React.ComponentProps<'button'>)
type="button"
{...props}
>
<span>Next</span>
<span>{t.ui.pagination.next}</span>
<Codicon name="chevron-right" size="0.75rem" />
</button>
)
@@ -2,6 +2,7 @@ import type { ReactNode, RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { Loader2, Search } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -35,6 +36,7 @@ export function SearchField({
trailingAction,
'aria-label': ariaLabel
}: SearchFieldProps) {
const { t } = useI18n()
const clear = onClear ?? (() => onChange(''))
return (
@@ -64,7 +66,7 @@ export function SearchField({
<Loader2 className="pointer-events-none size-3.5 shrink-0 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
aria-label={t.ui.search.clear}
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
+8 -2
View File
@@ -4,6 +4,7 @@ import { Dialog as SheetPrimitive } from 'radix-ui'
import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
@@ -45,6 +46,8 @@ function SheetContent({
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
}) {
const { t } = useI18n()
return (
<SheetPortal>
<SheetOverlay />
@@ -66,9 +69,12 @@ function SheetContent({
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-3 right-3 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 ring-offset-background transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<SheetPrimitive.Close
aria-label={t.common.close}
className="absolute top-3 right-3 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 ring-offset-background transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary"
>
<Codicon name="close" size="1rem" />
<span className="sr-only">Close</span>
<span className="sr-only">{t.common.close}</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
+9 -5
View File
@@ -11,6 +11,7 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useIsMobile } from '@/hooks/use-mobile'
import { useI18n } from '@/i18n'
import { PanelLeftIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -152,6 +153,7 @@ function Sidebar({
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { t } = useI18n()
if (collapsible === 'none') {
return (
@@ -181,8 +183,8 @@ function Sidebar({
}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>{t.ui.sidebar.title}</SheetTitle>
<SheetDescription>{t.ui.sidebar.description}</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -240,6 +242,7 @@ function Sidebar({
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { t } = useI18n()
return (
<Button
@@ -255,17 +258,18 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<span className="sr-only">{t.ui.sidebar.toggle}</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
const { t } = useI18n()
return (
<button
aria-label="Toggle Sidebar"
aria-label={t.ui.sidebar.toggle}
className={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
@@ -279,7 +283,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
data-slot="sidebar-rail"
onClick={toggleSidebar}
tabIndex={-1}
title="Toggle Sidebar"
title={t.ui.sidebar.toggle}
{...props}
/>
)