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:
@@ -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 won’t 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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('已复制')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user