Skip to content

Commit cfc675e

Browse files
os-zhuangclaude
andauthored
fix(i18n): unconditional Chinese in the chatbot confirm card and field inspector (#2884, #2885) (#2900)
* fix(i18n): unconditional Chinese in the chatbot confirm card and field inspector (#2884, #2885) Two issues split out of the #2871 survey because neither is a language *branch* — both render Chinese for every user regardless of locale. #2884 — the confirm-before-change card. Heading, buttons, hint and the verb column of each change row were Chinese literals. They now follow the same prop-with-English-default convention the plan card already uses, with the console passing translated values from `console.ai.*`. The serious half was the outbound message: clicking Confirm sent '确认修改,应用你刚才提议的改动。' unconditionally, so 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. It now routes through the same `convZh` conversation-language switch as `planApproveMessage`. The Chinese string is unchanged, so the cloud confirm gate sees byte-for-byte what it already accepted; `i18n.test.ts` pins it against the mirrored `APPROVAL_RE`. Also here: the error banner's `Response failed` / `Details` / `Retry` were hard-coded English, and it and the quota banner used a bare `t(key)` that renders the raw key with no `I18nProvider` mounted — both now use `useSafeTranslate`. The `「…」` corner brackets are now neutral quotes. #2885 — `ObjectFieldInspector` appended a bare `(草稿)` to draft objects in the lookup picker, the only Chinese literal in a file whose other 101 strings all go through `t(key, locale)`. It now reads `engine.inspector.draftSuffix`. The 18 new keys went into all ten locale packs, so the #2872 part (a) gap held at 469/471 rather than widening. Both new guards were mutation-tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC * fix(plugin-chatbot): terminate the changesTitleLabel doc comment The `*/` was lost in an editing pass, so `changesTitleLabel?: string;` was swallowed into the comment and never declared on `ChatbotEnhancedProps`. `tsc --noEmit` in app-shell caught it as "Property 'changesTitleLabel' does not exist", which failed `@object-ui/app-shell#build` and left the Console Performance Budget job reporting blank metrics. The documented default was wrong too — the heading defaults to "Confirm changes"; only the button was shortened to "Confirm". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc13718 commit cfc675e

18 files changed

Lines changed: 640 additions & 43 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@object-ui/i18n": patch
3+
"@object-ui/plugin-chatbot": patch
4+
"@object-ui/app-shell": patch
5+
---
6+
7+
fix(i18n): unconditional Chinese in the chatbot confirm card and the field inspector (objectui#2884, objectui#2885)
8+
9+
Two issues split out of the objectui#2871 survey because neither is a language
10+
*branch* — both are copy that renders in Chinese for every user regardless of
11+
locale.
12+
13+
**objectui#2884 — the confirm-before-change card.** Heading, buttons, hint and
14+
the verb column of each change row were Chinese literals, so an English user
15+
read the whole confirm gate in Chinese. They now follow the same
16+
prop-with-English-default convention the plan card already uses
17+
(`changesTitleLabel`, `changesConfirmLabel`, `changeVerbLabels`, …), with the
18+
console passing translated values from `console.ai.*`.
19+
20+
The serious half was the outbound message. Clicking Confirm sent
21+
`'确认修改,应用你刚才提议的改动。'` unconditionally — an English user's click
22+
told the agent, in Chinese, to apply the changes, and the agent answered in
23+
Chinese for the rest of the thread. That message now routes through the same
24+
`convZh` (conversation-language) switch as `planApproveMessage`, so it matches
25+
the language actually being spoken rather than the UI or a hard-coded literal.
26+
27+
Note this is deliberately *not* "always send English": the repo already decided
28+
outbound agent text follows the CONVERSATION, and the cloud confirm gate
29+
(`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`) matches on approval
30+
keywords. The Chinese string is unchanged, so that path is byte-for-byte what
31+
the gate already accepted; `i18n.test.ts` now pins it against the mirrored gate
32+
regex alongside the two plan messages.
33+
34+
Also in this component: the error banner's `Response failed` / `Details` /
35+
`Retry` were hard-coded English, and both it and the quota banner used a bare
36+
`t(key)` that renders the raw key when the chat is mounted without an
37+
`I18nProvider`. Both now use `useSafeTranslate`, so they degrade to English
38+
instead of to `chatbotError.title`. The `「…」` corner brackets around the
39+
target-app name are now neutral quotes.
40+
41+
**objectui#2885 — the draft-field suffix.** `ObjectFieldInspector` appended a
42+
bare `(草稿)` to draft objects in the lookup picker — the only Chinese literal
43+
in a 1500-line file where the other 101 strings all go through `t(key, locale)`.
44+
It now reads `engine.inspector.draftSuffix` from the Studio catalog.
45+
46+
The 18 new keys were added to all ten locale packs, so the objectui#2872 part
47+
(a) gap held at 469/471 rather than widening.

packages/app-shell/src/console/ai/AiChatPage.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,29 @@ export function ChatPane({
15961596
: t('console.ai.planApproveDefaultsMessage', {
15971597
defaultValue: 'Build it with your best assumptions; use sensible defaults for the open questions.',
15981598
});
1599+
// Same rule for the granular change-confirm card. It used to send a hard-coded
1600+
// Chinese sentence regardless of the conversation, so an English user clicking
1601+
// Confirm flipped the agent into Chinese for the rest of the thread (#2884).
1602+
const changesConfirmMessage = convZh
1603+
? '确认修改,应用你刚才提议的改动。'
1604+
: t('console.ai.changesConfirmMessage', {
1605+
defaultValue: 'Confirm the changes — apply what you just proposed.',
1606+
});
1607+
// Verb column of the change rows. Unlike the message above these are LABELS,
1608+
// so they follow the UI locale like every other label on the card.
1609+
const changeVerbLabels = useMemo(
1610+
() => ({
1611+
create_object: t('console.ai.changeVerb.createObject', { defaultValue: 'Create object' }),
1612+
add_field: t('console.ai.changeVerb.addField', { defaultValue: 'Add field' }),
1613+
modify_field: t('console.ai.changeVerb.modifyField', { defaultValue: 'Modify field' }),
1614+
delete_field: t('console.ai.changeVerb.deleteField', { defaultValue: 'Delete field' }),
1615+
create_metadata: t('console.ai.changeVerb.createMetadata', { defaultValue: 'Create' }),
1616+
update_metadata: t('console.ai.changeVerb.updateMetadata', { defaultValue: 'Modify' }),
1617+
create_seed: t('console.ai.changeVerb.createSeed', { defaultValue: 'Generate sample data' }),
1618+
create_package: t('console.ai.changeVerb.createPackage', { defaultValue: 'Create app package' }),
1619+
}),
1620+
[t],
1621+
);
15991622

16001623
// ADR-0037: refresh the live preview when a turn finishes while the canvas is
16011624
// open. The per-artifact `onDraftArtifacts` signal covers a build streaming in,
@@ -2176,6 +2199,14 @@ export function ChatPane({
21762199
})}
21772200
planApproveMessage={planApproveMessage}
21782201
planApproveDefaultsMessage={planApproveDefaultsMessage}
2202+
changesTitleLabel={t('console.ai.changesTitle', { defaultValue: 'Confirm changes' })}
2203+
changesConfirmedLabel={t('console.ai.changesConfirmed', { defaultValue: 'Confirmed' })}
2204+
changesConfirmLabel={t('console.ai.changesConfirm', { defaultValue: 'Confirm' })}
2205+
changesConfirmHintLabel={t('console.ai.changesConfirmHint', {
2206+
defaultValue: 'Reply to confirm or adjust this change.',
2207+
})}
2208+
changesConfirmMessage={changesConfirmMessage}
2209+
changeVerbLabels={changeVerbLabels}
21792210
planAnswerMessage={(question, option) =>
21802211
t('console.ai.planAnswerMessage', {
21812212
question,

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,9 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
306306
'engine.inspector.widget.width': 'Width',
307307
'engine.inspector.widget.height': 'Height',
308308
'engine.inspector.widget.remove': 'Remove widget',
309+
// Marks an object that exists only as an unpublished draft in the object
310+
// picker, so the author can tell it apart from a published sibling.
311+
'engine.inspector.draftSuffix': '(draft)',
309312
// Dataset binding (ADR-0021) — governed cross-object semantic layer.
310313
'engine.inspector.widget.datasetSection': 'Dataset binding',
311314
'engine.inspector.widget.dataset': 'Dataset',
@@ -1866,6 +1869,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
18661869
'engine.inspector.widget.width': '宽度',
18671870
'engine.inspector.widget.height': '高度',
18681871
'engine.inspector.widget.remove': '删除组件',
1872+
'engine.inspector.draftSuffix': '(草稿)',
18691873
// Dataset binding (ADR-0021) — governed cross-object semantic layer.
18701874
'engine.inspector.widget.datasetSection': '数据集绑定',
18711875
'engine.inspector.widget.dataset': '数据集',
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import * as React from 'react';
4+
import { describe, it, expect, vi, afterEach } from 'vitest';
5+
import { render, cleanup, waitFor } from '@testing-library/react';
6+
7+
// A draft object AND a published one, so the assertions can tell the two
8+
// label shapes apart. The sibling test file stubs both surfaces empty; this
9+
// one needs a draft to exist at all.
10+
vi.mock('../useMetadata', () => ({
11+
useMetadataClient: () => ({
12+
list: vi.fn().mockResolvedValue([{ name: 'crm_account', label: 'Account' }]),
13+
listDrafts: vi.fn().mockResolvedValue([{ name: 'crm_quote' }]),
14+
}),
15+
}));
16+
17+
vi.mock('../previews/useObjectFields', () => ({
18+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
19+
}));
20+
21+
import { ObjectFieldInspector } from './ObjectFieldInspector';
22+
23+
afterEach(cleanup);
24+
25+
/** The lookup branch is the only one that renders the object picker. */
26+
function renderLookup(locale: string) {
27+
return render(
28+
<ObjectFieldInspector
29+
type="object"
30+
name="account"
31+
draft={{ name: 'account', fields: { owner: { type: 'lookup', label: 'Owner' } } }}
32+
selection={{ kind: 'field', id: 'owner' }}
33+
onPatch={vi.fn()}
34+
onClearSelection={vi.fn()}
35+
onSelectionChange={vi.fn()}
36+
readOnly={false}
37+
locale={locale}
38+
/>,
39+
);
40+
}
41+
42+
async function draftOptionLabel(container: HTMLElement): Promise<string> {
43+
const opt = await waitFor(() => {
44+
const found = [...container.querySelectorAll('option')].find(
45+
(o) => o.getAttribute('value') === 'crm_quote',
46+
);
47+
expect(found).toBeTruthy();
48+
return found!;
49+
});
50+
return opt.textContent ?? '';
51+
}
52+
53+
const CJK = /[-鿿]/;
54+
55+
describe('ObjectFieldInspector — draft-object suffix is localized', () => {
56+
it('renders no CJK in the draft label under an English locale', async () => {
57+
const { container } = renderLookup('en-US');
58+
const label = await draftOptionLabel(container);
59+
60+
expect(label).toBe('crm_quote (draft)');
61+
// The regression this pins: the suffix used to be a bare `(草稿)`
62+
// literal, so an English user saw `crm_quote (草稿)`.
63+
expect(label).not.toMatch(CJK);
64+
});
65+
66+
it('still renders the Chinese suffix under a zh locale', async () => {
67+
const { container } = renderLookup('zh-CN');
68+
expect(await draftOptionLabel(container)).toBe('crm_quote (草稿)');
69+
});
70+
71+
it('leaves published objects unsuffixed in both locales', async () => {
72+
for (const locale of ['en-US', 'zh-CN']) {
73+
const { container, unmount } = renderLookup(locale);
74+
const opt = await waitFor(() => {
75+
const found = [...container.querySelectorAll('option')].find(
76+
(o) => o.getAttribute('value') === 'crm_account',
77+
);
78+
expect(found).toBeTruthy();
79+
return found!;
80+
});
81+
expect(opt.textContent).toBe('Account (crm_account)');
82+
unmount();
83+
}
84+
});
85+
});

packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export function ObjectFieldInspector({
192192
? ((draft as any).fieldGroups as Array<{ key?: string; label?: string }>)
193193
: [];
194194

195-
const objectOptions = useObjectOptions();
195+
const objectOptions = useObjectOptions(locale);
196196

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

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

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

@@ -1507,7 +1507,10 @@ function useObjectOptions(): Array<{ value: string; label: string }> {
15071507
for (const d of drafts ?? []) {
15081508
const name = (d as { name?: string }).name;
15091509
if (typeof name === 'string' && name && !byName.has(name)) {
1510-
byName.set(name, { value: name, label: `${name} (草稿)` });
1510+
byName.set(name, {
1511+
value: name,
1512+
label: `${name} ${t('engine.inspector.draftSuffix', locale)}`,
1513+
});
15111514
}
15121515
}
15131516
setOpts([...byName.values()].sort((a, b) => a.value.localeCompare(b.value)));
@@ -1518,7 +1521,7 @@ function useObjectOptions(): Array<{ value: string; label: string }> {
15181521
return () => {
15191522
cancelled = true;
15201523
};
1521-
}, [client]);
1524+
}, [client, locale]);
15221525

15231526
return opts;
15241527
}

packages/i18n/src/__tests__/i18n.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,24 @@ describe('@object-ui/i18n', () => {
111111
expect(i18n.t('console.ai.planApproveMessage')).toMatch(gate);
112112
expect(i18n.t('console.ai.planApproveDefaultsMessage')).toMatch(gate);
113113
expect('帮我搭建一个 CRM').not.toMatch(gate);
114+
115+
// The granular change-confirm card (objectui#2884) sends its own message
116+
// through the SAME gate, so it carries the same 确认 anchor. This one is
117+
// unchanged from the string that was previously hard-coded in the
118+
// component, so the Chinese path is byte-for-byte what the gate already
119+
// accepted — the fix only stopped it being sent to English conversations.
120+
expect(i18n.t('console.ai.changesConfirmMessage')).toMatch(gate);
121+
});
122+
123+
// The English half of the same contract. The cloud APPROVAL_RE's English
124+
// clause is not mirrored here (nothing in this repo can see it), so this
125+
// only pins the two tokens the message is built around — enough to catch a
126+
// careless reword that drops the approval verb entirely.
127+
it('AI change-confirm message keeps its approval verb in English', () => {
128+
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
129+
const msg = i18n.t('console.ai.changesConfirmMessage');
130+
expect(msg).toMatch(/^Confirm\b/i);
131+
expect(msg).toMatch(/\bapply\b/i);
114132
});
115133

116134
it('translates common keys in Japanese', () => {

packages/i18n/src/locales/ar.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,23 @@ const ar = {
852852
},
853853
},
854854
console: {
855+
ai: {
856+
changesTitle: "تأكيد التغييرات",
857+
changesConfirmed: "تم التأكيد",
858+
changesConfirm: "تأكيد",
859+
changesConfirmHint: "أجب لتأكيد هذا التغيير أو تعديله.",
860+
changesConfirmMessage: "تم تأكيد التغييرات — طبّق ما اقترحته للتو.",
861+
changeVerb: {
862+
createObject: "إنشاء كائن",
863+
addField: "إضافة حقل",
864+
modifyField: "تعديل حقل",
865+
deleteField: "حذف حقل",
866+
createMetadata: "إنشاء",
867+
updateMetadata: "تعديل",
868+
createSeed: "إنشاء بيانات تجريبية",
869+
createPackage: "إنشاء حزمة تطبيق",
870+
},
871+
},
855872
title: "وحدة تحكم ObjectStack",
856873
initializing: "جاري تهيئة التطبيق...",
857874
loadingHint: "قد يستغرق إعداد بيئة جديدة بعض الوقت.",
@@ -1911,6 +1928,13 @@ const ar = {
19111928
sourceInherits: "مثل build/ask",
19121929
sourcePinned: "مثبّت بواسطة {{source}}",
19131930
},
1931+
chatbotError: {
1932+
title: "فشل الرد",
1933+
fallbackDetail: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
1934+
details: "التفاصيل",
1935+
hide: "إخفاء",
1936+
retry: "إعادة المحاولة",
1937+
},
19141938
chatbotQuota: {
19151939
title: "الترقية مطلوبة",
19161940
fallbackMessage: "لقد بلغت حصتك من الذكاء الاصطناعي.",

packages/i18n/src/locales/de.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,23 @@ const de = {
854854
},
855855
},
856856
console: {
857+
ai: {
858+
changesTitle: "Änderungen bestätigen",
859+
changesConfirmed: "Bestätigt",
860+
changesConfirm: "Bestätigen",
861+
changesConfirmHint: "Antworten Sie, um diese Änderung zu bestätigen oder anzupassen.",
862+
changesConfirmMessage: "Änderungen bestätigt — wende an, was du gerade vorgeschlagen hast.",
863+
changeVerb: {
864+
createObject: "Objekt erstellen",
865+
addField: "Feld hinzufügen",
866+
modifyField: "Feld ändern",
867+
deleteField: "Feld löschen",
868+
createMetadata: "Erstellen",
869+
updateMetadata: "Ändern",
870+
createSeed: "Beispieldaten erzeugen",
871+
createPackage: "App-Paket erstellen",
872+
},
873+
},
857874
title: "ObjectStack Konsole",
858875
initializing: "Anwendung wird initialisiert...",
859876
loadingHint: "Die Einrichtung einer neuen Umgebung kann einen Moment dauern.",
@@ -1913,6 +1930,13 @@ const de = {
19131930
sourceInherits: "wie build/ask",
19141931
sourcePinned: "festgelegt durch {{source}}",
19151932
},
1933+
chatbotError: {
1934+
title: "Antwort fehlgeschlagen",
1935+
fallbackDetail: "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
1936+
details: "Details",
1937+
hide: "Ausblenden",
1938+
retry: "Erneut versuchen",
1939+
},
19161940
chatbotQuota: {
19171941
title: "Upgrade erforderlich",
19181942
fallbackMessage: "Sie haben Ihr KI-Kontingent erreicht.",

packages/i18n/src/locales/en.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,6 +1490,21 @@ const en = {
14901490
planApproveDefaultsMessage:
14911491
'Build it with your best assumptions; use sensible defaults for the open questions.',
14921492
planAnswerMessage: 'For "{{question}}", go with: {{option}}.',
1493+
changesTitle: 'Confirm changes',
1494+
changesConfirmed: 'Confirmed',
1495+
changesConfirm: 'Confirm',
1496+
changesConfirmHint: 'Reply to confirm or adjust this change.',
1497+
changesConfirmMessage: 'Confirm the changes — apply what you just proposed.',
1498+
changeVerb: {
1499+
createObject: 'Create object',
1500+
addField: 'Add field',
1501+
modifyField: 'Modify field',
1502+
deleteField: 'Delete field',
1503+
createMetadata: 'Create',
1504+
updateMetadata: 'Modify',
1505+
createSeed: 'Generate sample data',
1506+
createPackage: 'Create app package',
1507+
},
14931508
justNow: 'just now',
14941509
minutesAgo: '{{count}}m ago',
14951510
hoursAgo: '{{count}}h ago',
@@ -2568,6 +2583,13 @@ const en = {
25682583
sourceInherits: 'same as build/ask',
25692584
sourcePinned: 'pinned by {{source}}',
25702585
},
2586+
chatbotError: {
2587+
title: 'Response failed',
2588+
fallbackDetail: 'Something went wrong. Please try again.',
2589+
details: 'Details',
2590+
hide: 'Hide',
2591+
retry: 'Retry',
2592+
},
25712593
chatbotQuota: {
25722594
title: 'Upgrade needed',
25732595
fallbackMessage: 'You have reached your AI quota.',

0 commit comments

Comments
 (0)