diff --git a/.changeset/zh-branch-migration-part1.md b/.changeset/zh-branch-migration-part1.md
new file mode 100644
index 000000000..b8c63067c
--- /dev/null
+++ b/.changeset/zh-branch-migration-part1.md
@@ -0,0 +1,37 @@
+---
+"@object-ui/i18n": patch
+"@object-ui/components": patch
+"@object-ui/app-shell": patch
+---
+
+fix(i18n): retire four hand-rolled zh/en branches (objectui#2871, part 1)
+
+Four surfaces decided their language with a hand-written `startsWith('zh')`
+check instead of the locale packs, so the other eight shipped languages
+silently rendered English and the strings could never be translated without a
+code change.
+
+- **`RecordTitleChip`** carried a private zh-CN/zh-TW dictionary behind a
+ comment claiming "components is i18n-free". That is not true —
+ `@object-ui/components` declares `@object-ui/i18n` and its sibling
+ `containers.tsx` already uses it. All four of its keys (`detail.copied`,
+ `detail.copyRecordId`, `detail.addToFavorites`, `detail.removeFromFavorites`)
+ already existed in **all ten packs**, so this deletes ~35 lines and fixes ten
+ locales with zero new translations. It renders on every record detail page.
+- **`EnvironmentListToolbar`**'s three state-aware CTA labels move to a new
+ `environment.*` namespace. This surface had already regressed once for the
+ same reason (#844) and was fixed then with inline `{en,zh}` pairs.
+- **`StudioAiCopilot`**'s dock title moves to the Studio catalog as
+ `engine.studio.aiCopilot`.
+- **`StudioHomePage.relativeTime`** now uses `Intl.RelativeTimeFormat` with
+ `numeric: 'auto'` instead of five `zh ? … : …` ternaries. This is strictly
+ better than adding ten catalog keys: it covers every locale, applies the
+ correct plural rules, and yields "yesterday" / 「昨天」 rather than "1d ago".
+ Arabic gets its dual form («أسبوعين») — something a ternary cannot express.
+
+The new `environment.*` keys are added to all ten packs, so this does not widen
+the gap tracked by objectui#2872 part (a).
+
+`EnvironmentListToolbar`'s tests now render inside a real `I18nProvider` pinned
+to `en`. Without one, `t()` returns the raw key, so the previous assertions on
+literal English would have been asserting nothing.
diff --git a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx
index 1cf27a1fb..1b0b59a7f 100644
--- a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx
+++ b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx
@@ -25,6 +25,7 @@ import { useEffect, useRef, useState } from 'react';
import { SchemaRenderer } from '@object-ui/react';
import { Button } from '@object-ui/components';
import { Plus } from 'lucide-react';
+import { useObjectTranslation } from '@object-ui/i18n';
import {
decideEnvironmentCta,
upgradeDialogSpec,
@@ -34,13 +35,6 @@ import {
const CREATE_ACTION = 'create_environment';
-/** Resolve an inline {en,zh} label against the document locale. */
-function pick(label: { en: string; zh: string }): string {
- const lang =
- (typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
- return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
-}
-
/**
* Deep-link support (#844): `?runAction=create_environment` on the
* environments route auto-opens the create dialog once entitlements have
@@ -89,6 +83,7 @@ interface Props {
}
export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Props) {
+ const { t } = useObjectTranslation();
const toolbarActions = (actions || []).filter((a: any) => a?.locations?.includes('list_toolbar'));
const ctaKind = entitlements?.ready ? decideEnvironmentCta(entitlements) : null;
const autoRunCreate = useAutoRunCreate(toolbarActions.length > 0 ? ctaKind : null);
@@ -124,7 +119,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
data-testid="environment-add-upgrade"
>
- {pick({ en: 'Add environment', zh: '新建环境' })}
+ {t('environment.addEnvironment')}
>
);
@@ -134,13 +129,15 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
// the create action's label (and promoting production setup to a primary CTA).
// Labels are locale-aware — the metadata label is already localized by the
// caller, but these state-aware overrides used to be hard-coded English,
- // which flashed an English button in a zh console (#844).
+ // which flashed an English button in a zh console (#844). They now resolve
+ // from the locale packs, so all ten languages work rather than just zh
+ // (objectui#2871).
const renderedActions = toolbarActions.map((a: any) => {
if (a?.name !== CREATE_ACTION || ctaKind == null) return a;
if (ctaKind === 'setup_production') {
return {
...a,
- label: pick({ en: 'Set up your production environment', zh: '创建你的生产环境' }),
+ label: t('environment.setUpProduction'),
variant: 'primary',
...(autoRunCreate ? { autoTrigger: true } : {}),
};
@@ -148,7 +145,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
// add_development
return {
...a,
- label: pick({ en: 'Add development environment', zh: '新建开发环境' }),
+ label: t('environment.addDevelopment'),
...(autoRunCreate ? { autoTrigger: true } : {}),
};
});
diff --git a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx
index e17d1c210..5b9758726 100644
--- a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx
+++ b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx
@@ -20,9 +20,23 @@ vi.mock('@object-ui/react', async (importActual) => ({
),
}));
+import { I18nProvider } from '@object-ui/i18n';
import { EnvironmentListToolbar } from '../EnvironmentListToolbar';
import type { EnvironmentEntitlementsState } from '../entitlements';
+/**
+ * The state-aware CTA labels resolve from the locale packs (objectui#2871),
+ * so these renders need a real i18n context — asserting the English strings
+ * without one would only be asserting the raw key. Pinned to `en` and
+ * `detectBrowserLanguage: false` so the expectations stay deterministic.
+ */
+const renderToolbar = (ui: React.ReactElement) =>
+ render(
+
+ {ui}
+ ,
+ );
+
const CREATE = {
name: 'create_environment', label: 'Create Environment',
type: 'api', variant: 'primary', locations: ['list_toolbar'],
@@ -32,7 +46,7 @@ const st = (o: Partial): EnvironmentEntitlementsSt
describe('EnvironmentListToolbar', () => {
it('no production env → "Set up your production environment" (primary)', () => {
- render();
+ renderToolbar();
const btn = screen.getByText('Set up your production environment');
expect(btn).toBeTruthy();
expect(btn.getAttribute('data-variant')).toBe('primary');
@@ -40,14 +54,14 @@ describe('EnvironmentListToolbar', () => {
});
it('has prod + dev allowed (paid) → "Add development environment"', () => {
- render();
+ renderToolbar();
expect(screen.getByText('Add development environment')).toBeTruthy();
expect(screen.queryByTestId('environment-add-upgrade')).toBeNull();
});
it('has prod + dev locked (free) → upgrade button, NO create POST affordance', () => {
const onUpgrade = vi.fn();
- render();
+ renderToolbar();
// The create action is NOT rendered as a bar button (no POST-then-403).
expect(screen.queryByText('Create Environment')).toBeNull();
expect(screen.queryByText('Add development environment')).toBeNull();
@@ -57,7 +71,7 @@ describe('EnvironmentListToolbar', () => {
});
it('still resolving (null) → neutral default label, no upgrade button', () => {
- render();
+ renderToolbar();
expect(screen.getByText('Create Environment')).toBeTruthy();
expect(screen.queryByTestId('environment-add-upgrade')).toBeNull();
});
@@ -72,7 +86,7 @@ describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#8
it('marks the create action autoTrigger once entitlements resolve, then strips the param', async () => {
withRunActionParam();
- render();
+ renderToolbar();
const btn = screen.getByText('Set up your production environment');
// The SchemaRenderer stub surfaces autoTrigger as data-autotrigger.
expect(btn.getAttribute('data-autotrigger')).toBe('true');
@@ -85,7 +99,7 @@ describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#8
it('upgrade state: deep link opens the upgrade prompt instead of a create POST', async () => {
withRunActionParam();
const onUpgrade = vi.fn();
- render();
+ renderToolbar();
await vi.waitFor(() => expect(onUpgrade).toHaveBeenCalledTimes(1));
expect(onUpgrade.mock.calls[0][0]).toMatchObject({ code: 'DEV_ENV_PLAN_LOCKED' });
});
diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx
index bddbaca5f..e3cb33b94 100644
--- a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx
+++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx
@@ -108,20 +108,32 @@ function greetingKey(): string {
return 'engine.home.greetingEvening';
}
+/**
+ * Relative timestamp for the "recently visited" list.
+ *
+ * Uses `Intl.RelativeTimeFormat` rather than a hand-written en/zh branch
+ * (objectui#2871): the platform already knows how to say "3 days ago" in every
+ * locale the console ships, with the right plural rules and the right word
+ * order — none of which a `zh ? … : …` ternary can express. This also drops
+ * the last hard-coded strings in this file.
+ *
+ * `numeric: 'auto'` is what turns "1 day ago" into "yesterday" / 「昨天」.
+ */
function relativeTime(iso: string, locale: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
- const diff = Date.now() - then;
- const zh = locale.startsWith('zh');
- const mins = Math.floor(diff / 60000);
- if (mins < 1) return zh ? '刚刚' : 'just now';
- if (mins < 60) return zh ? `${mins} 分钟前` : `${mins}m ago`;
+ const mins = Math.floor((Date.now() - then) / 60000);
+
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
+ // Negative values read as past ("3 minutes ago"); pick the coarsest unit
+ // that still has a whole number, matching the previous behaviour.
+ if (mins < 1) return rtf.format(0, 'minute');
+ if (mins < 60) return rtf.format(-mins, 'minute');
const hrs = Math.floor(mins / 60);
- if (hrs < 24) return zh ? `${hrs} 小时前` : `${hrs}h ago`;
+ if (hrs < 24) return rtf.format(-hrs, 'hour');
const days = Math.floor(hrs / 24);
- if (days < 7) return zh ? `${days} 天前` : `${days}d ago`;
- const weeks = Math.floor(days / 7);
- return zh ? `${weeks} 周前` : `${weeks}w ago`;
+ if (days < 7) return rtf.format(-days, 'day');
+ return rtf.format(-Math.floor(days / 7), 'week');
}
export function StudioHomePage() {
diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts
index 19108b176..7eaebefc6 100644
--- a/packages/app-shell/src/views/metadata-admin/i18n.ts
+++ b/packages/app-shell/src/views/metadata-admin/i18n.ts
@@ -1207,6 +1207,7 @@ const ENGINE_STRINGS_EN: Record = {
'engine.studio.toggleRail': 'Toggle sidebar',
'engine.studio.home': 'Back to home',
// Pillar tab labels
+ 'engine.studio.aiCopilot': 'AI copilot',
'engine.studio.pillar.data': 'Data',
'engine.studio.pillar.automations': 'Automations',
'engine.studio.pillar.interfaces': 'Interfaces',
@@ -2741,6 +2742,7 @@ const ENGINE_STRINGS_ZH: Record = {
'engine.studio.toggleRail': '切换侧栏',
'engine.studio.home': '返回主页',
// Pillar tab labels
+ 'engine.studio.aiCopilot': 'AI 副驾',
'engine.studio.pillar.data': '数据',
'engine.studio.pillar.automations': '自动化',
'engine.studio.pillar.interfaces': '界面',
diff --git a/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx b/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx
index e612500f7..53c5d81b3 100644
--- a/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx
+++ b/packages/app-shell/src/views/studio-design/StudioAiCopilot.tsx
@@ -8,6 +8,7 @@ import { useAgents } from '@object-ui/plugin-chatbot';
import { useChatConversation } from '../../hooks/useChatConversation';
import { chatConversationScope, chatProductOfAgent } from '../../hooks/chatScope';
import { resolveSurfaceAgent } from '../../hooks/surfaceAgent';
+import { t } from '../metadata-admin/i18n';
import { ChatPane, resolveApiBase, type PendingFirstMessage } from '../../console/ai/AiChatPage';
import {
ChatDockPanel,
@@ -127,7 +128,6 @@ export interface StudioChatDockProps {
* (ADR-0037 — the preview needs more width than the rail has).
*/
export function StudioChatDock({ packageId, locale }: StudioChatDockProps): React.ReactElement | null {
- const zh = (locale ?? '').toLowerCase().startsWith('zh');
const navigate = useNavigate();
const location = useLocation();
const apiBase = React.useMemo(() => resolveApiBase(), []);
@@ -176,7 +176,7 @@ export function StudioChatDock({ packageId, locale }: StudioChatDockProps): Reac
> = {
- 'zh-CN': {
- addToFavorites: '加入收藏',
- removeFromFavorites: '从收藏移除',
- copyRecordId: '复制记录 ID',
- copied: '已复制',
- },
- 'zh-TW': {
- addToFavorites: '加入收藏',
- removeFromFavorites: '從收藏移除',
- copyRecordId: '複製記錄 ID',
- copied: '已複製',
- },
-};
-
-const detectLocale = (): string => {
- if (typeof document !== 'undefined') {
- const docLang = document.documentElement?.lang;
- if (docLang) return docLang;
- }
- if (typeof navigator !== 'undefined' && navigator.language) {
- return navigator.language;
- }
- return 'en';
-};
-
-const tt = (key: keyof typeof T['zh-CN'], fallback: string): string => {
- const locale = detectLocale();
- const exact = T[locale];
- const base = locale.split('-')[0];
- const dict = exact || (base === 'zh' ? T['zh-CN'] : undefined);
- return (dict && dict[key]) || fallback;
-};
-
export interface RecordTitleChipProps {
/** Resolved title text (already interpolated against the record). */
title: string;
@@ -102,6 +67,7 @@ export const RecordTitleChip: React.FC = ({
className,
inlineExtras,
}) => {
+ const { t } = useObjectTranslation();
const [internalFav, setInternalFav] = React.useState(false);
const [idCopied, setIdCopied] = React.useState(false);
const isFavorite = isFavoriteProp ?? internalFav;
@@ -126,11 +92,9 @@ export const RecordTitleChip: React.FC = ({
}, [resourceId]);
const favLabel = isFavorite
- ? tt('removeFromFavorites', 'Remove from favourites')
- : tt('addToFavorites', 'Add to favourites');
- const copyLabel = idCopied
- ? tt('copied', 'Copied')
- : tt('copyRecordId', 'Copy record ID');
+ ? t('detail.removeFromFavorites')
+ : t('detail.addToFavorites');
+ const copyLabel = idCopied ? t('detail.copied') : t('detail.copyRecordId');
return (
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 1abf44bb5..ca4ac884b 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -1876,6 +1876,11 @@ const ar = {
done: 'تم — إخفاء',
},
},
+ environment: {
+ addEnvironment: "إضافة بيئة",
+ setUpProduction: "إعداد بيئة الإنتاج",
+ addDevelopment: "إضافة بيئة تطوير",
+ },
cloudConnection: {
checking: "جارٍ التحقق من الاتصال…",
retry: "إعادة المحاولة",
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index 62a27895a..7ac153415 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -1878,6 +1878,11 @@ const de = {
done: 'Fertig — ausblenden',
},
},
+ environment: {
+ addEnvironment: "Umgebung hinzufügen",
+ setUpProduction: "Produktionsumgebung einrichten",
+ addDevelopment: "Entwicklungsumgebung hinzufügen",
+ },
cloudConnection: {
checking: "Verbindung wird geprüft…",
retry: "Erneut versuchen",
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 652747d83..e4d506759 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -2534,6 +2534,11 @@ const en = {
done: 'Done — hide it',
},
},
+ environment: {
+ addEnvironment: 'Add environment',
+ setUpProduction: 'Set up your production environment',
+ addDevelopment: 'Add development environment',
+ },
cloudConnection: {
checking: 'Checking connection…',
retry: 'Try again',
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index c9aabef43..dbd96cae7 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -1878,6 +1878,11 @@ const es = {
done: 'Listo — ocultar',
},
},
+ environment: {
+ addEnvironment: "Añadir entorno",
+ setUpProduction: "Configure su entorno de producción",
+ addDevelopment: "Añadir entorno de desarrollo",
+ },
cloudConnection: {
checking: "Comprobando la conexión…",
retry: "Reintentar",
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index 0ff09c2e4..f6f4d8fa0 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -1876,6 +1876,11 @@ const fr = {
done: 'Terminé — masquer',
},
},
+ environment: {
+ addEnvironment: "Ajouter un environnement",
+ setUpProduction: "Configurer votre environnement de production",
+ addDevelopment: "Ajouter un environnement de développement",
+ },
cloudConnection: {
checking: "Vérification de la connexion…",
retry: "Réessayer",
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index bd070af34..054e1d2da 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -1878,6 +1878,11 @@ const ja = {
done: '完了 — 非表示にする',
},
},
+ environment: {
+ addEnvironment: "環境を追加",
+ setUpProduction: "本番環境をセットアップ",
+ addDevelopment: "開発環境を追加",
+ },
cloudConnection: {
checking: "接続を確認しています…",
retry: "再試行",
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index 36d94f76c..8c74957e3 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -1878,6 +1878,11 @@ const ko = {
done: '완료 — 숨기기',
},
},
+ environment: {
+ addEnvironment: "환경 추가",
+ setUpProduction: "프로덕션 환경 설정",
+ addDevelopment: "개발 환경 추가",
+ },
cloudConnection: {
checking: "연결을 확인하는 중…",
retry: "다시 시도",
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index c72d18631..c7169c3ba 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -1876,6 +1876,11 @@ const pt = {
done: 'Concluído — ocultar',
},
},
+ environment: {
+ addEnvironment: "Adicionar ambiente",
+ setUpProduction: "Configure seu ambiente de produção",
+ addDevelopment: "Adicionar ambiente de desenvolvimento",
+ },
cloudConnection: {
checking: "Verificando a conexão…",
retry: "Tentar novamente",
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index f02453d48..a8af8a16b 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -1876,6 +1876,11 @@ const ru = {
done: 'Готово — скрыть',
},
},
+ environment: {
+ addEnvironment: "Добавить окружение",
+ setUpProduction: "Настройте продакшн-окружение",
+ addDevelopment: "Добавить окружение разработки",
+ },
cloudConnection: {
checking: "Проверка подключения…",
retry: "Повторить",
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 023219484..98387e7ca 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -2524,6 +2524,11 @@ const zh = {
done: '完成——隐藏',
},
},
+ environment: {
+ addEnvironment: '新建环境',
+ setUpProduction: '创建你的生产环境',
+ addDevelopment: '新建开发环境',
+ },
cloudConnection: {
checking: '正在检查连接…',
retry: '重试',