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
47 changes: 47 additions & 0 deletions .changeset/chatbot-and-inspector-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@object-ui/i18n": patch
"@object-ui/plugin-chatbot": patch
"@object-ui/app-shell": patch
---

fix(i18n): unconditional Chinese in the chatbot confirm card and the field inspector (objectui#2884, objectui#2885)

Two issues split out of the objectui#2871 survey because neither is a language
*branch* — both are copy that renders in Chinese for every user regardless of
locale.

**objectui#2884 — the confirm-before-change card.** Heading, buttons, hint and
the verb column of each change row were Chinese literals, so an English user
read the whole confirm gate in Chinese. They now follow the same
prop-with-English-default convention the plan card already uses
(`changesTitleLabel`, `changesConfirmLabel`, `changeVerbLabels`, …), with the
console passing translated values from `console.ai.*`.

The serious half was the outbound message. Clicking Confirm sent
`'确认修改,应用你刚才提议的改动。'` unconditionally — an English user's click
told the agent, in Chinese, to apply the changes, and the agent answered in
Chinese for the rest of the thread. That message now routes through the same
`convZh` (conversation-language) switch as `planApproveMessage`, so it matches
the language actually being spoken rather than the UI or a hard-coded literal.

Note this is deliberately *not* "always send English": the repo already decided
outbound agent text follows the CONVERSATION, and the cloud confirm gate
(`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`) matches on approval
keywords. The Chinese string is unchanged, so that path is byte-for-byte what
the gate already accepted; `i18n.test.ts` now pins it against the mirrored gate
regex alongside the two plan messages.

Also in this component: the error banner's `Response failed` / `Details` /
`Retry` were hard-coded English, and both it and the quota banner used a bare
`t(key)` that renders the raw key when the chat is mounted without an
`I18nProvider`. Both now use `useSafeTranslate`, so they degrade to English
instead of to `chatbotError.title`. The `「…」` corner brackets around the
target-app name are now neutral quotes.

**objectui#2885 — the draft-field suffix.** `ObjectFieldInspector` appended a
bare `(草稿)` to draft objects in the lookup picker — the only Chinese literal
in a 1500-line file where the other 101 strings all go through `t(key, locale)`.
It now reads `engine.inspector.draftSuffix` from the Studio catalog.

The 18 new keys were added to all ten locale packs, so the objectui#2872 part
(a) gap held at 469/471 rather than widening.
31 changes: 31 additions & 0 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1596,6 +1596,29 @@ export function ChatPane({
: t('console.ai.planApproveDefaultsMessage', {
defaultValue: 'Build it with your best assumptions; use sensible defaults for the open questions.',
});
// Same rule for the granular change-confirm card. It used to send a hard-coded
// Chinese sentence regardless of the conversation, so an English user clicking
// Confirm flipped the agent into Chinese for the rest of the thread (#2884).
const changesConfirmMessage = convZh
? '确认修改,应用你刚才提议的改动。'
: t('console.ai.changesConfirmMessage', {
defaultValue: 'Confirm the changes — apply what you just proposed.',
});
// Verb column of the change rows. Unlike the message above these are LABELS,
// so they follow the UI locale like every other label on the card.
const changeVerbLabels = useMemo(
() => ({
create_object: t('console.ai.changeVerb.createObject', { defaultValue: 'Create object' }),
add_field: t('console.ai.changeVerb.addField', { defaultValue: 'Add field' }),
modify_field: t('console.ai.changeVerb.modifyField', { defaultValue: 'Modify field' }),
delete_field: t('console.ai.changeVerb.deleteField', { defaultValue: 'Delete field' }),
create_metadata: t('console.ai.changeVerb.createMetadata', { defaultValue: 'Create' }),
update_metadata: t('console.ai.changeVerb.updateMetadata', { defaultValue: 'Modify' }),
create_seed: t('console.ai.changeVerb.createSeed', { defaultValue: 'Generate sample data' }),
create_package: t('console.ai.changeVerb.createPackage', { defaultValue: 'Create app package' }),
}),
[t],
);

// ADR-0037: refresh the live preview when a turn finishes while the canvas is
// open. The per-artifact `onDraftArtifacts` signal covers a build streaming in,
Expand Down Expand Up @@ -2176,6 +2199,14 @@ export function ChatPane({
})}
planApproveMessage={planApproveMessage}
planApproveDefaultsMessage={planApproveDefaultsMessage}
changesTitleLabel={t('console.ai.changesTitle', { defaultValue: 'Confirm changes' })}
changesConfirmedLabel={t('console.ai.changesConfirmed', { defaultValue: 'Confirmed' })}
changesConfirmLabel={t('console.ai.changesConfirm', { defaultValue: 'Confirm' })}
changesConfirmHintLabel={t('console.ai.changesConfirmHint', {
defaultValue: 'Reply to confirm or adjust this change.',
})}
changesConfirmMessage={changesConfirmMessage}
changeVerbLabels={changeVerbLabels}
planAnswerMessage={(question, option) =>
t('console.ai.planAnswerMessage', {
question,
Expand Down
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.widget.width': 'Width',
'engine.inspector.widget.height': 'Height',
'engine.inspector.widget.remove': 'Remove widget',
// Marks an object that exists only as an unpublished draft in the object
// picker, so the author can tell it apart from a published sibling.
'engine.inspector.draftSuffix': '(draft)',
// Dataset binding (ADR-0021) — governed cross-object semantic layer.
'engine.inspector.widget.datasetSection': 'Dataset binding',
'engine.inspector.widget.dataset': 'Dataset',
Expand Down Expand Up @@ -1866,6 +1869,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.widget.width': '宽度',
'engine.inspector.widget.height': '高度',
'engine.inspector.widget.remove': '删除组件',
'engine.inspector.draftSuffix': '(草稿)',
// Dataset binding (ADR-0021) — governed cross-object semantic layer.
'engine.inspector.widget.datasetSection': '数据集绑定',
'engine.inspector.widget.dataset': '数据集',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';

// A draft object AND a published one, so the assertions can tell the two
// label shapes apart. The sibling test file stubs both surfaces empty; this
// one needs a draft to exist at all.
vi.mock('../useMetadata', () => ({
useMetadataClient: () => ({
list: vi.fn().mockResolvedValue([{ name: 'crm_account', label: 'Account' }]),
listDrafts: vi.fn().mockResolvedValue([{ name: 'crm_quote' }]),
}),
}));

vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [], loading: false, error: null }),
}));

