Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/plan-building-label-ui-locale-3837.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 8 additions & 3 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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.',
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
ChatbotEnhanced: (props: Record<string, unknown>) => {
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<Record<string, unknown>>();
return { ...actual, useMetadata: () => ({ apps: [] }) };
});
vi.mock('../../../providers/AdapterProvider', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
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(
<I18nProvider config={{ defaultLanguage: uiLocale, detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={['/ai/build/conv-1']}>
<ChatPane
agents={[{ name: 'build', label: 'Builder' } as unknown as AgentDescriptor]}
agentsLoading={false}
agentsError={undefined}
activeAgent="build"
chatApi="/api/v1/ai/agents/build/chat"
apiBase="/api/v1/ai"
conversationId="conv-1"
initialMessages={initialMessages}
pendingFirstMessageRef={{ current: null }}
onSent={vi.fn()}
onShare={vi.fn()}
/>
</MemoryRouter>
</I18nProvider>,
);
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.');
});
});
12 changes: 9 additions & 3 deletions packages/app-shell/src/console/ai/conversationLanguage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 35 additions & 10 deletions packages/i18n/src/__tests__/console-namespace-3546.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading