From 4f0f6fc85df9330d34ed32cba2f93f687ac73a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 00:39:37 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(app-shell):=20=E6=96=B9=E6=A1=88?= =?UTF-8?q?=E5=8D=A1=E7=9A=84=20Building=E2=80=A6=20=E5=BE=BD=E6=A0=87?= =?UTF-8?q?=E9=9A=8F=20UI=20=E8=AF=AD=E8=A8=80=E5=8F=96=E5=80=BC,=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=E4=BC=9A=E8=AF=9D=E8=AF=AD=E8=A8=80=E9=97=A8?= =?UTF-8?q?=E6=8E=A7=20(#3837)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AiChatPage` 用 `convZh`(会话语言)门控四个字符串,因为 cloud 确认门 (`service-ai-studio` `confirm-gate.ts` `APPROVAL_RE`)只认中英,发进线程的 文本必须与线程本身同语言(#772/#2884)。该门上方注释的后半句写明了另一半规 则:button LABELS stay on the UI locale。 `planBuildingLabel`(#2632 引入)落在了这句的错误一侧,中文会话恒取硬编码的 `正在搭建…`,两个后果都已实测: - **混合语言的卡片**:英文控制台里,中文会话的方案卡 `Proposed plan` / `Build it` / `Built` / `Not yet built` 全英文,中间夹一个中文徽标。#2458 第 4 条记录的是同一个病的反方向。 - **翻译永远读不到**:中文会话恒走字面量,`console.ai.planBuilding` 的 zh 值对中文读者无效。#3546 切片四(PR #3839)刚把该 key 补进十包,只能做围 堵 —— 把 zh 值写成与字面量逐字节相同并把两者钉在一起。 徽标现在与相邻十二个标签一样读 `t('console.ai.planBuilding', …)`:十个包全 部可达(德文控制台 + 中文会话渲染 `Wird erstellt…`),zh 包成为该中文措辞的 唯一来源(值不变,中文读者看到的字符串与改动前完全一致)。 三条出站文本(`planApproveMessage` / `planApproveDefaultsMessage` / `changesConfirmMessage`)一行未动,仍随会话语言 —— 复核确认三者都交给 `onSendMessage`(`ChatbotEnhanced.tsx:1598`、`:2395`)、由门读取,正是 `convZh` 分支存在的那一类;全文件再无其它 `*Label`/`*Title` 被该门控住 (`convZh` 全部读点:定义 1 处 + 这三条)。 钉子: - `packages/app-shell/.../AiChatPage.planCardLocale.test.tsx`(新增)真渲染 `ChatPane` + 真 `I18nProvider` + 真 `isConversationZh`,把 `ChatbotEnhanced` 换成 props 记录器:en 控制台 + 中文会话 → `Building…` 且卡上 `*Label` 无一含中日韩字符;de 控制台 + 中文会话 → `Wird erstellt…` (三方语言证明是包在应答,而非两路三元);zh 控制台 → `正在搭建…`;三条 出站文本仍随会话。 - `packages/i18n/.../console-namespace-3546.test.tsx`:切片四埋的围堵钉按其 自述翻转 —— 从「字面量与 zh 包逐字节相同」改为钉住门已移除,并把不变量放 宽一步:`convZh` 只许门控出站 `*Message`,任何新读点都会红。 反向验证(方向先判后跑,与预判一致):还原三元 → 3 红 —— i18n 钉子按自定义 消息点名、en 控制台用例、de 控制台用例;zh 控制台用例与三条出站文本保持绿 (前者按设计逐字节相同,不具区分力,已在测试头注明)。 顺带:`conversationLanguage.ts` 模块头把 progress labels 也列进「随会话」, 该句在本改动后不再为真,收窄为「只治离开控制台发给 agent 的文本」。 写钉子时量到发送半边另有一个缺陷(zh 控制台 + 英文会话会把中文确认句发进英 文线程;`planAnswerMessage` 完全没有门控),不在本单范围,已立 #3896。 测试:`packages/app-shell/src/console/ai` + `packages/i18n` 47 文件 654 通过; 两包 type-check 通过,全仓 `turbo run type-check` 78/78 通过;改动文件 eslint 零告警;`check-control-bytes` / `check-i18n-call-site-keys` / `check-i18n-en-drift` 均通过(零 en 值变更)。 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt Co-authored-by: Claude --- .../plan-building-label-ui-locale-3837.md | 40 ++++ .../app-shell/src/console/ai/AiChatPage.tsx | 11 +- .../AiChatPage.planCardLocale.test.tsx | 183 ++++++++++++++++++ .../src/console/ai/conversationLanguage.ts | 12 +- .../__tests__/console-namespace-3546.test.tsx | 45 ++++- 5 files changed, 275 insertions(+), 16 deletions(-) create mode 100644 .changeset/plan-building-label-ui-locale-3837.md create mode 100644 packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx 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..be0aa9eae5 --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx @@ -0,0 +1,183 @@ +// 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 { 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', () => { From adb3f6ba5ac034064db5995b7f5d54569c86f856 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 00:43:18 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(app-shell):=20=E9=92=89=E5=AD=90?= =?UTF-8?q?=E9=87=8C=E7=9A=84=20agent=20=E6=A1=A9=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=85=B7=E5=90=8D=E7=B1=BB=E5=9E=8B=E6=96=AD=E8=A8=80,?= =?UTF-8?q?=E4=B8=8D=E7=94=A8=20as=20never?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审可读性,零行为改动:`ChatPane` 的 agents 桩从 `as never` 改成 `as unknown as AgentDescriptor`(类型导入,运行期擦除;该模块在本文件里被 vi.mock,类型不受影响)。 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt Co-authored-by: Claude --- .../console/ai/__tests__/AiChatPage.planCardLocale.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index be0aa9eae5..309c499809 100644 --- a/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.planCardLocale.test.tsx @@ -73,6 +73,7 @@ vi.mock('../../../providers/AdapterProvider', async (importOriginal) => { }); import { I18nProvider } from '@object-ui/i18n'; +import type { AgentDescriptor } from '@object-ui/plugin-chatbot'; import { ChatPane } from '../AiChatPage'; import type { HydratedUIMessage } from '../../../hooks/useChatConversation'; @@ -107,7 +108,7 @@ function renderPane(uiLocale: string, initialMessages: HydratedUIMessage[]) {