import { ObjectFieldInspector } from './ObjectFieldInspector';

afterEach(cleanup);

/** The lookup branch is the only one that renders the object picker. */
function renderLookup(locale: string) {
return render(
<ObjectFieldInspector
type="object"
name="account"
draft={{ name: 'account', fields: { owner: { type: 'lookup', label: 'Owner' } } }}
selection={{ kind: 'field', id: 'owner' }}
onPatch={vi.fn()}
onClearSelection={vi.fn()}
onSelectionChange={vi.fn()}
readOnly={false}
locale={locale}
/>,
);
}

async function draftOptionLabel(container: HTMLElement): Promise<string> {
const opt = await waitFor(() => {
const found = [...container.querySelectorAll('option')].find(
(o) => o.getAttribute('value') === 'crm_quote',
);
expect(found).toBeTruthy();
return found!;
});
return opt.textContent ?? '';
}

const CJK = /[一-鿿]/;

describe('ObjectFieldInspector — draft-object suffix is localized', () => {
it('renders no CJK in the draft label under an English locale', async () => {
const { container } = renderLookup('en-US');
const label = await draftOptionLabel(container);

expect(label).toBe('crm_quote (draft)');
// The regression this pins: the suffix used to be a bare `(草稿)`
// literal, so an English user saw `crm_quote (草稿)`.
expect(label).not.toMatch(CJK);
});

it('still renders the Chinese suffix under a zh locale', async () => {
const { container } = renderLookup('zh-CN');
expect(await draftOptionLabel(container)).toBe('crm_quote (草稿)');
});

it('leaves published objects unsuffixed in both locales', async () => {
for (const locale of ['en-US', 'zh-CN']) {
const { container, unmount } = renderLookup(locale);
const opt = await waitFor(() => {
const found = [...container.querySelectorAll('option')].find(
(o) => o.getAttribute('value') === 'crm_account',
);
expect(found).toBeTruthy();
return found!;
});
expect(opt.textContent).toBe('Account (crm_account)');
unmount();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export function ObjectFieldInspector({
? ((draft as any).fieldGroups as Array<{ key?: string; label?: string }>)
: [];

const objectOptions = useObjectOptions();
const objectOptions = useObjectOptions(locale);

// Spec `Field.returnType` is stamped from the formula's inferred CEL type,
// but ONLY once the author actually edits the formula in this session —
Expand Down Expand Up @@ -1479,7 +1479,7 @@ function SummaryConfigFields({

/* ─────────────── Hook: load object list for lookup picker ─────────────── */

function useObjectOptions(): Array<{ value: string; label: string }> {
function useObjectOptions(locale?: string): Array<{ value: string; label: string }> {
const client: MetadataClient = useMetadataClient();
const [opts, setOpts] = React.useState<Array<{ value: string; label: string }>>([]);

Expand Down Expand Up @@ -1507,7 +1507,10 @@ function useObjectOptions(): Array<{ value: string; label: string }> {
for (const d of drafts ?? []) {
const name = (d as { name?: string }).name;
if (typeof name === 'string' && name && !byName.has(name)) {
byName.set(name, { value: name, label: `${name} (草稿)` });
byName.set(name, {
value: name,
label: `${name} ${t('engine.inspector.draftSuffix', locale)}`,
});
}
}
setOpts([...byName.values()].sort((a, b) => a.value.localeCompare(b.value)));
Expand All @@ -1518,7 +1521,7 @@ function useObjectOptions(): Array<{ value: string; label: string }> {
return () => {
cancelled = true;
};
}, [client]);
}, [client, locale]);

return opts;
}
18 changes: 18 additions & 0 deletions packages/i18n/src/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@ describe('@object-ui/i18n', () => {
expect(i18n.t('console.ai.planApproveMessage')).toMatch(gate);
expect(i18n.t('console.ai.planApproveDefaultsMessage')).toMatch(gate);
expect('帮我搭建一个 CRM').not.toMatch(gate);

// The granular change-confirm card (objectui#2884) sends its own message
// through the SAME gate, so it carries the same 确认 anchor. This one is
// unchanged from the string that was previously hard-coded in the
// component, so the Chinese path is byte-for-byte what the gate already
// accepted — the fix only stopped it being sent to English conversations.
expect(i18n.t('console.ai.changesConfirmMessage')).toMatch(gate);
});

// The English half of the same contract. The cloud APPROVAL_RE's English
// clause is not mirrored here (nothing in this repo can see it), so this
// only pins the two tokens the message is built around — enough to catch a
// careless reword that drops the approval verb entirely.
it('AI change-confirm message keeps its approval verb in English', () => {
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
const msg = i18n.t('console.ai.changesConfirmMessage');
expect(msg).toMatch(/^Confirm\b/i);
expect(msg).toMatch(/\bapply\b/i);
});

it('translates common keys in Japanese', () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,23 @@ const ar = {
},
},
console: {
ai: {
changesTitle: "تأكيد التغييرات",
changesConfirmed: "تم التأكيد",
changesConfirm: "تأكيد",
changesConfirmHint: "أجب لتأكيد هذا التغيير أو تعديله.",
changesConfirmMessage: "تم تأكيد التغييرات — طبّق ما اقترحته للتو.",
changeVerb: {
createObject: "إنشاء كائن",
addField: "إضافة حقل",
modifyField: "تعديل حقل",
deleteField: "حذف حقل",
createMetadata: "إنشاء",
updateMetadata: "تعديل",
createSeed: "إنشاء بيانات تجريبية",
createPackage: "إنشاء حزمة تطبيق",
},
},
title: "وحدة تحكم ObjectStack",
initializing: "جاري تهيئة التطبيق...",
loadingHint: "قد يستغرق إعداد بيئة جديدة بعض الوقت.",
Expand Down Expand Up @@ -1911,6 +1928,13 @@ const ar = {
sourceInherits: "مثل build/ask",
sourcePinned: "مثبّت بواسطة {{source}}",
},
chatbotError: {
title: "فشل الرد",
fallbackDetail: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
details: "التفاصيل",
hide: "إخفاء",
retry: "إعادة المحاولة",
},
chatbotQuota: {
title: "الترقية مطلوبة",
fallbackMessage: "لقد بلغت حصتك من الذكاء الاصطناعي.",
Expand Down
24 changes: 24 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,23 @@ const de = {
},
},
console: {
ai: {
changesTitle: "Änderungen bestätigen",
changesConfirmed: "Bestätigt",
changesConfirm: "Bestätigen",
changesConfirmHint: "Antworten Sie, um diese Änderung zu bestätigen oder anzupassen.",
changesConfirmMessage: "Änderungen bestätigt — wende an, was du gerade vorgeschlagen hast.",
changeVerb: {
createObject: "Objekt erstellen",
addField: "Feld hinzufügen",
modifyField: "Feld ändern",
deleteField: "Feld löschen",
createMetadata: "Erstellen",
updateMetadata: "Ändern",
createSeed: "Beispieldaten erzeugen",
createPackage: "App-Paket erstellen",
},
},
title: "ObjectStack Konsole",
initializing: "Anwendung wird initialisiert...",
loadingHint: "Die Einrichtung einer neuen Umgebung kann einen Moment dauern.",
Expand Down Expand Up @@ -1913,6 +1930,13 @@ const de = {
sourceInherits: "wie build/ask",
sourcePinned: "festgelegt durch {{source}}",
},
chatbotError: {
title: "Antwort fehlgeschlagen",
fallbackDetail: "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
details: "Details",
hide: "Ausblenden",
retry: "Erneut versuchen",
},
chatbotQuota: {
title: "Upgrade erforderlich",
fallbackMessage: "Sie haben Ihr KI-Kontingent erreicht.",
Expand Down
22 changes: 22 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,21 @@ const en = {
planApproveDefaultsMessage:
'Build it with your best assumptions; use sensible defaults for the open questions.',
planAnswerMessage: 'For "{{question}}", go with: {{option}}.',
changesTitle: 'Confirm changes',
changesConfirmed: 'Confirmed',
changesConfirm: 'Confirm',
changesConfirmHint: 'Reply to confirm or adjust this change.',
changesConfirmMessage: 'Confirm the changes — apply what you just proposed.',
changeVerb: {
createObject: 'Create object',
addField: 'Add field',
modifyField: 'Modify field',
deleteField: 'Delete field',
createMetadata: 'Create',
updateMetadata: 'Modify',
createSeed: 'Generate sample data',
createPackage: 'Create app package',
},
justNow: 'just now',
minutesAgo: '{{count}}m ago',
hoursAgo: '{{count}}h ago',
Expand Down Expand Up @@ -2568,6 +2583,13 @@ const en = {
sourceInherits: 'same as build/ask',
sourcePinned: 'pinned by {{source}}',
},
chatbotError: {
title: 'Response failed',
fallbackDetail: 'Something went wrong. Please try again.',
details: 'Details',
hide: 'Hide',
retry: 'Retry',
},
chatbotQuota: {
title: 'Upgrade needed',
fallbackMessage: 'You have reached your AI quota.',
Expand Down
Loading
Loading