fix(web): translate Design Files refresh strings instead of hardcoding English (#1254) (#1300)

* fix(web): translate Design Files / live artifact refresh strings instead of hardcoding English

When the app language was set to Chinese, the Design Files refresh
flow showed Chinese for the surrounding chrome but kept English for
every label and message originating in describeRefreshStatus,
describeEventPhase, and the refresh-event timeline body of
LiveArtifactRefreshHistoryPanel. Same-screen mixed-language UX, the
exact symptom reported in #1254.

Root cause: those three sites bypassed i18n entirely. describeRefreshStatus
returned hardcoded English label + description strings for the
running / succeeded / failed / idle / never statuses;
describeEventPhase returned hardcoded Started / Succeeded / Failed
labels; the timeline body inlined "Refresh started…",
"<n> source(s) updated", and "Refresh failed." string literals; and
the empty-timeline copy ("No refresh activity yet in this session.
Trigger Refresh to record a timeline…") was hardcoded too.

Fix: thread the existing TranslateFn through both helpers, swap every
hardcoded string for a t() lookup, and pull the empty-timeline copy
and the failure-fallback through the same path. Added 13 new keys
under liveArtifact.refresh.* — statusRunning, the five
*Description keys, three event-phase labels (eventStarted/Succeeded/Failed),
eventStartedDetail, sourcesUpdatedOne/Many with an {n} placeholder,
and timelineEmpty. Status labels for succeeded / failed / ready / never
already had keys (statusSucceeded / statusFailed / statusReady /
statusNever) so those are reused unchanged.

Locales: full Chinese translations added to zh-CN.ts (the locale
directly named in the issue). The other 16 locales pick up English
fallbacks through their existing ...en spread, so the locale-key
alignment test stays green; native translations for those locales
can land via the usual locale-team passes without re-touching the
source code.

* fix(web): cover the rest of the refresh panel under i18n + add a zh-CN render test

Lefarcen's review on #1254 / PR #1300 surfaced that the first pass
only translated three helpers (describeRefreshStatus,
describeEventPhase, session timeline body) and left the rest of the
panel in English. Under a Chinese UI the panel still mixed
languages, which was exactly the regression the issue was filed for.

This commit threads t() through every user-visible refresh-panel
string the user would see in the Chinese flow:

- Hero block: "Last refreshed" label + "Never" empty state.
- Created / Last updated facts + their "Unknown" empty label.
- Persisted refresh history header, hint, empty-state copy.
- Persisted timeline status badge: succeeded / running / failed /
  cancelled / skipped now resolve through describePersistedStatus,
  which uses an exhaustive switch off LiveArtifactRefreshLogEntry's
  status union so a future contract addition trips tsc.
- Session activity header, hint.
- Document source header, hint, Type / Tool / Connector field
  labels.
- Advanced debug metadata summary + note line.
- "just now" relative-time fallback in the persisted timeline.

22 new i18n keys total (23 with the new
heroLastRefreshedNever distinct from statusNever); zh-CN strings
authored alongside the English source, every other locale picks
them up via its existing ...en spread and the locale-key alignment
test stays green.

Intentionally untranslated surfaces: raw daemon payloads inside
the <details> debug panel (event.step / refreshId / error.message
and the JSON.stringify dump), since those are agent / connector
identifiers and stack-trace style strings, not localised copy.
The debug summary heading itself is translated; if the
debug section should be hidden in localised primary flows, that
is a separate UX call worth its own issue.

Test coverage: new render test wraps LiveArtifactRefreshHistoryPanel
in I18nProvider initial="zh-CN" and pins the Chinese rendering of
every translated label, plus negative assertions that the formerly
hardcoded English literals are NOT present in the markup. With the
no-provider fallback returning English, the existing static-markup
tests can't observe the regression this PR is meant to fix; the
zh-CN render test is the only one that would have caught the
original gap and will catch the next one.

Validated: pnpm guard, pnpm --filter @open-design/web typecheck,
locales.test.ts (5/5), FileViewer.test.tsx (69/69, +1 new zh-CN
test), full web suite (92 files, 841 tests).

* fix(web): route formatRelativeTime through Intl.RelativeTimeFormat so units localise

Lefarcen's second pass on PR #1300 caught the remaining hardcoded
English path: formatRelativeTime() still emitted units like `5s ago`
and `45m ago`, so Chinese users would see those strings inside the
otherwise-translated refresh panel. The function now takes the
active locale + TranslateFn and routes through
Intl.RelativeTimeFormat with style: 'narrow', numeric: 'always'.
That preserves the historical `5s ago` shape for English while
producing locale-correct output for every other locale (zh-CN gets
`5秒前` / `45分前`, with the right past / future suffix and word
order).

The `just now` carve-out (abs < 5s) keeps using
t('liveArtifact.refresh.justNow') since Intl's narrow output for
zero-delta reads awkwardly. A try/catch around the RTF constructor
falls back to 'en' if the runtime rejects the locale, so the
function is safe on engines with limited ICU data.

Callsites threaded through:
- LiveArtifactRefreshHistoryPanel hero metric (`lastRefreshedAt`)
- Session timeline event row (`event.startedAt`)
- Session timeline event time (`event.at`)
- LiveArtifactRefreshFact for the created / last-updated facts;
  the component now accepts optional `locale` + `t` props and the
  panel passes them in.

Test coverage extension:
- The existing zh-CN render test sets a real lastRefreshedAt
  (now - 45s) and real session-event timestamps, then asserts the
  Chinese past-tense suffix `前` appears AND the legacy English
  `Xs ago` / `Xm ago` shapes do NOT. That was the gap lefarcen
  pointed at: setting `lastRefreshedAt: undefined` couldn't see
  the regression because no relative-time formatting ran.
- Added a small second test for the lastRefreshedAt-undefined
  empty hero so the original `从未` coverage still pins.

Validated: pnpm guard, pnpm --filter @open-design/web typecheck,
FileViewer.test.tsx (70/70, +1 new test), locales.test.ts (5/5),
full web suite (92 files, 842 tests).

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
This commit is contained in:
Nagendhra Madishetti 2026-05-11 22:38:07 -04:00 committed by GitHub
parent 5fa861137d
commit 64510b790b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 365 additions and 57 deletions

View file

@ -3,8 +3,8 @@ import { createPortal } from 'react-dom';
import { APP_CHROME_FILE_ACTIONS_ID } from './AppChromeHeader';
import { MarkdownRenderer, artifactRendererRegistry } from '../artifacts/renderer-registry';
import { renderMarkdownToSafeHtml } from '../artifacts/markdown';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import { useT, useI18n } from '../i18n';
import type { Dict, Locale } from '../i18n/types';
import {
fetchLiveArtifact,
fetchLiveArtifactCode,
@ -1046,20 +1046,44 @@ function formatAbsoluteDateTime(iso: string | number | undefined): string | null
}
}
function formatRelativeTime(iso: string | number | undefined, now = Date.now()): string | null {
function formatRelativeTime(
iso: string | number | undefined,
now = Date.now(),
locale: Locale = 'en',
t?: TranslateFn,
): string | null {
if (iso === undefined || iso === null) return null;
const ms = typeof iso === 'number' ? iso : new Date(iso).getTime();
if (Number.isNaN(ms)) return null;
const deltaSec = Math.round((ms - now) / 1000);
const abs = Math.abs(deltaSec);
const suffix = deltaSec <= 0 ? ' ago' : ' from now';
if (abs < 5) return 'just now';
if (abs < 60) return `${abs}s${suffix}`;
if (abs < 3600) return `${Math.round(abs / 60)}m${suffix}`;
if (abs < 86400) return `${Math.round(abs / 3600)}h${suffix}`;
if (abs < 86400 * 30) return `${Math.round(abs / 86400)}d${suffix}`;
if (abs < 86400 * 365) return `${Math.round(abs / (86400 * 30))}mo${suffix}`;
return `${Math.round(abs / (86400 * 365))}y${suffix}`;
if (abs < 5) {
// "just now" lives in the i18n dict because Intl.RelativeTimeFormat's
// "0 seconds ago" reads awkwardly in narrow style and we want a
// single canonical translation per locale. Fall back to the English
// literal only when called without t (background utilities, tests).
return t ? t('liveArtifact.refresh.justNow') : 'just now';
}
// Intl.RelativeTimeFormat handles tense (past / future), pluralisation,
// and word-order per locale so the panel matches the rest of the
// localised UI instead of mixing in English units like `5s ago`.
// `style: 'narrow'` keeps the English output close to the historical
// `5s ago` shape; `numeric: 'always'` forces numeric output so we
// don't get "yesterday" / "now" mixed in unexpectedly with the
// bucketing above.
let rtf: Intl.RelativeTimeFormat;
try {
rtf = new Intl.RelativeTimeFormat(locale, { style: 'narrow', numeric: 'always' });
} catch {
rtf = new Intl.RelativeTimeFormat('en', { style: 'narrow', numeric: 'always' });
}
const value = deltaSec; // negative = past, positive = future
if (abs < 60) return rtf.format(value, 'second');
if (abs < 3600) return rtf.format(Math.round(value / 60), 'minute');
if (abs < 86400) return rtf.format(Math.round(value / 3600), 'hour');
if (abs < 86400 * 30) return rtf.format(Math.round(value / 86400), 'day');
if (abs < 86400 * 365) return rtf.format(Math.round(value / (86400 * 30)), 'month');
return rtf.format(Math.round(value / (86400 * 365)), 'year');
}
function formatDurationMs(ms: number | undefined): string | null {
@ -1077,48 +1101,76 @@ interface RefreshStatusDescriptor {
description: string;
}
function describeRefreshStatus(status: LiveArtifactRefreshStatus): RefreshStatusDescriptor {
function describeRefreshStatus(
status: LiveArtifactRefreshStatus,
t: TranslateFn,
): RefreshStatusDescriptor {
switch (status) {
case 'running':
return {
label: 'Refreshing',
label: t('liveArtifact.refresh.statusRunning'),
tone: 'running',
description: 'A refresh run is currently in progress.',
description: t('liveArtifact.refresh.statusRunningDescription'),
};
case 'succeeded':
return {
label: 'Up to date',
label: t('liveArtifact.refresh.statusSucceeded'),
tone: 'success',
description: 'The last refresh finished successfully.',
description: t('liveArtifact.refresh.statusSucceededDescription'),
};
case 'failed':
return {
label: 'Refresh failed',
label: t('liveArtifact.refresh.statusFailed'),
tone: 'error',
description: 'The last refresh attempt did not complete successfully.',
description: t('liveArtifact.refresh.statusFailedDescription'),
};
case 'idle':
return {
label: 'Ready to refresh',
label: t('liveArtifact.refresh.statusReady'),
tone: 'neutral',
description: 'Refreshable sources are configured but no run is in progress.',
description: t('liveArtifact.refresh.statusReadyDescription'),
};
case 'never':
default:
return {
label: 'Not refreshable',
label: t('liveArtifact.refresh.statusNever'),
tone: 'warning',
description: 'This live artifact has no refresh source yet.',
description: t('liveArtifact.refresh.statusNeverDescription'),
};
}
}
function describeEventPhase(
event: LiveArtifactRefreshEvent,
t: TranslateFn,
): { label: string; tone: 'running' | 'success' | 'error' } {
if (event.phase === 'started') return { label: 'Started', tone: 'running' };
if (event.phase === 'succeeded') return { label: 'Succeeded', tone: 'success' };
return { label: 'Failed', tone: 'error' };
if (event.phase === 'started')
return { label: t('liveArtifact.refresh.eventStarted'), tone: 'running' };
if (event.phase === 'succeeded')
return { label: t('liveArtifact.refresh.eventSucceeded'), tone: 'success' };
return { label: t('liveArtifact.refresh.eventFailed'), tone: 'error' };
}
function describePersistedStatus(
status: LiveArtifactRefreshLogEntry['status'],
t: TranslateFn,
): string {
switch (status) {
case 'succeeded':
return t('liveArtifact.refresh.persistedStatusSucceeded');
case 'running':
return t('liveArtifact.refresh.persistedStatusRunning');
case 'failed':
return t('liveArtifact.refresh.persistedStatusFailed');
case 'cancelled':
return t('liveArtifact.refresh.persistedStatusCancelled');
case 'skipped':
return t('liveArtifact.refresh.persistedStatusSkipped');
default: {
const exhaustive: never = status;
return exhaustive;
}
}
}
export function LiveArtifactRefreshHistoryPanel({
@ -1136,6 +1188,8 @@ export function LiveArtifactRefreshHistoryPanel({
sessionEvents: LiveArtifactRefreshEvent[];
persistedEvents?: LiveArtifactRefreshLogEntry[];
}) {
const t = useT();
const { locale } = useI18n();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
@ -1147,7 +1201,7 @@ export function LiveArtifactRefreshHistoryPanel({
const status: LiveArtifactRefreshStatus = isRunning
? 'running'
: liveArtifact?.refreshStatus ?? fallbackRefreshStatus;
const descriptor = describeRefreshStatus(status);
const descriptor = describeRefreshStatus(status, t);
const lastRefreshedAt = liveArtifact?.lastRefreshedAt ?? fallbackLastRefreshedAt;
const createdAt = liveArtifact?.createdAt;
const updatedAt = liveArtifact?.updatedAt;
@ -1176,11 +1230,13 @@ export function LiveArtifactRefreshHistoryPanel({
</div>
<div className="live-artifact-refresh-hero-meta">
<div className="live-artifact-refresh-hero-metric">
<span className="live-artifact-refresh-label">Last refreshed</span>
<span className="live-artifact-refresh-label">
{t('liveArtifact.refresh.heroLastRefreshedLabel')}
</span>
{lastRefreshedAt ? (
<>
<span className="live-artifact-refresh-value">
{formatRelativeTime(lastRefreshedAt, now) ?? '—'}
{formatRelativeTime(lastRefreshedAt, now, locale, t) ?? '—'}
</span>
<span
className="live-artifact-refresh-sub"
@ -1190,7 +1246,9 @@ export function LiveArtifactRefreshHistoryPanel({
</span>
</>
) : (
<span className="live-artifact-refresh-value muted">Never</span>
<span className="live-artifact-refresh-value muted">
{t('liveArtifact.refresh.heroLastRefreshedNever')}
</span>
)}
</div>
</div>
@ -1198,29 +1256,33 @@ export function LiveArtifactRefreshHistoryPanel({
<section className="live-artifact-refresh-facts">
<LiveArtifactRefreshFact
label="Created"
label={t('liveArtifact.refresh.factCreated')}
iso={createdAt}
emptyLabel="Unknown"
emptyLabel={t('liveArtifact.refresh.factUnknown')}
now={now}
locale={locale}
t={t}
/>
<LiveArtifactRefreshFact
label="Last updated"
label={t('liveArtifact.refresh.factLastUpdated')}
iso={updatedAt}
emptyLabel="Unknown"
emptyLabel={t('liveArtifact.refresh.factUnknown')}
now={now}
locale={locale}
t={t}
/>
</section>
<section className="live-artifact-refresh-section">
<header className="live-artifact-refresh-section-header">
<h4>Persisted refresh history</h4>
<h4>{t('liveArtifact.refresh.persistedTitle')}</h4>
<span className="live-artifact-refresh-hint">
Entries loaded from refreshes.jsonl
{t('liveArtifact.refresh.persistedHint')}
</span>
</header>
{reversedPersistedEvents.length === 0 ? (
<div className="live-artifact-refresh-empty">
No persisted refresh history yet.
{t('liveArtifact.refresh.persistedEmpty')}
</div>
) : (
<ol className="live-artifact-refresh-timeline">
@ -1239,11 +1301,12 @@ export function LiveArtifactRefreshHistoryPanel({
<div className="live-artifact-refresh-event-body">
<div className="live-artifact-refresh-event-row">
<span className={`live-artifact-badge refresh-status tone-${tone}`}>
{event.status}
{describePersistedStatus(event.status, t)}
</span>
<strong>{event.step}</strong>
<span className="live-artifact-refresh-event-time">
{formatRelativeTime(event.startedAt, now) ?? 'just now'}
{formatRelativeTime(event.startedAt, now, locale, t)
?? t('liveArtifact.refresh.justNow')}
</span>
</div>
<div className="live-artifact-refresh-event-meta">
@ -1261,21 +1324,21 @@ export function LiveArtifactRefreshHistoryPanel({
<section className="live-artifact-refresh-section">
<header className="live-artifact-refresh-section-header">
<h4>Session activity</h4>
<h4>{t('liveArtifact.refresh.sessionTitle')}</h4>
<span className="live-artifact-refresh-hint">
Events observed while this tab is open
{t('liveArtifact.refresh.sessionHint')}
</span>
</header>
{reversedEvents.length === 0 ? (
<div className="live-artifact-refresh-empty">
No refresh activity yet in this session. Trigger
{' '}<em>Refresh</em>{' '}to record a timeline, or wait for automated runs.
{t('liveArtifact.refresh.timelineEmpty')}
</div>
) : (
<ol className="live-artifact-refresh-timeline">
{reversedEvents.map((event) => {
const phase = describeEventPhase(event);
const phase = describeEventPhase(event, t);
const duration = formatDurationMs(event.durationMs);
const refreshedCount = event.refreshedSourceCount ?? 0;
return (
<li key={event.id} className={`live-artifact-refresh-event tone-${phase.tone}`}>
<span className="live-artifact-refresh-event-dot" aria-hidden />
@ -1290,24 +1353,27 @@ export function LiveArtifactRefreshHistoryPanel({
className="live-artifact-refresh-event-time"
title={formatAbsoluteDateTime(event.at) ?? undefined}
>
{formatRelativeTime(event.at, now) ?? ''}
{formatRelativeTime(event.at, now, locale, t) ?? ''}
</span>
</div>
<div className="live-artifact-refresh-event-detail">
{event.phase === 'succeeded' ? (
<span>
{`${event.refreshedSourceCount ?? 0} source${
(event.refreshedSourceCount ?? 0) === 1 ? '' : 's'
} updated`}
{t(
refreshedCount === 1
? 'liveArtifact.refresh.sourcesUpdatedOne'
: 'liveArtifact.refresh.sourcesUpdatedMany',
{ n: refreshedCount },
)}
{duration ? ` · ${duration}` : ''}
</span>
) : event.phase === 'failed' ? (
<span>
{event.error ?? 'Refresh failed.'}
{event.error ?? t('liveArtifact.refresh.genericFailure')}
{duration ? ` · ${duration}` : ''}
</span>
) : (
<span>Refresh started</span>
<span>{t('liveArtifact.refresh.eventStartedDetail')}</span>
)}
</div>
</div>
@ -1321,19 +1387,19 @@ export function LiveArtifactRefreshHistoryPanel({
{documentSource ? (
<section className="live-artifact-refresh-section">
<header className="live-artifact-refresh-section-header">
<h4>Document source</h4>
<h4>{t('liveArtifact.refresh.docSourceTitle')}</h4>
<span className="live-artifact-refresh-hint">
Source configured
{t('liveArtifact.refresh.docSourceHint')}
</span>
</header>
<dl className="live-artifact-refresh-kv">
<div>
<dt>Type</dt>
<dt>{t('liveArtifact.refresh.docSourceType')}</dt>
<dd>{documentSource.type}</dd>
</div>
{documentSource.toolName ? (
<div>
<dt>Tool</dt>
<dt>{t('liveArtifact.refresh.docSourceTool')}</dt>
<dd>
<code>{documentSource.toolName}</code>
</dd>
@ -1341,7 +1407,7 @@ export function LiveArtifactRefreshHistoryPanel({
) : null}
{documentSource.connector ? (
<div>
<dt>Connector</dt>
<dt>{t('liveArtifact.refresh.docSourceConnector')}</dt>
<dd>
{documentSource.connector.accountLabel ??
documentSource.connector.connectorId}
@ -1354,9 +1420,9 @@ export function LiveArtifactRefreshHistoryPanel({
{rawDebugPayload != null ? (
<details className="live-artifact-refresh-raw">
<summary>Advanced debug metadata</summary>
<summary>{t('liveArtifact.refresh.debugSummary')}</summary>
<p className="live-artifact-refresh-raw-note">
May include connector IDs, file names, source metadata, and internal artifact paths.
{t('liveArtifact.refresh.debugNote')}
</p>
<pre className="viewer-source">{JSON.stringify(rawDebugPayload, null, 2)}</pre>
</details>
@ -1372,6 +1438,8 @@ function LiveArtifactRefreshFact({
helper,
emptyLabel,
now,
locale,
t,
}: {
label: string;
iso?: string;
@ -1379,8 +1447,10 @@ function LiveArtifactRefreshFact({
helper?: string;
emptyLabel?: string;
now?: number;
locale?: Locale;
t?: TranslateFn;
}) {
const relative = iso !== undefined ? formatRelativeTime(iso, now) : null;
const relative = iso !== undefined ? formatRelativeTime(iso, now, locale, t) : null;
const absolute = iso !== undefined ? formatAbsoluteDateTime(iso) : null;
const resolved = value ?? relative ?? emptyLabel ?? '—';
const sub = helper ?? (iso !== undefined ? absolute ?? '' : '');

View file

@ -907,6 +907,49 @@ export const en: Dict = {
'liveArtifact.refresh.statusReady': 'Ready to refresh',
'liveArtifact.refresh.statusSucceeded': 'Up to date',
'liveArtifact.refresh.statusFailed': 'Refresh failed',
'liveArtifact.refresh.statusRunning': 'Refreshing',
'liveArtifact.refresh.statusRunningDescription':
'A refresh run is currently in progress.',
'liveArtifact.refresh.statusSucceededDescription':
'The last refresh finished successfully.',
'liveArtifact.refresh.statusFailedDescription':
'The last refresh attempt did not complete successfully.',
'liveArtifact.refresh.statusReadyDescription':
'Refreshable sources are configured but no run is in progress.',
'liveArtifact.refresh.statusNeverDescription':
'This live artifact has no refresh source yet.',
'liveArtifact.refresh.eventStarted': 'Started',
'liveArtifact.refresh.eventSucceeded': 'Succeeded',
'liveArtifact.refresh.eventFailed': 'Failed',
'liveArtifact.refresh.eventStartedDetail': 'Refresh started…',
'liveArtifact.refresh.sourcesUpdatedOne': '{n} source updated',
'liveArtifact.refresh.sourcesUpdatedMany': '{n} sources updated',
'liveArtifact.refresh.timelineEmpty':
'No refresh activity yet in this session. Trigger Refresh to record a timeline, or wait for automated runs.',
'liveArtifact.refresh.heroLastRefreshedLabel': 'Last refreshed',
'liveArtifact.refresh.heroLastRefreshedNever': 'Never',
'liveArtifact.refresh.justNow': 'just now',
'liveArtifact.refresh.factCreated': 'Created',
'liveArtifact.refresh.factLastUpdated': 'Last updated',
'liveArtifact.refresh.factUnknown': 'Unknown',
'liveArtifact.refresh.persistedTitle': 'Persisted refresh history',
'liveArtifact.refresh.persistedHint': 'Entries loaded from refreshes.jsonl',
'liveArtifact.refresh.persistedEmpty': 'No persisted refresh history yet.',
'liveArtifact.refresh.persistedStatusSucceeded': 'succeeded',
'liveArtifact.refresh.persistedStatusRunning': 'running',
'liveArtifact.refresh.persistedStatusFailed': 'failed',
'liveArtifact.refresh.persistedStatusCancelled': 'cancelled',
'liveArtifact.refresh.persistedStatusSkipped': 'skipped',
'liveArtifact.refresh.sessionTitle': 'Session activity',
'liveArtifact.refresh.sessionHint': 'Events observed while this tab is open',
'liveArtifact.refresh.docSourceTitle': 'Document source',
'liveArtifact.refresh.docSourceHint': 'Source configured',
'liveArtifact.refresh.docSourceType': 'Type',
'liveArtifact.refresh.docSourceTool': 'Tool',
'liveArtifact.refresh.docSourceConnector': 'Connector',
'liveArtifact.refresh.debugSummary': 'Advanced debug metadata',
'liveArtifact.refresh.debugNote':
'May include connector IDs, file names, source metadata, and internal artifact paths.',
'liveArtifact.viewer.tabPreview': 'Preview',
'liveArtifact.viewer.tabCode': 'Code',
'liveArtifact.viewer.tabData': 'Data',

View file

@ -894,6 +894,44 @@ export const zhCN: Dict = {
'liveArtifact.refresh.statusReady': '可刷新',
'liveArtifact.refresh.statusSucceeded': '已是最新',
'liveArtifact.refresh.statusFailed': '刷新失败',
'liveArtifact.refresh.statusRunning': '正在刷新',
'liveArtifact.refresh.statusRunningDescription': '刷新任务正在进行中。',
'liveArtifact.refresh.statusSucceededDescription': '上次刷新已成功完成。',
'liveArtifact.refresh.statusFailedDescription': '上次刷新未能成功完成。',
'liveArtifact.refresh.statusReadyDescription':
'已配置可刷新的数据源,但暂无正在进行的任务。',
'liveArtifact.refresh.statusNeverDescription': '该实时产物尚未配置刷新数据源。',
'liveArtifact.refresh.eventStarted': '已开始',
'liveArtifact.refresh.eventSucceeded': '已成功',
'liveArtifact.refresh.eventFailed': '失败',
'liveArtifact.refresh.eventStartedDetail': '刷新已开始…',
'liveArtifact.refresh.sourcesUpdatedOne': '已更新 {n} 个数据源',
'liveArtifact.refresh.sourcesUpdatedMany': '已更新 {n} 个数据源',
'liveArtifact.refresh.timelineEmpty':
'本次会话还没有刷新记录。点击「刷新」即可记录时间线,或等待自动任务完成。',
'liveArtifact.refresh.heroLastRefreshedLabel': '上次刷新',
'liveArtifact.refresh.heroLastRefreshedNever': '从未',
'liveArtifact.refresh.justNow': '刚刚',
'liveArtifact.refresh.factCreated': '创建时间',
'liveArtifact.refresh.factLastUpdated': '最后更新',
'liveArtifact.refresh.factUnknown': '未知',
'liveArtifact.refresh.persistedTitle': '持久化刷新记录',
'liveArtifact.refresh.persistedHint': '来自 refreshes.jsonl 的条目',
'liveArtifact.refresh.persistedEmpty': '尚无持久化的刷新记录。',
'liveArtifact.refresh.persistedStatusSucceeded': '成功',
'liveArtifact.refresh.persistedStatusRunning': '进行中',
'liveArtifact.refresh.persistedStatusFailed': '失败',
'liveArtifact.refresh.persistedStatusCancelled': '已取消',
'liveArtifact.refresh.persistedStatusSkipped': '已跳过',
'liveArtifact.refresh.sessionTitle': '会话活动',
'liveArtifact.refresh.sessionHint': '本标签页打开期间观察到的事件',
'liveArtifact.refresh.docSourceTitle': '文档来源',
'liveArtifact.refresh.docSourceHint': '已配置的数据源',
'liveArtifact.refresh.docSourceType': '类型',
'liveArtifact.refresh.docSourceTool': '工具',
'liveArtifact.refresh.docSourceConnector': '连接器',
'liveArtifact.refresh.debugSummary': '高级调试元数据',
'liveArtifact.refresh.debugNote': '可能包含连接器 ID、文件名、来源元数据以及内部 artifact 路径。',
'fileViewer.deployToVercel': '部署到 Vercel',
'fileViewer.redeployToVercel': '重新部署',
'fileViewer.deployingToVercel': '正在部署到 Vercel…',

View file

@ -1163,6 +1163,42 @@ export interface Dict {
'liveArtifact.refresh.statusReady': string;
'liveArtifact.refresh.statusSucceeded': string;
'liveArtifact.refresh.statusFailed': string;
'liveArtifact.refresh.statusRunning': string;
'liveArtifact.refresh.statusRunningDescription': string;
'liveArtifact.refresh.statusSucceededDescription': string;
'liveArtifact.refresh.statusFailedDescription': string;
'liveArtifact.refresh.statusReadyDescription': string;
'liveArtifact.refresh.statusNeverDescription': string;
'liveArtifact.refresh.eventStarted': string;
'liveArtifact.refresh.eventSucceeded': string;
'liveArtifact.refresh.eventFailed': string;
'liveArtifact.refresh.eventStartedDetail': string;
'liveArtifact.refresh.sourcesUpdatedOne': string;
'liveArtifact.refresh.sourcesUpdatedMany': string;
'liveArtifact.refresh.timelineEmpty': string;
'liveArtifact.refresh.heroLastRefreshedLabel': string;
'liveArtifact.refresh.heroLastRefreshedNever': string;
'liveArtifact.refresh.justNow': string;
'liveArtifact.refresh.factCreated': string;
'liveArtifact.refresh.factLastUpdated': string;
'liveArtifact.refresh.factUnknown': string;
'liveArtifact.refresh.persistedTitle': string;
'liveArtifact.refresh.persistedHint': string;
'liveArtifact.refresh.persistedEmpty': string;
'liveArtifact.refresh.persistedStatusSucceeded': string;
'liveArtifact.refresh.persistedStatusRunning': string;
'liveArtifact.refresh.persistedStatusFailed': string;
'liveArtifact.refresh.persistedStatusCancelled': string;
'liveArtifact.refresh.persistedStatusSkipped': string;
'liveArtifact.refresh.sessionTitle': string;
'liveArtifact.refresh.sessionHint': string;
'liveArtifact.refresh.docSourceTitle': string;
'liveArtifact.refresh.docSourceHint': string;
'liveArtifact.refresh.docSourceType': string;
'liveArtifact.refresh.docSourceTool': string;
'liveArtifact.refresh.docSourceConnector': string;
'liveArtifact.refresh.debugSummary': string;
'liveArtifact.refresh.debugNote': string;
'fileViewer.deployProviderLabel': string;
'fileViewer.vercelProvider': string;
'fileViewer.cloudflarePagesProvider': string;

View file

@ -30,6 +30,7 @@ import {
} from '../../src/components/FileViewer';
import type { InspectOverrideMap } from '../../src/components/FileViewer';
import type { LiveArtifact, LiveArtifactWorkspaceEntry, ProjectFile } from '../../src/types';
import { I18nProvider } from '../../src/i18n';
afterEach(() => {
cleanup();
@ -1891,4 +1892,124 @@ describe('LiveArtifactRefreshHistoryPanel', () => {
expect(markup).toContain('2 sources updated');
expect(markup).toContain('3.8s');
});
// Lefarcen review on PR #1300: the existing renderToStaticMarkup
// assertions above can't prove that the panel actually routes its
// strings through i18n, because the no-provider fallback returns
// English no matter what locale the rest of the app is set to. This
// test wraps the panel in `I18nProvider initial="zh-CN"` and pins
// the Chinese rendering of the strings issue #1254 was filed for:
// the badge descriptor, the hero label + empty state, the session
// section header + hint, the empty-timeline copy, the persisted
// section + its empty copy, started / succeeded event labels, the
// pluralised source-count line, the document-source labels, and the
// advanced debug summary. If a future change drops `t()` off any
// of those callsites, this test catches it before the user sees
// the mixed-language regression.
it('renders Chinese strings end-to-end when wrapped in I18nProvider initial="zh-CN"', () => {
const now = Date.now();
const markup = renderToStaticMarkup(
<I18nProvider initial="zh-CN">
<LiveArtifactRefreshHistoryPanel
liveArtifact={baseLiveArtifact({
refreshStatus: 'succeeded',
// Real lastRefreshedAt + non-empty session events so the
// relative-time path also runs under zh-CN; the lefarcen
// P1 review specifically called out that the formerly
// hardcoded `Xs ago` / `Xm ago` strings would still leak
// English under a Chinese UI without this.
lastRefreshedAt: new Date(now - 45_000).toISOString(),
document: {
format: 'html_template_v1',
templatePath: 'template.html',
generatedPreviewPath: 'index.html',
dataPath: 'data.json',
dataJson: { title: 'Launch Metrics' },
sourceJson: {
type: 'connector_tool',
toolName: 'design-files.list',
input: {},
refreshPermission: 'none',
connector: {
connectorId: 'figma',
toolName: 'design-files.list',
accountLabel: 'figma:acct-1',
},
},
},
})}
fallbackRefreshStatus="succeeded"
isRunning={false}
sessionEvents={[
{ id: 1, phase: 'started', at: now - 5_000 },
{
id: 2,
phase: 'succeeded',
at: now - 1_200,
durationMs: 3_800,
refreshedSourceCount: 1,
},
]}
persistedEvents={[]}
/>
</I18nProvider>,
);
// Hero
expect(markup).toContain('上次刷新');
// Session activity section
expect(markup).toContain('会话活动');
expect(markup).toContain('本标签页打开期间观察到的事件');
// Event labels + pluralised source count for n === 1
expect(markup).toContain('已开始');
expect(markup).toContain('已成功');
expect(markup).toContain('已更新 1 个数据源');
// Persisted history section + empty copy
expect(markup).toContain('持久化刷新记录');
expect(markup).toContain('尚无持久化的刷新记录。');
// Document source section
expect(markup).toContain('文档来源');
expect(markup).toContain('已配置的数据源');
expect(markup).toContain('类型');
expect(markup).toContain('工具');
expect(markup).toContain('连接器');
// Advanced debug metadata
expect(markup).toContain('高级调试元数据');
// English label that previously leaked through must NOT appear
// (mixed-language is exactly the regression issue #1254 filed for).
expect(markup).not.toContain('Last refreshed');
expect(markup).not.toContain('Session activity');
expect(markup).not.toContain('Persisted refresh history');
expect(markup).not.toContain('Document source');
expect(markup).not.toContain('Advanced debug metadata');
// Relative-time output must be Chinese, not English. The lefarcen
// P1 review pointed out that formatRelativeTime was hardcoding
// English units (`Xs ago`), so a 45s-old hero metric would still
// read `45s ago` even with every label translated. Assert against
// the Chinese past-tense suffix `前` and rule out the English
// suffixes the legacy function emitted.
expect(markup).toContain('前');
expect(markup).not.toContain(' ago');
expect(markup).not.toContain('from now');
expect(markup).not.toMatch(/\b\d+s ago\b/);
expect(markup).not.toMatch(/\b\d+m ago\b/);
});
it('renders the zh-CN empty hero ("从未") when lastRefreshedAt is missing', () => {
const markup = renderToStaticMarkup(
<I18nProvider initial="zh-CN">
<LiveArtifactRefreshHistoryPanel
liveArtifact={baseLiveArtifact({ refreshStatus: 'never', lastRefreshedAt: undefined })}
fallbackRefreshStatus="never"
isRunning={false}
sessionEvents={[]}
/>
</I18nProvider>,
);
expect(markup).toContain('上次刷新');
expect(markup).toContain('从未');
expect(markup).not.toContain('Last refreshed');
expect(markup).not.toContain('>Never<');
});
});