feat(desktop): localize desktop chrome

Co-authored-by: Kiro 有点Yes <246816394+sdyckjq-lab@users.noreply.github.com>
This commit is contained in:
Jim Liu 宝玉 2026-06-05 22:43:16 -05:00 committed by Teknium
parent 812dc6957e
commit fbd423b94d
14 changed files with 297 additions and 70 deletions

View file

@ -83,6 +83,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)
@ -102,11 +109,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"
/>
@ -114,22 +121,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
@ -296,7 +313,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,
@ -518,7 +535,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
}
>
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph status={displayStatus === 'success' ? undefined : displayStatus} />
<ToolGlyph copy={copy} status={displayStatus === 'success' ? undefined : displayStatus} />
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,

View file

@ -51,6 +51,11 @@ 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
@ -112,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>
@ -128,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}
</>
@ -141,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'
@ -161,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')} />

View file

@ -116,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 })
@ -196,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"

View file

@ -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('已复制')
})
})

View file

@ -3,6 +3,7 @@ import * as React from 'react'
import { Button } from '@/components/ui/button'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Copy, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -59,10 +60,10 @@ export function CopyButton({
children,
className,
disabled = false,
errorMessage = 'Copy failed',
errorMessage,
haptic = true,
iconClassName,
label = 'Copy',
label,
onCopied,
onCopyError,
preventDefault = false,
@ -71,6 +72,9 @@ export function CopyButton({
text,
title
}: CopyButtonProps) {
const { t } = useI18n()
const resolvedErrorMessage = errorMessage ?? t.common.copyFailed
const resolvedLabel = label ?? t.common.copy
const [status, setStatus] = React.useState<CopyStatus>('idle')
const resetRef = React.useRef<number | null>(null)
@ -138,10 +142,10 @@ export function CopyButton({
const visibleChildren =
(showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row'))
? status === 'copied'
? 'Copied'
? t.common.copied
: status === 'error'
? 'Failed'
: (children ?? label)
? t.common.failed
: (children ?? resolvedLabel)
: null
const content = (
@ -151,8 +155,9 @@ export function CopyButton({
</>
)
const feedbackLabel = status === 'copied' ? 'Copied' : status === 'error' ? errorMessage : (title ?? label)
const ariaLabel = status === 'idle' ? label : feedbackLabel
const feedbackLabel =
status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
if (appearance === 'menu-item') {
return (