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
47 changes: 47 additions & 0 deletions .changeset/10900-zh-cn-leftovers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@object-ui/core': minor
'@object-ui/auth': minor
'@object-ui/react': patch
'@object-ui/i18n': patch
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

Four Console surfaces that read English under a zh-CN session now read the session's
language (objectui#10900). English stays the default.

- **The generic action success toast.** When an action declares no `successMessage` and
the server returns no message, the runner falls back to "Action completed successfully".
That fallback now goes through a translator: `ActionRunner.setTranslator(translate)` is
new in `@object-ui/core`, and `<ActionProvider>` and `useActionRunner` in
`@object-ui/react` install the session's `t` on the runner they build. The pack key is
`actions.completedSuccessfully`. An author's `successMessage` and a server-returned
message still reach the toast verbatim, and a runner with no translator installed still
toasts the English sentence.
- **The social sign-in buttons on the login and sign-up pages.** `SocialSignInButtons`
takes a new `buttonText` prop, a template whose `{provider}` is replaced with the
provider's display name; unset, the buttons keep "Continue with {provider}" and
"Sign up with {provider}". `LoginForm` and `RegisterForm` take a new `socialButton`
label and pass it, with their existing `orText` label, to the buttons. `orText` was
documented as the divider label but rendered nowhere; it now sets the divider under the
buttons, and its documented default is corrected from "or" to "or continue with email",
the text that divider has always shown. The console's login and sign-up pages and
`@object-ui/app-shell`'s `DefaultLoginPage` / `DefaultRegisterPage` pass the new
`auth.login.*` and `auth.register.*` `socialButton` / `orText` keys. The provider's display
name is inserted as-is: the component's own label for the branded providers it knows,
otherwise the name the server reports.
- **Build Doctor.** The build conversation's Build Doctor button (its accessible name and
both tooltips) and the title of the drawer it opens read `console.ai.buildDoctor`,
`console.ai.buildDoctorTitle` and `console.ai.buildDoctorDisabledTitle`.
- **Setup → marketplace.** On `system/marketplace` and the pages under it, the breadcrumb
segment after System reads `console.breadcrumb.marketplace` instead of the humanized URL
slug; other `system/*` segments are unchanged. The zh marketplace search placeholder
reads 「按名称或标识搜索应用…」 instead of 「按名称或 manifest ID 搜索应用…」. The search
itself is unchanged: it matches the display name, the identifier and the description.

All ten locale packs carry the nine new keys; no existing `en` value changes.

**Clause-②: yes** — besides the nine pack keys, the public surface widens by four optional
members: `ActionRunner.setTranslator`, `SocialSignInButtonsProps.buttonText`,
`LoginFormLabels.socialButton` and `RegisterFormLabels.socialButton`. Nothing is removed,
renamed or narrowed, no accept set changes, and `@object-ui/react` exports nothing new.
4 changes: 4 additions & 0 deletions apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,10 @@ function LoginFormCard({
usePasswordSignInText: t('auth.login.usePasswordSignInText', {
defaultValue: 'Sign in with password instead',
}),
// `{provider}` is `SocialSignInButtons`' own single-brace hole — kept
// out of i18next's `{{…}}` interpolation on purpose.
socialButton: t('auth.login.socialButton', { defaultValue: 'Continue with {provider}' }),
orText: t('auth.login.orText', { defaultValue: 'or continue with email' }),
}}
/>
);
Expand Down
4 changes: 4 additions & 0 deletions apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ export function RegisterPage() {
submittingButton: t('auth.register.submittingButton', { defaultValue: 'Creating account…' }),
hasAccountText: t('auth.register.hasAccountText', { defaultValue: 'Already have an account?' }),
signInText: t('auth.register.signInText', { defaultValue: 'Sign in' }),
// `{provider}` is `SocialSignInButtons`' own single-brace hole — kept
// out of i18next's `{{…}}` interpolation on purpose.
socialButton: t('auth.register.socialButton', { defaultValue: 'Sign up with {provider}' }),
orText: t('auth.register.orText', { defaultValue: 'or continue with email' }),
}}
/>
</Card>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* 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#10900 — the environment login and sign-up pages render their social
* provider button and the divider under it in the session's language.
*
* The 2026-09-28 cloud E2E, browser in zh-CN, read `Continue with ObjectStack`
* and `OR CONTINUE WITH EMAIL` (the capitals are the divider's `uppercase`
* class) on the environment login page. Both were `SocialSignInButtons`
* defaults that no label reached. The pages now pass `auth.login.*` /
* `auth.register.*` `socialButton` and `orText` through the forms' `labels`.
*
* Rendered as shipped: the console page, `@object-ui/auth`'s real form and
* buttons, a real `AuthProvider` and a real `I18nProvider`; only the auth
* client is a stub, answering `/auth/config` with one social provider. The
* provider's own name stays as the server reports it.
*/
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { I18nProvider } from '@object-ui/i18n';
import { AuthProvider } from '@object-ui/auth';
import type { AuthClient } from '@object-ui/auth';
import { LoginPage } from '../LoginPage';
import { RegisterPage } from '../RegisterPage';

