Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/zh-branch-migration-part1.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 8 additions & 11 deletions packages/app-shell/src/environment/EnvironmentListToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -124,7 +119,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro
data-testid="environment-add-upgrade"
>
<Plus className="h-4 w-4" />
<span>{pick({ en: 'Add environment', zh: '新建环境' })}</span>
<span>{t('environment.addEnvironment')}</span>
</Button>
</>
);
Expand All @@ -134,21 +129,23 @@ 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 } : {}),
};
}
// add_development
return {
...a,
label: pick({ en: 'Add development environment', zh: '新建开发环境' }),
label: t('environment.addDevelopment'),
...(autoRunCreate ? { autoTrigger: true } : {}),
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
{ui}
</I18nProvider>,
);

const CREATE = {
name: 'create_environment', label: 'Create Environment',
type: 'api', variant: 'primary', locations: ['list_toolbar'],
Expand All @@ -32,22 +46,22 @@ const st = (o: Partial<EnvironmentEntitlementsState>): EnvironmentEntitlementsSt

describe('EnvironmentListToolbar', () => {
it('no production env → "Set up your production environment" (primary)', () => {
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
const btn = screen.getByText('Set up your production environment');
expect(btn).toBeTruthy();
expect(btn.getAttribute('data-variant')).toBe('primary');
expect(screen.queryByTestId('environment-add-upgrade')).toBeNull();
});

it('has prod + dev allowed (paid) → "Add development environment"', () => {
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: true })} onUpgrade={vi.fn()} />);
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: true })} onUpgrade={vi.fn()} />);
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(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
// 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();
Expand All @@ -57,7 +71,7 @@ describe('EnvironmentListToolbar', () => {
});

it('still resolving (null) → neutral default label, no upgrade button', () => {
render(<EnvironmentListToolbar actions={[CREATE]} entitlements={null} onUpgrade={vi.fn()} />);
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={null} onUpgrade={vi.fn()} />);
expect(screen.getByText('Create Environment')).toBeTruthy();
expect(screen.queryByTestId('environment-add-upgrade')).toBeNull();
});
Expand All @@ -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(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ hasProductionEnv: false })} onUpgrade={vi.fn()} />);
const btn = screen.getByText('Set up your production environment');
// The SchemaRenderer stub surfaces autoTrigger as data-autotrigger.
expect(btn.getAttribute('data-autotrigger')).toBe('true');
Expand All @@ -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(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
renderToolbar(<EnvironmentListToolbar actions={[CREATE]} entitlements={st({ canCreateDevelopmentEnv: false, plan: 'free' })} onUpgrade={onUpgrade} />);
await vi.waitFor(() => expect(onUpgrade).toHaveBeenCalledTimes(1));
expect(onUpgrade.mock.calls[0][0]).toMatchObject({ code: 'DEV_ENV_PLAN_LOCKED' });
});
Expand Down
30 changes: 21 additions & 9 deletions packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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',
Expand Down Expand Up @@ -2741,6 +2742,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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': '界面',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(), []);
Expand Down Expand Up @@ -176,7 +176,7 @@ export function StudioChatDock({ packageId, locale }: StudioChatDockProps): Reac
<ChatDockMobileSheet
open={mobileOpen}
onOpenChange={setMobileOpen}
title={zh ? 'AI 副驾' : 'AI copilot'}
title={t('engine.studio.aiCopilot', locale)}
// Bridge to the package's full-page build thread; deferred so the
// sheet closes cleanly before the route changes.
onMaximize={openFullPage}
Expand All @@ -194,7 +194,7 @@ export function StudioChatDock({ packageId, locale }: StudioChatDockProps): Reac
return (
<ChatDockPanel
dock={dock}
title={zh ? 'AI 副驾' : 'AI copilot'}
title={t('engine.studio.aiCopilot', locale)}
onMaximize={openFullPage}
>
<StudioCopilotConversation
Expand Down
46 changes: 5 additions & 41 deletions packages/components/src/custom/RecordTitleChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,44 +29,9 @@ import {
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
import { useObjectTranslation } from '@object-ui/i18n';
import { cn } from '../lib/utils';

/** Inline translator — components is i18n-free, so we mirror the
* zh-CN / zh-TW dictionary used by `containers.tsx` for tab labels. */
const T: Record<string, Record<string, string>> = {
'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;
Expand Down Expand Up @@ -102,6 +67,7 @@ export const RecordTitleChip: React.FC<RecordTitleChipProps> = ({
className,
inlineExtras,
}) => {
const { t } = useObjectTranslation();
const [internalFav, setInternalFav] = React.useState(false);
const [idCopied, setIdCopied] = React.useState(false);
const isFavorite = isFavoriteProp ?? internalFav;
Expand All @@ -126,11 +92,9 @@ export const RecordTitleChip: React.FC<RecordTitleChipProps> = ({
}, [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 (
<TooltipProvider>
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1876,6 +1876,11 @@ const ar = {
done: 'تم — إخفاء',
},
},
environment: {
addEnvironment: "إضافة بيئة",
setUpProduction: "إعداد بيئة الإنتاج",
addDevelopment: "إضافة بيئة تطوير",
},
cloudConnection: {
checking: "جارٍ التحقق من الاتصال…",
retry: "إعادة المحاولة",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading