feat(desktop): localize desktop chrome
Co-authored-by: Kiro 有点Yes <246816394+sdyckjq-lab@users.noreply.github.com>
This commit is contained in:
parent
812dc6957e
commit
fbd423b94d
14 changed files with 297 additions and 70 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
36
apps/desktop/src/components/ui/copy-button.test.tsx
Normal file
36
apps/desktop/src/components/ui/copy-button.test.tsx
Normal 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('已复制')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue