diff --git a/.changeset/studio-jargon-and-tool-card-i18n.md b/.changeset/studio-jargon-and-tool-card-i18n.md new file mode 100644 index 0000000000..c1fddafc88 --- /dev/null +++ b/.changeset/studio-jargon-and-tool-card-i18n.md @@ -0,0 +1,27 @@ +--- +'@object-ui/plugin-chatbot': minor +'@object-ui/app-shell': minor +'@object-ui/i18n': minor +--- + +Studio workbench and AI tool cards speak the author's language (objectui#7254) + +- The Interfaces breadcrumb, canvas caption and navigation rail show the + metadata label plus a translated kind; the internal `type · name` pair moves + to the tooltip. An unlabelled nav leaf now falls back to its object name + instead of rendering an empty row. +- The Studio top-bar package switcher reads the package's human name from + either position the packages endpoint serves it in, instead of degrading a + registry-shaped entry to its reverse-domain id. +- The dashboard property panel is localized: the spec's authoring form is + overlaid through the platform's own `metadataForms.` convention, so + section headings, field labels, hints and the `header` composite's sub-fields + render in Chinese (developer vocabulary such as "Tailwind units" is replaced + with something an author can act on, not transliterated). +- AI tool cards: tool titles resolve through `chatbot.tool.` (all thirty + platform-provided tools, ten locale packs), the header status badge is + localized, and the plan count strip is a real plural family instead of an + English `+ "s"` concatenation. +- The tool card's header badge and its body badge now come from one producer: + a proposal that has been confirmed, built or published no longer keeps a + header reading "Awaiting Approval". diff --git a/packages/app-shell/src/views/metadata-admin/dashboard-schema.ts b/packages/app-shell/src/views/metadata-admin/dashboard-schema.ts index 722a409eff..7cf9b63a38 100644 --- a/packages/app-shell/src/views/metadata-admin/dashboard-schema.ts +++ b/packages/app-shell/src/views/metadata-admin/dashboard-schema.ts @@ -24,6 +24,8 @@ import { z } from 'zod'; import { DashboardSchema, dashboardForm as specDashboardForm } from '@objectstack/spec/ui'; import type { FormViewSpec } from './SchemaForm.js'; +import type { SupportedLocale } from './i18n.js'; +import { localizeMetadataForm } from './metadata-form-i18n.js'; type JsonSchema = Record; @@ -54,16 +56,28 @@ export function getDashboardSchema(): JsonSchema | undefined { return _dashboardDocSchema; } -let _dashboardForm: FormViewSpec | undefined; +/** + * Per-locale, because the form now carries LOCALIZED copy (objectui#7254) and + * a single slot would have served whichever locale asked first to everyone + * after it — a cache that is right until someone switches language. + */ +const _dashboardForm = new Map(); /** * The canonical authoring FormView, with the fields the curated inspector * owns directly (widgets / label / description / name) pruned from every * section so they are not double-rendered. Everything else — layout, * filters, performance — flows through verbatim from the spec. + * + * The spec authors these strings in English. `localizeMetadataForm` overlays + * the active locale on top (objectui#7254) through the platform's own + * `metadataForms.` convention, so the panel speaks the same language as + * the Studio around it. A locale the overlay does not carry gets the spec's + * English, exactly as before. */ -export function getDashboardForm(): FormViewSpec | undefined { - if (_dashboardForm) return _dashboardForm; +export function getDashboardForm(locale?: SupportedLocale | string): FormViewSpec | undefined { + const cacheKey = String(locale ?? ''); + if (_dashboardForm.has(cacheKey)) return _dashboardForm.get(cacheKey); if (!specDashboardForm || typeof specDashboardForm !== 'object') return undefined; try { const clone = JSON.parse(JSON.stringify(specDashboardForm)) as FormViewSpec; @@ -77,12 +91,19 @@ export function getDashboardForm(): FormViewSpec | undefined { clone.sections = (clone.sections ?? []).filter( (s: any) => (s.fields ?? []).length > 0, ); - _dashboardForm = clone; + // Localize AFTER pruning: the overlay only has to reach what renders, and + // pruning cannot drop a section the overlay just renamed. + const localized = localizeMetadataForm( + clone as unknown as Record, + 'dashboard', + locale, + ) as unknown as FormViewSpec; + _dashboardForm.set(cacheKey, localized); + return localized; } catch (err) { if (typeof console !== 'undefined') { console.warn('[dashboard-schema] failed to prepare dashboardForm from spec', err); } return undefined; } - return _dashboardForm; } diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index d06ba2decb..2a0bc5d6f0 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1737,6 +1737,13 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.nav.noObjects': 'This package has no objects yet — create one in the Data pillar first.', // Interfaces pillar 'engine.studio.if.pickLeft': 'Select a menu item on the left', + // objectui#7254 — the tooltip that keeps the DEVELOPER identity reachable + // after the breadcrumb/caption stopped printing it beside a localized + // label. Prefixed rather than bare: `dashboard · customer_dashboard` on + // its own does not say what it is, and it is also what the rail item's + // own tooltip carries — three elements sharing one tooltip string is a + // page nothing (a person or a test) can address unambiguously. + 'engine.studio.if.internalId': 'Internal id', 'engine.studio.if.navHeading': '{app} · Navigation', 'engine.studio.if.editNavTitle': 'Edit navigation (drag to reorder / rename / add-remove)', 'engine.studio.if.doneEditTitle': 'Done editing', @@ -3623,6 +3630,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.nav.noObjects': '这个软件包还没有对象 — 先到 Data 支柱创建。', // Interfaces pillar 'engine.studio.if.pickLeft': '从左侧选择一个菜单项', + 'engine.studio.if.internalId': '内部标识', 'engine.studio.if.navHeading': '{app} · 导航', 'engine.studio.if.editNavTitle': '编辑导航(拖拽排序 / 重命名 / 增删)', 'engine.studio.if.doneEditTitle': '完成编辑', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx index 86d408e4be..66ad7ad29f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx @@ -130,7 +130,9 @@ export function DashboardDefaultInspector({ () => mergeServerFields({ bundledSchema: getDashboardSchema(), - bundledForm: getDashboardForm(), + // objectui#7254 — the spec form is English; the locale overlay lives + // inside `getDashboardForm` so every consumer of it gets the same copy. + bundledForm: getDashboardForm(locale), serverSchema, excludeFields: DASHBOARD_CURATED_FIELDS, sectionTitle: t('engine.inspector.moreFields', locale), diff --git a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts new file mode 100644 index 0000000000..47d9b31ad8 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7254 — the dashboard property panel was an all-English island + * inside a Chinese Studio. + * + * The panel is spec-driven on purpose (`dashboardForm` from + * `@objectstack/spec/ui` fed straight into SchemaForm), and the spec authors + * its copy in English, so every section heading, field label and hint reached + * the author untranslated — including "Grid gap (Tailwind units)", a unit only + * a developer can act on. + * + * These pins assert the OVERLAY, and specifically the two things a hand-rolled + * walker would have got wrong and a shape-only test would not have noticed: + * the section key is the SLUGGED label (not the label text), and a `composite` + * field's sub-rows are synthesized from the bundle — completely, or the ones + * left out vanish from the form. + */ +import { describe, expect, it } from 'vitest'; +import { dashboardForm } from '@objectstack/spec/ui'; +import { localizeMetadataForm, hasMetadataFormOverlay } from './metadata-form-i18n'; +import { getDashboardForm } from './dashboard-schema'; + +/** The two form shapes these assertions read. `unknown` everywhere else. */ +interface FormField { + field?: string; + type?: string; + label?: string; + helpText?: string; + fields?: FormField[]; +} +interface Section { + label?: string; + description?: string; + fields?: FormField[]; +} + +function sections(form: unknown): Section[] { + return ((form as { sections?: Section[] } | undefined)?.sections ?? []) as Section[]; +} + +function fieldByName(form: unknown, name: string): FormField | undefined { + for (const s of sections(form)) { + for (const f of s.fields ?? []) { + if (f?.field === name) return f; + } + } + return undefined; +} + +describe('localizeMetadataForm — the spec form, in the author’s language', () => { + it('has an overlay for `dashboard` at zh and none at en', () => { + expect(hasMetadataFormOverlay('dashboard', 'zh-CN')).toBe(true); + expect(hasMetadataFormOverlay('dashboard', 'en-US')).toBe(false); + }); + + it('leaves the form untouched (same object) outside zh', () => { + const form = dashboardForm as unknown as Record; + expect(localizeMetadataForm(form, 'dashboard', 'en-US')).toBe(form); + }); + + it('leaves a type the overlay does not carry untouched', () => { + const form = dashboardForm as unknown as Record; + expect(localizeMetadataForm(form, 'report', 'zh-CN')).toBe(form); + }); + + it('translates section headings — addressed by the SLUGGED label, not the label text', () => { + const zh = localizeMetadataForm( + dashboardForm as unknown as Record, + 'dashboard', + 'zh-CN', + ); + const labels = sections(zh).map((s) => s.label); + expect(labels).toContain('布局'); // 'Layout' → section key `layout` + expect(labels).toContain('筛选'); // 'Filters' → `filters` + expect(labels).not.toContain('Layout'); + const layout = sections(zh).find((s) => s.label === '布局'); + expect(layout?.description).toBe('栅格尺寸与刷新频率。'); + expect(layout?.description).not.toMatch(/refresh cadence/); + }); + + it('translates the layout field labels and hints the card named', () => { + const zh = localizeMetadataForm( + dashboardForm as unknown as Record, + 'dashboard', + 'zh-CN', + ); + expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' }); + expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新'); + expect(fieldByName(zh, 'header')?.label).toBe('页眉'); + }); + + it('drops the "Tailwind units" developer vocabulary rather than transliterating it', () => { + const zh = localizeMetadataForm( + dashboardForm as unknown as Record, + 'dashboard', + 'zh-CN', + ); + const gap = fieldByName(zh, 'gap'); + expect(gap?.label).toBe('间距'); + expect(gap?.helpText).not.toMatch(/Tailwind/i); + // The English source still says it — that copy is the spec's to fix, and + // this overlay deliberately does not rewrite the producer's own text. + expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i); + }); + + it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => { + const zh = localizeMetadataForm( + dashboardForm as unknown as Record, + 'dashboard', + 'zh-CN', + ); + const header = fieldByName(zh, 'header'); + expect(header?.type).toBe('composite'); + const children = (header?.fields ?? []).map((f) => f.field); + // Exactly `DashboardHeaderSchema`'s three properties. SchemaForm prefers a + // declared `fields` array over the schema-derived one, so anything absent + // here would disappear from the panel. + expect(children.sort()).toEqual(['actions', 'showDescription', 'showTitle']); + const byName = new Map((header!.fields ?? []).map((f) => [f.field, f])); + expect(byName.get('showTitle')?.label).toBe('显示标题'); + expect(byName.get('showTitle')?.helpText).toBe('在页眉中显示仪表板名称'); + expect(byName.get('showDescription')?.label).toBe('显示描述'); + expect(byName.get('actions')?.label).toBe('操作按钮'); + }); +}); + +describe('getDashboardForm — the overlay reaches the panel, and caches per locale', () => { + it('serves the spec’s English by default and Chinese to a zh console', () => { + const en = getDashboardForm('en-US'); + const zh = getDashboardForm('zh-CN'); + expect(sections(en).map((s) => s.label)).toContain('Layout'); + expect(sections(zh).map((s) => s.label)).toContain('布局'); + }); + + it('does not serve one locale’s copy to another (the single-slot cache bug)', () => { + // Order matters: ask for zh first, then en. A single memo slot would hand + // the Chinese form to the English console. + expect(sections(getDashboardForm('zh-CN')).map((s) => s.label)).toContain('布局'); + expect(sections(getDashboardForm('en-US')).map((s) => s.label)).toContain('Layout'); + expect(sections(getDashboardForm('en-US')).map((s) => s.label)).not.toContain('布局'); + }); + + it('is stable per locale', () => { + expect(getDashboardForm('zh-CN')).toBe(getDashboardForm('zh-CN')); + }); + + it('still prunes the fields the curated inspector owns', () => { + const zh = getDashboardForm('zh-CN'); + for (const owned of ['name', 'label', 'description', 'widgets']) { + expect(fieldByName(zh, owned)).toBeUndefined(); + } + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts new file mode 100644 index 0000000000..bb8e5d1fe0 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Locale overlay for the SPEC-DRIVEN property panels (objectui#7254). + * + * ## The gap + * + * A curated default inspector renders its config fields by feeding + * `@objectstack/spec`'s canonical authoring form (`dashboardForm`, …) straight + * into {@link SchemaForm} — deliberately, so a new prop added to the spec shows + * up with zero code changes here. Those forms are authored in English + * (`label: 'Layout'`, `helpText: 'Grid gap (Tailwind units)'`), and nothing on + * this side translated them. Result: a Chinese author editing a dashboard read + * a fully Chinese Studio with one all-English panel inside it — section + * headings, every field label, every hint, including developer vocabulary + * ("Tailwind units") that means nothing to the person the panel is for. + * + * ## Why the platform's own resolver, not a second convention + * + * The strings belong to the spec, and the platform already declares how they + * are translated: `metadataForms..{label,description}`, + * `.sections.
.{label,description}`, + * `.fields..{label,helpText,placeholder}` — resolved by + * `resolveMetadataFormLabels` from `@objectstack/spec/system`, which the + * framework's own `/meta/types` handler calls to localize the very same forms + * before serving them. This module supplies a bundle to THAT function rather + * than inventing a parallel key scheme, so: + * + * - the section-name derivation, the dot-path field addressing and the + * composite sub-field synthesis all come from the producer's implementation + * (a hand-rolled walker would have had to re-derive `header.showTitle` and + * would have got the section names wrong — they are slugged labels, not the + * `label` text); + * - when the environment's own translation bundle reaches this surface, it + * drops in as a higher-precedence source with no call-site change. + * + * ## Scope: `en` + `zh`, matching this console's carve-out + * + * These entries live beside `./i18n.ts`'s `engine.*` table and share its + * documented posture (see that file's header and `packages/i18n/README.md`, + * "Scope — the `engine.*` carve-out"): the metadata designer ships `en` and + * `zh`, and the other eight shipped locales render the producer's English. + * `en` is deliberately ABSENT rather than a copy of the spec's sentences — an + * English console falls through to the producer's own text instead of reading + * a duplicate this repo would then have to keep in step by hand (the same rule + * `tOptional` exists for). So this bundle carries `zh-CN` only. + * + * ⚠️ It is an OVERLAY, not a fork: a type the bundle does not name, or a field + * it does not name, is returned untouched. + * + * ⛔ One sharp edge, load-bearing for anyone adding a type here: for a + * `composite` / `repeater` / `record` field the spec resolver SYNTHESIZES the + * sub-field list from the bundle's direct children of that path, and + * `SchemaForm` prefers a declared `fields` array over the schema-derived one. + * Enumerate ALL of a composite's children or none — naming two of three makes + * the third disappear from the form. + */ + +import { resolveMetadataFormLabels } from '@objectstack/spec/system'; +import type { TranslationBundle } from '@objectstack/spec/system'; +import { isZhLocale, type SupportedLocale } from './i18n.js'; + +/** + * zh-CN strings for the spec authoring forms this console renders. + * + * Keys follow the platform convention exactly — a section is addressed by its + * SLUGGED label (`'Grid sizing…'` section labelled `Layout` → `layout`), a + * field by its dot path from the form root. + */ +const METADATA_FORM_BUNDLE: TranslationBundle = { + 'zh-CN': { + metadataForms: { + dashboard: { + label: '仪表板', + sections: { + basics: { label: '基本信息', description: '仪表板的名称与描述。' }, + layout: { label: '布局', description: '栅格尺寸与刷新频率。' }, + widgets: { label: '组件', description: '放在栅格上的卡片与图表。' }, + filters: { label: '筛选', description: '应用到全部组件的默认筛选与全局筛选。' }, + advanced: { label: '高级', description: '无障碍与性能调优。' }, + }, + fields: { + name: { label: '名称', helpText: 'snake_case 唯一标识' }, + label: { label: '显示名称', helpText: '展示给使用者的名称' }, + description: { label: '描述' }, + columns: { label: '列数', helpText: '栅格列数(默认 12)' }, + // The spec's own hint here is `Grid gap (Tailwind units)` — a unit + // only a developer can act on. Reported upstream rather than + // "translated" literally; this says what the author can decide. + gap: { label: '间距', helpText: '组件之间的间距,数值越大越松' }, + refreshInterval: { label: '自动刷新', helpText: '自动刷新间隔(秒),0 表示不自动刷新' }, + header: { label: '页眉', helpText: '页眉设置:标题、描述与操作按钮' }, + // All three children of the `header` composite — see the ⛔ note above. + 'header.showTitle': { label: '显示标题', helpText: '在页眉中显示仪表板名称' }, + 'header.showDescription': { label: '显示描述', helpText: '在页眉中显示仪表板描述' }, + 'header.actions': { label: '操作按钮', helpText: '显示在页眉里的操作按钮' }, + widgets: { label: '组件', helpText: '仪表板组件,含位置与尺寸' }, + dateRange: { label: '日期范围', helpText: '默认的日期范围选择器' }, + globalFilters: { label: '全局筛选', helpText: '应用到全部组件的筛选条件' }, + }, + }, + }, + }, +}; + +/** + * The bundle locale an active console locale resolves to, or `undefined` when + * this overlay has nothing to say (every locale but `zh`), in which case the + * caller must hand the form through unchanged. + */ +function bundleLocale(locale?: SupportedLocale | string): string | undefined { + return isZhLocale(locale) ? 'zh-CN' : undefined; +} + +/** + * Overlay `metadataForms.` strings onto a spec authoring form. + * + * Returns the SAME object when there is nothing to apply, so a caller can + * keep memoising on identity. + */ +export function localizeMetadataForm>( + form: T | undefined, + type: string, + locale?: SupportedLocale | string, +): T | undefined { + if (!form) return form; + const target = bundleLocale(locale); + if (!target) return form; + return resolveMetadataFormLabels(form, type, METADATA_FORM_BUNDLE, { locale: target }); +} + +/** Whether this overlay carries anything for `type` at `locale`. Exported for tests. */ +export function hasMetadataFormOverlay(type: string, locale?: SupportedLocale | string): boolean { + const target = bundleLocale(locale); + if (!target) return false; + return Boolean(METADATA_FORM_BUNDLE[target]?.metadataForms?.[type]); +} diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesAction.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesAction.test.tsx index f24f89a6a1..9275615579 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesAction.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesAction.test.tsx @@ -111,15 +111,16 @@ describe('Interfaces pillar — action nav entries (objectui#4019)', () => { const entry = await screen.findByTitle('action · sync_now'); fireEvent.click(entry); - // Canvas breadcrumb — the surface the pillar is now editing. Matched on - // the element's whole text: the breadcrumb is `{type} · {name}` in JSX, so - // it reaches the DOM as three sibling text nodes and a plain string query - // would never match it. - await waitFor(() => - expect( - screen.getAllByText((_content, el) => el?.tagName === 'SPAN' && el.textContent === 'action · sync_now'), - ).not.toHaveLength(0), - ); + // Canvas caption — the surface the pillar is now editing. objectui#7254 + // changed WHAT it says, not whether it says it: the caption used to print + // the internal `action · sync_now` pair and now names the item the way the + // author does (its nav label) plus the metadata kind, with the internal + // pair moved to the tooltip. Asserted through the caption's own testid + // rather than by matching its whole text, so the next copy change does not + // land here again. + const caption = await waitFor(() => screen.getByTestId('if-canvas-caption')); + expect(caption).toHaveTextContent('Run Sync'); + expect(caption).toHaveAttribute('title', 'Internal id: action · sync_now'); // ...rendered by the registered `ActionPreview`, which draws the action's // own label as the faux button an author is designing. await waitFor(() => expect(screen.getAllByText('Sync Now').length).toBeGreaterThan(0)); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.surfaceIdentity.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.surfaceIdentity.test.tsx new file mode 100644 index 0000000000..235c081e13 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.surfaceIdentity.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7254 — the Interfaces workbench spoke two vocabularies at once. + * + * The rail heading said 「客户管理 · 导航」 while, one strip away, the + * breadcrumb chip printed `dashboard · customer_dashboard` and the canvas + * caption printed the same pair — raw metadata type and internal name, beside + * a Chinese label, for a customer who has never seen either. + * + * The ruling: the top bar / breadcrumb / rail show the metadata LABEL; the + * internal name belongs in a developer view or a tooltip. So these pins assert + * both halves — the label and translated KIND are what is READ, and the + * internal identity is still REACHABLE on the tooltip (removing it outright + * would take the one handle a developer debugging a binding actually uses). + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; + +const NAV = [ + { id: 'nav_dash', type: 'dashboard', label: '客户仪表盘', dashboardName: 'b2r4_customer_dashboard' }, + // A leaf the author never labelled — the rail used to render an EMPTY row. + { id: 'nav_obj', type: 'object', objectName: 'b2r4_customer' }, +]; + +const mockClient = { + list: vi.fn(async (type: string) => + type === 'app' ? [{ name: 'acme_app', label: '客户管理' }] : [], + ), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + if (type === 'app') return { effective: { name: 'acme_app', label: '客户管理', navigation: NAV } }; + if (type === 'dashboard') return { effective: { name, label: '客户仪表盘', widgets: [] } }; + return { effective: { name } }; + }), + getDraft: vi.fn(async () => null), + save: vi.fn(async () => ({})), + get: vi.fn(async () => undefined), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ entries: [] }), + }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +import { InterfacesPillar } from './StudioDesignSurface'; + +afterEach(cleanup); + +function renderZhPillar() { + return render( + + + + + , + ); +} + +describe('Interfaces workbench — labels on screen, internal names on the tooltip', () => { + it('the rail names an unlabelled leaf by its object name instead of rendering a blank row', async () => { + renderZhPillar(); + // The dashboard leaf carries a label; the object leaf does not. (The + // labelled one also appears in the breadcrumb/caption once the pillar + // auto-selects it, hence `findAllByText`.) + expect((await screen.findAllByText('客户仪表盘')).length).toBeGreaterThan(0); + expect(await screen.findByText('b2r4_customer')).toBeInTheDocument(); + }); + + it('the rail kind chip is translated, not the raw English metadata type', async () => { + renderZhPillar(); + await screen.findAllByText('客户仪表盘'); + expect(screen.getAllByText('仪表板').length).toBeGreaterThan(0); + expect(screen.queryByText('DASHBOARD')).not.toBeInTheDocument(); + expect(screen.queryByText('dashboard')).not.toBeInTheDocument(); + }); + + it('the breadcrumb reads label + translated kind, with the internal pair on its tooltip', async () => { + renderZhPillar(); + fireEvent.click(await screen.findByTitle('dashboard · b2r4_customer_dashboard')); + const crumb = await waitFor(() => screen.getByTestId('if-breadcrumb'), { timeout: 4000 }); + expect(crumb).toHaveTextContent('客户仪表盘'); + expect(crumb).toHaveTextContent('仪表板'); + // The pair is reachable, but not printed at the customer. + // Prefixed, so the tooltip SAYS what the pair is — and so the rail item, + // the breadcrumb and the caption stop sharing one addressable string. + expect(crumb).toHaveAttribute('title', '内部标识: dashboard · b2r4_customer_dashboard'); + expect(crumb.textContent).not.toContain('b2r4_customer_dashboard'); + }); + + it('the canvas caption follows the same rule', async () => { + renderZhPillar(); + fireEvent.click(await screen.findByTitle('dashboard · b2r4_customer_dashboard')); + const caption = await waitFor(() => screen.getByTestId('if-canvas-caption'), { timeout: 4000 }); + expect(caption).toHaveTextContent('客户仪表盘'); + expect(caption).toHaveAttribute('title', '内部标识: dashboard · b2r4_customer_dashboard'); + expect(caption.textContent).not.toContain('b2r4_customer_dashboard'); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index f4c9809243..64758dd208 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -107,7 +107,7 @@ import { useSurfaceDeepLink, resolveSurfaceDeepLink, type SurfaceTarget } from ' import { SurfaceDeepLinkProvider, useRequestedSurface } from './surfaceDeepLinkChannel.js'; import { buildObjectSkeleton, buildFlowSkeleton, buildAppSkeleton, buildPermissionSkeleton } from './skeletons.js'; import { OWD_CREATE_MODELS, OWD_DEFAULT, type OwdCreateModel } from './owd-sharing.js'; -import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; +import { t, tFormat, translateMetadataType, useMetadataLocale } from '../metadata-admin/i18n.js'; import { SuggestedBindingsPanel } from '../../components/SuggestedBindingsPanel.js'; import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas.js'; import { @@ -975,6 +975,7 @@ function NavTree({ /** object name → its metadata icon, so object nav items show their own glyph. */ objectIcons?: Record; }): React.ReactElement { + const locale = useMetadataLocale(); return ( <> {nodes.map((node, i) => { @@ -1009,10 +1010,17 @@ function NavTree({ } > - {node.label} + {/* objectui#7254 — a nav item with no declared label used to render + an EMPTY row; the internal name is a poor label but an honest + one, and it beats a blank the author cannot click by name. */} + {node.label || surface?.name} {surface && surface.type !== 'page' && ( - - {surface.type} + // The kind chip was the raw English metadata type in an otherwise + // localized rail. `uppercase` is dropped with it: it is a + // Latin-script affordance that does nothing for CJK and mangles + // nothing else only by luck. + + {translateMetadataType(surface.type, locale)} )} @@ -1651,9 +1659,20 @@ export function InterfacesPillar({ )} + {/* objectui#7254 — the canvas caption names WHAT you are editing, in + the author's own vocabulary: the item's metadata label plus its + translated KIND ("客户仪表盘 · 仪表板"). The internal `type · name` + pair it used to print verbatim is developer identity and moves to + the tooltip, which the ruling keeps as its allowed home. With no + label declared the internal name is still shown — a blank caption + would be worse, and the gap is the producer's to close. */} {current && ( - - {current.type} · {current.name} + + {current.label || current.name} · {translateMetadataType(current.type, locale)} )} @@ -1942,11 +1961,21 @@ export function InterfacesPillar({ > + {/* objectui#7254 — the breadcrumb's chip carried the raw + `dashboard · customer_dashboard` beside a Chinese label, so the same + strip spoke two vocabularies at once. The chip now carries the + translated KIND; the internal identity is on the tooltip. */} {current ? ( - - {current.label} + + + {current.label || current.name} + - {current.type} · {current.name} + {translateMetadataType(current.type, locale)} ) : ( diff --git a/packages/app-shell/src/views/studio-design/packages-io.test.ts b/packages/app-shell/src/views/studio-design/packages-io.test.ts index 19b5043477..ab0ff42491 100644 --- a/packages/app-shell/src/views/studio-design/packages-io.test.ts +++ b/packages/app-shell/src/views/studio-design/packages-io.test.ts @@ -47,6 +47,52 @@ describe('parsePackages — namespace resolution', () => { }); }); +/** + * objectui#7254 — the Studio top bar showed `app.b2r4` where the author + * expected the app's name. + * + * `GET /api/v1/packages` merges two producers: the durable half nests fields + * under `manifest`, the registry half (`getMetaItems({type:'package'})`) hands + * back the package metadata DOCUMENT with its fields top-level. The server's + * own list handler reads `item.manifest?.id || item.id` on both halves, so + * both positions are declared. This reader matched that for `id` and only for + * `id`; `name` was manifest-only and every registry-shaped entry degraded to + * showing its reverse-domain id as if that were its name. + */ +describe('parsePackages — the human name, from either declared position', () => { + it('reads a manifest-nested name (the durable half)', () => { + const [pkg] = parsePackages(wrap([{ manifest: { id: 'app.b2r4', name: '客户管理' } }])); + expect(pkg.name).toBe('客户管理'); + }); + + it('reads a top-level name (the registry half) instead of falling back to the id', () => { + const [pkg] = parsePackages(wrap([{ id: 'app.b2r4', name: '客户管理' }])); + expect(pkg.id).toBe('app.b2r4'); + expect(pkg.name).toBe('客户管理'); + }); + + it('prefers the manifest position when both carry a name', () => { + const [pkg] = parsePackages( + wrap([{ id: 'app.b2r4', name: 'stale', manifest: { id: 'app.b2r4', name: '客户管理' } }]), + ); + expect(pkg.name).toBe('客户管理'); + }); + + it('falls back to the id only when NO name is declared anywhere', () => { + // The honest degradation: the producer wrote no name, so there is none to + // show. Contract-first — the gap closes at the producer, not here. + const [pkg] = parsePackages(wrap([{ manifest: { id: 'app.b2r4' } }])); + expect(pkg.name).toBe('app.b2r4'); + }); + + it('treats a blank / whitespace name as absent rather than rendering an empty top bar', () => { + expect(parsePackages(wrap([{ manifest: { id: 'app.b2r4', name: ' ' } }]))[0].name).toBe( + 'app.b2r4', + ); + expect(parsePackages(wrap([{ id: 'app.b2r4', name: '' }]))[0].name).toBe('app.b2r4'); + }); +}); + describe('prefixObjectName', () => { it('prepends the namespace to a prefix-less name', () => { expect(prefixObjectName('ticket', 'hr')).toBe('hr_ticket'); diff --git a/packages/app-shell/src/views/studio-design/packages-io.ts b/packages/app-shell/src/views/studio-design/packages-io.ts index c94002e170..3707462776 100644 --- a/packages/app-shell/src/views/studio-design/packages-io.ts +++ b/packages/app-shell/src/views/studio-design/packages-io.ts @@ -31,6 +31,14 @@ import { deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@ob export interface PkgEntry { id: string; + /** + * The package's HUMAN name — what the Studio top bar shows the author. + * + * Falls back to the id, which is what the switcher renders when the producer + * declared no name at all. That fallback is the honest degradation; reading + * the name from only ONE of the two shapes this endpoint serves was not + * (objectui#7254) — see {@link parsePackages}. + */ name: string; writable: boolean; /** @@ -42,6 +50,32 @@ export interface PkgEntry { namespace: string | null; } +/** + * `GET /api/v1/packages` merges TWO producers into one array, and they carry + * their fields in two different positions: + * + * - the durable half (`PackageService.list()`, published artifacts) nests + * everything under `manifest`; + * - the registry half (`protocol.getMetaItems({ type: 'package' })`) hands + * back the package METADATA DOCUMENT, whose fields are top-level. + * + * That is not an inference: the server's own list handler reads + * `item.manifest?.id || item.id` on BOTH halves, i.e. the producer declares + * both positions for the same field. This reader already matched it for `id` + * — and only for `id`. `name` was read as `manifest.name ?? id`, so every + * registry-shaped entry lost its human name and the Studio top bar showed the + * reverse-domain package id (`app.b2r4`) where the author expected the app's + * name (objectui#7254). Closing the asymmetry inside the one reader that + * already declares both positions — not a new tolerant dialect. + * + * `writable` reads only the TOP level, and that is not the same asymmetry: it + * is the server's own computed verdict (see the module doc), a field the + * manifest does not and should not carry. + * + * `scope` is deliberately left manifest-only: widening it would change which + * packages the switcher HIDES, which is a different question from what it + * NAMES and is not this card's. + */ export function parsePackages(payload: unknown): PkgEntry[] { const root = (payload as { data?: unknown })?.data ?? payload; const raw = Array.isArray(root) ? root : ((root as { packages?: unknown[] })?.packages ?? []); @@ -58,7 +92,12 @@ export function parsePackages(payload: unknown): PkgEntry[] { // Server first, heuristic only when the key is absent (see the module doc). // A non-boolean value is not a verdict, so it falls back too. const writable = typeof p.writable === 'boolean' ? p.writable : scope !== 'project'; - out.push({ id, name: String(m.name ?? id), writable, namespace }); + // Same two positions as `id` above, same order (objectui#7254). An empty + // string is not a name — it falls through to the id like an absent one. + const declaredName = + (typeof m.name === 'string' && m.name.trim() ? m.name : undefined) ?? + (typeof p.name === 'string' && p.name.trim() ? p.name : undefined); + out.push({ id, name: declaredName ?? id, writable, namespace }); } return out; } diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 065d3e0258..42c9bf6ff1 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2853,6 +2853,80 @@ const ar = { sourceInherits: "مثل build/ask", sourcePinned: "مثبّت بواسطة {{source}}", }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: "تجميع البيانات", + get_record: "جلب السجل", + query_data: "الاستعلام عن البيانات", + query_records: "الاستعلام عن السجلات", + search_knowledge: "البحث في قاعدة المعرفة", + visualize_data: "إنشاء رسم بياني", + add_field: "إضافة حقل", + apply_blueprint: "بناء التطبيق", + apply_edit: "تطبيق التغييرات", + create_metadata: "إنشاء بيانات وصفية", + create_object: "إنشاء كائن", + create_package: "إنشاء حزمة", + create_seed: "توليد بيانات تجريبية", + delete_field: "حذف حقل", + describe_metadata: "عرض البيانات الوصفية", + describe_object: "عرض بنية الكائن", + get_active_package: "جلب الحزمة النشطة", + get_metadata_schema: "جلب مخطط البيانات الوصفية", + get_package: "جلب الحزمة", + list_metadata: "سرد البيانات الوصفية", + list_objects: "سرد الكائنات", + list_packages: "سرد الحزم", + modify_field: "تعديل حقل", + propose_blueprint: "تصميم خطة التطبيق", + set_active_package: "تبديل الحزمة النشطة", + suggest_builder: "اقتراح طريقة البناء", + todo_write: "تدوين المهام", + update_metadata: "تحديث البيانات الوصفية", + validate_expression: "التحقق من التعبير", + verify_build: "التحقق من البناء", + }, + toolState: { + agentActivity: "نشاط الوكيل", + pending: "قيد الانتظار", + running: "قيد التنفيذ", + awaitingApproval: "بانتظار الموافقة", + responded: "تمت الاستجابة", + completed: "مكتمل", + error: "خطأ", + denied: "مرفوض", + failed: "فشل", + }, + plan: { + countObjects: "{{count}} كائنات", + countObjects_one: "{{count}} كائن", + countViews: "{{count}} طرق عرض", + countViews_one: "{{count}} طريقة عرض", + countDashboards: "{{count}} لوحات معلومات", + countDashboards_one: "{{count}} لوحة معلومات", + countSeedData: "بيانات تجريبية", + }, + }, chatbotError: { title: "فشل الرد", fallbackDetail: "حدث خطأ ما. يرجى المحاولة مرة أخرى.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index d41b46744e..07fcdb3747 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2846,6 +2846,80 @@ const de = { sourceInherits: "wie build/ask", sourcePinned: "festgelegt durch {{source}}", }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: "Daten aggregieren", + get_record: "Datensatz lesen", + query_data: "Daten abfragen", + query_records: "Datensätze abfragen", + search_knowledge: "Wissensdatenbank durchsuchen", + visualize_data: "Diagramm erstellen", + add_field: "Feld hinzufügen", + apply_blueprint: "App erstellen", + apply_edit: "Änderungen anwenden", + create_metadata: "Metadaten anlegen", + create_object: "Objekt anlegen", + create_package: "Paket anlegen", + create_seed: "Beispieldaten erzeugen", + delete_field: "Feld löschen", + describe_metadata: "Metadaten ansehen", + describe_object: "Objektstruktur ansehen", + get_active_package: "Aktives Paket lesen", + get_metadata_schema: "Metadatenschema lesen", + get_package: "Paket lesen", + list_metadata: "Metadaten auflisten", + list_objects: "Objekte auflisten", + list_packages: "Pakete auflisten", + modify_field: "Feld ändern", + propose_blueprint: "App-Entwurf erstellen", + set_active_package: "Aktives Paket wechseln", + suggest_builder: "Vorgehen vorschlagen", + todo_write: "Aufgaben notieren", + update_metadata: "Metadaten aktualisieren", + validate_expression: "Ausdruck prüfen", + verify_build: "Aufbau prüfen", + }, + toolState: { + agentActivity: "Agentenaktivität", + pending: "Ausstehend", + running: "Läuft", + awaitingApproval: "Wartet auf Freigabe", + responded: "Beantwortet", + completed: "Abgeschlossen", + error: "Fehler", + denied: "Abgelehnt", + failed: "Fehlgeschlagen", + }, + plan: { + countObjects: "{{count}} Objekte", + countObjects_one: "{{count}} Objekt", + countViews: "{{count}} Ansichten", + countViews_one: "{{count}} Ansicht", + countDashboards: "{{count}} Dashboards", + countDashboards_one: "{{count}} Dashboard", + countSeedData: "Beispieldaten", + }, + }, chatbotError: { title: "Antwort fehlgeschlagen", fallbackDetail: "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index c23633530d..d2c4bc7ee1 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3199,6 +3199,80 @@ const en = { sourceInherits: 'same as build/ask', sourcePinned: 'pinned by {{source}}', }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: 'Aggregate data', + get_record: 'Get record', + query_data: 'Query data', + query_records: 'Query records', + search_knowledge: 'Search knowledge', + visualize_data: 'Visualize data', + add_field: 'Add field', + apply_blueprint: 'Apply blueprint', + apply_edit: 'Apply edit', + create_metadata: 'Create metadata', + create_object: 'Create object', + create_package: 'Create package', + create_seed: 'Create seed', + delete_field: 'Delete field', + describe_metadata: 'Describe metadata', + describe_object: 'Describe object', + get_active_package: 'Get active package', + get_metadata_schema: 'Get metadata schema', + get_package: 'Get package', + list_metadata: 'List metadata', + list_objects: 'List objects', + list_packages: 'List packages', + modify_field: 'Modify field', + propose_blueprint: 'Propose blueprint', + set_active_package: 'Set active package', + suggest_builder: 'Suggest builder', + todo_write: 'Todo write', + update_metadata: 'Update metadata', + validate_expression: 'Validate expression', + verify_build: 'Verify build', + }, + toolState: { + agentActivity: 'Agent activity', + pending: 'Pending', + running: 'Running', + awaitingApproval: 'Awaiting approval', + responded: 'Responded', + completed: 'Completed', + error: 'Error', + denied: 'Denied', + failed: 'Failed', + }, + plan: { + countObjects: '{{count}} objects', + countObjects_one: '{{count}} object', + countViews: '{{count}} views', + countViews_one: '{{count}} view', + countDashboards: '{{count}} dashboards', + countDashboards_one: '{{count}} dashboard', + countSeedData: 'sample data', + }, + }, chatbotError: { title: 'Response failed', fallbackDetail: 'Something went wrong. Please try again.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 5fdcd3fb35..18e2b38775 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2850,6 +2850,80 @@ const es = { sourceInherits: "igual que build/ask", sourcePinned: "fijado por {{source}}", }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: "Resumir datos", + get_record: "Obtener registro", + query_data: "Consultar datos", + query_records: "Consultar registros", + search_knowledge: "Buscar en la base de conocimiento", + visualize_data: "Crear gráfico", + add_field: "Añadir campo", + apply_blueprint: "Construir la aplicación", + apply_edit: "Aplicar cambios", + create_metadata: "Crear metadatos", + create_object: "Crear objeto", + create_package: "Crear paquete", + create_seed: "Generar datos de ejemplo", + delete_field: "Eliminar campo", + describe_metadata: "Consultar metadatos", + describe_object: "Consultar la estructura del objeto", + get_active_package: "Obtener el paquete activo", + get_metadata_schema: "Obtener el esquema de metadatos", + get_package: "Obtener paquete", + list_metadata: "Listar metadatos", + list_objects: "Listar objetos", + list_packages: "Listar paquetes", + modify_field: "Modificar campo", + propose_blueprint: "Diseñar el plan de la aplicación", + set_active_package: "Cambiar el paquete activo", + suggest_builder: "Sugerir cómo construirlo", + todo_write: "Anotar tareas", + update_metadata: "Actualizar metadatos", + validate_expression: "Validar expresión", + verify_build: "Verificar la construcción", + }, + toolState: { + agentActivity: "Actividad del agente", + pending: "Pendiente", + running: "En curso", + awaitingApproval: "Esperando aprobación", + responded: "Respondido", + completed: "Completado", + error: "Error", + denied: "Denegado", + failed: "Fallido", + }, + plan: { + countObjects: "{{count}} objetos", + countObjects_one: "{{count}} objeto", + countViews: "{{count}} vistas", + countViews_one: "{{count}} vista", + countDashboards: "{{count}} paneles", + countDashboards_one: "{{count}} panel", + countSeedData: "datos de ejemplo", + }, + }, chatbotError: { title: "Error en la respuesta", fallbackDetail: "Algo salió mal. Vuelva a intentarlo.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index ac605a1512..3244172d20 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2848,6 +2848,80 @@ const fr = { sourceInherits: "identique à build/ask", sourcePinned: "figé par {{source}}", }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: "Agréger les données", + get_record: "Lire un enregistrement", + query_data: "Interroger les données", + query_records: "Interroger les enregistrements", + search_knowledge: "Rechercher dans la base de connaissances", + visualize_data: "Créer un graphique", + add_field: "Ajouter un champ", + apply_blueprint: "Construire l’application", + apply_edit: "Appliquer les modifications", + create_metadata: "Créer des métadonnées", + create_object: "Créer un objet", + create_package: "Créer un paquet", + create_seed: "Générer des données d’exemple", + delete_field: "Supprimer un champ", + describe_metadata: "Consulter les métadonnées", + describe_object: "Consulter la structure de l’objet", + get_active_package: "Lire le paquet actif", + get_metadata_schema: "Lire le schéma des métadonnées", + get_package: "Lire le paquet", + list_metadata: "Lister les métadonnées", + list_objects: "Lister les objets", + list_packages: "Lister les paquets", + modify_field: "Modifier un champ", + propose_blueprint: "Concevoir le plan de l’application", + set_active_package: "Changer le paquet actif", + suggest_builder: "Proposer une méthode de construction", + todo_write: "Noter les tâches", + update_metadata: "Mettre à jour les métadonnées", + validate_expression: "Valider l’expression", + verify_build: "Vérifier la construction", + }, + toolState: { + agentActivity: "Activité de l’agent", + pending: "En attente", + running: "En cours", + awaitingApproval: "En attente d’approbation", + responded: "Répondu", + completed: "Terminé", + error: "Erreur", + denied: "Refusé", + failed: "Échec", + }, + plan: { + countObjects: "{{count}} objets", + countObjects_one: "{{count}} objet", + countViews: "{{count}} vues", + countViews_one: "{{count}} vue", + countDashboards: "{{count}} tableaux de bord", + countDashboards_one: "{{count}} tableau de bord", + countSeedData: "données d’exemple", + }, + }, chatbotError: { title: "Échec de la réponse", fallbackDetail: "Une erreur s'est produite. Veuillez réessayer.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 4749bda9ca..34780046f7 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2848,6 +2848,80 @@ const ja = { sourceInherits: "build/ask と同じ", sourcePinned: "{{source}} で固定", }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: "データを集計", + get_record: "レコードを取得", + query_data: "データを照会", + query_records: "レコードを照会", + search_knowledge: "ナレッジを検索", + visualize_data: "グラフを作成", + add_field: "項目を追加", + apply_blueprint: "アプリを構築", + apply_edit: "変更を適用", + create_metadata: "メタデータを作成", + create_object: "オブジェクトを作成", + create_package: "パッケージを作成", + create_seed: "サンプルデータを生成", + delete_field: "項目を削除", + describe_metadata: "メタデータを確認", + describe_object: "オブジェクト構造を確認", + get_active_package: "現在のパッケージを取得", + get_metadata_schema: "メタデータ構造を取得", + get_package: "パッケージを取得", + list_metadata: "メタデータ一覧", + list_objects: "オブジェクト一覧", + list_packages: "パッケージ一覧", + modify_field: "項目を変更", + propose_blueprint: "アプリ設計案を作成", + set_active_package: "現在のパッケージを切替", + suggest_builder: "構築方法を提案", + todo_write: "タスクを記録", + update_metadata: "メタデータを更新", + validate_expression: "式を検証", + verify_build: "構築結果を検証", + }, + toolState: { + agentActivity: "エージェントの動作", + pending: "待機中", + running: "実行中", + awaitingApproval: "承認待ち", + responded: "応答済み", + completed: "完了", + error: "エラー", + denied: "拒否", + failed: "失敗", + }, + plan: { + countObjects: "{{count}} 件のオブジェクト", + countObjects_one: "{{count}} 件のオブジェクト", + countViews: "{{count}} 件のビュー", + countViews_one: "{{count}} 件のビュー", + countDashboards: "{{count}} 件のダッシュボード", + countDashboards_one: "{{count}} 件のダッシュボード", + countSeedData: "サンプルデータ", + }, + }, chatbotError: { title: "応答に失敗しました", fallbackDetail: "問題が発生しました。もう一度お試しください。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 4a7a6b0516..fdd5d3dd63 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2845,6 +2845,80 @@ const ko = { sourceInherits: "build/ask와 동일", sourcePinned: "{{source}}에 의해 고정됨", }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: "데이터 집계", + get_record: "레코드 가져오기", + query_data: "데이터 조회", + query_records: "레코드 조회", + search_knowledge: "지식 검색", + visualize_data: "차트 생성", + add_field: "필드 추가", + apply_blueprint: "앱 생성", + apply_edit: "변경 사항 적용", + create_metadata: "메타데이터 생성", + create_object: "오브젝트 생성", + create_package: "패키지 생성", + create_seed: "샘플 데이터 생성", + delete_field: "필드 삭제", + describe_metadata: "메타데이터 확인", + describe_object: "오브젝트 구조 확인", + get_active_package: "현재 패키지 가져오기", + get_metadata_schema: "메타데이터 구조 가져오기", + get_package: "패키지 가져오기", + list_metadata: "메타데이터 목록", + list_objects: "오브젝트 목록", + list_packages: "패키지 목록", + modify_field: "필드 수정", + propose_blueprint: "앱 설계안 작성", + set_active_package: "현재 패키지 전환", + suggest_builder: "구축 방법 제안", + todo_write: "할 일 기록", + update_metadata: "메타데이터 업데이트", + validate_expression: "표현식 검증", + verify_build: "빌드 검증", + }, + toolState: { + agentActivity: "에이전트 활동", + pending: "대기 중", + running: "실행 중", + awaitingApproval: "승인 대기", + responded: "응답함", + completed: "완료", + error: "오류", + denied: "거부됨", + failed: "실패", + }, + plan: { + countObjects: "오브젝트 {{count}}개", + countObjects_one: "오브젝트 {{count}}개", + countViews: "뷰 {{count}}개", + countViews_one: "뷰 {{count}}개", + countDashboards: "대시보드 {{count}}개", + countDashboards_one: "대시보드 {{count}}개", + countSeedData: "샘플 데이터", + }, + }, chatbotError: { title: "응답 실패", fallbackDetail: "문제가 발생했습니다. 다시 시도해 주세요.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 2ca9f45a7b..65d51733b5 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2845,6 +2845,80 @@ const pt = { sourceInherits: "igual a build/ask", sourcePinned: "fixado por {{source}}", }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: "Resumir dados", + get_record: "Obter registro", + query_data: "Consultar dados", + query_records: "Consultar registros", + search_knowledge: "Pesquisar na base de conhecimento", + visualize_data: "Criar gráfico", + add_field: "Adicionar campo", + apply_blueprint: "Construir o aplicativo", + apply_edit: "Aplicar alterações", + create_metadata: "Criar metadados", + create_object: "Criar objeto", + create_package: "Criar pacote", + create_seed: "Gerar dados de exemplo", + delete_field: "Excluir campo", + describe_metadata: "Consultar metadados", + describe_object: "Consultar a estrutura do objeto", + get_active_package: "Obter o pacote ativo", + get_metadata_schema: "Obter o esquema de metadados", + get_package: "Obter pacote", + list_metadata: "Listar metadados", + list_objects: "Listar objetos", + list_packages: "Listar pacotes", + modify_field: "Modificar campo", + propose_blueprint: "Projetar o plano do aplicativo", + set_active_package: "Trocar o pacote ativo", + suggest_builder: "Sugerir como construir", + todo_write: "Anotar tarefas", + update_metadata: "Atualizar metadados", + validate_expression: "Validar expressão", + verify_build: "Verificar a construção", + }, + toolState: { + agentActivity: "Atividade do agente", + pending: "Pendente", + running: "Em execução", + awaitingApproval: "Aguardando aprovação", + responded: "Respondido", + completed: "Concluído", + error: "Erro", + denied: "Negado", + failed: "Falhou", + }, + plan: { + countObjects: "{{count}} objetos", + countObjects_one: "{{count}} objeto", + countViews: "{{count}} visões", + countViews_one: "{{count}} visão", + countDashboards: "{{count}} painéis", + countDashboards_one: "{{count}} painel", + countSeedData: "dados de exemplo", + }, + }, chatbotError: { title: "Falha na resposta", fallbackDetail: "Algo deu errado. Tente novamente.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 96da511b72..056030f4ad 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2859,6 +2859,80 @@ const ru = { sourceInherits: "как у build/ask", sourcePinned: "закреплено через {{source}}", }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: "Сводка данных", + get_record: "Получить запись", + query_data: "Запросить данные", + query_records: "Запросить записи", + search_knowledge: "Поиск по базе знаний", + visualize_data: "Построить график", + add_field: "Добавить поле", + apply_blueprint: "Собрать приложение", + apply_edit: "Применить изменения", + create_metadata: "Создать метаданные", + create_object: "Создать объект", + create_package: "Создать пакет", + create_seed: "Сгенерировать примеры данных", + delete_field: "Удалить поле", + describe_metadata: "Посмотреть метаданные", + describe_object: "Посмотреть структуру объекта", + get_active_package: "Получить активный пакет", + get_metadata_schema: "Получить схему метаданных", + get_package: "Получить пакет", + list_metadata: "Список метаданных", + list_objects: "Список объектов", + list_packages: "Список пакетов", + modify_field: "Изменить поле", + propose_blueprint: "Спроектировать приложение", + set_active_package: "Сменить активный пакет", + suggest_builder: "Предложить способ сборки", + todo_write: "Записать задачи", + update_metadata: "Обновить метаданные", + validate_expression: "Проверить выражение", + verify_build: "Проверить сборку", + }, + toolState: { + agentActivity: "Действия агента", + pending: "Ожидание", + running: "Выполняется", + awaitingApproval: "Ожидает подтверждения", + responded: "Отвечено", + completed: "Завершено", + error: "Ошибка", + denied: "Отклонено", + failed: "Ошибка выполнения", + }, + plan: { + countObjects: "объектов: {{count}}", + countObjects_one: "{{count}} объект", + countViews: "представлений: {{count}}", + countViews_one: "{{count}} представление", + countDashboards: "дашбордов: {{count}}", + countDashboards_one: "{{count}} дашборд", + countSeedData: "демоданные", + }, + }, chatbotError: { title: "Ошибка ответа", fallbackDetail: "Что-то пошло не так. Попробуйте ещё раз.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 5226bd3178..689803c69e 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2983,6 +2983,80 @@ const zh = { sourceInherits: '与 build/ask 相同', sourcePinned: '被 {{source}} 钉住', }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. The `en` values + // are deliberately EQUAL to what that title-caser produces: + // adding the key must not silently reword the English UI. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `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, + // which is what keeps ru/ar in their own language. + chatbot: { + tool: { + aggregate_data: '汇总数据', + get_record: '读取记录', + query_data: '查询数据', + query_records: '查询记录', + search_knowledge: '搜索知识库', + visualize_data: '生成图表', + add_field: '添加字段', + apply_blueprint: '搭建应用', + apply_edit: '应用修改', + create_metadata: '新建元数据', + create_object: '新建对象', + create_package: '新建应用包', + create_seed: '生成示例数据', + delete_field: '删除字段', + describe_metadata: '查看元数据', + describe_object: '查看对象结构', + get_active_package: '读取当前应用包', + get_metadata_schema: '读取元数据结构', + get_package: '读取应用包', + list_metadata: '列出元数据', + list_objects: '列出对象', + list_packages: '列出应用包', + modify_field: '修改字段', + propose_blueprint: '设计应用方案', + set_active_package: '切换当前应用包', + suggest_builder: '推荐搭建方式', + todo_write: '记录待办', + update_metadata: '更新元数据', + validate_expression: '校验表达式', + verify_build: '校验搭建结果', + }, + toolState: { + agentActivity: '智能体活动', + pending: '等待中', + running: '运行中', + awaitingApproval: '待确认', + responded: '已回复', + completed: '已完成', + error: '出错', + denied: '已拒绝', + failed: '失败', + }, + plan: { + countObjects: '{{count}} 个对象', + countObjects_one: '{{count}} 个对象', + countViews: '{{count}} 个视图', + countViews_one: '{{count}} 个视图', + countDashboards: '{{count}} 个仪表板', + countDashboards_one: '{{count}} 个仪表板', + countSeedData: '示例数据', + }, + }, chatbotError: { title: '响应失败', fallbackDetail: '出了点问题,请重试。', diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index c70e9f59da..deac5cb362 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -30,6 +30,7 @@ import { parseAiQuotaError, summarizeChatError, unwrapToolResult, + type ToolTitleTranslator, } from './tool-display'; import { Conversation, @@ -67,6 +68,7 @@ import { ToolContent, ToolInput, ToolOutput, + type ToolStatusLabels, } from './elements/tool'; import { Reasoning, @@ -969,6 +971,61 @@ export function getToolState(tool: ChatToolInvocation): ToolSummaryState { return 'running'; } +/** + * How far a confirm-gate proposal card has actually got — the SINGLE producer + * of that fact (objectui#7254). + * + * The card BODY already renders a terminal badge from four separate memos: + * `builtPlanIds` (已搭建), `approvedPlanIds` (building), `confirmedChangeIds` + * (已确认) and `replayOutcomeByProposalId` (已生效 / 已暂存为草稿 / 未生效). + * The HEADER badge read none of them: it derived from `isProposalResult(result)` + * alone, which is a fact about the tool's *own* output and never changes once + * the tool has returned. So a card whose body said 已搭建 / 已生效 kept a header + * reading "Awaiting Approval" forever — the same class of divergence recorded on + * cloud#787, mirrored. Fixing it in the header's own branch would have made a + * FIFTH producer of the same fact; this function is the one both sides read. + * + * Precedence is "what actually happened" over "what was asked for": a replay + * verdict is the server's own answer and outranks the positional heuristics, + * and `built`/`confirmed` (a later commit was observed in the stream) outrank + * `approved` (the user clicked, nothing has landed yet). + */ +export type ProposalCardState = 'pending' | 'in-progress' | 'settled' | 'failed'; + +export function resolveProposalCardState(input: { + /** A replay verdict for this proposal, when one has arrived. */ + replayOutcome?: { kind: 'published' | 'drafted' | 'failed' | 'applying' }; + /** An `apply_blueprint` ran after this plan card (`builtPlanIds`). */ + built?: boolean; + /** A later same-tool commit followed this proposal (`confirmedChangeIds`). */ + confirmed?: boolean; + /** The user approved, but no result has come back yet (`approvedPlanIds`). */ + approved?: boolean; +}): ProposalCardState { + const kind = input.replayOutcome?.kind; + if (kind === 'failed') return 'failed'; + if (kind === 'published' || kind === 'drafted') return 'settled'; + if (kind === 'applying') return 'in-progress'; + if (input.built || input.confirmed) return 'settled'; + if (input.approved) return 'in-progress'; + return 'pending'; +} + +/** + * The header badge state each {@link ProposalCardState} maps to. Kept as a + * table rather than a chain so the header can never grow a fifth state the + * body does not have. + */ +const PROPOSAL_HEADER_STATE: Record< + ProposalCardState, + NonNullable +> = { + pending: 'approval-requested', + 'in-progress': 'input-available', + settled: 'output-available', + failed: 'output-error', +}; + /** * English display names for the change verbs. Overridable per-consumer via the * `changeVerbLabels` prop — the console passes the translated set. These were @@ -1146,7 +1203,68 @@ function isToolCallPlaceholder(content: string): boolean { return /^\((?:called [^)]*|tool call|no content)\)$/.test(content.trim()); } -function summarizeTools(tools: ChatToolInvocation[]): ToolSummaryGroup[] { +/** + * The "1 object · 1 view · 1 dashboard" strip under a proposed plan / a live + * design panel, localized (objectui#7254). + * + * It was assembled by string concatenation with an English `+ 's'` plural, so + * it read as English inside an otherwise Chinese conversation AND could only + * ever be right for the two-form languages. Each noun is a plural FAMILY (base + * key + `_one`): i18next asks `Intl.PluralRules` for the one suffix the active + * language needs and lands on the base key for every category a pack does not + * enumerate, so `ru` (few/many) and `ar` (two/few/many/zero) stay in their own + * language instead of falling through to English — the rule + * `all-locales-key-parity.test.ts` states for plural families. + * + * Returned as a hook-bound formatter because both call sites live in different + * components (the proposed-plan card body and `BlueprintProgressPanel`), and + * two hand-rolled copies is how they drifted in the first place. + */ +function useMetadataCountBits(): (counts: { + objects?: number; + views?: number; + dashboards?: number; + seedData?: number; +}) => string[] { + const { t } = useObjectTranslation(); + return React.useCallback( + (counts) => { + const bits: string[] = []; + if (counts?.objects) + bits.push( + t('chatbot.plan.countObjects', { + count: counts.objects, + defaultValue: '{{count}} objects', + }), + ); + if (counts?.views) + bits.push( + t('chatbot.plan.countViews', { count: counts.views, defaultValue: '{{count}} views' }), + ); + if (counts?.dashboards) + bits.push( + t('chatbot.plan.countDashboards', { + count: counts.dashboards, + defaultValue: '{{count}} dashboards', + }), + ); + if (counts?.seedData) + bits.push(t('chatbot.plan.countSeedData', { defaultValue: 'sample data' })); + return bits; + }, + [t], + ); +} + +function summarizeTools( + tools: ChatToolInvocation[], + /** + * objectui#7254 — the activity chips name the same tools the detailed cards + * do, so they take the same `chatbot.tool.*` lookup. Optional so a + * provider-less caller keeps the English title-caser it always had. + */ + translateToolTitle?: ToolTitleTranslator, +): ToolSummaryGroup[] { const groups = new Map(); for (const tool of tools) { @@ -1160,7 +1278,7 @@ function summarizeTools(tools: ChatToolInvocation[]): ToolSummaryGroup[] { } groups.set(key, { key, - title: humanizeToolName(tool.toolName) || tool.toolName, + title: humanizeToolName(tool.toolName, translateToolTitle) || tool.toolName, rawName: tool.toolName, count: 1, state, @@ -1341,7 +1459,21 @@ const ChatbotEnhanced = React.forwardRef( // re-render while the error sits in state). const restoredErrorRef = React.useRef(null); - // Resolve localizable strings once, English defaults preserved. + // The localized "N objects · N views · N dashboards" strip, shared with + // `BlueprintProgressPanel` so the plan card and the live design panel can + // not word the same counts differently (objectui#7254). + const countBitsOf = useMetadataCountBits(); + + // objectui#7254 — the pack lookup for every string this component owns. + // `useSafeTranslate` is provider-safe: with no I18nProvider (tests, + // standalone hosts) each call returns the English fallback passed beside + // it, so nothing below can render a raw key. + const tt = useSafeTranslate(); + + // Resolve localizable strings once. Precedence is host `labels` prop -> + // locale pack -> the English default this component always shipped, so a + // console that already translates a string keeps winning and a host that + // passes nothing now gets the user's language instead of English. const L = React.useMemo( () => ({ emptyTitle: labels?.emptyTitle ?? 'Start a conversation', @@ -1350,11 +1482,20 @@ const ChatbotEnhanced = React.forwardRef( 'Ask anything — the assistant has access to your current app context.', clear: labels?.clear ?? 'Clear', sendHint: labels?.sendHint ?? 'to send', - agentActivity: labels?.agentActivity ?? 'Agent activity', - toolCompleted: labels?.toolCompleted ?? 'Completed', - toolRunning: labels?.toolRunning ?? 'Running', - toolAwaitingApproval: labels?.toolAwaitingApproval ?? 'Awaiting approval', - toolFailed: labels?.toolFailed ?? 'Failed', + agentActivity: labels?.agentActivity ?? tt('chatbot.toolState.agentActivity', 'Agent activity'), + toolCompleted: labels?.toolCompleted ?? tt('chatbot.toolState.completed', 'Completed'), + toolRunning: labels?.toolRunning ?? tt('chatbot.toolState.running', 'Running'), + toolAwaitingApproval: + labels?.toolAwaitingApproval ?? tt('chatbot.toolState.awaitingApproval', 'Awaiting approval'), + toolFailed: labels?.toolFailed ?? tt('chatbot.toolState.failed', 'Failed'), + // The vendored `ToolHeader` badge's own vocabulary (objectui#7254). It + // carried a private English table, so a fully Chinese conversation + // still read "Awaiting Approval" / "Completed" on every card header + // while the summary chips beside it were translated. + toolPending: tt('chatbot.toolState.pending', 'Pending'), + toolResponded: tt('chatbot.toolState.responded', 'Responded'), + toolDenied: tt('chatbot.toolState.denied', 'Denied'), + toolError: tt('chatbot.toolState.error', 'Error'), toolDetailsHidden: labels?.toolDetailsHidden ?? 'Detailed tool inputs and outputs are hidden in this view.', @@ -1381,7 +1522,23 @@ const ChatbotEnhanced = React.forwardRef( // to deliberately disable the rotation while keeping the lead-in label. designingPlanHints: labels?.designingPlanHints ?? DEFAULT_DESIGNING_PLAN_HINTS, }), - [labels], + [labels, tt], + ); + + // The vendored `ToolHeader`'s badge vocabulary, keyed by the AI-SDK state + // it renders. Same strings as the activity chips read from `L`, so the two + // surfaces can no longer disagree about what "Completed" is called. + const toolStatusLabels: ToolStatusLabels = React.useMemo( + () => ({ + 'input-streaming': L.toolPending, + 'input-available': L.toolRunning, + 'approval-requested': L.toolAwaitingApproval, + 'approval-responded': L.toolResponded, + 'output-available': L.toolCompleted, + 'output-error': L.toolError, + 'output-denied': L.toolDenied, + }), + [L], ); // Draft tool calls this chat has published (auto or via the manual button), @@ -1876,7 +2033,13 @@ const ChatbotEnhanced = React.forwardRef( state === 'approval-requested' && Boolean(onToolApprove) && !decision; const hidePendingPayload = state === 'approval-requested' && Boolean(tool.pendingActionId); - const friendlyTitle = humanizeToolName(tool.toolName); + // objectui#7254 / cloud#1658 — `humanizeToolName`'s translator seam has + // existed since the tool titles were found untranslatable, but no call + // site ever passed one, so every card header read English + // ("Propose blueprint", "Apply edit", "Verify build") inside an otherwise + // Chinese conversation. Unknown/custom tools still fall back to the same + // English title-caser. + const friendlyTitle = humanizeToolName(tool.toolName, tt); const renderableResult = unwrapToolResult(tool.result); const showRawName = processVisibility === 'debug' && @@ -1912,9 +2075,20 @@ const ChatbotEnhanced = React.forwardRef( // "Awaiting Approval", not "Completed": nothing was applied, it's waiting // for the user. Only the header badge is remapped — the local `state` // (which gates payload display / HITL) is untouched. + // + // objectui#7254 — "waiting for the user" is only true until the user acts. + // The header used to stop reading here, so a card whose BODY had already + // collapsed to 已搭建 / 已生效 / 未生效 kept saying "Awaiting Approval". + // `resolveProposalCardState` is now the one producer both sides read. + const proposalCardState = resolveProposalCardState({ + replayOutcome: replayOutcomeByProposalId.get(tool.toolCallId), + built: builtPlanIds.has(tool.toolCallId), + confirmed: confirmedChangeIds.has(tool.toolCallId), + approved: approvedPlanIds.has(tool.toolCallId), + }); const headerState = state === 'output-available' && isProposalResult(tool.result) - ? ('approval-requested' as typeof state) + ? PROPOSAL_HEADER_STATE[proposalCardState] : state; const titleNode = ( @@ -1958,7 +2132,12 @@ const ChatbotEnhanced = React.forwardRef( isUnstructuredBuildProposal(tool) } > - + {showPayload && tool.args !== undefined ? ( @@ -2293,13 +2472,7 @@ const ChatbotEnhanced = React.forwardRef( ) : null} {(() => { - const c = tool.proposedPlan!.counts; - const bits: string[] = []; - if (c.objects) bits.push(`${c.objects} object${c.objects === 1 ? '' : 's'}`); - if (c.views) bits.push(`${c.views} view${c.views === 1 ? '' : 's'}`); - if (c.dashboards) - bits.push(`${c.dashboards} dashboard${c.dashboards === 1 ? '' : 's'}`); - if (c.seedData) bits.push('sample data'); + const bits = countBitsOf(tool.proposedPlan!.counts); return bits.length ? ( {bits.join(' · ')} ) : null; @@ -2890,7 +3063,7 @@ const ChatbotEnhanced = React.forwardRef( ) : null} {!isUser && processVisibility === 'summary' && summaryTools.length > 0 ? ( ) : null} @@ -3646,6 +3819,7 @@ function BlueprintProgressPanel({ offlineLabel?: string; }) { const { phase, summary, appLabel, targetApp, objects, counts, seq } = progress; + const countBitsOf = useMetadataCountBits(); const isDone = phase === 'done'; // Real activity key, mirroring BuildProgressPanel: prefer the server's // monotonic `seq` (it also advances on keep-alive heartbeats, where the @@ -3657,12 +3831,7 @@ function BlueprintProgressPanel({ // designing, the localized "Designing your app…" lead-in pairs with the // summary shown on its own line below. const headerText = isDone ? summary || appLabel || designingLabel : designingLabel; - const countBits: string[] = []; - if (counts?.objects) - countBits.push(`${counts.objects} object${counts.objects === 1 ? '' : 's'}`); - if (counts?.views) countBits.push(`${counts.views} view${counts.views === 1 ? '' : 's'}`); - if (counts?.dashboards) - countBits.push(`${counts.dashboards} dashboard${counts.dashboards === 1 ? '' : 's'}`); + const countBits = countBitsOf(counts ?? {}); return (
diff --git a/packages/plugin-chatbot/src/__tests__/toolCardHeaderState-7254.test.tsx b/packages/plugin-chatbot/src/__tests__/toolCardHeaderState-7254.test.tsx new file mode 100644 index 0000000000..01940ce7f9 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/toolCardHeaderState-7254.test.tsx @@ -0,0 +1,148 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#7254 — the tool card's HEADER badge and its BODY badge must be one + * fact. + * + * The header derived "Awaiting Approval" from `isProposalResult(tool.result)` + * alone — a property of the tool's own output, which never changes once the + * tool has returned. Meanwhile the body collapsed to 已生效 / 已搭建 / 未生效 + * from four other memos the header did not read. So a card the user had + * already confirmed, and whose body said so, kept a header telling them it was + * still waiting for them. Same class as the divergence recorded on cloud#787, + * mirrored. + * + * These pins drive the real message stream (proposal turn + replay turn) and + * assert BOTH badges together — asserting either alone is what let them drift. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { + ChatbotEnhanced, + resolveProposalCardState, + type ChatMessage, +} from '../ChatbotEnhanced'; + +/** A granular edit that RETURNED a confirm-gate preview. */ +function proposalMessage(): ChatMessage { + return { + id: 'a1', + role: 'assistant', + content: '', + toolInvocations: [ + { + toolCallId: 't1', + toolName: 'apply_edit', + state: 'output-available', + // The header reads the RESULT, so the pin has to carry the real + // envelope — `proposedChanges` alone never reached that branch. + result: { status: 'changes_proposed', changes: [{ verb: 'add_field' }] }, + proposedChanges: { + changes: [{ verb: 'add_field', object: 'task', field: 'priority' }], + }, + }, + ], + }; +} + +function replayMessage( + outcome: NonNullable[number]['replayOutcome']>, +): ChatMessage { + return { + id: 'a2', + role: 'assistant', + content: '', + toolInvocations: [ + { + toolCallId: 'replay_turn_0', + toolName: 'apply_edit', + state: 'output-available', + result: '{}', + replayOutcome: outcome, + }, + ], + }; +} + +/** The header badge text of the (single) tool card on screen. */ +function headerBadgeText(): string { + const trigger = document.querySelector('[data-state]')!; + return trigger.textContent ?? ''; +} + +describe('resolveProposalCardState — one producer for both badges', () => { + it('nothing has happened yet → pending', () => { + expect(resolveProposalCardState({})).toBe('pending'); + }); + + it('a replay verdict outranks the positional heuristics', () => { + expect(resolveProposalCardState({ replayOutcome: { kind: 'published' } })).toBe('settled'); + expect(resolveProposalCardState({ replayOutcome: { kind: 'drafted' } })).toBe('settled'); + expect(resolveProposalCardState({ replayOutcome: { kind: 'applying' } })).toBe('in-progress'); + // A failure with `confirmed` also true stays failed — the user DID confirm, + // and the card must not report the confirmation as the outcome. + expect( + resolveProposalCardState({ replayOutcome: { kind: 'failed' }, confirmed: true }), + ).toBe('failed'); + }); + + it('an observed later commit settles the card; a bare approval only starts it', () => { + expect(resolveProposalCardState({ built: true })).toBe('settled'); + expect(resolveProposalCardState({ confirmed: true })).toBe('settled'); + expect(resolveProposalCardState({ approved: true })).toBe('in-progress'); + // "Built" is an observation of the stream, "approved" is a click — the + // observation wins when both are set. + expect(resolveProposalCardState({ approved: true, built: true })).toBe('settled'); + }); +}); + +describe('ChatbotEnhanced — the header badge follows the card body (objectui#7254)', () => { + it('an unanswered proposal still reads as awaiting on both surfaces', () => { + render(); + expect(headerBadgeText()).toContain('Awaiting approval'); + // The body is still offering the confirm button — nothing has been applied. + expect(screen.getByTestId('proposed-changes-confirm')).toBeInTheDocument(); + }); + + it('a published replay flips the header to Completed, matching the Applied body badge', () => { + render( + , + ); + expect(screen.getByTestId('proposed-changes-applied')).toBeInTheDocument(); + const header = headerBadgeText(); + expect(header).toContain('Completed'); + expect(header).not.toContain('Awaiting'); + }); + + it('a drafted replay is settled too — the change left the proposal state', () => { + render( + , + ); + expect(screen.getByTestId('proposed-changes-drafted')).toBeInTheDocument(); + expect(headerBadgeText()).not.toContain('Awaiting'); + }); + + it('a failed replay reads as an error, not as "still waiting for you"', () => { + render( + , + ); + expect(screen.getByTestId('proposed-changes-failed')).toBeInTheDocument(); + const header = headerBadgeText(); + expect(header).toContain('Error'); + expect(header).not.toContain('Awaiting'); + }); +}); diff --git a/packages/plugin-chatbot/src/__tests__/toolCardI18n-7254.test.tsx b/packages/plugin-chatbot/src/__tests__/toolCardI18n-7254.test.tsx new file mode 100644 index 0000000000..7fe165cd0f --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/toolCardI18n-7254.test.tsx @@ -0,0 +1,103 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#7254 — the copilot's tool cards, in the user's language. + * + * `humanizeToolName` grew a translator seam when the tool titles were found + * untranslatable (cloud#1658), and then nothing used it: no locale pack + * carried a `chatbot.tool.*` key and no call site passed a translator, so a + * fully Chinese conversation still read "Propose blueprint · Awaiting + * Approval". A dormant mechanism reads exactly like a working one from the + * code, which is why this pin drives the RENDERED card through a real + * `I18nProvider` rather than asserting the helper in isolation + * (`tool-display-i18n.test.ts` already owns the helper). + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ChatbotEnhanced, type ChatMessage } from '../ChatbotEnhanced'; + +function renderZh(ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +const proposal: ChatMessage = { + id: 'a1', + role: 'assistant', + content: '', + toolInvocations: [ + { + toolCallId: 't1', + toolName: 'apply_edit', + state: 'output-available', + result: { status: 'changes_proposed', changes: [{ verb: 'add_field' }] }, + proposedChanges: { changes: [{ verb: 'add_field', object: 'task', field: 'priority' }] }, + }, + ], +}; + +const plan: ChatMessage = { + id: 'a2', + role: 'assistant', + content: '', + toolInvocations: [ + { + toolCallId: 't2', + toolName: 'propose_blueprint', + state: 'output-available', + result: { status: 'blueprint_proposed', blueprint: {} }, + proposedPlan: { + summary: '', + objects: [], + questions: [], + assumptions: [], + counts: { objects: 1, views: 1, dashboards: 1, seedData: 0 }, + }, + }, + ], +}; + +describe('tool cards under a zh console (objectui#7254)', () => { + it('names the tool in Chinese instead of title-casing its internal name', () => { + renderZh(); + expect(screen.getByText('应用修改')).toBeInTheDocument(); + expect(screen.queryByText('Apply edit')).not.toBeInTheDocument(); + }); + + it('localizes the header status badge, which carried its own English table', () => { + renderZh(); + expect(screen.getByText('待确认')).toBeInTheDocument(); + expect(screen.queryByText(/Awaiting/i)).not.toBeInTheDocument(); + }); + + it('localizes the plan count strip, which was concatenated with an English "+ s" plural', () => { + renderZh(); + expect(screen.getByText('设计应用方案')).toBeInTheDocument(); + expect(screen.getByText('1 个对象 · 1 个视图 · 1 个仪表板')).toBeInTheDocument(); + }); + + it('an unknown / third-party tool still degrades to the English title-caser', () => { + renderZh( + , + ); + expect(screen.getByText(/Forecast revenue/)).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-chatbot/src/elements/tool.tsx b/packages/plugin-chatbot/src/elements/tool.tsx index 8a305a1516..834328976f 100644 --- a/packages/plugin-chatbot/src/elements/tool.tsx +++ b/packages/plugin-chatbot/src/elements/tool.tsx @@ -35,14 +35,30 @@ export const Tool = ({ className, ...props }: ToolProps) => ( /> ); +/** + * Localized replacements for the status-badge vocabulary below. + * + * ObjectUI addition to the vendored element (objectui#7254), in the same spirit + * as the `PromptInputFileItem.file` extension: the upstream table is English + * literals, and this component renders inside a console whose every other + * string is translated — a Chinese conversation showed "Awaiting Approval" on + * each card header. Additive and optional: an entry the caller omits keeps the + * upstream English word verbatim, so no existing consumer changes. + */ +export type ToolStatusLabels = Partial>; + export type ToolHeaderProps = { title?: ReactNode; type: ToolUIPart["type"]; state: ToolUIPart["state"]; className?: string; + statusLabels?: ToolStatusLabels; }; -const getStatusBadge = (status: ToolUIPart["state"]) => { +const getStatusBadge = ( + status: ToolUIPart["state"], + statusLabels?: ToolStatusLabels, +) => { const labels: Record = { "input-streaming": "Pending", "input-available": "Running", @@ -68,7 +84,7 @@ const getStatusBadge = (status: ToolUIPart["state"]) => { return ( {icons[status]} - {labels[status]} + {statusLabels?.[status] ?? labels[status]} ); }; @@ -78,6 +94,7 @@ export const ToolHeader = ({ title, type, state, + statusLabels, ...props }: ToolHeaderProps) => ( {title ?? type.split("-").slice(1).join("-")} - {getStatusBadge(state)} + {getStatusBadge(state, statusLabels)}