afterEach(cleanup);
beforeEach(() => {
window.localStorage.clear();
});

const CONFIG = {
socialProviders: [{ id: 'objectstack-cloud', name: 'ObjectStack', enabled: true, type: 'oidc' }],
emailPassword: { enabled: true, disableSignUp: false },
};

function createMockClient(): AuthClient {
return {
getSession: vi.fn().mockResolvedValue(null),
getConfig: vi.fn().mockResolvedValue(CONFIG),
} as unknown as AuthClient;
}

const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const;
const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const;

function renderAt(path: '/login' | '/register', config: typeof ZH | typeof EN) {
window.history.replaceState({}, '', path);
return render(
<I18nProvider config={config} persistLanguage={false}>
<AuthProvider authUrl="/api/v1/auth" client={createMockClient()}>
<MemoryRouter initialEntries={[path]}>
{path === '/login' ? <LoginPage /> : <RegisterPage />}
</MemoryRouter>
</AuthProvider>
</I18nProvider>,
);
}

// Button names are matched at their END: an unknown provider id renders a
// one-letter icon badge inside the button, ahead of the label.
describe('console LoginPage — social sign-in under zh-CN (objectui#10900)', () => {
it('renders the button and the divider in Chinese under zh', async () => {
renderAt('/login', ZH);
expect(await screen.findByRole('button', { name: /使用 ObjectStack 继续$/ })).toBeInTheDocument();
expect(screen.getByText('或使用邮箱继续')).toBeInTheDocument();
expect(screen.queryByText(/Continue with/)).toBeNull();
expect(screen.queryByText('or continue with email')).toBeNull();
});

it('stays English under en', async () => {
renderAt('/login', EN);
expect(await screen.findByRole('button', { name: /Continue with ObjectStack$/ })).toBeInTheDocument();
expect(screen.getByText('or continue with email')).toBeInTheDocument();
});
});

describe('console RegisterPage — social sign-up under zh-CN (objectui#10900)', () => {
it('renders the button and the divider in Chinese under zh', async () => {
renderAt('/register', ZH);
expect(await screen.findByRole('button', { name: /使用 ObjectStack 注册$/ })).toBeInTheDocument();
expect(screen.getByText('或使用邮箱继续')).toBeInTheDocument();
expect(screen.queryByText(/Sign up with/)).toBeNull();
});

it('stays English under en', async () => {
renderAt('/register', EN);
expect(await screen.findByRole('button', { name: /Sign up with ObjectStack$/ })).toBeInTheDocument();
expect(screen.getByText('or continue with email')).toBeInTheDocument();
});
});
7 changes: 6 additions & 1 deletion content/docs/core/enhanced-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,12 @@ authored callback fails at the authoring site with the migration in the message.
What to write instead:

