From 6129b3b0c95200a9979d94828119b938b5b8a7fa Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:39:57 +0800 Subject: [PATCH] fix(ui): v1-honest start chips, assistant panel scroll, toast lifetime, and the remaining built-in aggregate captions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cloud#1984: reword the five maker start chips in all ten packs (and the call-site defaultValue fallbacks) so they ask only for what ADR-0112 v1 builds; note beside the keys to revert when v2 re-adds flows. - objectui#7481: add step labels for get_authoring_rules, load_tools, open_record, test_flow and toggle_flow, which are newer than the pinned spec's platform-tool registry. - objectui#7480: re-arm StickToBottom on every send path so the assistant rail follows the thread; message appends deliberately do not. - objectui#7482: drop the console's bottom-right toaster override — the corner belongs to the ChatDock composer, where a resting pointer also pauses sonner's dismiss timer indefinitely. - objectui#7534: resolve builtinAggregate through buildDatasetFieldHelpers at the five headerLabel call sites, so a KPI/table/pivot/preview caption reads what the chart legend beside it reads. - objectui#7476: gate the sys_activity read on the object registry the shell already loads; every uncertainty still reads. --- .../1984-v1-honest-chips-and-ui-fragments.md | 72 ++++++++ apps/console/src/App.tsx | 14 +- .../ConsoleToaster.autoDismiss-7482.test.tsx | 108 ++++++++++++ .../app-shell/src/chrome/ConsoleToaster.tsx | 20 ++- .../consoleToasterAnchor.ratchet-7482.test.ts | 66 +++++++ .../app-shell/src/console/ai/AiChatPage.tsx | 20 ++- .../AiChatPage.startChips-1984.test.ts | 67 +++++++ ...sharedUserFeeds.activityGate-7476.test.tsx | 166 ++++++++++++++++++ packages/app-shell/src/hooks/index.ts | 7 + .../app-shell/src/hooks/sharedUserFeeds.ts | 37 +++- .../app-shell/src/hooks/useObjectPresence.ts | 103 +++++++++++ .../previews/DatasetPreview.tsx | 17 +- ...taset-format.builtinAggregate-7534.test.ts | 121 +++++++++++++ packages/core/src/utils/dataset-format.ts | 23 ++- .../makerStartChips-v1-scope-1984.test.ts | 145 +++++++++++++++ packages/i18n/src/locales/ar.ts | 31 +++- packages/i18n/src/locales/de.ts | 31 +++- packages/i18n/src/locales/en.ts | 31 +++- packages/i18n/src/locales/es.ts | 31 +++- packages/i18n/src/locales/fr.ts | 31 +++- packages/i18n/src/locales/ja.ts | 31 +++- packages/i18n/src/locales/ko.ts | 31 +++- packages/i18n/src/locales/pt.ts | 31 +++- packages/i18n/src/locales/ru.ts | 31 +++- packages/i18n/src/locales/zh.ts | 31 +++- .../plugin-chatbot/src/ChatbotEnhanced.tsx | 47 ++++- ...ChatbotEnhanced.followOnSend-7480.test.tsx | 136 ++++++++++++++ .../toolLabels-locale-parity-7481.test.ts | 127 ++++++++++++++ .../plugin-dashboard/src/DatasetWidget.tsx | 12 +- ...idget.builtinAggregateHeader-7534.test.tsx | 108 ++++++++++++ .../src/DatasetReportRenderer.tsx | 33 +++- 31 files changed, 1689 insertions(+), 70 deletions(-) create mode 100644 .changeset/1984-v1-honest-chips-and-ui-fragments.md create mode 100644 packages/app-shell/src/chrome/ConsoleToaster.autoDismiss-7482.test.tsx create mode 100644 packages/app-shell/src/chrome/consoleToasterAnchor.ratchet-7482.test.ts create mode 100644 packages/app-shell/src/console/ai/__tests__/AiChatPage.startChips-1984.test.ts create mode 100644 packages/app-shell/src/hooks/__tests__/sharedUserFeeds.activityGate-7476.test.tsx create mode 100644 packages/app-shell/src/hooks/useObjectPresence.ts create mode 100644 packages/core/src/utils/__tests__/dataset-format.builtinAggregate-7534.test.ts create mode 100644 packages/i18n/src/__tests__/makerStartChips-v1-scope-1984.test.ts create mode 100644 packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.followOnSend-7480.test.tsx create mode 100644 packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts create mode 100644 packages/plugin-dashboard/src/__tests__/DatasetWidget.builtinAggregateHeader-7534.test.tsx diff --git a/.changeset/1984-v1-honest-chips-and-ui-fragments.md b/.changeset/1984-v1-honest-chips-and-ui-fragments.md new file mode 100644 index 0000000000..e0266c4eaa --- /dev/null +++ b/.changeset/1984-v1-honest-chips-and-ui-fragments.md @@ -0,0 +1,72 @@ +--- +'@object-ui/i18n': minor +'@object-ui/core': minor +'@object-ui/app-shell': minor +'@object-ui/plugin-chatbot': minor +'@object-ui/plugin-dashboard': minor +'@object-ui/plugin-report': minor +'@object-ui/console': minor +--- + +Six user-visible fixes across the maker surface, the assistant rail and the +dataset captions. + +**The maker's start chips now promise only what ADR-0112 v1 builds +(cloud#1984).** Two of the five asked for automation the first version has no +flows or actions for — the ticket chip said 「状态流转」, the inventory chip said +「低库存预警」 — and the measured behaviour was not a refusal but a silent +degrade: a status kanban and a low-stock view. The chip promised an alert and +delivered a page. All five are reworded in all ten packs (and in the call-site +`defaultValue` fallbacks, which are a second copy of the same strings) to ask +for objects, fields, views, pages, dashboards and sample data, keeping each a +real business scenario — the ticket chip now asks for a status field and a board +grouped by it, the inventory chip for a view that filters below the reorder +point. A note beside the keys says to revert when v2 re-adds flows. + +**Five newer AI tools get their step labels (objectui#7481).** A zh conversation +read `✓ Get authoring rules 已完成` between 「读取元数据结构」 and 「列出对象」: +`get_authoring_rules` (cloud#1837), plus `load_tools`, `open_record`, +`test_flow` and `toggle_flow`, are registered by the cloud AI runtime but are +newer than the pinned spec's tool registry, so they had no `chatbot.tool.*` +entry in any pack and fell through to the English title-caser. + +**The assistant rail follows the thread when you send (objectui#7480).** The +rail and the full-page maker are the same component; what differs is width. A +reply that still ends on screen in the wide column runs two or three times +taller in a ~360px rail, so `StickToBottom`'s lock is escaped by the time the +user types and the new bubble, the tool steps and the streaming answer all land +below the fold. Every send path now re-arms the lock — including the plan-card +"Build it" and 确认修改 approvals, whose own code comments already named this +miss. Message APPENDS deliberately do not, so a user reading back through the +thread mid-answer is never yanked to the bottom. + +**Console toasts move off the assistant composer (objectui#7482).** 「客户更新 +成功」 sat on the ChatDock composer's send button and stayed there. One defect, +two symptoms: `apps/console` pinned the toaster to `bottom-right` — an override +that predates ADR-0057 P3a — so a toast both covered the button and, because +sonner pauses a toast's dismiss timer while the pointer is inside the toaster +region, never got to run its 4s timer with a pointer resting on the composer +underneath. The override is gone; the console takes `ConsoleToaster`'s own +documented top-right anchor, and the 4s success duration is now pinned. + +**Built-in aggregate captions follow the locale everywhere (objectui#7534).** +objectui#7258 taught `buildChartSeries()` to resolve a server-minted default +measure through the locale map, so a chart legend read `计数` while the table +beneath it, the KPI caption, the pivot header and the dataset preview still +printed the server's hard-coded English `Count`. `buildDatasetFieldHelpers()` +takes the same optional `builtinAggregateLabels`, resolving through the one +`resolveMeasureLabel` order, and the five call sites pass it. Omitting the +argument reproduces the previous output byte for byte, and an author-declared +measure still keeps its own label verbatim (objectui#4106). + +**The activity feed stops asking for an object the environment does not have +(objectui#7476).** A tenant environment has no `sys_activity`, so every page +load issued a request that 404'd. Everything downstream was already correct — +the adapter memoizes the missing collection, its logger demotes the failure, the +feed retires as an ANSWER and the panel renders its earned empty state — so what +is left is the request itself, and `data-objectstack` states the rule for it: +the cure for a doomed request is not issuing it. New `useObjectPresence` reads +the object registry the shell loads for the nav anyway; only a registry that has +ANSWERED and lists other objects without this one skips the read. Every +uncertainty — no provider, empty registry, still loading, errored — reads as +before, because a wrong skip would cost a real deployment its feed. diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index e7cb02e2f5..a1634a870f 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -148,7 +148,19 @@ function BrandingSync() { export function App() { return ( - + {/* objectui#7482 — no `position` override: the console takes + `ConsoleToaster`'s own documented top-right anchor. + + `bottom-right` predates the ChatDock (ADR-0057 P3a) and the FAB that + launches it, and the bottom-right corner now belongs to them: a + success toast landed exactly on the assistant composer's send button. + Two things went wrong, not one. It COVERED the button — and because + sonner pauses a toast's dismiss timer while the pointer is over the + toaster region (`expanded || interacting || isDocumentHidden`), a + pointer resting on the composer under it kept 「客户更新成功」 on + screen indefinitely, so the only way out was the × . The toaster's + 4s default was never wrong; it just never got to run. */} + diff --git a/packages/app-shell/src/chrome/ConsoleToaster.autoDismiss-7482.test.tsx b/packages/app-shell/src/chrome/ConsoleToaster.autoDismiss-7482.test.tsx new file mode 100644 index 0000000000..871d6df557 --- /dev/null +++ b/packages/app-shell/src/chrome/ConsoleToaster.autoDismiss-7482.test.tsx @@ -0,0 +1,108 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7482 — 「客户更新成功」 was still on screen 90 seconds after a record + * save, sitting exactly on the assistant rail's send button; only the × closed + * it. + * + * Two facts, and the second explains the first. The toaster has always carried + * a 4s default and `crud_success` passes no duration of its own, so nothing + * about the DURATION was wrong. What was wrong is where the toaster was + * anchored: `apps/console` overrode it to `bottom-right`, the corner ADR-0057 + * later gave to the ChatDock composer and its FAB. Sonner pauses a toast's + * dismiss timer whenever the pointer is inside the toaster region — + * + * if (expanded || interacting || isDocumentHidden) pauseTimer(); + * else startTimer(); (sonner 2.0.8, Toast effect) + * + * — and `expanded` is set by the region's own `onMouseEnter`/`onMouseMove`. A + * pointer resting on the composer underneath therefore held the timer at zero + * for as long as it stayed there. Covering the button and never dismissing were + * one defect, not two. + * + * So this file pins the two properties the fix rests on: the default anchor is + * the top-right corner (nothing interactive lives there), and a success toast + * really does dismiss itself inside the 3–5s band the card asked for. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, cleanup, waitFor } from '@testing-library/react'; +import { toast } from 'sonner'; +import { ConsoleToaster } from './ConsoleToaster.js'; +import { ThemeProvider } from './ThemeProvider.js'; + +const renderToaster = (props: Record = {}) => + render( + + + , + ); + +afterEach(() => { + cleanup(); +}); + +/** Sonner only mounts the positioned `
    ` once there is a toast in it. */ +async function anchorOf(props: Record = {}): Promise<[string | null, string | null]> { + renderToaster(props); + act(() => { + toast.success('anchor probe'); + }); + const region = await waitFor(() => { + const el = document.querySelector('[data-sonner-toaster]'); + expect(el, 'sonner did not mount its toaster region').toBeTruthy(); + return el as HTMLElement; + }); + return [region.getAttribute('data-y-position'), region.getAttribute('data-x-position')]; +} + +describe('ConsoleToaster anchor (objectui#7482)', () => { + it('defaults to the top-right corner, away from the assistant composer', async () => { + expect(await anchorOf()).toEqual(['top', 'right']); + }); + + it('is still overridable — the default is a default, not a lock', async () => { + // The component's contract has always been "spread `{...props}` wins". + // Losing that would be a different regression from the one above. + expect(await anchorOf({ position: 'bottom-left' })).toEqual(['bottom', 'left']); + }); +}); + +describe('ConsoleToaster success toasts dismiss themselves (objectui#7482)', () => { + beforeEach(() => { + // Real timers advance sonner's own `setTimeout`; fake ones let this run in + // milliseconds instead of seconds. + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('the card`s own toast is gone within the 3–5s band', async () => { + renderToaster(); + act(() => { + toast.success('客户更新成功'); + }); + expect(await screen.findByText('客户更新成功')).toBeInTheDocument(); + + // Still there just before the band closes… + act(() => { + vi.advanceTimersByTime(2_500); + }); + expect(screen.queryByText('客户更新成功')).toBeInTheDocument(); + + // …and gone after it (plus sonner's unmount delay). + act(() => { + vi.advanceTimersByTime(3_000); + }); + await waitFor(() => expect(screen.queryByText('客户更新成功')).not.toBeInTheDocument()); + }); +}); diff --git a/packages/app-shell/src/chrome/ConsoleToaster.tsx b/packages/app-shell/src/chrome/ConsoleToaster.tsx index e58ee2f13b..c269dfc226 100644 --- a/packages/app-shell/src/chrome/ConsoleToaster.tsx +++ b/packages/app-shell/src/chrome/ConsoleToaster.tsx @@ -24,6 +24,15 @@ export function ConsoleToaster(props: ToasterProps) { // UX defaults chosen for an enterprise console — match the Linear / // Notion pattern users expect. Callers can still override any of // these via the spread `{...props}` below. + // + // objectui#7482 — `top-right` is load-bearing, not cosmetic. The + // bottom-right corner belongs to the ChatDock's composer and the FAB + // that launches it (ADR-0057 P3a/P3b), and a toaster anchored there does + // more than overlap them: sonner pauses a toast's dismiss timer while + // the pointer is inside the toaster region (`expanded || interacting || + // isDocumentHidden` in its Toast effect), so a pointer resting on the + // composer underneath keeps the toast on screen until the user clicks ×. + // Override the position only onto a corner nothing interactive occupies. position="top-right" closeButton richColors @@ -39,7 +48,16 @@ export function ConsoleToaster(props: ToasterProps) { }} toastOptions={{ // 4s default keeps actionable toasts visible long enough to - // click an Undo button without feeling sticky. + // click an Undo button without feeling sticky. objectui#7482 asked for + // 3–5s on success toasts and this already sits in that band; it is + // pinned in `__tests__/ConsoleToaster.autoDismiss-7482` because + // nothing checked it, and a success toast that outlives its own + // information is what that card was reported as. + // + // NOT split per intent ("errors may persist"): sonner has no per-type + // duration on `Toaster`, so the only way to say it is a `duration` at + // each of the ~100 `toast.error(...)` call sites. `closeButton` below + // already gives every toast a manual exit. duration: 4000, classNames: { toast: diff --git a/packages/app-shell/src/chrome/consoleToasterAnchor.ratchet-7482.test.ts b/packages/app-shell/src/chrome/consoleToasterAnchor.ratchet-7482.test.ts new file mode 100644 index 0000000000..3f33274b25 --- /dev/null +++ b/packages/app-shell/src/chrome/consoleToasterAnchor.ratchet-7482.test.ts @@ -0,0 +1,66 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7482 ratchet — no console may re-anchor the toaster onto the corner + * the assistant lives in. + * + * `ConsoleToaster.autoDismiss-7482` next door pins the COMPONENT: its default + * is top-right, and a success toast dismisses itself inside the 3–5s band the + * card asked for. Neither assertion can see the MOUNT, and the mount is where + * the defect actually was — `apps/console/src/App.tsx` carried a + * `position="bottom-right"` override that predates ADR-0057 P3a, which dropped + * every toast onto the ChatDock composer's send button. That both covered the + * button and, because sonner pauses a toast's dismiss timer while the pointer + * is inside the toaster region (`expanded || interacting || isDocumentHidden`), + * stopped the 4s default from ever running. One defect, two symptoms. + * + * Read from SOURCE rather than by rendering ``: answering a question about + * one prop should not need the router, the auth provider and the whole console + * graph. Same shape as `providers/expressionUser.mountSites.ratchet`. + * + * If this fails: the toaster may be moved, but only onto a corner nothing + * interactive occupies. Bottom-right is the FAB and the assistant composer. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '../../../..'); + +/** Every `` element in a file, as written. */ +const MOUNT_RE = /]*\/?>/g; + +/** The consoles that mount the toaster. One today; the list is the point. */ +const MOUNT_FILES = ['apps/console/src/App.tsx'] as const; + +describe('console toaster anchor (objectui#7482)', () => { + it.each(MOUNT_FILES)('%s mounts exactly one ConsoleToaster', (rel) => { + const source = readFileSync(path.join(repoRoot, rel), 'utf8'); + expect(source.match(MOUNT_RE) ?? []).toHaveLength(1); + }); + + it.each(MOUNT_FILES)('%s passes no `position` — the component owns the anchor', (rel) => { + const source = readFileSync(path.join(repoRoot, rel), 'utf8'); + const mount = (source.match(MOUNT_RE) ?? [])[0]; + expect(mount, 'the assistant composer and the FAB own bottom-right').not.toMatch(/bottom-right/); + expect(mount).not.toMatch(/\bposition\s*=/); + }); + + it('the control: the matcher WOULD have caught the override it replaced', () => { + // Non-vacuity — a matcher that has silently stopped matching passes both + // assertions above while checking nothing. + const retired = ' '; + const found = retired.match(MOUNT_RE) ?? []; + expect(found).toHaveLength(1); + expect(found[0]).toMatch(/bottom-right/); + expect(found[0]).toMatch(/\bposition\s*=/); + }); +}); diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index d795e13962..dcb7c1b203 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -2581,12 +2581,22 @@ function metadataAssistantSuggestions(t: TranslationFn): string[] { // Creation-first starters: the authoring agent's job is to BUILD from a // natural-language description (the magic moment), so the empty-state nudges // toward "describe a system" rather than inspecting existing schema. + // + // cloud#1984 — these are the PRODUCT's own recommendations, so they may only + // ask for what ADR-0112 v1 builds: objects, fields, views (grid / kanban / + // calendar / gallery), pages, dashboards and sample data. No wording that + // promises autonomous behaviour (alert / remind / notify / automate / status + // workflow): v1 has no flows, actions or schedules, and the measured + // behaviour is that the model silently DEGRADES such a request into a board + // or a filtered view — so the chip promises an alert and delivers a page. + // REVERT to the automation wording when ADR-0112 v2 re-adds flows and + // actions. The `defaultValue`s below are byte-equal to the `en` pack. return [ - t('console.ai.suggestions.metadataAssistant.buildCrm', { defaultValue: 'Build a sales CRM — customers, contacts, and a deal pipeline I can total by stage.' }), - t('console.ai.suggestions.metadataAssistant.buildApp', { defaultValue: 'Create a project tracker — projects, tasks with owners and due dates, and a board by status.' }), - t('console.ai.suggestions.metadataAssistant.buildFlow', { defaultValue: 'Design a support desk — tickets with priority, a status workflow, and customer links.' }), - t('console.ai.suggestions.metadataAssistant.buildInventory', { defaultValue: 'Build an inventory app — products, stock levels, suppliers, and low-stock visibility.' }), - t('console.ai.suggestions.metadataAssistant.buildRecruiting', { defaultValue: 'Make an applicant tracker — candidates, open roles, interview stages, and notes.' }), + t('console.ai.suggestions.metadataAssistant.buildCrm', { defaultValue: 'Build a sales CRM — customers, contacts, and deals with a stage field, plus a dashboard that totals deal value by stage.' }), + t('console.ai.suggestions.metadataAssistant.buildApp', { defaultValue: 'Create a project tracker — projects, tasks with owners and due dates, a board grouped by status, and a calendar of due dates.' }), + t('console.ai.suggestions.metadataAssistant.buildFlow', { defaultValue: 'Design a support desk — tickets with priority and status fields, a board grouped by status, and links to customers.' }), + t('console.ai.suggestions.metadataAssistant.buildInventory', { defaultValue: 'Build an inventory app — products, stock levels, suppliers, and a view that filters the items below their reorder point.' }), + t('console.ai.suggestions.metadataAssistant.buildRecruiting', { defaultValue: 'Make an applicant tracker — candidates, open roles, an interview-stage field, and a board grouped by stage.' }), ]; } diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.startChips-1984.test.ts b/packages/app-shell/src/console/ai/__tests__/AiChatPage.startChips-1984.test.ts new file mode 100644 index 0000000000..bc557443ed --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.startChips-1984.test.ts @@ -0,0 +1,67 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * cloud#1984 — the CALL-SITE half of the maker start chips. + * + * `packages/i18n/src/__tests__/makerStartChips-v1-scope-1984.test.ts` guards + * the ten packs. This guards the other copy of the same five strings: the + * `defaultValue` fallbacks in `metadataAssistantSuggestions()`, which are what + * a host with no I18nProvider (and any locale that ever loses the key) + * actually renders. Two copies of one string is exactly where a scope fix gets + * applied to one of them — the packs were reworded for ADR-0112 v1 and the + * fallbacks would have kept promising a status workflow and low-stock + * visibility, invisibly, on precisely the surface with the least i18n. + * + * So: byte-equality with the `en` pack, and the same automation-vocabulary ban. + */ +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '@object-ui/i18n'; +import { buildAgentSuggestions } from '../AiChatPage.js'; + +/** A `t` that misses every key — i.e. what a provider-less host resolves. */ +const missEverything = (_key: string, options?: Record): string => + String(options?.defaultValue ?? _key); + +/** The `en` pack's chip block. */ +const enChips = ( + builtInLocales.en as { + console: { ai: { suggestions: { metadataAssistant: Record } } }; + } +).console.ai.suggestions.metadataAssistant; + +const CHIP_KEYS = ['buildCrm', 'buildApp', 'buildFlow', 'buildInventory', 'buildRecruiting'] as const; + +/** The English half of the i18n suite's banned vocabulary. @see makerStartChips-v1-scope-1984 */ +const BANNED_EN = ['alert', 'remind', 'notif', 'automat', 'workflow', 'trigger', 'schedule']; + +describe('metadataAssistantSuggestions — the build agent`s five start chips (cloud#1984)', () => { + it('renders the five authoring starters for the build agent', () => { + const chips = buildAgentSuggestions('build', 'Build', missEverything); + expect(chips).toHaveLength(5); + }); + + it('every `defaultValue` is byte-equal to the en pack', () => { + const chips = buildAgentSuggestions('build', 'Build', missEverything); + expect(chips).toEqual(CHIP_KEYS.map((k) => enChips[k])); + }); + + it('no fallback promises autonomous behaviour', () => { + for (const chip of buildAgentSuggestions('build', 'Build', missEverything)) { + const hits = BANNED_EN.filter((term) => chip.toLowerCase().includes(term)); + expect(hits, chip).toEqual([]); + } + }); + + it('the control: the retired wording WOULD have been flagged', () => { + // Non-vacuity, same reasoning as the pack-side suite: a banned list that + // has stopped matching anything passes the assertion above in silence. + const retired = 'Design a support desk — tickets with priority, a status workflow, and customer links.'; + expect(BANNED_EN.filter((term) => retired.toLowerCase().includes(term))).toContain('workflow'); + }); +}); diff --git a/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.activityGate-7476.test.tsx b/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.activityGate-7476.test.tsx new file mode 100644 index 0000000000..a711c79c04 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.activityGate-7476.test.tsx @@ -0,0 +1,166 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7476 — a tenant environment has no `sys_activity`, so opening the + * home page (or 系统概览) fired `GET /api/v1/data/sys_activity` and took a 404, + * every load. + * + * The card offered two remedies. "Handle the absence quietly" was already done + * — four layers of it, and none of them changes here. So this is the other + * one: consult the object registry the shell loads anyway and DON'T ASK when + * the environment does not declare the object. + * + * The risk is entirely one-sided, so the assertions are too. A missed skip + * costs one request that already degrades correctly; a wrong skip costs a real + * deployment its activity feed with no error anywhere. Hence four of the six + * cases below are "still reads" — no-provider, empty registry, still-loading, + * present — and only one is "does not read". + * + * The empty-registry case is the one that would actually have shipped broken: + * `useMetadata()` outside a `` returns a frozen no-op whose + * `getTypeStatus` says `'ready'` and whose `getItemsByType` says `[]`, which + * reads exactly like "the registry answered and your object is not in it". + * Every existing test in this directory mounts the hook that way. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { MetadataCtx, type MetadataContextValue, type MetadataTypeStatus } from '@object-ui/react'; + +vi.mock('@object-ui/auth', () => ({ useAuth: () => ({ user: { id: 'u1' } }) })); + +const ACTIVITY_ROWS = [ + { + id: 'r1', + type: 'created', + summary: 'created the lead', + object_name: 'crm_lead', + actor_name: 'Li Si', + timestamp: '2026-08-20T10:00:00Z', + }, +]; + +/** Every `find` the hook issues, so "did not ask" is directly observable. */ +const finds: string[] = []; +const fakeAdapter = { + find: (object: string) => { + finds.push(object); + return Promise.resolve({ data: object === 'sys_activity' ? ACTIVITY_ROWS : [] }); + }, + getClient: () => undefined, +}; +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => fakeAdapter })); + +const { useSharedActivityFeed, __resetSharedUserFeeds } = await import('../sharedUserFeeds'); +const { objectPresence } = await import('../useObjectPresence'); + +const settle = () => act(async () => { await vi.advanceTimersByTimeAsync(0); }); + +/** A metadata context that answers `object` with exactly these items/status. */ +function registry(status: MetadataTypeStatus, objects: Array<{ name: string }>): MetadataContextValue { + return { + apps: [], + objects, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => objects, + getItem: async () => null, + getItemsByType: (type: string) => (type === 'object' ? objects : []), + getTypeStatus: () => status, + } as unknown as MetadataContextValue; +} + +/** Mount the feed under a given registry — or under none at all. */ +async function activityReads(ctx: MetadataContextValue | null): Promise { + const wrapper = ({ children }: { children: React.ReactNode }) => + ctx ? {children} : <>{children}; + renderHook(() => useSharedActivityFeed(), { wrapper }); + await settle(); + return finds.filter((o) => o === 'sys_activity'); +} + +const TENANT_OBJECTS = [{ name: 'crm_lead' }, { name: 'crm_account' }, { name: 'sys_user' }]; +const WITH_AUDIT = [...TENANT_OBJECTS, { name: 'sys_activity' }]; + +beforeEach(() => { + vi.useFakeTimers(); + __resetSharedUserFeeds(); + finds.length = 0; + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('{}', { status: 404 })))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('the activity feed does not ask for an object the environment does not declare (objectui#7476)', () => { + it('a tenant registry without sys_activity ⇒ no request at all', async () => { + expect(await activityReads(registry('ready', TENANT_OBJECTS))).toEqual([]); + }); + + it('the same registry WITH sys_activity ⇒ the read happens', async () => { + expect(await activityReads(registry('ready', WITH_AUDIT))).toEqual(['sys_activity']); + }); +}); + +describe('every uncertainty still reads — a wrong skip is the expensive mistake (objectui#7476)', () => { + it('no MetadataProvider at all ⇒ unchanged behaviour', async () => { + // The frozen no-op fallback answers `ready` + `[]`. Reading that as + // "absent" is the regression this case exists to refuse. + expect(await activityReads(null)).toEqual(['sys_activity']); + }); + + it('a registry that is ready but lists NOTHING ⇒ reads', async () => { + expect(await activityReads(registry('ready', []))).toEqual(['sys_activity']); + }); + + it('a registry that has errored ⇒ reads', async () => { + expect(await activityReads(registry('error', []))).toEqual(['sys_activity']); + }); + + it('a registry still loading ⇒ asks nothing YET, and claims nothing', async () => { + // Not the same as "absent": no key, so the feed has asked nothing. The + // request arrives (or does not) when the registry answers. + expect(await activityReads(registry('loading', []))).toEqual([]); + }); +}); + +describe('objectPresence — absence has to be earned (objectui#7476)', () => { + it.each([ + ['idle' as const, [{ name: 'crm_lead' }], 'unknown'], + ['loading' as const, [{ name: 'crm_lead' }], 'unknown'], + ['error' as const, [{ name: 'crm_lead' }], 'unknown'], + ['ready' as const, [], 'unknown'], + ['ready' as const, [{ name: 'crm_lead' }], 'absent'], + ['ready' as const, [{ name: 'sys_activity' }], 'present'], + ])('status=%s objects=%j ⇒ %s', (status, objects, expected) => { + expect(objectPresence('sys_activity', status, objects)).toBe(expected); + }); + + it('an absent `getTypeStatus` (hand-rolled context) reads as ready', () => { + // The context type documents the optional member as "absent means always + // ready"; honouring that is what lets a hand-rolled test context still gate. + expect(objectPresence('sys_activity', undefined, [{ name: 'crm_lead' }])).toBe('absent'); + expect(objectPresence('sys_activity', undefined, [])).toBe('unknown'); + }); + + it('ignores malformed registry entries rather than throwing', () => { + expect(objectPresence('sys_activity', 'ready', [null, undefined, 'x', { name: 'sys_activity' }])).toBe( + 'present', + ); + }); +}); diff --git a/packages/app-shell/src/hooks/index.ts b/packages/app-shell/src/hooks/index.ts index 4dcb8a0c9a..d68e29bcd5 100644 --- a/packages/app-shell/src/hooks/index.ts +++ b/packages/app-shell/src/hooks/index.ts @@ -14,6 +14,13 @@ export { type UseNavigationSyncReturn, } from './useNavigationSync.js'; export { useObjectActions } from './useObjectActions.js'; +export { + useObjectPresence, + objectPresence, + metadataTypeSettled, + type ObjectPresence, + type ObjectPresenceReading, +} from './useObjectPresence.js'; export { useAiUsage, type UseAiUsageOptions, diff --git a/packages/app-shell/src/hooks/sharedUserFeeds.ts b/packages/app-shell/src/hooks/sharedUserFeeds.ts index 687fd01255..3acb9dee18 100644 --- a/packages/app-shell/src/hooks/sharedUserFeeds.ts +++ b/packages/app-shell/src/hooks/sharedUserFeeds.ts @@ -51,6 +51,7 @@ import { errorCodeIs } from '@object-ui/types'; // Re-exported from `@object-ui/react` — import it through the provider module // so a consumer that stubs the provider stubs this too. import { useAdapter } from '../providers/AdapterProvider.js'; +import { useObjectPresence } from './useObjectPresence.js'; import { bearerAuthHeaders } from '../utils/authToken.js'; import type { ActivityItem } from '../layout/ActivityFeed.js'; import { activityRowToActivityItem } from '../layout/activityItemType.js'; @@ -615,15 +616,49 @@ export function isMissingResource(err: unknown): boolean { * Not polled — it is a landing-surface feed on both consumers, and the bell * never polled it either. Degrades to empty when `sys_activity` is absent * (no plugin-audit) and retires the feed for the rest of the page. + * + * ## Not asking, rather than asking and being told no (objectui#7476) + * + * A tenant environment has no `sys_activity`, so this read 404'd on every page + * load. The 404 was already handled correctly at four layers — the adapter + * memoizes the missing collection, its quiet logger demotes it to `debug`, the + * feed retires as an ANSWER (`ready`, empty), and the panel renders its earned + * 「暂无最近动态」 — but it was still one doomed request per load, and + * `data-objectstack` states the rule for exactly this shape: the cure for a + * doomed request is not issuing it. + * + * So the object registry (which the shell loads for the nav either way) + * decides. It is a THREE-valued answer and only one value skips the read: + * + * - not settled yet → no key, so nothing is asked and nothing is claimed. + * `useSharedFeed` hands a consumer the `idle` snapshot in that window, + * which is the honest one: this feed has not asked anything; + * - `absent` → `markUnavailable()` WITHOUT a request. Same terminal state the + * 404 produced — `ready`, empty, poll stopped — so every consumer of this + * feed and the affirmative empty copy (#4315) are byte-for-byte unchanged; + * - `present` / `unknown` → the read, exactly as before. Every uncertainty + * lands here on purpose (see {@link useObjectPresence}): a registry with no + * provider, still loading, errored, or listing nothing is not evidence of + * absence, and a wrong `absent` would cost a real deployment its feed. */ export function useSharedActivityFeed(): ActivityItem[] { const dataSource = useAdapter(); + const activity = useObjectPresence('sys_activity'); return useSharedFeed( activityFeed, - adapterKey(dataSource), + // The presence verdict is part of the key so the feed re-attaches (and + // re-decides) when the registry finally answers — it is `null` until then, + // which is what keeps the doomed request from going out in that window. + activity.settled ? adapterKey(dataSource) : null, async ({ markUnavailable, markFailed }) => { if (!dataSource) return undefined; + if (activity.presence === 'absent') { + // This deployment declares no `sys_activity`. That IS the answer the + // 404 used to carry, arrived at without the round trip. + markUnavailable(); + return undefined; + } const res = await Promise.resolve( dataSource.find('sys_activity', { $orderby: { timestamp: 'desc' }, $top: 20 }) as Promise<{ data?: unknown[]; diff --git a/packages/app-shell/src/hooks/useObjectPresence.ts b/packages/app-shell/src/hooks/useObjectPresence.ts new file mode 100644 index 0000000000..2cea7d1c00 --- /dev/null +++ b/packages/app-shell/src/hooks/useObjectPresence.ts @@ -0,0 +1,103 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * "Does this deployment HAVE that object?", answered from the metadata registry + * the shell already loads — so a surface that reads an OPTIONAL system object + * can decline to ask rather than asking and being told no. + * + * ## Why (objectui#7476) + * + * A tenant environment has no `sys_activity` (no plugin-audit), so the home + * page's activity card and the bell's Activity tab issued + * `GET /api/v1/data/sys_activity` on every page load and got a 404. Everything + * DOWNSTREAM of that 404 is already correct and stays correct: the adapter + * memoizes the missing collection so no second request goes out, its quiet + * logger demotes the failure to `debug`, `sharedUserFeeds` retires the feed as + * an ANSWER (`ready`, not `error`), and the panel renders its earned + * 「暂无最近动态」 empty state. What was left is one doomed request per load — + * and `data-objectstack`'s own rule for exactly this case says how to read + * that: *"The cure for doomed requests is not issuing them, never hiding them + * once issued."* + * + * ## The predicate, and why every uncertainty reads as `unknown` + * + * The cost of a wrong `absent` is not one extra request, it is a feed that + * never loads on a deployment that DOES have the object — so absence has to be + * evidence, never the default: + * + * - the registry has not answered (`idle` / `loading` / `error`) → `unknown`; + * - the registry is `ready` but lists ZERO objects → `unknown`. This is the + * load-bearing clause. `useMetadata()` outside a `` + * returns a frozen no-op whose `getTypeStatus` answers `'ready'` and whose + * `getItemsByType` answers `[]` — a shape that reads as "ready, and the + * object is not there" while meaning "nobody is answering". An empty + * registry is not evidence of anything; + * - `ready`, non-empty, and the name is in it → `present`; + * - `ready`, non-empty, and the name is not → `absent`. Only here. + * + * `sys_*` objects ARE in this list where they exist — `AppHeader` filters them + * out of the app-object picker by name (`!o.name.startsWith('sys_')`), which + * it would not need to do if they were absent, and the console resolves + * `/apps/{any app}/sys_activity` as an ordinary object route (objectui#4074). + * + * ## Cost + * + * None: `getItemsByType('object')` reads the same cache the nav, the object + * views and `AppHeader` already populate, and kicks the fetch itself when the + * type is still `idle` (`MetadataProvider`'s `readType`). No consumer of this + * hook adds a request; the point is to remove one. + */ +import { useMetadata, type MetadataTypeStatus } from '@object-ui/react'; + +/** What the metadata registry can say about one object name. */ +export type ObjectPresence = 'present' | 'absent' | 'unknown'; + +/** A presence reading plus whether it is worth waiting for a better one. */ +export interface ObjectPresenceReading { + presence: ObjectPresence; + /** + * The registry has said its piece — `ready`, or `error` (which will not + * improve by waiting), or there is no provider to wait on. A caller that + * gates a read on presence should hold off until this is true, then act on + * `presence`: `absent` skips the read, anything else performs it. + */ + settled: boolean; +} + +/** @see ObjectPresenceReading.settled */ +export function metadataTypeSettled(status: MetadataTypeStatus | undefined): boolean { + // `undefined` is the documented "always ready" of a hand-rolled context value. + return status === undefined || status === 'ready' || status === 'error'; +} + +/** + * The pure predicate — exported so the decision can be pinned without a + * provider tree. See the module comment for why absence must be earned. + */ +export function objectPresence( + name: string, + status: MetadataTypeStatus | undefined, + objects: readonly unknown[], +): ObjectPresence { + if (status !== undefined && status !== 'ready') return 'unknown'; + if (objects.length === 0) return 'unknown'; + const found = objects.some((o) => (o as { name?: unknown } | null | undefined)?.name === name); + return found ? 'present' : 'absent'; +} + +/** {@link objectPresence} bound to the shell's metadata registry. */ +export function useObjectPresence(name: string): ObjectPresenceReading { + const { getItemsByType, getTypeStatus } = useMetadata(); + // Reading the items is also what ENSURES the type is fetched (MetadataProvider + // `readType`), so a surface that only ever asks this question still gets an + // answer instead of waiting on somebody else to populate the cache. + const objects = getItemsByType('object'); + const status = getTypeStatus?.('object'); + return { presence: objectPresence(name, status, objects), settled: metadataTypeSettled(status) }; +} diff --git a/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx index 580ba99c95..a6e9e5eda2 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx @@ -27,7 +27,7 @@ import { buildDatasetFieldHelpers, type DatasetResultField, } from '@object-ui/core'; -import { useSafeFieldLabel, useDisplayLocale } from '@object-ui/i18n'; +import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate, useDisplayLocale } from '@object-ui/i18n'; // Lazy-loaded so the (recharts-backed) chart bundle only loads when a dataset // preview actually renders a chart — keeps the metadata-admin bundle small. @@ -48,6 +48,9 @@ type PreviewState = export function DatasetPreview({ draft }: MetadataPreviewProps) { const adapter = useAdapter(); const { fieldLabel } = useSafeFieldLabel(); + // objectui#7534 — the locale bundle's built-in aggregate labels; `@object-ui/ + // core` is i18n-free, so the layer holding the provider resolves them. + const tt = useSafeTranslate(); // The display locale the measure / dimension cells below format in // (objectui#4575, completing objectui#4566's channel). Deliberately NOT the // `locale` PROP in scope: that one is the metadata designer's own chrome @@ -132,7 +135,17 @@ export function DatasetPreview({ draft }: MetadataPreviewProps) { // number when the server result carries no field metadata. const resultFields = state.status === 'ok' ? state.fields : undefined; const resultObject = state.status === 'ok' ? state.object : undefined; - const { measureField, headerLabel } = buildDatasetFieldHelpers(resultFields, resultObject, fieldLabel); + // objectui#7534 — a preview column the analytics service minted as a + // built-in default measure carries `builtinAggregate` and a hard-coded + // English `label`; resolve it through the shared seam (#7258) so the author + // previewing a dataset on a zh console reads the same caption the published + // widget will show. + const { measureField, headerLabel } = buildDatasetFieldHelpers( + resultFields, + resultObject, + fieldLabel, + builtinAggregateLabels(tt), + ); const columns = [...dimensionNames, ...measureNames]; // A ratio/percent measure (format like `0.0%`) on the same axis as a diff --git a/packages/core/src/utils/__tests__/dataset-format.builtinAggregate-7534.test.ts b/packages/core/src/utils/__tests__/dataset-format.builtinAggregate-7534.test.ts new file mode 100644 index 0000000000..53b8dad5fe --- /dev/null +++ b/packages/core/src/utils/__tests__/dataset-format.builtinAggregate-7534.test.ts @@ -0,0 +1,121 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7534 — `buildDatasetFieldHelpers().headerLabel` and + * `buildChartSeries()` must resolve a BUILT-IN default measure's caption the + * SAME way, or one dataset renders two names for one column: #7258 taught the + * chart seam to read `builtinAggregate` (objectstack#14492), so a report chart + * legend already said `计数` while the summary table under it, the KPI caption, + * the pivot header and the dataset preview all still printed the server's + * hard-coded English `Count`. + * + * The resolution order this pins (one order, both seams): + * 1. `builtinAggregate` ∈ the closed vocabulary AND the caller resolved a + * locale label for it → that label; + * 2. otherwise the wire `label` verbatim — objectui#4106, an author-declared + * measure named or labelled `Count` keeps its own words; + * 3. otherwise the field `name`. + * …and the existing object-field i18n convention still wraps the result, which + * is the only thing about the old behaviour that had to stay untouched. + * + * The last two cases are the load-bearing ones: they say the argument is + * OPTIONAL and costs nothing, which is what let the five call sites be wired + * one at a time. + */ +import { describe, it, expect } from 'vitest'; +import { BUILTIN_AGGREGATES, type BuiltinAggregateLabels } from '../chart-series.js'; +import { buildDatasetFieldHelpers, type DatasetResultField } from '../dataset-format.js'; + +/** What `builtinAggregateLabels(tt)` resolves under the zh pack. */ +const ZH: BuiltinAggregateLabels = { + count: '计数', + count_distinct: '去重计数', + sum: '求和', + avg: '平均', + min: '最小值', + max: '最大值', +}; + +/** The server's built-in default measure: English `label` + the discriminator. */ +const BUILTIN_COUNT = { + name: 'count', + type: 'number', + label: 'Count', + builtinAggregate: 'count', +} as unknown as DatasetResultField; + +/** An author-declared measure — no discriminator, own label (objectui#4106). */ +const AUTHORED = { + name: 'task_count', + type: 'number', + label: 'Tasks', +} as unknown as DatasetResultField; + +const FIELDS = [BUILTIN_COUNT, AUTHORED] as DatasetResultField[]; + +describe('headerLabel resolves a built-in default measure through the locale map (objectui#7534)', () => { + it('a built-in count reads 计数, not the server`s Count', () => { + const { headerLabel } = buildDatasetFieldHelpers(FIELDS, undefined, undefined, ZH); + expect(headerLabel('count')).toBe('计数'); + }); + + it('covers every member of the closed vocabulary', () => { + for (const aggregate of BUILTIN_AGGREGATES) { + const field = { name: aggregate, type: 'number', label: 'Whatever', builtinAggregate: aggregate } as unknown as DatasetResultField; + const { headerLabel } = buildDatasetFieldHelpers([field], undefined, undefined, ZH); + expect(headerLabel(aggregate), aggregate).toBe(ZH[aggregate]); + } + }); + + it('an author-declared measure keeps its own label verbatim', () => { + const { headerLabel } = buildDatasetFieldHelpers(FIELDS, undefined, undefined, ZH); + expect(headerLabel('task_count')).toBe('Tasks'); + }); + + it('a field literally NAMED count, with no discriminator, keeps its label', () => { + // The ruling's structural half: never match on the name or the label text. + const namedCount = { name: 'count', type: 'number', label: 'Headcount' } as unknown as DatasetResultField; + const { headerLabel } = buildDatasetFieldHelpers([namedCount], undefined, undefined, ZH); + expect(headerLabel('count')).toBe('Headcount'); + }); + + it('an unrecognised discriminator costs nothing', () => { + const median = { name: 'median_age', type: 'number', label: 'Median age', builtinAggregate: 'median' } as unknown as DatasetResultField; + const { headerLabel } = buildDatasetFieldHelpers([median], undefined, undefined, ZH); + expect(headerLabel('median_age')).toBe('Median age'); + }); + + it('a discriminator the caller resolved no label for falls back to the wire label', () => { + const { headerLabel } = buildDatasetFieldHelpers(FIELDS, undefined, undefined, { sum: '求和' }); + expect(headerLabel('count')).toBe('Count'); + }); + + it('an EMPTY resolved label is treated as absent', () => { + const { headerLabel } = buildDatasetFieldHelpers(FIELDS, undefined, undefined, { count: '' }); + expect(headerLabel('count')).toBe('Count'); + }); +}); + +describe('the argument is optional and additive (objectui#7534)', () => { + it('omitting it reproduces the previous output byte for byte', () => { + const { headerLabel } = buildDatasetFieldHelpers(FIELDS, undefined); + expect(headerLabel('count')).toBe('Count'); + expect(headerLabel('task_count')).toBe('Tasks'); + expect(headerLabel('missing')).toBe('missing'); + }); + + it('the object-field i18n convention still wraps the resolved label', () => { + const fieldLabel = (_o: string, _f: string, fb: string) => `i18n:${fb}`; + const { headerLabel } = buildDatasetFieldHelpers(FIELDS, 'deal', fieldLabel, ZH); + // The built-in label becomes the FALLBACK the convention receives — the + // convention is not bypassed, it just no longer starts from `Count`. + expect(headerLabel('count')).toBe('i18n:计数'); + expect(headerLabel('missing')).toBe('i18n:missing'); + }); +}); diff --git a/packages/core/src/utils/dataset-format.ts b/packages/core/src/utils/dataset-format.ts index 4c176afe22..ee67e70495 100644 --- a/packages/core/src/utils/dataset-format.ts +++ b/packages/core/src/utils/dataset-format.ts @@ -22,6 +22,7 @@ import type { PercentScale } from '@objectstack/spec/data'; import { formatDisplayNumber, type DisplayNumberFormatOptions } from './number-display.js'; import { formatDate, formatDateTime } from './date-display.js'; +import { resolveMeasureLabel, type BuiltinAggregateLabels } from './chart-series.js'; /** * Column metadata the analytics server returns alongside the rows — the spec's @@ -396,11 +397,30 @@ export function formatDimensionValue(v: unknown, locale?: string): string { * * `fieldLabel` is injected (rather than imported) so this stays React/i18n-free; * callers pass `useSafeFieldLabel().fieldLabel`. + * + * ## `builtinAggregateLabels` (objectui#7534, sibling of #7258) + * + * A result field the analytics service minted as a BUILT-IN default measure + * carries `builtinAggregate` (objectstack#14492) and a hard-coded English + * `label` ('Count'). {@link resolveMeasureLabel} is the ONE resolver for that — + * #7258 wired it into `buildChartSeries()`, so a chart legend already read + * `计数` on a zh console while the table underneath it, the KPI caption, the + * pivot header and the dataset preview all still said `Count`, because they + * resolve their titles through here instead. + * + * Passing the caller's resolved labels closes that gap without a second + * resolution order: the discriminator is consulted FIRST (exactly as on the + * chart seam), the existing `fieldLabel` convention still applies on top, and + * the raw name is still the floor. Omitting the argument reproduces the + * previous output byte for byte — an author-declared measure carries no + * discriminator and keeps its wire `label` verbatim (objectui#4106), and a + * provider-less host resolves no labels and so changes nothing. */ export function buildDatasetFieldHelpers( fields: DatasetResultField[] | undefined, object: string | undefined, fieldLabel?: (objectName: string, fieldName: string, fallback: string) => string, + builtinAggregateLabels?: BuiltinAggregateLabels, ): { measureField: (name: string) => DatasetResultField | undefined; headerLabel: (name: string) => string; @@ -408,7 +428,8 @@ export function buildDatasetFieldHelpers( const fieldByName = new Map((fields ?? []).map((f) => [f.name, f] as const)); const measureField = (name: string) => fieldByName.get(name); const headerLabel = (name: string) => { - const fallback = measureField(name)?.label ?? name; + const field = measureField(name); + const fallback = field ? resolveMeasureLabel(field, builtinAggregateLabels) : name; return object && fieldLabel ? fieldLabel(object, name, fallback) : fallback; }; return { measureField, headerLabel }; diff --git a/packages/i18n/src/__tests__/makerStartChips-v1-scope-1984.test.ts b/packages/i18n/src/__tests__/makerStartChips-v1-scope-1984.test.ts new file mode 100644 index 0000000000..a0e04167a4 --- /dev/null +++ b/packages/i18n/src/__tests__/makerStartChips-v1-scope-1984.test.ts @@ -0,0 +1,145 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * cloud#1984 — the maker's five start chips are the PRODUCT's own + * recommendations, so what they ask for has to be what the product builds. + * + * ADR-0112 v1 (cloud#1956 / PR #1970) authors objects, fields, views, pages, + * dashboards and sample data; it has no flows, actions or schedules. The + * measured behaviour of the two chips that asked for automation was NOT a + * refusal — the model silently degraded 「状态流转」 into a status board and + * 「低库存预警」 into a filtered view — so the user was promised an alert and + * handed a page. This suite is the standing guard on that gap. + * + * Three things, and the third is what keeps the other two honest: + * + * 1. Every shipped pack carries all five chips, non-empty. (`all-locales-key- + * parity` already enforces en ↔ pack KEY parity generically; the value side + * is what matters here.) + * 2. No chip in any pack uses vocabulary that promises autonomous behaviour, + * scanned with that pack's OWN banned list — an English-only scan would + * have declared the five non-Latin packs clean without reading a character + * of them (AGENTS.md i18n forensics rule). + * 3. A NON-VACUITY control: each pack's banned list is re-run against the + * RETIRED wording that pack actually shipped before this card, and must + * flag it. Without this, deleting a term from a banned list (or getting a + * locale's spelling wrong) would leave every assertion green while + * checking nothing. + * + * REVERT the wording (and relax this suite) when ADR-0112 v2 re-adds flows and + * actions — see the note beside the keys in every pack. + */ +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '../locales'; + +type LocaleCode = keyof typeof builtInLocales; +const LANGS = Object.keys(builtInLocales) as LocaleCode[]; + +/** The five chips `metadataAssistantSuggestions()` renders, in order. */ +const CHIP_KEYS = ['buildCrm', 'buildApp', 'buildFlow', 'buildInventory', 'buildRecruiting'] as const; + +/** The pack's chip block, reached through the one shape the call site reads. */ +const chipsOf = (lang: LocaleCode) => + ( + builtInLocales[lang] as { + console?: { ai?: { suggestions?: { metadataAssistant?: Record } } }; + } + ).console?.ai?.suggestions?.metadataAssistant; + +/** + * Vocabulary that promises the product will ACT on its own — an alert, a + * reminder, a notification, an automation, a status workflow, a trigger, a + * schedule. Per locale, because the whole point is that the check reads each + * pack in its own script; `u` + substring matching (never ASCII `\w`), since + * five of the ten packs are non-Latin and CJK has no word boundaries. + * + * Stems, not whole words: `automat` covers automate/automatic/automation and + * their Romance cognates, `notifi` covers notify/notification/notificación. + */ +const BANNED: Record = { + en: ['alert', 'remind', 'notif', 'automat', 'workflow', 'trigger', 'schedule'], + zh: ['提醒', '预警', '警报', '自动', '流转', '通知', '触发', '定时'], + de: ['automat', 'erinner', 'warnung', 'alarm', 'benachrichtig', 'workflow', 'auslös', 'ablauf'], + fr: ['automat', 'alerte', 'rappel', 'notifi', 'workflow', 'flux', 'déclench'], + es: ['automat', 'alerta', 'recordar', 'recordatorio', 'notifi', 'flujo', 'disparador'], + pt: ['automa', 'alerta', 'lembr', 'notifi', 'fluxo', 'gatilho'], + ru: ['автомат', 'оповещ', 'уведомл', 'напомин', 'триггер', 'процесс', 'поток'], + ja: ['自動', '通知', 'アラート', 'リマイン', 'ワークフロー', 'フロー', 'トリガー'], + ko: ['자동', '알림', '알람', '워크플로', '흐름', '트리거'], + ar: ['أتمتة', 'مؤتمت', 'تنبيه', 'إشعار', 'تذكير', 'سير عمل'], +}; + +/** Every banned term this text hits, lower-cased for the case-insensitive scripts. */ +function bannedHits(lang: LocaleCode, text: string): string[] { + const haystack = text.toLocaleLowerCase(lang === 'zh' ? 'zh' : undefined); + return BANNED[lang].filter((term) => haystack.includes(term.toLocaleLowerCase())); +} + +/** + * The wording each pack shipped BEFORE cloud#1984 — the ticket chip's status + * workflow, plus the two the card quoted verbatim (zh's 低库存预警, ar's + * تنبيه لانخفاض المخزون). Kept as the control sample, NOT as a fixture to + * revert to: it is here only so a banned list that has stopped working fails + * loudly instead of passing silently. + */ +const RETIRED: Record = { + en: ['Design a support desk — tickets with priority, a status workflow, and customer links.'], + zh: ['设计一个工单系统:工单、优先级、状态流转。', '做一个库存管理应用:商品、库存量、供应商,以及低库存预警。'], + de: ['Entwirf einen Support-Desk — Tickets mit Priorität, einen Status-Workflow und Kundenverknüpfungen.'], + fr: ["Conçois un service d'assistance — tickets avec priorité, un flux de statuts et des liens vers les clients."], + es: ['Diseña una mesa de ayuda — tickets con prioridad, un flujo de estados y vínculos con los clientes.'], + pt: ['Projete uma central de suporte — chamados com prioridade, um fluxo de status e vínculos com clientes.'], + ru: ['Спроектируй службу поддержки — заявки с приоритетом, процесс статусов и связи с клиентами.'], + ja: ['サポートデスクを設計して — 優先度つきチケット、ステータスのワークフロー、顧客との紐付け。'], + ko: ['고객 지원 데스크를 설계해 줘 — 우선순위가 있는 티켓, 상태 워크플로, 고객 연결.'], + ar: ['صمّم مكتب دعم — تذاكر بأولوية وسير عمل للحالات وروابط بالعملاء.', 'أنشئ تطبيق مخزون — منتجات ومستويات مخزون وموردين وتنبيه لانخفاض المخزون.'], +}; + +describe('maker start chips — every shipped pack carries all five (cloud#1984)', () => { + it('covers all ten built-in packs', () => { + expect(LANGS).toHaveLength(10); + }); + + it.each(LANGS)('%s defines a non-empty string for every chip', (lang) => { + const block = chipsOf(lang); + expect(block, `${lang} has no console.ai.suggestions.metadataAssistant block`).toBeTruthy(); + for (const key of CHIP_KEYS) { + expect(typeof block![key], `${lang}.${key}`).toBe('string'); + expect(block![key].trim().length, `${lang}.${key} is empty`).toBeGreaterThan(0); + } + // No chip beyond the five the call site renders — an extra key is a chip + // nobody sees, and a sixth chip means the call site changed without this. + expect(Object.keys(block!).sort()).toEqual([...CHIP_KEYS].sort()); + }); +}); + +describe('maker start chips — none promises autonomous behaviour (cloud#1984)', () => { + it.each(LANGS)('%s: no chip uses that pack`s automation vocabulary', (lang) => { + const block = chipsOf(lang)!; + for (const key of CHIP_KEYS) { + expect(bannedHits(lang, block[key]), `${lang}.${key}: "${block[key]}"`).toEqual([]); + } + }); + + it.each(LANGS)('%s: the banned list still flags the wording this pack retired', (lang) => { + // Non-vacuity. A green run above means nothing unless this is green too. + for (const retired of RETIRED[lang]) { + expect(bannedHits(lang, retired).length, `${lang} control: "${retired}"`).toBeGreaterThan(0); + } + }); + + it('the two chips the card named read as UI, not as automation', () => { + const zh = chipsOf('zh')!; + // 「状态流转」 → a board grouped by status; 「低库存预警」 → a filtered view. + expect(zh.buildFlow).toContain('看板'); + expect(zh.buildFlow).not.toContain('流转'); + expect(zh.buildInventory).toContain('视图'); + expect(zh.buildInventory).not.toContain('预警'); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 48dfa880a4..e2389ad7b3 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1623,12 +1623,20 @@ const ar = { recentRecords: "اذكر أحدث 5 سجلات تم إنشاؤها.", recordCounts: "احسب عدد السجلات لكل كائن.", }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: "أنشئ نظام إدارة علاقات عملاء للمبيعات — عملاء وجهات اتصال ومسار صفقات يمكنني تجميعه حسب المرحلة.", - buildApp: "أنشئ متتبّع مشاريع — مشاريع ومهام بمسؤولين وتواريخ استحقاق ولوحة حسب الحالة.", - buildFlow: "صمّم مكتب دعم — تذاكر بأولوية وسير عمل للحالات وروابط بالعملاء.", - buildInventory: "أنشئ تطبيق مخزون — منتجات ومستويات مخزون وموردين وتنبيه لانخفاض المخزون.", - buildRecruiting: "أنشئ متتبّع متقدمين — مرشحون ووظائف شاغرة ومراحل مقابلات وملاحظات.", + buildCrm: "أنشئ نظام إدارة علاقات عملاء للمبيعات — عملاء وجهات اتصال وصفقات بحقل للمرحلة، ولوحة معلومات تجمع قيمة الصفقات حسب المرحلة.", + buildApp: "أنشئ متتبّع مشاريع — مشاريع ومهام بمسؤولين وتواريخ استحقاق، ولوحة حسب الحالة، وتقويم لتواريخ الاستحقاق.", + buildFlow: "صمّم مكتب دعم — تذاكر بحقلي الأولوية والحالة، ولوحة حسب الحالة، وروابط بالعملاء.", + buildInventory: "أنشئ تطبيق مخزون — منتجات ومستويات مخزون وموردين، وعرض يصفّي الأصناف دون حد إعادة الطلب.", + buildRecruiting: "أنشئ متتبّع متقدمين — مرشحون ووظائف شاغرة وحقل لمرحلة المقابلة، ولوحة حسب المرحلة.", }, generic: { help: "بماذا يمكنك مساعدتي؟", @@ -2896,6 +2904,14 @@ const ar = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: "تجميع البيانات", @@ -2915,16 +2931,21 @@ const ar = { describe_metadata: "عرض البيانات الوصفية", describe_object: "عرض بنية الكائن", get_active_package: "جلب الحزمة النشطة", + get_authoring_rules: "قراءة قواعد التأليف", get_metadata_schema: "جلب مخطط البيانات الوصفية", get_package: "جلب الحزمة", list_metadata: "سرد البيانات الوصفية", list_objects: "سرد الكائنات", list_packages: "سرد الحزم", + load_tools: "تحميل الأدوات", modify_field: "تعديل حقل", + open_record: "فتح السجل", propose_blueprint: "تصميم خطة التطبيق", set_active_package: "تبديل الحزمة النشطة", suggest_builder: "اقتراح طريقة البناء", + test_flow: "اختبار التدفق", todo_write: "تدوين المهام", + toggle_flow: "تفعيل أو تعطيل التدفق", update_metadata: "تحديث البيانات الوصفية", validate_expression: "التحقق من التعبير", verify_build: "التحقق من البناء", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 389ec39031..e79625bd79 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1616,12 +1616,20 @@ const de = { recentRecords: "Liste die 5 zuletzt erstellten Datensätze auf.", recordCounts: "Zähle die Datensätze je Objekt.", }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: "Erstelle ein Vertriebs-CRM — Kunden, Kontakte und eine Deal-Pipeline, die ich nach Phase summieren kann.", - buildApp: "Erstelle einen Projekt-Tracker — Projekte, Aufgaben mit Verantwortlichen und Fälligkeitsdaten sowie ein Board nach Status.", - buildFlow: "Entwirf einen Support-Desk — Tickets mit Priorität, einen Status-Workflow und Kundenverknüpfungen.", - buildInventory: "Erstelle eine Bestands-App — Produkte, Lagerbestände, Lieferanten und Sichtbarkeit niedriger Bestände.", - buildRecruiting: "Erstelle einen Bewerber-Tracker — Kandidaten, offene Stellen, Interviewphasen und Notizen.", + buildCrm: "Erstelle ein Vertriebs-CRM — Kunden, Kontakte und Deals mit einem Phasenfeld sowie ein Dashboard, das den Deal-Wert je Phase summiert.", + buildApp: "Erstelle einen Projekt-Tracker — Projekte, Aufgaben mit Verantwortlichen und Fälligkeitsdaten, ein Board nach Status und einen Kalender der Fälligkeiten.", + buildFlow: "Entwirf einen Support-Desk — Tickets mit Prioritäts- und Statusfeld, ein Board nach Status und Verknüpfungen zu Kunden.", + buildInventory: "Erstelle eine Bestands-App — Produkte, Lagerbestände, Lieferanten und eine Ansicht, die Artikel unter ihrem Meldebestand filtert.", + buildRecruiting: "Erstelle einen Bewerber-Tracker — Kandidaten, offene Stellen, ein Feld für die Interviewphase und ein Board nach Phase.", }, generic: { help: "Wobei kannst du mir helfen?", @@ -2889,6 +2897,14 @@ const de = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: "Daten aggregieren", @@ -2908,16 +2924,21 @@ const de = { describe_metadata: "Metadaten ansehen", describe_object: "Objektstruktur ansehen", get_active_package: "Aktives Paket lesen", + get_authoring_rules: "Autorenregeln lesen", get_metadata_schema: "Metadatenschema lesen", get_package: "Paket lesen", list_metadata: "Metadaten auflisten", list_objects: "Objekte auflisten", list_packages: "Pakete auflisten", + load_tools: "Werkzeuge laden", modify_field: "Feld ändern", + open_record: "Datensatz öffnen", propose_blueprint: "App-Entwurf erstellen", set_active_package: "Aktives Paket wechseln", suggest_builder: "Vorgehen vorschlagen", + test_flow: "Flow testen", todo_write: "Aufgaben notieren", + toggle_flow: "Flow ein- oder ausschalten", update_metadata: "Metadaten aktualisieren", validate_expression: "Ausdruck prüfen", verify_build: "Aufbau prüfen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7ff3c21e14..6260d48434 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1976,12 +1976,20 @@ const en = { recentRecords: 'List the 5 most recently created records.', recordCounts: 'Count records for each object.', }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: 'Build a sales CRM — customers, contacts, and a deal pipeline I can total by stage.', - buildApp: 'Create a project tracker — projects, tasks with owners and due dates, and a board by status.', - buildFlow: 'Design a support desk — tickets with priority, a status workflow, and customer links.', - buildInventory: 'Build an inventory app — products, stock levels, suppliers, and low-stock visibility.', - buildRecruiting: 'Make an applicant tracker — candidates, open roles, interview stages, and notes.', + buildCrm: 'Build a sales CRM — customers, contacts, and deals with a stage field, plus a dashboard that totals deal value by stage.', + buildApp: 'Create a project tracker — projects, tasks with owners and due dates, a board grouped by status, and a calendar of due dates.', + buildFlow: 'Design a support desk — tickets with priority and status fields, a board grouped by status, and links to customers.', + buildInventory: 'Build an inventory app — products, stock levels, suppliers, and a view that filters the items below their reorder point.', + buildRecruiting: 'Make an applicant tracker — candidates, open roles, an interview-stage field, and a board grouped by stage.', }, generic: { help: 'What can you help me with?', @@ -3252,6 +3260,14 @@ const en = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: 'Aggregate data', @@ -3271,16 +3287,21 @@ const en = { describe_metadata: 'Describe metadata', describe_object: 'Describe object', get_active_package: 'Get active package', + get_authoring_rules: 'Get authoring rules', get_metadata_schema: 'Get metadata schema', get_package: 'Get package', list_metadata: 'List metadata', list_objects: 'List objects', list_packages: 'List packages', + load_tools: 'Load tools', modify_field: 'Modify field', + open_record: 'Open record', propose_blueprint: 'Propose blueprint', set_active_package: 'Set active package', suggest_builder: 'Suggest builder', + test_flow: 'Test flow', todo_write: 'Todo write', + toggle_flow: 'Toggle flow', update_metadata: 'Update metadata', validate_expression: 'Validate expression', verify_build: 'Verify build', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index e99e542cd1..937dfb5676 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1620,12 +1620,20 @@ const es = { recentRecords: "Enumera los 5 registros creados más recientemente.", recordCounts: "Cuenta los registros de cada objeto.", }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: "Crea un CRM de ventas — clientes, contactos y un embudo de oportunidades que pueda totalizar por etapa.", - buildApp: "Crea un seguimiento de proyectos — proyectos, tareas con responsables y fechas de vencimiento, y un tablero por estado.", - buildFlow: "Diseña una mesa de ayuda — tickets con prioridad, un flujo de estados y vínculos con los clientes.", - buildInventory: "Crea una aplicación de inventario — productos, niveles de stock, proveedores y visibilidad de stock bajo.", - buildRecruiting: "Crea un seguimiento de candidaturas — candidatos, vacantes, etapas de entrevista y notas.", + buildCrm: "Crea un CRM de ventas — clientes, contactos y oportunidades con un campo de etapa, y un panel que sume el importe por etapa.", + buildApp: "Crea un seguimiento de proyectos — proyectos, tareas con responsables y fechas de vencimiento, un tablero por estado y un calendario de vencimientos.", + buildFlow: "Diseña una mesa de ayuda — tickets con campos de prioridad y estado, un tablero por estado y vínculos con los clientes.", + buildInventory: "Crea una aplicación de inventario — productos, niveles de stock, proveedores y una vista que filtre los artículos por debajo de su punto de pedido.", + buildRecruiting: "Crea un seguimiento de candidaturas — candidatos, vacantes, un campo de etapa de entrevista y un tablero por etapa.", }, generic: { help: "¿En qué puedes ayudarme?", @@ -2893,6 +2901,14 @@ const es = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: "Resumir datos", @@ -2912,16 +2928,21 @@ const es = { describe_metadata: "Consultar metadatos", describe_object: "Consultar la estructura del objeto", get_active_package: "Obtener el paquete activo", + get_authoring_rules: "Leer las reglas de autoría", get_metadata_schema: "Obtener el esquema de metadatos", get_package: "Obtener paquete", list_metadata: "Listar metadatos", list_objects: "Listar objetos", list_packages: "Listar paquetes", + load_tools: "Cargar herramientas", modify_field: "Modificar campo", + open_record: "Abrir registro", propose_blueprint: "Diseñar el plan de la aplicación", set_active_package: "Cambiar el paquete activo", suggest_builder: "Sugerir cómo construirlo", + test_flow: "Probar el flujo", todo_write: "Anotar tareas", + toggle_flow: "Activar o desactivar el flujo", update_metadata: "Actualizar metadatos", validate_expression: "Validar expresión", verify_build: "Verificar la construcción", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 31a50048af..8c0a8311d6 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1618,12 +1618,20 @@ const fr = { recentRecords: "Liste les 5 enregistrements créés le plus récemment.", recordCounts: "Compte les enregistrements pour chaque objet.", }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: "Crée un CRM commercial — clients, contacts et un pipeline d'affaires que je peux totaliser par étape.", - buildApp: "Crée un suivi de projets — projets, tâches avec responsables et échéances, et un tableau par statut.", - buildFlow: "Conçois un service d'assistance — tickets avec priorité, un flux de statuts et des liens vers les clients.", - buildInventory: "Crée une application de stock — produits, niveaux de stock, fournisseurs et visibilité des stocks faibles.", - buildRecruiting: "Crée un suivi de candidatures — candidats, postes ouverts, étapes d'entretien et notes.", + buildCrm: "Crée un CRM commercial — clients, contacts et affaires avec un champ étape, plus un tableau de bord qui totalise le montant par étape.", + buildApp: "Crée un suivi de projets — projets, tâches avec responsables et échéances, un tableau par statut et un calendrier des échéances.", + buildFlow: "Conçois un service d'assistance — tickets avec des champs priorité et statut, un tableau par statut et des liens vers les clients.", + buildInventory: "Crée une application de stock — produits, niveaux de stock, fournisseurs et une vue qui filtre les articles sous leur seuil de réapprovisionnement.", + buildRecruiting: "Crée un suivi de candidatures — candidats, postes ouverts, un champ étape d'entretien et un tableau par étape.", }, generic: { help: "En quoi peux-tu m'aider ?", @@ -2891,6 +2899,14 @@ const fr = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: "Agréger les données", @@ -2910,16 +2926,21 @@ const fr = { describe_metadata: "Consulter les métadonnées", describe_object: "Consulter la structure de l’objet", get_active_package: "Lire le paquet actif", + get_authoring_rules: "Lire les règles d'écriture", 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", + load_tools: "Charger les outils", modify_field: "Modifier un champ", + open_record: "Ouvrir la fiche", propose_blueprint: "Concevoir le plan de l’application", set_active_package: "Changer le paquet actif", suggest_builder: "Proposer une méthode de construction", + test_flow: "Tester le flux", todo_write: "Noter les tâches", + toggle_flow: "Activer ou désactiver le flux", update_metadata: "Mettre à jour les métadonnées", validate_expression: "Valider l’expression", verify_build: "Vérifier la construction", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3d8de3c2e2..e0a34496d6 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1618,12 +1618,20 @@ const ja = { recentRecords: "最近作成されたレコードを 5 件表示してください。", recordCounts: "オブジェクトごとのレコード数を数えてください。", }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: "営業 CRM を作って — 顧客、担当者、ステージ別に集計できる商談パイプライン。", - buildApp: "プロジェクト管理を作って — プロジェクト、担当者と期日つきのタスク、ステータス別ボード。", - buildFlow: "サポートデスクを設計して — 優先度つきチケット、ステータスのワークフロー、顧客との紐付け。", - buildInventory: "在庫管理アプリを作って — 商品、在庫数、仕入先、在庫僅少の可視化。", - buildRecruiting: "採用管理を作って — 候補者、募集職種、面接ステージ、メモ。", + buildCrm: "営業 CRM を作って — 顧客、担当者、ステージ項目つきの商談、ステージ別に金額を集計するダッシュボード。", + buildApp: "プロジェクト管理を作って — プロジェクト、担当者と期日つきのタスク、ステータス別ボード、期日のカレンダー。", + buildFlow: "サポートデスクを設計して — 優先度とステータスの項目を持つチケット、ステータス別ボード、顧客との紐付け。", + buildInventory: "在庫管理アプリを作って — 商品、在庫数、仕入先、発注点を下回る商品を絞り込むビュー。", + buildRecruiting: "採用管理を作って — 候補者、募集職種、面接ステージ項目、ステージ別ボード。", }, generic: { help: "どんなことを手伝ってもらえますか?", @@ -2891,6 +2899,14 @@ const ja = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: "データを集計", @@ -2910,16 +2926,21 @@ const ja = { describe_metadata: "メタデータを確認", describe_object: "オブジェクト構造を確認", get_active_package: "現在のパッケージを取得", + get_authoring_rules: "作成ルールを取得", get_metadata_schema: "メタデータ構造を取得", get_package: "パッケージを取得", list_metadata: "メタデータ一覧", list_objects: "オブジェクト一覧", list_packages: "パッケージ一覧", + load_tools: "ツールを読み込む", modify_field: "項目を変更", + open_record: "レコードを開く", propose_blueprint: "アプリ設計案を作成", set_active_package: "現在のパッケージを切替", suggest_builder: "構築方法を提案", + test_flow: "フローをテスト", todo_write: "タスクを記録", + toggle_flow: "フローの有効・無効を切り替え", update_metadata: "メタデータを更新", validate_expression: "式を検証", verify_build: "構築結果を検証", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 1c4bd04c79..9a37f467c1 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1616,12 +1616,20 @@ const ko = { recentRecords: "가장 최근에 생성된 레코드 5건을 보여 주세요.", recordCounts: "객체별 레코드 수를 세어 주세요.", }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: "영업 CRM을 만들어 줘 — 고객, 담당자, 단계별로 합계를 낼 수 있는 거래 파이프라인.", - buildApp: "프로젝트 트래커를 만들어 줘 — 프로젝트, 담당자와 마감일이 있는 작업, 상태별 보드.", - buildFlow: "고객 지원 데스크를 설계해 줘 — 우선순위가 있는 티켓, 상태 워크플로, 고객 연결.", - buildInventory: "재고 앱을 만들어 줘 — 제품, 재고 수량, 공급업체, 재고 부족 표시.", - buildRecruiting: "채용 트래커를 만들어 줘 — 지원자, 채용 중인 직무, 면접 단계, 메모.", + buildCrm: "영업 CRM을 만들어 줘 — 고객, 담당자, 단계 필드가 있는 거래, 단계별 금액을 합산하는 대시보드.", + buildApp: "프로젝트 트래커를 만들어 줘 — 프로젝트, 담당자와 마감일이 있는 작업, 상태별 보드, 마감일 캘린더.", + buildFlow: "고객 지원 데스크를 설계해 줘 — 우선순위와 상태 필드가 있는 티켓, 상태별 보드, 고객 연결.", + buildInventory: "재고 앱을 만들어 줘 — 제품, 재고 수량, 공급업체, 재주문점 미만 제품을 걸러내는 뷰.", + buildRecruiting: "채용 트래커를 만들어 줘 — 지원자, 채용 중인 직무, 면접 단계 필드, 단계별 보드.", }, generic: { help: "무엇을 도와줄 수 있나요?", @@ -2888,6 +2896,14 @@ const ko = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: "데이터 집계", @@ -2907,16 +2923,21 @@ const ko = { describe_metadata: "메타데이터 확인", describe_object: "오브젝트 구조 확인", get_active_package: "현재 패키지 가져오기", + get_authoring_rules: "작성 규칙 가져오기", get_metadata_schema: "메타데이터 구조 가져오기", get_package: "패키지 가져오기", list_metadata: "메타데이터 목록", list_objects: "오브젝트 목록", list_packages: "패키지 목록", + load_tools: "도구 불러오기", modify_field: "필드 수정", + open_record: "레코드 열기", propose_blueprint: "앱 설계안 작성", set_active_package: "현재 패키지 전환", suggest_builder: "구축 방법 제안", + test_flow: "플로우 테스트", todo_write: "할 일 기록", + toggle_flow: "플로우 켜기·끄기", update_metadata: "메타데이터 업데이트", validate_expression: "표현식 검증", verify_build: "빌드 검증", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 310bad016a..052fe93627 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1615,12 +1615,20 @@ const pt = { recentRecords: "Liste os 5 registros criados mais recentemente.", recordCounts: "Conte os registros de cada objeto.", }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: "Crie um CRM de vendas — clientes, contatos e um funil de negócios que eu possa totalizar por etapa.", - buildApp: "Crie um controle de projetos — projetos, tarefas com responsáveis e prazos, e um quadro por status.", - buildFlow: "Projete uma central de suporte — chamados com prioridade, um fluxo de status e vínculos com clientes.", - buildInventory: "Crie um aplicativo de estoque — produtos, níveis de estoque, fornecedores e visibilidade de estoque baixo.", - buildRecruiting: "Crie um controle de candidaturas — candidatos, vagas abertas, etapas de entrevista e anotações.", + buildCrm: "Crie um CRM de vendas — clientes, contatos e negócios com um campo de etapa, e um painel que some o valor por etapa.", + buildApp: "Crie um controle de projetos — projetos, tarefas com responsáveis e prazos, um quadro por status e um calendário de prazos.", + buildFlow: "Projete uma central de suporte — chamados com campos de prioridade e status, um quadro por status e vínculos com clientes.", + buildInventory: "Crie um aplicativo de estoque — produtos, níveis de estoque, fornecedores e uma visão que filtre os itens abaixo do ponto de reposição.", + buildRecruiting: "Crie um controle de candidaturas — candidatos, vagas abertas, um campo de etapa de entrevista e um quadro por etapa.", }, generic: { help: "Com o que você pode me ajudar?", @@ -2888,6 +2896,14 @@ const pt = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: "Resumir dados", @@ -2907,16 +2923,21 @@ const pt = { describe_metadata: "Consultar metadados", describe_object: "Consultar a estrutura do objeto", get_active_package: "Obter o pacote ativo", + get_authoring_rules: "Ler as regras de autoria", get_metadata_schema: "Obter o esquema de metadados", get_package: "Obter pacote", list_metadata: "Listar metadados", list_objects: "Listar objetos", list_packages: "Listar pacotes", + load_tools: "Carregar ferramentas", modify_field: "Modificar campo", + open_record: "Abrir registro", propose_blueprint: "Projetar o plano do aplicativo", set_active_package: "Trocar o pacote ativo", suggest_builder: "Sugerir como construir", + test_flow: "Testar o fluxo", todo_write: "Anotar tarefas", + toggle_flow: "Ativar ou desativar o fluxo", update_metadata: "Atualizar metadados", validate_expression: "Validar expressão", verify_build: "Verificar a construção", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 0ae3051913..02a211d8bd 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1628,12 +1628,20 @@ const ru = { recentRecords: "Перечисли 5 последних созданных записей.", recordCounts: "Посчитай количество записей по каждому объекту.", }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: "Создай CRM для продаж — клиенты, контакты и воронка сделок с итогами по этапам.", - buildApp: "Создай трекер проектов — проекты, задачи с ответственными и сроками, доска по статусам.", - buildFlow: "Спроектируй службу поддержки — заявки с приоритетом, процесс статусов и связи с клиентами.", - buildInventory: "Создай приложение для склада — товары, остатки, поставщики и индикация низких остатков.", - buildRecruiting: "Создай трекер кандидатов — кандидаты, открытые вакансии, этапы собеседований и заметки.", + buildCrm: "Создай CRM для продаж — клиенты, контакты и сделки с полем этапа, а также дашборд с суммой сделок по этапам.", + buildApp: "Создай трекер проектов — проекты, задачи с ответственными и сроками, доска по статусам и календарь сроков.", + buildFlow: "Спроектируй службу поддержки — заявки с полями приоритета и статуса, доска по статусам и связи с клиентами.", + buildInventory: "Создай приложение для склада — товары, остатки, поставщики и представление, отбирающее товары ниже точки заказа.", + buildRecruiting: "Создай трекер кандидатов — кандидаты, открытые вакансии, поле этапа собеседования и доска по этапам.", }, generic: { help: "Чем ты можешь мне помочь?", @@ -2902,6 +2910,14 @@ const ru = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: "Сводка данных", @@ -2921,16 +2937,21 @@ const ru = { describe_metadata: "Посмотреть метаданные", describe_object: "Посмотреть структуру объекта", get_active_package: "Получить активный пакет", + get_authoring_rules: "Читать правила разработки", get_metadata_schema: "Получить схему метаданных", get_package: "Получить пакет", list_metadata: "Список метаданных", list_objects: "Список объектов", list_packages: "Список пакетов", + load_tools: "Загрузить инструменты", modify_field: "Изменить поле", + open_record: "Открыть запись", propose_blueprint: "Спроектировать приложение", set_active_package: "Сменить активный пакет", suggest_builder: "Предложить способ сборки", + test_flow: "Проверить процесс", todo_write: "Записать задачи", + toggle_flow: "Включить или выключить процесс", update_metadata: "Обновить метаданные", validate_expression: "Проверить выражение", verify_build: "Проверить сборку", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 495a321a4a..5862fcec38 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1807,12 +1807,20 @@ const zh = { recentRecords: '帮我列出最近创建的 5 条记录。', recordCounts: '统计每个对象的记录数。', }, + // cloud#1984 — these five chips are the maker's own recommendations, so + // they may only ask for what ADR-0112 v1 BUILDS: objects, fields, views + // (grid/kanban/calendar/gallery), pages, dashboards and sample data. No + // wording that promises autonomous behaviour (alert / remind / notify / + // automate / status workflow) — v1 has no flows, actions or schedules, and + // the model silently degrades such a request into a board or a filtered + // view, so the chip would promise an alert and deliver a page. REVERT to + // the automation wording when ADR-0112 v2 re-adds flows and actions. metadataAssistant: { - buildCrm: '帮我搭建一个 CRM:客户、联系人、商机,并建立它们之间的关系。', - buildApp: '做一个项目管理应用:项目、任务、成员。', - buildFlow: '设计一个工单系统:工单、优先级、状态流转。', - buildInventory: '做一个库存管理应用:商品、库存量、供应商,以及低库存预警。', - buildRecruiting: '做一个招聘跟踪应用:候选人、在招岗位、面试阶段和面试记录。', + buildCrm: '帮我搭建一个 CRM:客户、联系人、商机,以及一个按阶段汇总商机金额的仪表盘。', + buildApp: '做一个项目管理应用:项目、任务(负责人、截止日期),按状态分组的看板,以及按截止日期的日历视图。', + buildFlow: '设计一个工单系统:工单、优先级、状态(下拉),以及按状态分组的看板。', + buildInventory: '做一个库存管理应用:商品、库存量、供应商,并用一个视图筛出低于安全库存的商品。', + buildRecruiting: '做一个招聘跟踪应用:候选人、在招岗位、面试阶段字段,以及按阶段分组的看板。', }, generic: { help: '你可以帮我做什么?', @@ -3026,6 +3034,14 @@ const zh = { // 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. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. chatbot: { tool: { aggregate_data: '汇总数据', @@ -3045,16 +3061,21 @@ const zh = { describe_metadata: '查看元数据', describe_object: '查看对象结构', get_active_package: '读取当前应用包', + get_authoring_rules: '读取编写规范', get_metadata_schema: '读取元数据结构', get_package: '读取应用包', list_metadata: '列出元数据', list_objects: '列出对象', list_packages: '列出应用包', + load_tools: '加载工具', modify_field: '修改字段', + open_record: '打开记录', propose_blueprint: '设计应用方案', set_active_package: '切换当前应用包', suggest_builder: '推荐搭建方式', + test_flow: '测试流程', todo_write: '记录待办', + toggle_flow: '启停流程', update_metadata: '更新元数据', validate_expression: '校验表达式', verify_build: '校验搭建结果', diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index ea71b0f497..70d4d60475 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -32,6 +32,7 @@ import { unwrapToolResult, type ToolTitleTranslator, } from './tool-display'; +import type { StickToBottomContext } from 'use-stick-to-bottom'; import { Conversation, ConversationContent, @@ -1748,6 +1749,38 @@ const ChatbotEnhanced = React.forwardRef( } }, [messages, onBuildMaterialized]); + /** + * The thread's stick-to-bottom controls, reached from OUTSIDE the + * `` subtree (the composer and the cards live beside it, so + * `useStickToBottomContext()` is not available to them). `contextRef` is + * the library's own escape hatch, which keeps `elements/conversation.tsx` + * — a vendored ai-elements file — untouched. + */ + const stickToBottomRef = React.useRef(null); + + /** + * objectui#7480 — follow the thread to the bottom because the user just + * SENT something. + * + * `StickToBottom` only auto-follows while the view is at the bottom: read a + * long reply, and the lock is escaped. In the wide full-page maker a reply + * usually still ends on screen, so the next send looks like it scrolls; in + * the narrow assistant rail the same reply is two or three times taller, so + * the lock is almost always escaped by the time the user types — and the + * new user bubble, the tool steps and the streaming reply all land below + * the fold with no sign the agent started. Same component, opposite + * behaviour, purely because of the rail's width. + * + * Sending is an explicit request to see what happens next, so every send + * path re-arms the lock here. This is deliberately NOT hooked to message + * APPENDS: once re-armed, `StickToBottom` follows the stream on its own and + * releases the moment the user scrolls up — so a user reading back through + * the thread mid-answer is never yanked to the bottom. + */ + const followThreadToBottom = React.useCallback(() => { + stickToBottomRef.current?.scrollToBottom({ animation: 'smooth' }); + }, []); + const handleSubmit = React.useCallback( (payload: PromptInputMessage) => { const hasText = Boolean(payload.text?.trim()); @@ -1767,8 +1800,9 @@ const ChatbotEnhanced = React.forwardRef( // approval already reached the server (#2627). lastApprovedPlanIdRef.current = null; onSendMessage?.(text, files); + followThreadToBottom(); }, - [onSendMessage] + [onSendMessage, followThreadToBottom] ); const handleSuggestionClick = React.useCallback( @@ -1778,8 +1812,9 @@ const ChatbotEnhanced = React.forwardRef( // (see the send-failure effect below). lastSubmittedRef.current = ''; onSendMessage?.(text); + followThreadToBottom(); }, - [onSendMessage] + [onSendMessage, followThreadToBottom] ); // The "Proposed plan" card's one-click confirm gate. Approving sends a plain @@ -1813,8 +1848,9 @@ const ChatbotEnhanced = React.forwardRef( }); } onSendMessage?.(hasOpenQuestions ? planApproveDefaultsMessage : planApproveMessage); + followThreadToBottom(); }, - [onSendMessage, planApproveMessage, planApproveDefaultsMessage] + [onSendMessage, planApproveMessage, planApproveDefaultsMessage, followThreadToBottom] ); // objectui#5695 — same optimistic pattern for the 确认修改 card: flip it to // an "Applying…" badge the moment the approval is sent (double-click guard + @@ -1848,8 +1884,9 @@ const ChatbotEnhanced = React.forwardRef( }); } onSendMessage?.(changesConfirmMessage); + followThreadToBottom(); }, - [onSendMessage, changesConfirmMessage], + [onSendMessage, changesConfirmMessage, followThreadToBottom], ); // "Adjust" doesn't send anything — it just drops the cursor into the input so @@ -2951,7 +2988,7 @@ const ChatbotEnhanced = React.forwardRef( ) : null} - + ` is observable. That vendored ai-elements file is not edited + * by this change: `contextRef` is `StickToBottom`'s own escape hatch and flows + * through its prop spread. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +/** The scroll control the component reaches for on every send. */ +const scrollToBottom = vi.fn(); +/** Whether `ChatbotEnhanced` handed `` a `contextRef` at all. */ +let sawContextRef = false; + +vi.mock('../elements/conversation', () => { + const Conversation = ({ + children, + contextRef, + }: { + children?: React.ReactNode; + contextRef?: React.MutableRefObject | ((v: unknown) => void); + }) => { + sawContextRef = Boolean(contextRef); + // Publish a stand-in context exactly as `StickToBottom` does. + const ctx = { scrollToBottom, stopScroll: vi.fn(), isAtBottom: false, escapedFromLock: true }; + if (typeof contextRef === 'function') contextRef(ctx); + else if (contextRef) contextRef.current = ctx; + return
    {children}
    ; + }; + return { + Conversation, + ConversationContent: ({ children }: { children?: React.ReactNode }) =>
    {children}
    , + ConversationEmptyState: ({ children }: { children?: React.ReactNode }) =>
    {children}
    , + ConversationScrollButton: () => null, + }; +}); + +const { ChatbotEnhanced } = await import('../ChatbotEnhanced'); +type ChatMessage = import('../ChatbotEnhanced').ChatMessage; + +beforeEach(() => { + scrollToBottom.mockClear(); + sawContextRef = false; +}); + +async function submit(text: string, onSendMessage: () => void) { + const textarea = screen.getByPlaceholderText('Ask…') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: text } }); + fireEvent.submit(textarea.closest('form')!); + // prompt-input calls onSubmit in a microtask (after blob conversion). + await waitFor(() => expect(onSendMessage).toHaveBeenCalled()); +} + +describe('ChatbotEnhanced follows the thread when the user SENDS (objectui#7480)', () => { + it('hands the conversation a contextRef at all', () => { + render(); + expect(sawContextRef).toBe(true); + }); + + it('scrolls to the bottom after a typed send', async () => { + const onSendMessage = vi.fn(); + render(); + await submit('build me a CRM', onSendMessage); + await waitFor(() => expect(scrollToBottom).toHaveBeenCalled()); + }); + + it('scrolls after a start-chip send too', async () => { + const onSendMessage = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('Build a sales CRM')); + await waitFor(() => expect(onSendMessage).toHaveBeenCalledWith('Build a sales CRM')); + expect(scrollToBottom).toHaveBeenCalled(); + }); + + it('does NOT scroll when messages merely arrive — the user may have scrolled up', async () => { + const onSendMessage = vi.fn(); + const first: ChatMessage[] = [{ id: 'm1', role: 'user', content: 'hello' } as ChatMessage]; + const { rerender } = render( + , + ); + scrollToBottom.mockClear(); + + // A streaming append, and then another — the shape of an assistant reply + // growing token by token. `StickToBottom` owns whether to follow these; the + // component must not force it, or reading back through the thread while the + // answer streams would keep snapping the view to the bottom. + rerender( + , + ); + rerender( + , + ); + expect(scrollToBottom).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts b/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts new file mode 100644 index 0000000000..7d20898219 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7481 — the tool-step list read + * + * ✓ 查看对象结构 ×3 已完成 + * ✓ 读取元数据结构 已完成 + * ✓ 列出对象 ×2 已完成 + * ✓ Get authoring rules 已完成 + * + * on a zh console: `get_authoring_rules` (cloud#1837) had no `chatbot.tool.*` + * entry, so `humanizeToolName` fell through to its English title-caser — the + * exact cloud#1658 symptom, one tool at a time, and it recurs every time the + * cloud AI packages register a tool. + * + * The registry (`@objectstack/spec`'s `PLATFORM_TOOLS_BY_PACKAGE`) is the + * contract, so it is what this suite reads. But the pinned spec LAGS the cloud + * runtime — the five names in {@link AHEAD_OF_PIN} are registered by + * `service-ai-studio` today and are not in the snapshot — and a step label the + * user reads must not wait on a pin bump. So the coverage set is + * `registry ∪ AHEAD_OF_PIN`, and a member of `AHEAD_OF_PIN` that the registry + * has caught up with must be REMOVED from that list, which the last case here + * enforces. That keeps the hand-held half shrinking rather than growing into a + * second, permanent registry. + * + * The English pin is the other half. The packs' own comment states the rule — + * "the `en` values are deliberately EQUAL to what that title-caser produces: + * adding the key must not silently reword the English UI" — and nothing + * checked it until now, so an `en` entry could quietly change what an English + * console has always said. + */ +import { describe, it, expect } from 'vitest'; +import * as specSystem from '@objectstack/spec/system'; +import { builtInLocales } from '@object-ui/i18n'; +import { humanizeToolName, toolTitleKey } from '../tool-display.js'; + +type LocaleCode = keyof typeof builtInLocales; +const LANGS = Object.keys(builtInLocales) as LocaleCode[]; + +const REGISTRY = ( + specSystem as { PLATFORM_TOOLS_BY_PACKAGE?: Readonly> } +).PLATFORM_TOOLS_BY_PACKAGE; + +/** + * Tools the cloud AI runtime registers that the PINNED spec snapshot does not + * list yet. Sourced from cloud `service-ai-studio`'s `plugin.ts` tool + * definitions (`AUTHORING_RULE_`, `LOAD_TOOLS_`, `OPEN_RECORD_`, `FLOW_`). + * Shrink this list on a pin bump — never grow it as a habit. + */ +const AHEAD_OF_PIN = ['get_authoring_rules', 'load_tools', 'open_record', 'test_flow', 'toggle_flow'] as const; + +/** Every tool name that must carry a step label in every pack. */ +const COVERED: string[] = [ + ...new Set([...Object.values(REGISTRY ?? {}).flat(), ...AHEAD_OF_PIN]), +].sort(); + +/** The pack's `chatbot.tool` block, reached through the one shape the reader uses. */ +const toolsOf = (lang: LocaleCode) => + (builtInLocales[lang] as { chatbot?: { tool?: Record } }).chatbot?.tool; + +/** A `useSafeTranslate()` stand-in bound to one pack — a real value wins, else the fallback. */ +const ttFrom = (lang: LocaleCode) => (key: string, fallback: string): string => { + const value = key + .split('.') + .reduce((node, part) => (node as Record | undefined)?.[part], builtInLocales[lang]); + return typeof value === 'string' && value !== '' ? value : fallback; +}; + +describe('chatbot.tool.* covers every tool the runtime can show (objectui#7481)', () => { + it('covers all ten built-in packs', () => { + expect(LANGS).toHaveLength(10); + }); + + it('reads a non-empty coverage set', () => { + // Guards against the whole suite going vacuously green if the spec export + // disappears AND the hand-held list is emptied. + expect(COVERED.length).toBeGreaterThanOrEqual(AHEAD_OF_PIN.length); + expect(COVERED).toEqual(expect.arrayContaining([...AHEAD_OF_PIN])); + }); + + it.each(LANGS)('%s defines a non-empty label for every covered tool', (lang) => { + const block = toolsOf(lang); + expect(block, `${lang} has no chatbot.tool block`).toBeTruthy(); + const missing = COVERED.filter((name) => { + const value = block![name]; + return typeof value !== 'string' || value.trim() === ''; + }); + expect(missing, `${lang} is missing chatbot.tool entries`).toEqual([]); + }); + + it('the en labels are byte-equal to the English title-caser', () => { + const en = toolsOf('en')!; + for (const name of COVERED) { + expect(en[name], `en.chatbot.tool.${name}`).toBe(humanizeToolName(name)); + } + }); + + it('AHEAD_OF_PIN carries only names the pinned registry still lacks', () => { + // The moment `.objectstack-sha` advances past a tool's registry entry, the + // hand-held list must lose it — otherwise this file becomes a second + // registry that nobody keeps in sync. + const inRegistry = new Set(Object.values(REGISTRY ?? {}).flat()); + const caughtUp = AHEAD_OF_PIN.filter((name) => inRegistry.has(name)); + expect(caughtUp, 'remove these from AHEAD_OF_PIN — the pinned spec now lists them').toEqual([]); + }); +}); + +describe('humanizeToolName resolves the newer tools through the pack (objectui#7481)', () => { + it('zh: the card`s own step reads Chinese, not `Get authoring rules`', () => { + expect(humanizeToolName('get_authoring_rules', ttFrom('zh'))).toBe('读取编写规范'); + expect(humanizeToolName('get_authoring_rules', ttFrom('zh'))).not.toBe('Get authoring rules'); + }); + + it('zh: the other four newly-mapped tools resolve too', () => { + const tt = ttFrom('zh'); + for (const name of AHEAD_OF_PIN) { + expect(humanizeToolName(name, tt), name).toBe(toolsOf('zh')![name]); + } + }); + + it('an UNKNOWN tool still degrades to the English title-caser', () => { + // The optionality this whole seam was built on (cloud#1658): a custom or + // third-party tool must be no worse off than before. + expect(humanizeToolName('forecast_revenue', ttFrom('zh'))).toBe('Forecast revenue'); + expect(toolTitleKey('forecast_revenue')).toBe('chatbot.tool.forecast_revenue'); + }); +}); diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index 50ba25014a..9bc7cc2ca0 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -747,7 +747,17 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // Measure metadata (label + format + currency) + header-label resolution, // shared with the report renderer via @object-ui/core. - const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel); + // + // objectui#7534 — the same `builtinAggregateLabels` seam the chart below + // uses (#7258): the KPI/metric caption and the table & pivot column headers + // resolve through `headerLabel`, so without it a widget's legend read `计数` + // while its own header still said the server's built-in English `Count`. + const { measureField, headerLabel } = buildDatasetFieldHelpers( + state.fields, + state.object, + fieldLabel, + builtinAggregateLabels(tt), + ); // --- Comparison overlay (objectui#3337) --------------------------------- // The executor attaches a `__compare` column per measure once it has diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.builtinAggregateHeader-7534.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.builtinAggregateHeader-7534.test.tsx new file mode 100644 index 0000000000..9bd1166572 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.builtinAggregateHeader-7534.test.tsx @@ -0,0 +1,108 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7534 — the SURFACE pin for the other half of the built-in aggregate + * seam: the captions that resolve through `buildDatasetFieldHelpers().header + * Label` rather than through `buildChartSeries()`. + * + * `DatasetWidget.builtinAggregateLabel` (#7258) next door proves the chart + * legend reads `计数`. This one proves the KPI caption and the table column + * header beside it say the same word — the visible defect this card is about + * was a dashboard whose bar legend read `计数` over a table header that still + * read `Count`. + * + * Pure resolution order lives in `@object-ui/core` + * (`dataset-format.builtinAggregate-7534`); what only a render can show is the + * WIRING — that this widget resolves the six strings from its provider and + * passes them into the helper. A declared-but-unwired seam is exactly the + * shape #7258 was split to avoid repeating. + * + * DIRECTIONS, written before the reverse verification: both zh cases are RED + * before the change (the caption/header read `Count`); the `en` case and the + * author-declared case are GREEN on both sides. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { DatasetWidget } from '../DatasetWidget'; + +vi.mock('../DrillDownDrawer', () => ({ DrillDownDrawer: () => null })); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) }))); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** What objectstack#14492 puts on the wire for the server's built-in count. */ +const BUILTIN_COUNT = { name: 'count', type: 'number', label: 'Count', builtinAggregate: 'count' }; +const STATUS = { name: 'status', type: 'string', label: 'Status' }; + +type WidgetSource = React.ComponentProps['dataSource']; + +const sourceOf = (fields: Array>, rows: Array>) => + ({ queryDataset: vi.fn(async () => ({ rows, fields })) }) as unknown as WidgetSource; + +function renderWidget(language: string, widget: Record, dataSource: WidgetSource) { + return render( + + + , + ); +} + +describe('DatasetWidget KPI caption — the built-in measure follows the locale (objectui#7534)', () => { + const metricWidget = { type: 'metric', dataset: 'customer_count', dimensions: [], values: ['count'] }; + + it('zh: the caption under the number reads 计数', async () => { + renderWidget('zh', metricWidget, sourceOf([BUILTIN_COUNT], [{ count: 6 }])); + expect(await screen.findByText('计数')).toBeTruthy(); + expect(screen.queryByText('Count')).toBeNull(); + }); + + it('en: the same wire field captions Count', async () => { + renderWidget('en', metricWidget, sourceOf([BUILTIN_COUNT], [{ count: 6 }])); + expect(await screen.findByText('Count')).toBeTruthy(); + }); + + it('zh: an author-declared measure keeps its verbatim label (objectui#4106)', async () => { + const authored = { name: 'opp_count', type: 'number', label: 'Opportunities' }; + renderWidget( + 'zh', + { type: 'metric', dataset: 'opps', dimensions: [], values: ['opp_count'] }, + sourceOf([authored], [{ opp_count: 6 }]), + ); + expect(await screen.findByText('Opportunities')).toBeTruthy(); + }); +}); + +describe('DatasetWidget table header — the built-in measure follows the locale (objectui#7534)', () => { + const rows = [ + { status: '合作中', count: 3 }, + { status: '已流失', count: 1 }, + ]; + const tableWidget = { type: 'table', dataset: 'customers_by_status', dimensions: ['status'], values: ['count'] }; + + it('zh: the measure column header reads 计数, not the wire`s English Count', async () => { + renderWidget('zh', tableWidget, sourceOf([STATUS, BUILTIN_COUNT], rows)); + await waitFor(() => expect(screen.getByText('计数')).toBeTruthy()); + expect(screen.queryByText('Count')).toBeNull(); + }); + + it('en: the same header reads Count', async () => { + renderWidget('en', tableWidget, sourceOf([STATUS, BUILTIN_COUNT], rows)); + await waitFor(() => expect(screen.getByText('Count')).toBeTruthy()); + }); +}); diff --git a/packages/plugin-report/src/DatasetReportRenderer.tsx b/packages/plugin-report/src/DatasetReportRenderer.tsx index 076c12976e..0ab8d796da 100644 --- a/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -554,7 +554,16 @@ function DatasetReportTable({ onDrill!({ dataset, groupKey, runtimeFilter, object: state.object, objectFilter }); }; - const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel); + // objectui#7534 — resolve a BUILT-IN default measure's caption through the + // same seam the report chart already uses for its legend (#7258), so the + // summary header / metric caption / pivot header cannot say `Count` while the + // chart beside them says `计数`. + const { measureField, headerLabel } = buildDatasetFieldHelpers( + state.fields, + state.object, + fieldLabel, + builtinAggregateLabels(tt), + ); const columns = [...rows, ...values]; // The rows as DISPLAYED (objectui#4330). Order and count are preserved, so // `state.rows[i]` / `drillRawRows[i]` stay index-aligned — which is what @@ -946,7 +955,16 @@ function DatasetReportChart({ // On error or empty, fall back silently to the table beneath. if (state.status === 'error' || state.rows.length === 0) return null; - const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel); + // objectui#7534 — resolve a BUILT-IN default measure's caption through the + // same seam the report chart already uses for its legend (#7258), so the + // summary header / metric caption / pivot header cannot say `Count` while the + // chart beside them says `计数`. + const { measureField, headerLabel } = buildDatasetFieldHelpers( + state.fields, + state.object, + fieldLabel, + builtinAggregateLabels(tt), + ); // The measure's display name, resolved ONCE for every branch below // (objectui#4020). Three levels, highest first: // @@ -1245,7 +1263,16 @@ function DatasetMatrixTable({ if (state.status === 'error') return ; if (!pivot || pivot.rowHeaders.length === 0) return ; - const { measureField, headerLabel } = buildDatasetFieldHelpers(state.fields, state.object, fieldLabel); + // objectui#7534 — resolve a BUILT-IN default measure's caption through the + // same seam the report chart already uses for its legend (#7258), so the + // summary header / metric caption / pivot header cannot say `Count` while the + // chart beside them says `计数`. + const { measureField, headerLabel } = buildDatasetFieldHelpers( + state.fields, + state.object, + fieldLabel, + builtinAggregateLabels(tt), + ); const totalText = tt('report.total', 'Total'); const canDrill = !!onDrill; // Down + across dims the server can map to object fields → raw-value filter.