diff --git a/.changeset/plan-building-label-ui-locale-3837.md b/.changeset/plan-building-label-ui-locale-3837.md new file mode 100644 index 0000000000..23e52be6af --- /dev/null +++ b/.changeset/plan-building-label-ui-locale-3837.md @@ -0,0 +1,40 @@ +--- +"@object-ui/app-shell": patch +--- + +The plan card's "Building…" badge follows the console UI locale, like every other label on it (objectui#3837) + +`AiChatPage` gates four strings on `convZh` — the language of the CONVERSATION, +not of the UI — because the cloud confirm gate (`service-ai-studio` +`confirm-gate.ts` `APPROVAL_RE`) recognises Chinese and English only, so what the +confirm cards SEND has to match the thread it is sent into (objectui#772 / +objectui#2884). The file's own comment above that gate ends with the other half +of the rule: "button LABELS stay on the UI locale." + +`planBuildingLabel` (objectui#2632) had drifted onto the wrong side of it, and +handed back a hard-coded `正在搭建…` for any Chinese thread. Two consequences, +both measured: + +- **A mixed-language card.** Under an English console, a Chinese thread's plan + card rendered `Proposed plan` / `Build it` / `Built` / `Not yet built` in + English with one Chinese badge in the middle. objectui#2458 item 4 recorded the + reverse direction of the same disease. +- **A dead translation.** A Chinese conversation always took the literal, so the + zh value of `console.ai.planBuilding` was unreachable for Chinese readers — + re-wording the pack changed nothing for them. objectui#3546 slice four had just + backfilled that key into all ten packs (PR #3839) and could only contain the + defect, by making the zh value byte-identical to the literal and pinning the two + together. + +The badge now reads `t('console.ai.planBuilding', …)` like its twelve neighbours, +so all ten packs are reachable — a German console with a Chinese thread renders +`Wird erstellt…` — and the zh pack is the single source of the Chinese wording +(unchanged: `正在搭建…`, so no Chinese reader sees a different string than before). + +The three OUTBOUND strings (`planApproveMessage`, +`planApproveDefaultsMessage`, `changesConfirmMessage`) are untouched and still +follow the conversation: each is passed to `onSendMessage` and read by the gate, +which is the class the `convZh` branch exists for. The slice-four containment pin +in `packages/i18n/src/__tests__/console-namespace-3546.test.tsx` is flipped in the +same change — it now fails if a gate reappears over that label, or if any future +`convZh` read feeds something rendered instead of something sent. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index dba0db6537..3153822c05 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -1584,6 +1584,13 @@ export function ChatPane({ // gate (service-ai-studio) accepts both languages, so this is a cosmetic — // but jarring — mismatch. Override the sent strings to Chinese when the // conversation is Chinese; button LABELS stay on the UI locale. + // + // That last clause is load-bearing: `planBuildingLabel` had drifted into this + // gate (#2632) and shipped the plan card's only Chinese word to English-UI + // readers while making the zh pack's `console.ai.planBuilding` unreachable for + // zh conversations (#3837). Nothing but OUTBOUND message text belongs below — + // a pin in `packages/i18n/src/__tests__/console-namespace-3546.test.tsx` fails + // if a `*Label` rejoins the gate. const convZh = useMemo( () => isConversationZh(messages as ChatMessage[]) || isConversationZh(initialMessages), [messages, initialMessages], @@ -2191,9 +2198,7 @@ export function ChatPane({ planApproveLabel={t('console.ai.planApprove', { defaultValue: 'Build it' })} planAdjustLabel={t('console.ai.planAdjust', { defaultValue: 'Adjust' })} planBuiltLabel={t('console.ai.planBuilt', { defaultValue: 'Built' })} - planBuildingLabel={ - convZh ? '正在搭建…' : t('console.ai.planBuilding', { defaultValue: 'Building…' }) - } + planBuildingLabel={t('console.ai.planBuilding', { defaultValue: 'Building…' })} planReadyLabel={t('console.ai.planReady', { defaultValue: 'The plan is ready. Build it now, or tell me what to adjust.', })} diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx new file mode 100644 index 0000000000..309c499809 --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which locale each string on the plan / confirm cards follows (objectui#3837). + * + * One gate, two rules (#772/#2884, stated in `AiChatPage`'s own comment above + * `convZh`): what a card SENDS to the agent follows the CONVERSATION's language + * — the cloud confirm gate's approval pattern only recognises Chinese and + * English — and what a card DISPLAYS follows the console UI locale, like every + * other label in the console. + * + * `planBuildingLabel` (#2632) was on the wrong side of that line: gated on + * `convZh`, it rendered a hard-coded `正在搭建…` for any Chinese thread, so an + * English console showed one Chinese badge among English labels, and the ten + * packs' `console.ai.planBuilding` was unreachable for the very readers it was + * translated for (#3546 slice four measured it; PR #3839 backfilled the key). + * + * The pane is rendered for real — real `I18nProvider`, real locale packs, real + * `isConversationZh` probe — with `ChatbotEnhanced` replaced by a prop recorder, + * because the props ChatPane hands down ARE where the two rules live. + * + * Note which cases can discriminate: only a UI locale that DIFFERS from the + * conversation's language can. `zh` UI + `zh` conversation reads the same string + * either way (the pack value is byte-identical to the deleted literal, and the + * containment pin in `packages/i18n/.../console-namespace-3546.test.tsx` is why), + * so it is kept as the invariance half, not as evidence of the fix. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import React from 'react'; + +/** Props of the last `ChatbotEnhanced` render — the surface under assertion. */ +let captured: Record = {}; + +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + ChatbotEnhanced: (props: Record) => { + captured = props; + return null; + }, + // No transport in a unit test: the conversation this pane reasons about + // arrives through `initialMessages` (the hydrated history), which is the + // half of `convZh` a fresh page load actually reads. + useObjectChat: () => ({ + messages: [], + isLoading: false, + error: undefined, + sendMessage: vi.fn(), + stop: vi.fn(), + reload: vi.fn(), + clear: vi.fn(), + setMessages: vi.fn(), + }), + useAiModels: () => ({ models: [], defaultModelId: undefined }), + }; +}); + +// The pane reads `apps` for the bound-package chip and the adapter for the +// Excel→App bar; neither is part of this invariant, and both otherwise want a +// live backend. +vi.mock('../../../providers/MetadataProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useMetadata: () => ({ apps: [] }) }; +}); +vi.mock('../../../providers/AdapterProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => null }; +}); + +import { I18nProvider } from '@object-ui/i18n'; +import type { AgentDescriptor } from '@object-ui/plugin-chatbot'; +import { ChatPane } from '../AiChatPage'; +import type { HydratedUIMessage } from '../../../hooks/useChatConversation'; + +// jsdom has no matchMedia — `useIsMobile` (mobile canvas overlay) needs a stub. +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +const CJK = /[一-鿿]/; + +function userTurn(text: string): HydratedUIMessage[] { + return [{ id: 'm1', role: 'user', parts: [{ type: 'text', text }] }] as HydratedUIMessage[]; +} + +/** A Chinese thread and an English one — the two conversation languages the gate knows. */ +const ZH_THREAD = userTurn('帮我做一个客户管理应用'); +const EN_THREAD = userTurn('Build me a CRM app'); + +function renderPane(uiLocale: string, initialMessages: HydratedUIMessage[]) { + // Unmount any previous tree first: a still-mounted pane re-renders on its own + // effects and would overwrite `captured` with ITS props after this render. + cleanup(); + captured = {}; + render( + + + + + , + ); + return captured; +} + +beforeEach(() => { + // The provider persists the last language (objectstack#5406); without this a + // stale locale leaks into the next case. + window.localStorage.clear(); +}); +afterEach(() => cleanup()); + +describe('#3837 — plan-card labels follow the UI locale, sent messages follow the conversation', () => { + it('en UI + Chinese thread: the Building badge is English, like every other label on the card', () => { + const props = renderPane('en', ZH_THREAD); + // The discriminating assertion: before the fix this was '正在搭建…'. + expect(props.planBuildingLabel).toBe('Building…'); + // …and the card it sits on has no other Chinese in it, which is the actual + // user-visible complaint (#2458 item 4 is this in the other direction). + const chineseLabels = Object.entries(props) + .filter(([k, v]) => k.endsWith('Label') && typeof v === 'string' && CJK.test(v)) + .map(([k]) => k); + expect(chineseLabels).toEqual([]); + }); + + it('de UI + Chinese thread: the badge comes from the German pack, not from a two-way ternary', () => { + // The removed gate could only ever choose between Chinese and the UI locale, + // so a third locale is the cleanest proof that the PACK now answers. + expect(renderPane('de', ZH_THREAD).planBuildingLabel).toBe('Wird erstellt…'); + }); + + it('zh UI: the badge is the zh pack value — now the only source of that string', () => { + // Cannot discriminate before/after (the deleted literal was byte-identical + // by design) — it pins that the fix did not change what a zh reader sees. + expect(renderPane('zh', ZH_THREAD).planBuildingLabel).toBe('正在搭建…'); + expect(renderPane('zh', EN_THREAD).planBuildingLabel).toBe('正在搭建…'); + }); + + it('the three OUTBOUND messages still follow the conversation, not the UI (#772/#2884 intact)', () => { + // The other half of the gate must be untouched: these are sent INTO the + // thread and the cloud confirm gate matches them by language. + const zhUnderEn = renderPane('en', ZH_THREAD); + expect(zhUnderEn.planApproveMessage).toBe('确认,开始搭建。'); + expect(zhUnderEn.planApproveDefaultsMessage).toBe('确认搭建,未决问题按你的合理假设和默认处理。'); + expect(zhUnderEn.changesConfirmMessage).toBe('确认修改,应用你刚才提议的改动。'); + + // An English thread is read under a NON-Chinese, non-English console on + // purpose: `outbound-agent-messages.test.ts` guarantees no pack but `en` and + // `zh` defines these keys, so `de` proves the English default — the wording + // the cloud gate matches — is what a non-Chinese thread sends. (A `zh` + // console is the one combination that does not hold; measured while writing + // this test and filed separately — it is the sent-message half of the gate, + // not the label half this PR narrows.) + const enUnderDe = renderPane('de', EN_THREAD); + expect(enUnderDe.planApproveMessage).toBe('Looks good — build it as proposed.'); + expect(enUnderDe.planApproveDefaultsMessage).toBe( + 'Build it with your best assumptions; use sensible defaults for the open questions.', + ); + expect(enUnderDe.changesConfirmMessage).toBe('Confirm — apply the change you just proposed.'); + }); +}); diff --git a/packages/app-shell/src/console/ai/conversationLanguage.ts b/packages/app-shell/src/console/ai/conversationLanguage.ts index 248d8a2b8b..03c8f1cd78 100644 --- a/packages/app-shell/src/console/ai/conversationLanguage.ts +++ b/packages/app-shell/src/console/ai/conversationLanguage.ts @@ -2,9 +2,15 @@ // // The language an AI CONVERSATION is being held in — distinct from the console // UI locale. A user chatting in Chinese under an English console must get -// Chinese canned messages, progress labels and confirm-card send text, not -// have English spliced into their thread (cloud#772). The conversation's own -// language wins; the UI locale is the fallback until a thread establishes one. +// Chinese canned messages and confirm-card SEND text, not have English spliced +// into their thread (cloud#772). The conversation's own language wins; the UI +// locale is the fallback until a thread establishes one. +// +// Scope, narrowly: this probe governs text that LEAVES the console for the +// agent. It does not govern anything RENDERED — labels, badges and progress +// chips follow the UI locale like the rest of the console, which is where every +// pack's translation of them becomes reachable (objectui#3837 removed the one +// label that had drifted in here). /** A message shape both the floating panel and the full-page chat can supply. */ interface LangProbeMessage { diff --git a/packages/i18n/src/__tests__/console-namespace-3546.test.tsx b/packages/i18n/src/__tests__/console-namespace-3546.test.tsx index 430bddf696..aebe4749e5 100644 --- a/packages/i18n/src/__tests__/console-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/console-namespace-3546.test.tsx @@ -296,17 +296,42 @@ describe('objectui#3546 slice four — the console namespace', () => { }); }); - it("zh planBuilding matches AiChatPage's hard-coded Chinese branch byte for byte", () => { - // `AiChatPage.tsx` gates `planBuildingLabel` on `convZh` (the CONVERSATION's - // language, #772/#2884) and hands back a literal '正在搭建…' instead of the - // pack. That ternary is a separate defect — the label is a LABEL and should - // follow the UI locale like every other label on the card, filed as a finding - // and NOT fixed here. Until it is, the two sources of that one string must - // agree, or a zh reader sees the pack's wording change nothing. + it('zh planBuilding is REACHABLE — no conversation-language gate shadows the pack (#3837)', () => { + // Slice four measured `planBuildingLabel` in `AiChatPage.tsx` gated on + // `convZh` (the CONVERSATION's language, #772/#2884) handing back a literal + // '正在搭建…' instead of the pack, which made the zh value below DEAD for every + // Chinese conversation — the pack could be re-worded and no zh reader would + // see it. That was filed as #3837 and fixed there; this assertion used to pin + // the literal and the pack byte-identical (the containment measure while the + // defect stood) and now pins its removal, which is the state that makes the + // pack the single source of the badge's text. const src = sourceOf('packages/app-shell/src/console/ai/AiChatPage.tsx'); - const literal = src.match(/convZh \? '([^']+)' : t\('console\.ai\.planBuilding'/); - expect(literal, 'the convZh planBuilding branch moved — recheck the finding').not.toBeNull(); - expect(at(builtInLocales.zh, 'console.ai.planBuilding')).toBe(literal![1]); + expect( + src, + 'the convZh gate is back over planBuildingLabel — the zh pack value is dead again (#3837)', + ).not.toMatch(/convZh \? '[^']*' : t\('console\.ai\.planBuilding'/); + expect(src, 'planBuildingLabel no longer reads the pack directly (#3837)').toMatch( + /planBuildingLabel=\{t\('console\.ai\.planBuilding'/, + ); + // Same invariant one step wider, so the next label to drift in is caught too: + // `convZh` may gate OUTBOUND message text only — the cloud confirm gate reads + // those two languages (see outbound-agent-messages.test.ts) — never anything + // RENDERED. Whole-line comments are stripped first so prose naming the + // identifier (there is some, right above it) can't be counted as a read. + const code = src.replace(/^\s*\/\/.*$/gm, ''); + const gated = [...code.matchAll(/const (\w+) = convZh\b/g)].map((m) => m[1]); + expect(gated).toEqual([ + 'planApproveMessage', + 'planApproveDefaultsMessage', + 'changesConfirmMessage', + ]); + expect( + [...code.matchAll(/\bconvZh\b/g)], + 'a convZh read appeared outside the outbound-message consts — if it feeds anything rendered, it follows the UI locale instead (#3837)', + ).toHaveLength(1 /* the useMemo that defines it */ + gated.length); + // And zh still spells the badge the way the deleted literal did, so the fix + // changed WHICH source answers a zh-UI reader, not what they read. + expect(at(builtInLocales.zh, 'console.ai.planBuilding')).toBe('正在搭建…'); }); it('the ratchet actually shrank — no console key is still baselined', () => {