diff --git a/.changeset/chatbot-build-panel-i18n-7388.md b/.changeset/chatbot-build-panel-i18n-7388.md new file mode 100644 index 0000000000..2fc22f1eab --- /dev/null +++ b/.changeset/chatbot-build-panel-i18n-7388.md @@ -0,0 +1,19 @@ +--- +"@object-ui/plugin-chatbot": patch +"@object-ui/i18n": patch +--- + +fix(plugin-chatbot): localize the build-progress panel, which was an English island (objectui#7388) + +Every label the build panel is HANDED was already localized — the host passes +`openBuiltAppLabel`, `designBuiltAppLabel`, `previewDraftLabel` and the three +connection cues through its own `t()`. Every string the panel OWNED was a +literal in the component, so a fully Chinese conversation watched its app get +built under `Building your app…`, over `Objects` / `Views` / `Dashboards` / +`App` / `Sample data` row headings — one per row, on every build — and a +`+N more` overflow counter. + +All of them now resolve through the console's pack as `chatbot.build.*`, added +to all ten locales. Behaviour for a known phase is unchanged in English, and +the unknown-artifact-type fallback still renders the raw type rather than a +raw i18n key. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index d595448b24..b1d59c0d4a 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -135,9 +135,9 @@ const ar = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: "يتم عرض أول {{shown}} من أصل {{total}} سجل. ضيّق عامل التصفية.", rowCeilingNoteUnknownTotal: "يتم عرض أول {{shown}} سجل. ضيّق عامل التصفية.", }, @@ -3008,6 +3008,20 @@ const ar = { countDashboards_one: "{{count}} لوحة معلومات", countSeedData: "بيانات تجريبية", }, + build: { + building: "جارٍ إنشاء {{app}}…", + built: "تم إنشاء {{app}}", + appFallback: "تطبيقك", + addingSampleData: "جارٍ إضافة بيانات تجريبية", + group: { + object: "الكائنات", + view: "طرق العرض", + dashboard: "لوحات المعلومات", + app: "التطبيق", + seed: "بيانات تجريبية", + }, + moreArtifacts: "+{{n}} أخرى", + }, }, chatbotError: { title: "فشل الرد", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 13cdf7eb07..512299f534 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -131,9 +131,9 @@ const de = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: "Erste {{shown}} von {{total}} Datensätzen. Filter eingrenzen.", rowCeilingNoteUnknownTotal: "Erste {{shown}} Datensätze. Filter eingrenzen.", }, @@ -3001,6 +3001,20 @@ const de = { countDashboards_one: "{{count}} Dashboard", countSeedData: "Beispieldaten", }, + build: { + building: "{{app}} wird erstellt…", + built: "{{app}} wurde erstellt", + appFallback: "Ihre App", + addingSampleData: "Beispieldaten werden hinzugefügt", + group: { + object: "Objekte", + view: "Ansichten", + dashboard: "Dashboards", + app: "App", + seed: "Beispieldaten", + }, + moreArtifacts: "+{{n}} weitere", + }, }, chatbotError: { title: "Antwort fehlgeschlagen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e3892dff17..c47056559f 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -155,9 +155,9 @@ const en = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: 'Showing the first {{shown}} of {{total}} records. Narrow the filter.', rowCeilingNoteUnknownTotal: 'Showing the first {{shown}} records. Narrow the filter.', }, @@ -3340,6 +3340,13 @@ const en = { // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE // set for both surfaces (they used to carry separate // tables and disagreed on casing). + // `build.*` — the apply_blueprint BUILD PANEL's own copy (objectui#7388). + // Everything else on that panel is fed by the host as an + // already-translated prop; these strings were literals in + // the component, so they stayed English in every language. + // `building`/`built` interpolate `appFallback` when the + // build has no app label, so each pack must keep BOTH + // frames in the one case/gender that noun phrase is in. // `plan.*` — the "N objects · N views · N dashboards" strip. Plural // FAMILIES (base key + `_one`): i18next resolves every // CLDR category a pack does not enumerate to the base key, @@ -3410,6 +3417,20 @@ const en = { countDashboards_one: '{{count}} dashboard', countSeedData: 'sample data', }, + build: { + building: 'Building {{app}}…', + built: 'Built {{app}}', + appFallback: 'your app', + addingSampleData: 'adding sample data', + group: { + object: 'Objects', + view: 'Views', + dashboard: 'Dashboards', + app: 'App', + seed: 'Sample data', + }, + moreArtifacts: '+{{n}} more', + }, }, chatbotError: { title: 'Response failed', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index b5ae9dc9e6..92ceb6e19d 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -130,9 +130,9 @@ const es = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: "Mostrando los primeros {{shown}} de {{total}} registros. Acota el filtro.", rowCeilingNoteUnknownTotal: "Mostrando los primeros {{shown}} registros. Acota el filtro.", }, @@ -3005,6 +3005,20 @@ const es = { countDashboards_one: "{{count}} panel", countSeedData: "datos de ejemplo", }, + build: { + building: "Creando {{app}}…", + built: "Creación de {{app}} completada", + appFallback: "su aplicación", + addingSampleData: "añadiendo datos de ejemplo", + group: { + object: "Objetos", + view: "Vistas", + dashboard: "Paneles", + app: "Aplicación", + seed: "Datos de ejemplo", + }, + moreArtifacts: "+{{n}} más", + }, }, chatbotError: { title: "Error en la respuesta", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 6300f10bb2..56bec518fd 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -131,9 +131,9 @@ const fr = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: "Affichage des {{shown}} premiers enregistrements sur {{total}}. Affinez le filtre.", rowCeilingNoteUnknownTotal: "Affichage des {{shown}} premiers enregistrements. Affinez le filtre.", }, @@ -3003,6 +3003,20 @@ const fr = { countDashboards_one: "{{count}} tableau de bord", countSeedData: "données d’exemple", }, + build: { + building: "Création de {{app}}…", + built: "Création de {{app}} terminée", + appFallback: "votre application", + addingSampleData: "ajout de données d’exemple", + group: { + object: "Objets", + view: "Vues", + dashboard: "Tableaux de bord", + app: "Application", + seed: "Données d’exemple", + }, + moreArtifacts: "+{{n}} autres", + }, }, chatbotError: { title: "Échec de la réponse", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index f40c05b21d..97589b04e2 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -131,9 +131,9 @@ const ja = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: "{{total}} 件中、最初の {{shown}} 件を表示しています。フィルターを絞り込んでください。", rowCeilingNoteUnknownTotal: "最初の {{shown}} 件を表示しています。フィルターを絞り込んでください。", }, @@ -3003,6 +3003,20 @@ const ja = { countDashboards_one: "{{count}} 件のダッシュボード", countSeedData: "サンプルデータ", }, + build: { + building: "{{app}}を構築しています…", + built: "{{app}}を構築しました", + appFallback: "アプリ", + addingSampleData: "サンプルデータを追加中", + group: { + object: "オブジェクト", + view: "ビュー", + dashboard: "ダッシュボード", + app: "アプリ", + seed: "サンプルデータ", + }, + moreArtifacts: "他 {{n}} 件", + }, }, chatbotError: { title: "応答に失敗しました", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 0ee2d2e7aa..8df34b3ebc 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -131,9 +131,9 @@ const ko = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: "전체 {{total}}개 중 처음 {{shown}}개를 표시합니다. 필터를 좁히세요.", rowCeilingNoteUnknownTotal: "처음 {{shown}}개를 표시합니다. 필터를 좁히세요.", }, @@ -3000,6 +3000,20 @@ const ko = { countDashboards_one: "대시보드 {{count}}개", countSeedData: "샘플 데이터", }, + build: { + building: "{{app}} 빌드 중…", + built: "{{app}} 빌드 완료", + appFallback: "앱", + addingSampleData: "샘플 데이터 추가 중", + group: { + object: "오브젝트", + view: "뷰", + dashboard: "대시보드", + app: "앱", + seed: "샘플 데이터", + }, + moreArtifacts: "외 {{n}}개", + }, }, chatbotError: { title: "응답 실패", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 51b2f6c17c..b9ac1d942c 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -130,9 +130,9 @@ const pt = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: "Mostrando os primeiros {{shown}} de {{total}} registros. Restrinja o filtro.", rowCeilingNoteUnknownTotal: "Mostrando os primeiros {{shown}} registros. Restrinja o filtro.", }, @@ -3000,6 +3000,20 @@ const pt = { countDashboards_one: "{{count}} painel", countSeedData: "dados de exemplo", }, + build: { + building: "Criando {{app}}…", + built: "Criação de {{app}} concluída", + appFallback: "seu aplicativo", + addingSampleData: "adicionando dados de exemplo", + group: { + object: "Objetos", + view: "Visões", + dashboard: "Painéis", + app: "Aplicativo", + seed: "Dados de exemplo", + }, + moreArtifacts: "+{{n}} mais", + }, }, chatbotError: { title: "Falha na resposta", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 84239fdbfc..9b9db070f3 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -137,9 +137,9 @@ const ru = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: "Показаны первые {{shown}} из {{total}} записей. Сузьте фильтр.", rowCeilingNoteUnknownTotal: "Показаны первые {{shown}} записей. Сузьте фильтр.", }, @@ -3014,6 +3014,20 @@ const ru = { countDashboards_one: "{{count}} дашборд", countSeedData: "демоданные", }, + build: { + building: "Создание {{app}}…", + built: "Создание {{app}} завершено", + appFallback: "вашего приложения", + addingSampleData: "добавление демоданных", + group: { + object: "Объекты", + view: "Представления", + dashboard: "Дашборды", + app: "Приложение", + seed: "Демоданные", + }, + moreArtifacts: "ещё {{n}}", + }, }, chatbotError: { title: "Ошибка ответа", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b6e5057450..18532df267 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -138,9 +138,9 @@ const zh = { // numbers, a missing one cannot name how many. Same split as // `grid.grouping.partialNotice`. Kept terse deliberately — this copy is // eagerly loaded, and since objectui#7399 these bytes are budgeted by the - // `i18n-locales` chunk, not `framework` — a ceiling set 8,924 B above the - // baseline it was measured from, about sixty short keys' worth across ten - // locales. `pnpm check:eager-closure` prints the figure in force. + // `i18n-locales` chunk, not `framework`. ⛔ That ceiling's headroom is + // deliberately NOT restated here — it moves on every re-baseline, and the + // figure that was here went stale. `pnpm check:eager-closure` prints it. rowCeilingNote: '仅显示 {{total}} 条记录中的前 {{shown}} 条。请缩小筛选范围。', rowCeilingNoteUnknownTotal: '仅显示前 {{shown}} 条记录。请缩小筛选范围。', }, @@ -3138,6 +3138,20 @@ const zh = { countDashboards_one: '{{count}} 个仪表板', countSeedData: '示例数据', }, + build: { + building: '正在构建{{app}}…', + built: '已构建{{app}}', + appFallback: '你的应用', + addingSampleData: '正在添加示例数据', + group: { + object: '对象', + view: '视图', + dashboard: '仪表板', + app: '应用', + seed: '示例数据', + }, + moreArtifacts: '+{{n}} 个', + }, }, chatbotError: { title: '响应失败', diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 8ae056bb62..b35dead958 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -3900,13 +3900,43 @@ function ToolRunningTimer({ offlineLabel }: { offlineLabel: string }) { } const BUILD_GROUP_ORDER = ['object', 'view', 'dashboard', 'app', 'seed']; -const BUILD_GROUP_LABEL: Record = { - object: 'Objects', - view: 'Views', - dashboard: 'Dashboards', - app: 'App', - seed: 'Sample data', -}; + +/** + * The build tree's per-type row headings, through the pack (objectui#7388). + * + * These were a module-level English `Record`, so every row of an otherwise + * localized panel read "Objects / Views / Dashboards / App / Sample data" in + * English — the same island the header strings were in, and the more visible + * half of it (one per row, on every build). + * + * The lookup stays a STATIC switch rather than `t('chatbot.build.group.' + type)`: + * a computed key is invisible to `check-i18n-call-site-keys`, which is the gate + * that proves each key exists in the `en` pack. The `default` arm keeps the old + * `?? type` behaviour verbatim — an unknown artifact type renders its raw type, + * which is honest, and never a raw i18n key. + */ +function useBuildGroupLabel(): (type: string) => string { + const { t } = useObjectTranslation(); + return React.useCallback( + (type: string) => { + switch (type) { + case 'object': + return t('chatbot.build.group.object', { defaultValue: 'Objects' }); + case 'view': + return t('chatbot.build.group.view', { defaultValue: 'Views' }); + case 'dashboard': + return t('chatbot.build.group.dashboard', { defaultValue: 'Dashboards' }); + case 'app': + return t('chatbot.build.group.app', { defaultValue: 'App' }); + case 'seed': + return t('chatbot.build.group.seed', { defaultValue: 'Sample data' }); + default: + return type; + } + }, + [t], + ); +} /** * Live "Designing…" panel for an in-flight blueprint DESIGN (`propose_blueprint`). @@ -4051,7 +4081,16 @@ function BuildProgressPanel({ offlineLabel?: string; }) { const { phase, appLabel, items, done, total, seq } = progress; + // objectui#7388 — every string this panel OWNS goes through the pack. The + // labels it receives as props (`openBuiltAppLabel`, the connection cues, …) + // are already localized by the host; these were the island left behind. + const { t } = useObjectTranslation(); + const groupLabelOf = useBuildGroupLabel(); const isDone = phase === 'done'; + // The unnamed-build stand-in is itself a translated noun phrase, so it can be + // interpolated into the two header frames the same way a real app label is — + // one hole per frame, which is what `check-i18n-call-site-keys` checks. + const appName = appLabel ?? t('chatbot.build.appFallback', { defaultValue: 'your app' }); // Real activity key: bumps whenever the server streams another build-progress // part. Prefer the server's monotonic `seq` (it also advances on the keep-alive // heartbeats during long, quiet seed-generation awaits, where the content @@ -4080,9 +4119,15 @@ function BuildProgressPanel({ ) : ( )} - {isDone ? `Built ${appLabel ?? 'your app'}` : `Building ${appLabel ?? 'your app'}…`} + + {isDone + ? t('chatbot.build.built', { app: appName, defaultValue: 'Built {{app}}' }) + : t('chatbot.build.building', { app: appName, defaultValue: 'Building {{app}}…' })} + {!isDone && phase === 'data' ? ( - adding sample data + + {t('chatbot.build.addingSampleData', { defaultValue: 'adding sample data' })} + ) : null} {!isDone ? ( @@ -4109,7 +4154,7 @@ function BuildProgressPanel({
  • - {BUILD_GROUP_LABEL[type] ?? type}{' '} + {groupLabelOf(type)}{' '} {entries.slice(0, 6).map((entry, i) => { // Deep-link the artifact to its direct-edit home (Studio // pillar) once the build is done — the host decides which @@ -4136,7 +4181,12 @@ function BuildProgressPanel({ ); })} - {entries.length > 6 ? ` +${entries.length - 6} more` : ''} + {entries.length > 6 + ? ` ${t('chatbot.build.moreArtifacts', { + n: entries.length - 6, + defaultValue: '+{{n}} more', + })}` + : ''}
  • ); diff --git a/packages/plugin-chatbot/src/__tests__/buildPanelI18n-7388.test.tsx b/packages/plugin-chatbot/src/__tests__/buildPanelI18n-7388.test.tsx new file mode 100644 index 0000000000..ef3d17de05 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/buildPanelI18n-7388.test.tsx @@ -0,0 +1,162 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#7388 — the apply_blueprint BUILD PANEL, in the user's language. + * + * Everything the panel is HANDED is localized: the host passes + * `openBuiltAppLabel` / `designBuiltAppLabel` / `previewDraftLabel` and the + * three connection cues through its own `t()`. Everything the panel OWNED was + * an English literal in the component — the two header frames, the + * sample-data suffix, the five per-type row headings, and the overflow + * counter. So a fully Chinese conversation watched a build under an English + * header, over five English row headings, one per row, on every build. + * + * The predicate this file pins is the CLASS, not a key count: no English + * literal on the panel bypasses the pack. Each subject case therefore also + * asserts the English it replaced is absent, which is what a missing key would + * bring back (the fallback renders the English default, never a raw key). + * + * ## The control, and why it is a real one + * + * `renderEn` runs the SAME three known phases (`structure` / `data` / `done`) + * through an `en` provider and pins them to the exact strings the component + * used to hard-code. It moves independently of the subject: it stays green if + * the `zh` values are wrong or missing, and it reddens only if the ENGLISH + * rendering changed — which is the regression this refactor could actually + * cause. The provider-less English world is pinned separately and was already + * there: `ChatbotEnhanced.test.tsx`'s "renders a live build tree" / + * "collapses to a Built summary". + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ChatbotEnhanced, type ChatMessage } from '../ChatbotEnhanced'; + +function renderIn(language: string, ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +const ITEMS = [ + { type: 'object', name: 'customer' }, + { type: 'view', name: 'customer.list' }, + { type: 'dashboard', name: 'sales' }, + { type: 'seed', name: 'customer_sample' }, +]; + +function buildMsg( + phase: 'structure' | 'data' | 'done', + over: Partial> = {}, +): ChatMessage { + return { + id: 'a1', + role: 'assistant', + content: '', + streaming: phase !== 'done', + buildProgress: { phase, appLabel: 'CRM', items: ITEMS, done: 4, total: 6, ...over }, + }; +} + +function panel(phase: 'structure' | 'data' | 'done', over = {}) { + return ; +} + +afterEach(cleanup); + +// --------------------------------------------------------------------------- +// CONTROL — the English rendering of the three KNOWN phases, unchanged. +// --------------------------------------------------------------------------- +describe('control: a known phase renders exactly as it did before (en)', () => { + it('structure — header, row headings, no sample-data suffix', () => { + renderIn('en', panel('structure')); + expect(screen.getByTestId('build-progress')).toBeInTheDocument(); + expect(screen.getByText('Building CRM…')).toBeInTheDocument(); + expect(screen.getByText('Objects')).toBeInTheDocument(); + expect(screen.getByText('Views')).toBeInTheDocument(); + expect(screen.getByText('Dashboards')).toBeInTheDocument(); + expect(screen.getByText('Sample data')).toBeInTheDocument(); + expect(screen.queryByText('adding sample data')).not.toBeInTheDocument(); + }); + + it('data — the sample-data suffix appears, header still "Building"', () => { + renderIn('en', panel('data')); + expect(screen.getByText('Building CRM…')).toBeInTheDocument(); + expect(screen.getByText('adding sample data')).toBeInTheDocument(); + }); + + it('done — collapses to the "Built" summary', () => { + renderIn('en', panel('done')); + expect(screen.getByText('Built CRM')).toBeInTheDocument(); + }); + + it('an unnamed build still says "your app"', () => { + renderIn('en', panel('structure', { appLabel: undefined })); + expect(screen.getByText('Building your app…')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// SUBJECT — the same panel under a zh console. +// --------------------------------------------------------------------------- +describe('the build panel under a zh console (objectui#7388)', () => { + it('localizes the in-flight header instead of hard-coding "Building X…"', () => { + renderIn('zh', panel('structure')); + expect(screen.getByText('正在构建CRM…')).toBeInTheDocument(); + expect(screen.queryByText(/Building/)).not.toBeInTheDocument(); + }); + + it('localizes the finished header instead of hard-coding "Built X"', () => { + renderIn('zh', panel('done')); + expect(screen.getByText('已构建CRM')).toBeInTheDocument(); + expect(screen.queryByText(/^Built /)).not.toBeInTheDocument(); + }); + + it('localizes the sample-data suffix', () => { + renderIn('zh', panel('data')); + expect(screen.getByText('正在添加示例数据')).toBeInTheDocument(); + expect(screen.queryByText('adding sample data')).not.toBeInTheDocument(); + }); + + it('localizes every per-type row heading — the five that render on every build', () => { + renderIn('zh', panel('structure')); + for (const zh of ['对象', '视图', '仪表板', '示例数据']) { + expect(screen.getByText(zh)).toBeInTheDocument(); + } + for (const en of ['Objects', 'Views', 'Dashboards', 'Sample data']) { + expect(screen.queryByText(en)).not.toBeInTheDocument(); + } + }); + + it('localizes the "App" row heading too', () => { + renderIn('zh', panel('done', { items: [...ITEMS, { type: 'app', name: 'crm' }] })); + expect(screen.getByText('应用')).toBeInTheDocument(); + expect(screen.queryByText('App')).not.toBeInTheDocument(); + }); + + it('localizes the unnamed-build stand-in, so the header is Chinese end to end', () => { + renderIn('zh', panel('structure', { appLabel: undefined })); + expect(screen.getByText('正在构建你的应用…')).toBeInTheDocument(); + expect(screen.queryByText(/your app/)).not.toBeInTheDocument(); + }); + + it('localizes the ">6 artifacts" overflow counter', () => { + const many = Array.from({ length: 8 }, (_, i) => ({ type: 'object', name: `obj_${i}` })); + renderIn('zh', panel('structure', { items: many })); + expect(screen.getByText(/\+2 个/)).toBeInTheDocument(); + expect(screen.queryByText(/more/)).not.toBeInTheDocument(); + }); + + // The safe shape the card's boundary asks for, on the OTHER unknown-value + // fallback this panel has: an artifact type no pack names renders its raw + // type, exactly as before. It must never render a raw i18n key. + it('an unknown artifact type still renders its raw type, not a key', () => { + renderIn('zh', panel('structure', { items: [{ type: 'flow', name: 'onboarding' }] })); + expect(screen.getByText('flow')).toBeInTheDocument(); + expect(screen.queryByText(/chatbot\.build/)).not.toBeInTheDocument(); + }); +}); diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 1e108dfdbe..bb279a5d08 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -551,15 +551,17 @@ export const REGRESSION_THIS_GATE_MUST_CATCH_BYTES = 89 * 1024; * line per named group; inventing one for it would be a number with no * incident behind it. * - * ⭐ Read the new `i18n-locales` headroom for what it is. 8,924 bytes above the - * baseline it was measured from is about sixty translation keys at the measured - * ~147 gzipped bytes a short key costs across ten locales — enough for the five - * PRs this unparked, and then the AGGREGATE line becomes the binding one. ⛔ Its - * headroom is not restated here: the figure that was went stale inside a - * fortnight (objectui#7518), and `pnpm check:eager-closure` prints both lines in - * force on your own build. That the aggregate is the correct place for the - * constraint to live is the argument for taking the catalogues out of the eager - * closure rather than for raising anything. + * ⭐ RETIRED by objectui#8816's raise — see "Why `i18n-locales` moved UP" below. + * The pair this paragraph sized (455,000 over 446,076, 8,924 bytes of headroom) + * is gone, and so is the unit it offered: "about sixty translation keys at ~147 + * gzipped bytes a short key" is an average across a spread now measured at 2.4x, + * two real claimants costing 9.2 and 22.3 bytes per key-times-locale. ⛔ Its + * forecast was wrong in the direction that matters too — the headroom was gone + * in seven days and the AGGREGATE never became the binding line; it is still + * carrying 0.26x with both claimants on it. What survives is the last sentence, + * which is why it is kept verbatim: that the aggregate is the correct place for + * the constraint to live is the argument for taking the catalogues out of the + * eager closure rather than for raising anything. * * ## Why `framework` moved UP — the maintainer ruling of 2026-09-08 * @@ -629,12 +631,138 @@ export const REGRESSION_THIS_GATE_MUST_CATCH_BYTES = 89 * 1024; * where {@link evaluateHeadroomSensitivity} calls a line blind — but "not blind" * is the floor this file refuses to fall through, not a standard it aims at. * + * ⚠️ That table and that ranking are `3f775eeb8`'s and stay pinned to it — the + * `i18n-locales` row in it was retired by objectui#8816's raise below. Re-read + * on `ba20b0bc0`, `framework` is still the loosest of the four at 0.29x, but + * against a tightest of 0.05x (`ui-components`) that is 6.1x, not an order of + * magnitude. + * * ⛔ {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES} did NOT move, and this is the * exact case the rule under {@link MAX_EAGER_CLOSURE_GZIP_BYTES} was written for: * a ceiling that rises while the sensitivity relaxes is a gate quietly retiring * itself. Nothing else moved either — not the other three ceilings, not the * aggregate, not {@link BASELINE}. One ceiling and its baseline, in one commit. * + * ## Why `i18n-locales` moved UP — objectui#8816 + * + * From 455,000 over a 446,076 payload to 465,000 over 456,196. The new baseline + * is a reading of the tree this pair was DERIVED FOR: `main` with both claimants + * merged into it. + * + * ⛔ Read what this is not, first, because the shape it resembles is the one the + * paragraph under "Raising one" forbids. It is not "the gate fired, so the + * number moved". objectui#8816 is a decision card opened 2026-09-09 that asks + * exactly this question and carries three routes, and it stood unclaimed while + * two finished pull requests queued behind it. The authorisation to take one of + * those routes rather than wait is the maintainer's instruction of 2026-09-10 + * that red pull requests are RESOLVED rather than parked; WHICH route is the + * measurement below, and it was taken on measurement, not on the instruction. + * + * ⛔ WHAT THE BYTES BUY — one console build per row, each from the repo ROOT, + * `i18n-locales` read out of the `apps/console/dist/eager-closure.json` the + * build itself writes. Four builds, one container, one instrument, so the + * deltas are directly comparable: + * + * | tree | `i18n-locales` | moved by | + * | `bbe285ee7` — `main` | 454,602 | — | + * | + objectui#8901, its merge `3949cf3a3` | 455,271 | +669 | + * | + objectui#8888, its merge `ea5eab7b3` | 455,519 | +917 | + * | both, their merge `ba20b0bc0` | 456,196 | +1,594 | + * + * The two deltas sum to 1,586 against a measured 1,594, so gzip's dictionary + * hands back nothing across them: two independent claimants on this chunk are + * ADDITIVE to within 8 bytes. That is the fact a SHARED budget needs and the one + * a per-pull-request reading cannot produce — each is 271 and 519 bytes over + * alone, together they are 1,196 over, and neither single reading licenses that + * sum without the third build. + * + * The bytes are THIRTEEN localization keys in ten locales and nothing else. + * objectui#8901 adds three `console.savedViews*` strings so that a REFUSED + * saved-view read stops rendering as "this object has no saved views"; + * objectui#8888 adds ten `chatbot.build.*` strings so the AI build-progress + * panel stops showing English literals inside a Chinese conversation. Neither + * ships a dependency or a component into this closure: `plugin-chatbot` is lazy + * and outside it, and objectui#8901's adapter growth landed in + * `vendor-objectstack`, which measured 1,236,299 on ALL FOUR builds — the lit + * control saying the movement is this chunk's and no other's. + * + * ⛔ WHY NOT TRIM INSTEAD, which is the half a raise has to answer. Measured, + * per claimant: + * + * - objectui#8888's ten keys include five generic console nouns (`Objects`, + * `Views`, `Dashboards`, `App`, `Sample data`), so reuse looks available. + * It is not: only `Objects` and `Dashboards` have any pre-existing + * equivalent in the `en` pack, and each of those already exists THREE times + * under three per-surface namespaces (`appDesigner.*`, + * `console.commandPalette.*`, `search.type*`). Per-surface keys are this + * pack's convention and cross-surface reuse is the deviation. Best measured + * saving 128 bytes against a 519-byte overage. + * - objectui#8901's three keys have NO reuse candidate, and that is + * structural rather than incidental: those strings exist precisely because + * saying what the neighbouring `console.importMappings*` strings say is the + * runtime lie the card was filed to remove. + * - Shortening the copy is the objectui#6759 lever ("say less, in ten + * languages") and it is ⛔ refused here. Widening a ceiling to get a green + * tick and narrowing a payload to get one are the same error facing in + * opposite directions; this file already forbids the first. + * + * ⇒ Trimming cannot reach 1,196 bytes inside these two changes. What CAN reach + * it is outside them, and it is recorded here because it is the work that makes + * the next raise unnecessary: `pnpm check:i18n-dead-keys` reports 364 candidates + * across 47 namespaces, 127 of them CONFIRMED with no textual footprint anywhere + * in this repository, in ten locales each. That gate is report-only by design + * and `@object-ui/i18n` PUBLISHES these packs, so deleting a key is a + * published-surface removal and a decision, not a byte-saving. It needs its own + * card and its own reverse verification — objectui#8816's route C note says so + * in as many words — and it is ⛔ deliberately not ridden in on a localization + * change. + * + * ## Why the new headroom is 0.10x and NOT the 804 bytes the overage needed + * + * The minimal raise — 457,000, exactly enough to admit both claimants — is the + * one option this card's own evidence rules OUT. objectui#8816 is not filed + * about a full budget. It is filed about what a budget with ~0 headroom DOES: + * the gate weighs the MERGE REF, so a sibling change that adds locale keys and + * lands first turns an in-flight, not-itself-over pull request red in the merge + * queue — an arithmetic collision that reads as a defect in that diff, and sends + * its author to investigate something that is not there. objectui#8554 is the + * same mechanism one step earlier: `framework` sat at 70,999 against 71,000 and + * printed a GREEN sensitivity row while it did, because + * {@link evaluateHeadroomSensitivity} has no floor. Re-pinning to 804 bytes + * would reproduce both inside a week. + * + * So the size comes from this key's own convention rather than from the overage: + * 8,804 bytes = 0.10x {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}, against the + * 8,924 (0.10x) the retired pair carried — slightly TIGHTER as a ratio, and on + * `ba20b0bc0` the second-tightest of the four ceilings (`ui-components` 0.05x, + * `i18n-locales` 0.10x, `vendor-objectstack` 0.19x, `framework` 0.29x). + * + * ⚠️ What it buys, stated as the interval it is rather than as a key count. Per + * key-times-locale this chunk cost 9.2 bytes for objectui#8888's ten short + * progress phrases and 22.3 bytes for objectui#8901's three long sentences — a + * 2.4x spread between two real claimants one shift apart, so ⛔ a quota written + * in keys is not derivable from this measurement. 8,804 bytes is between ~395 + * and ~958 key-times-locale slots: roughly 40 to 96 keys across ten locales. + * + * ⚠️ And how long that is, measured rather than hoped. The retired pair landed + * on `177afeba1`, 2026-09-03, at 446,076; `main` measured 454,602 on + * `bbe285ee7`, 2026-09-10. That is 8,526 bytes in seven days, with the last 398 + * of them claimed by two independent changes inside ONE shift. At that arrival + * rate this raise is about a week of runway, not a settlement — so ⛔ do not read + * it as one, and do not read a second raise as routine because this one was + * taken. The structural answer is the one this file already names: the aggregate + * is the correct place for this constraint to live, and taking the catalogues + * OUT of the eager closure is what retires this line instead of moving it. + * + * ⛔ Nothing else moved. Not {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES} — + * this is the exact case the rule under {@link MAX_EAGER_CLOSURE_GZIP_BYTES} was + * written for, and a ceiling that rises while the sensitivity relaxes is a gate + * quietly retiring itself. Not the other three per-chunk ceilings, weighed on + * the same four builds and unchanged. Not {@link MAX_EAGER_CLOSURE_GZIP_BYTES}: + * the aggregate carried 23,507 bytes of headroom (0.26x) with BOTH claimants on + * it, so it never objected and there is nothing to re-pin. One ceiling and its + * baseline, in one commit. + * * ## Raising one * * Same discipline as {@link MAX_EAGER_CLOSURE_GZIP_BYTES}, and the same two @@ -654,7 +782,13 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ // Headroom 18,971 bytes = 0.21x REGRESSION_THIS_GATE_MUST_CATCH_BYTES, the // proportion the retiring pair carried (18,539 = 0.20x). 'vendor-objectstack': 1_254_000, - 'i18n-locales': 455_000, + // Raised by objectui#8816, on the maintainer's instruction of 2026-09-10 that + // red pull requests are resolved rather than parked, and sized by the four + // console builds in "Why `i18n-locales` moved UP" above — ⛔ not by the + // overage, which is the one size that card's own evidence rules out. Headroom + // 8,804 bytes = 0.10x REGRESSION_THIS_GATE_MUST_CATCH_BYTES over the baseline + // below, marginally tighter than the 8,924 (0.10x) the retired pair carried. + 'i18n-locales': 465_000, // Raised by the maintainer ruling of 2026-09-08, ⛔ not by a measurement here: // `main` had been red on this line since `f76f43628`. The bytes that put it // there were UNATTRIBUTED when this moved and have since been measured to @@ -689,21 +823,29 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * file, and `scripts/check-*.mjs` is not a console build input — so the * {@link BASELINE} argument DOES cover it, and it is ⛔ NOT comparable to * the `i18n-locales` figure below, which is an older build on another commit. - * - `i18n-locales` — `e307c9896` plus objectui#7399's own re-attribution - * diff; see "Why `framework` moved DOWN" above. It was read from ONE console - * build together with the `framework` figure objectui#7399 recorded, so it - * is directly comparable to the 523,959 that same tree measured with the - * groups still tied — ⚠️ and, since objectui#8541's raise, ⛔ no longer to - * the `framework` entry above it. - * - * ⚠️ Unlike every other entry here, this one is NOT a reading of an - * unmodified tree: the chunk it names does not exist without the diff that - * recorded it, because that diff is what creates it. The + * - `i18n-locales` — `ba20b0bc0` (objectui#8816), a local merge of `main` + * `bbe285ee7` with BOTH pull requests the raise admits. It supersedes + * objectui#7399's `e307c9896` reading, and it is read from the same + * instrument and container as the three trees it is compared against, which + * is what makes those deltas subtractable. + * + * ⚠️ It is a FORWARD reading and the only entry here that is. It names the + * state `main` reaches once objectui#8901 and objectui#8888 have BOTH + * landed, so while only one of them has, the live payload sits below this + * constant (455,271 and 455,519, both measured) and + * `pnpm check:eager-closure` prints MORE headroom than arithmetic on these + * two constants gives. Read on purpose: a shared budget with two admitted + * claimants has no single-commit baseline that is not stale the moment the + * second one lands, and erring toward the larger payload is the direction + * that cannot hide growth. + * + * ⚠️ Unlike the entries above it, this is a reading of a tree carrying + * diffs of its own — the two claimants — which is the point rather than a + * contaminant: their bytes are the subject. The * `scripts/vite-*.ts`-versus-`scripts/check-*.mjs` argument {@link BASELINE} - * makes about its own commit does NOT cover it — `apps/console/vite.config.ts` - * IS a build input, deliberately, and moving it is the change. What keeps - * it honest instead is that the gate re-reads it on every CI build of the - * branch that carries the diff. + * makes DOES cover the ceiling edit itself, because this file is not a + * console build input; that was checked rather than assumed, and the check + * is recorded on objectui#8816. * * Exported so the ceilings are CHECKED against it instead of merely asserted * in this comment. @@ -773,7 +915,11 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ export const PER_CHUNK_BASELINE = Object.freeze({ // `34a1578ef`, the same build as BASELINE above (objectui#7122). 'vendor-objectstack': 1_235_029, - 'i18n-locales': 446_076, + // `ba20b0bc0` (objectui#8816) — `main` `bbe285ee7` with BOTH admitted pull + // requests merged in. A FORWARD reading; see the provenance note above for + // why this one names a state `main` has not reached yet and what that does to + // the printed headroom while only one claimant has landed. + 'i18n-locales': 456_196, // `3f775eeb8`, its OWN console build — ⛔ not the one above it and not // BASELINE's. Moved with the ceiling in the same commit, per the maintainer // ruling of 2026-09-08 and the rule stated under "Raising one".