- **A notice** — `successMessage` / `errorMessage`, plain strings (the runner
surfaces `successMessage` as a toast after a successful action).
surfaces `successMessage` as a toast after a successful action). The runner
shows `successMessage` as written. Without one, and without a message from
the server, it falls back to a generic "Action completed successfully",
which is the one toast text it translates: `<ActionProvider>` and
`useActionRunner` install the session's translator on their runner, and a
runner you build yourself takes one through `runner.setTranslator(t)`.
- **Post-success navigation** — the spec's `onSuccess` block, `{ navigate, openIn }`,
declared on `UIActionSchema` and forwarded to the runner (objectui#5934). It is
a spec key, not a member of this legacy type, so it is not shown in a fence here.
Expand Down
4 changes: 2 additions & 2 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2257,9 +2257,9 @@ export function ChatPane({
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={onDebug}
disabled={!conversationId}
aria-label="Build Doctor"
aria-label={t('console.ai.buildDoctor')}
data-testid="ai-chat-debug-button"
title={conversationId ? 'Build Doctor — what actually landed?' : 'Send a message first'}
title={conversationId ? t('console.ai.buildDoctorTitle') : t('console.ai.buildDoctorDisabledTitle')}
>
<Bug className="h-3.5 w-3.5" />
</Button>
Expand Down
5 changes: 3 additions & 2 deletions packages/app-shell/src/console/ai/BuildDebugDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/

import React, { useEffect, useState } from 'react';
import { useDisplayLocale } from '@object-ui/i18n';
import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n';
import {
Sheet,
SheetContent,
Expand All @@ -35,6 +35,7 @@ export function BuildDebugDrawer({ apiBase, conversationId, open, onOpenChange }
// Dates and numbers on this surface read the display locale; a bare
// `toLocale*()` call used the MACHINE's locale (objectui#9909).
const displayLocale = useDisplayLocale();
const { t } = useObjectTranslation();
const [report, setReport] = useState<BuildDebugReport | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -70,7 +71,7 @@ export function BuildDebugDrawer({ apiBase, conversationId, open, onOpenChange }
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Bug className="h-4 w-4" /> Build Doctor
<Bug className="h-4 w-4" /> {t('console.ai.buildDoctor')}
</SheetTitle>
<SheetDescription>
What the agent claimed vs what is actually live. Read-only diagnostic.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#10900 — the build conversation's Build Doctor button and the drawer
* it opens speak the session's language.
*
* The 2026-09-28 cloud E2E, browser in zh-CN, read `Build Doctor` in the
* build conversation header: the button's accessible name and both of its
* tooltips were English literals beside a Share button that already read
* `console.ai.*` keys, and the drawer's title was a literal too. Rendered here
* as shipped under a real `I18nProvider` — `ChatPane` with `useObjectChat`
* stubbed and `ChatbotEnhanced` reduced to the `headerSlot` ChatPane hands it
* (the way the sibling ChatPane suites stub them), and the drawer with its one
* request answered.
*/
import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import React from 'react';

vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
// The header the pane builds reaches the chat surface as `headerSlot`;
// render just that, so the button is in the DOM as ChatPane made it.
ChatbotEnhanced: (props: { headerSlot?: React.ReactNode }) => <div>{props.headerSlot}</div>,
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 }),
};
});

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 { BuildDebugDrawer } from '../BuildDebugDrawer';

window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;

const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const;
const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const;

function renderPane(config: typeof ZH | typeof EN, conversationId: string | undefined) {
return render(
<I18nProvider config={config} persistLanguage={false}>
<MemoryRouter initialEntries={['/ai/build']}>
<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={conversationId}
initialMessages={[]}
pendingFirstMessageRef={{ current: null }}
onSent={vi.fn()}
onShare={vi.fn()}
onDebug={vi.fn()}
showDebug
/>
</MemoryRouter>
</I18nProvider>,
);
}

function renderDrawer(config: typeof ZH | typeof EN) {
return render(
<I18nProvider config={config} persistLanguage={false}>
<BuildDebugDrawer apiBase="/api/v1/ai" conversationId="conv_x" open onOpenChange={() => {}} />
</I18nProvider>,
);
}

beforeEach(() => {
window.localStorage.clear();
// The drawer's one request; an unreadable answer is enough — the title is
// drawn before and regardless of the report.
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) })));
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe('Build Doctor button (objectui#10900)', () => {
it('zh: accessible name and tooltip are Chinese', () => {
renderPane(ZH, 'conv-1');
const button = screen.getByTestId('ai-chat-debug-button');
expect(button).toHaveAttribute('aria-label', '构建诊断');
expect(button).toHaveAttribute('title', '构建诊断 — 实际生效了哪些变更?');
});

it('zh: the tooltip before the first message is Chinese', () => {
renderPane(ZH, undefined);
expect(screen.getByTestId('ai-chat-debug-button')).toHaveAttribute('title', '请先发送一条消息');
});

it('en: unchanged English', () => {
renderPane(EN, 'conv-1');
const button = screen.getByTestId('ai-chat-debug-button');
expect(button).toHaveAttribute('aria-label', 'Build Doctor');
expect(button).toHaveAttribute('title', 'Build Doctor — what actually landed?');
});

it('en: the tooltip before the first message is unchanged English', () => {
renderPane(EN, undefined);
expect(screen.getByTestId('ai-chat-debug-button')).toHaveAttribute('title', 'Send a message first');
});
});

describe('Build Doctor drawer title (objectui#10900)', () => {
it('zh: the title is Chinese', () => {
renderDrawer(ZH);
expect(screen.getByRole('dialog', { name: '构建诊断' })).toBeInTheDocument();
});

it('en: the title is unchanged English', () => {
renderDrawer(EN);
expect(screen.getByRole('dialog', { name: 'Build Doctor' })).toBeInTheDocument();
});
});
2 changes: 2 additions & 0 deletions packages/app-shell/src/console/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export function LoginPage() {
submittingButton: t('auth.login.submittingButton'),
noAccountText: t('auth.login.noAccountText'),
signUpText: t('auth.login.signUpText'),
socialButton: t('auth.login.socialButton'),
orText: t('auth.login.orText'),
}}
/>
</AuthPageLayout>
Expand Down
2 changes: 2 additions & 0 deletions packages/app-shell/src/console/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ export function RegisterPage() {
submittingButton: t('auth.register.submittingButton'),
hasAccountText: t('auth.register.hasAccountText'),
signInText: t('auth.register.signInText'),
socialButton: t('auth.register.socialButton'),
orText: t('auth.register.orText'),
}}
/>
</AuthPageLayout>
Expand Down
Loading
Loading