diff --git a/backend/__tests__/unit/config/scoutWelcome.test.js b/backend/__tests__/unit/config/scoutWelcome.test.js new file mode 100644 index 000000000..8f704dfe9 --- /dev/null +++ b/backend/__tests__/unit/config/scoutWelcome.test.js @@ -0,0 +1,36 @@ +// TASK-550: Scout's welcome body is stored once, at signup, in the language +// the person signed up in. The English starts stay as they are, because Scout +// matches on those labels. +const { + SCOUT_WELCOME, + SCOUT_STARTS, + normalizeScoutLocale, + scoutWelcomeFor, +} = require('../../../config/native-agents/scout'); + +describe('Scout welcome locale', () => { + it('keeps the English welcome unchanged', () => { + expect(scoutWelcomeFor('en')).toBe('What would you like to do first?'); + expect(SCOUT_WELCOME).toBe('What would you like to do first?'); + }); + + it('returns a zh-CN welcome with no English in it', () => { + const zh = scoutWelcomeFor('zh-CN'); + expect(zh).not.toBe(SCOUT_WELCOME); + expect(zh).not.toMatch(/[A-Za-z]{2,}/); + }); + + it('falls back to English for anything that is not zh-CN', () => { + expect(normalizeScoutLocale(undefined)).toBe('en'); + expect(normalizeScoutLocale('fr')).toBe('en'); + expect(normalizeScoutLocale('zh')).toBe('en'); + expect(scoutWelcomeFor('fr')).toBe(SCOUT_WELCOME); + }); + + it('keeps the start labels in English, since Scout reads them', () => { + expect(SCOUT_STARTS.map((s) => s.label)).toEqual([ + 'Connect an agent I already use', + 'Start a pod for a project', + ]); + }); +}); diff --git a/backend/config/native-agents/scout.ts b/backend/config/native-agents/scout.ts index 14df6e7d1..f0115ef3e 100644 --- a/backend/config/native-agents/scout.ts +++ b/backend/config/native-agents/scout.ts @@ -41,8 +41,30 @@ export const SCOUT_STARTS = [ */ export const SCOUT_HOSTED_START = { id: 'run-hosted', label: 'Run an agent here' } as const; -/** Scout's first message in a new workspace; the starts render under it. */ -export const SCOUT_WELCOME = 'What would you like to do first?'; +/** + * Scout's first message in a new workspace; the starts render under it. The + * body is stored once, at signup, in the language the person signed up in + * (TASK-550). The starts above keep their English labels: Scout matches on + * them, and only the displayed button text is translated, by id. + */ +const SCOUT_WELCOME_BY_LOCALE = { + en: 'What would you like to do first?', + 'zh-CN': '你想先做什么?', +} as const; + +export type ScoutLocale = keyof typeof SCOUT_WELCOME_BY_LOCALE; + +/** Anything that is not zh-CN falls back to English. */ +export const normalizeScoutLocale = (value: unknown): ScoutLocale => ( + value === 'zh-CN' ? 'zh-CN' : 'en' +); + +export const scoutWelcomeFor = (locale: unknown = 'en'): string => ( + SCOUT_WELCOME_BY_LOCALE[normalizeScoutLocale(locale)] +); + +/** English welcome, kept for callers that have no locale. */ +export const SCOUT_WELCOME = SCOUT_WELCOME_BY_LOCALE.en; export const scoutApp = { agentName: 'scout', diff --git a/backend/controllers/authController.ts b/backend/controllers/authController.ts index a809014dd..019ea8253 100644 --- a/backend/controllers/authController.ts +++ b/backend/controllers/authController.ts @@ -166,7 +166,7 @@ const sendVerificationEmailInBackground = (user: any) => { // step's rejection surfaces as an unhandled rejection from the `void // finishWorkspaceOnboarding(...)` call site, which is a process crash under // Node's default --unhandled-rejections=throw, on the register path. -const finishWorkspaceOnboarding = async (pod: any, userId: any) => { +const finishWorkspaceOnboarding = async (pod: any, userId: any, locale: string = 'en') => { try { // Mirror the workspace into PostgreSQL immediately. The UI path // (`createPod`) already does this, but registration's workspace pod did @@ -240,7 +240,7 @@ const finishWorkspaceOnboarding = async (pod: any, userId: any) => { try { // eslint-disable-next-line global-require const { - scoutApp, SCOUT_HOSTED_START, SCOUT_STARTS, SCOUT_WELCOME, + scoutApp, SCOUT_HOSTED_START, SCOUT_STARTS, scoutWelcomeFor, } = require('../config/native-agents/scout'); // eslint-disable-next-line global-require const { buildInstallationConfig } = require('../scripts/seed-native-agents'); @@ -328,7 +328,7 @@ const finishWorkspaceOnboarding = async (pod: any, userId: any) => { instanceId: scoutInstanceId, podId: pod._id.toString(), displayName: scoutApp.displayName, - content: SCOUT_WELCOME, + content: scoutWelcomeFor(locale), // ADR-020 D3: the starts render as buttons under this message from // the payload, not from the text. Server-composed, never model prose. payload: { @@ -362,7 +362,7 @@ const finishWorkspaceOnboarding = async (pod: any, userId: any) => { // awaited. The queued call is not tracked anywhere: a pod-restart mid-tail // leaves a workspace with no Guide and no starter checklist, the same // best-effort limit the verification email already carries. -const createDefaultWorkspacePod = async (userId: any) => { +const createDefaultWorkspacePod = async (userId: any, locale: string = 'en') => { try { const pod = await Pod.create({ name: 'My Workspace', @@ -373,7 +373,7 @@ const createDefaultWorkspacePod = async (userId: any) => { members: [userId], }); - void finishWorkspaceOnboarding(pod, userId); + void finishWorkspaceOnboarding(pod, userId, locale); } catch (podError: any) { console.warn('[register] default workspace pod creation failed:', podError?.message); } @@ -408,6 +408,7 @@ exports.register = async (req: any, res: any) => { invitationCode, signupRef, signupSource, + locale, } = req.body; const normalizedEmail = normalizeEmail(email); @@ -544,7 +545,9 @@ exports.register = async (req: any, res: any) => { await user.save(); } - await createDefaultWorkspacePod(user._id); + // eslint-disable-next-line global-require + const { normalizeScoutLocale } = require('../config/native-agents/scout'); + await createDefaultWorkspacePod(user._id, normalizeScoutLocale(locale)); if (shouldAutoVerify) joinCommunityPodBestEffort(user._id); diff --git a/frontend/src/i18n/__tests__/scoutStartLabels.test.ts b/frontend/src/i18n/__tests__/scoutStartLabels.test.ts new file mode 100644 index 000000000..0d4df717a --- /dev/null +++ b/frontend/src/i18n/__tests__/scoutStartLabels.test.ts @@ -0,0 +1,33 @@ +import { act } from '@testing-library/react'; +import i18n, { i18nReady, LANGUAGE_STORAGE_KEY } from '..'; + +// TASK-550: Scout's welcome starts are sent to Scout by their English labels +// (backend/config/native-agents/scout.ts), so only the displayed text is +// translated, by id. Every id the welcome can offer needs a zh-CN label. +const START_IDS = ['connect-agent', 'start-pod', 'run-hosted']; + +describe('Scout welcome start labels', () => { + beforeAll(async () => { + await i18nReady; + }); + + afterAll(async () => { + localStorage.removeItem(LANGUAGE_STORAGE_KEY); + await act(async () => { + await i18n.changeLanguage('en'); + }); + }); + + it.each(START_IDS)('shows a zh-CN label for %s with no English left in it', (id) => { + const key = `podChat.starts.labels.${id}`; + const zh = i18n.t(key, { lng: 'zh-CN' }); + expect(zh).not.toBe(i18n.t(key, { lng: 'en' })); + // Short product terms such as "pod" may stay in Latin letters; longer English words may not. + expect(zh).not.toMatch(/[A-Za-z]{4,}/); + }); + + it('keeps the English label Scout reads when the language is English', () => { + expect(i18n.t('podChat.starts.labels.connect-agent', { lng: 'en' })).toBe('Connect an agent I already use'); + expect(i18n.t('podChat.starts.labels.start-pod', { lng: 'en' })).toBe('Start a pod for a project'); + }); +}); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3dbd8b52a..68f5d9eb4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -558,7 +558,12 @@ }, "starts": { "label": "Ways to start", - "openByo": "Open Bring your own" + "openByo": "Open Bring your own", + "labels": { + "connect-agent": "Connect an agent I already use", + "start-pod": "Start a pod for a project", + "run-hosted": "Run an agent here" + } }, "dmMark": "DM", "invite": "Invite to this pod", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 4755445bb..069377dc9 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -558,7 +558,12 @@ }, "starts": { "label": "开始方式", - "openByo": "去接入自己的智能体" + "openByo": "去接入自己的智能体", + "labels": { + "connect-agent": "接入我在用的智能体", + "start-pod": "新建项目 Pod", + "run-hosted": "在这里运行智能体" + } }, "dmMark": "私信", "invite": "邀请成员加入这个 Pod", diff --git a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx index f338073b9..c2296f61e 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx @@ -15,6 +15,7 @@ import axios from 'axios'; import V2Thread from '../components/V2Thread'; import { resetByoStepsForTest } from '../utils/byoTelemetry'; import { AuthContext } from '../../context/AuthContext'; +import i18n, { i18nReady } from '../../i18n'; jest.mock('../../context/SocketContext', () => ({ useSocket: () => ({ socket: null, connected: false }), @@ -630,3 +631,29 @@ describe('"Run an agent here" runs in place on the owner\'s own welcome (TASK-29 expect(calls('/api/hosted/provision')).toHaveLength(0); }); }); + +// TASK-550: the start buttons show zh-CN labels by id, while the click still +// sends the English label Scout matches on. This pins both halves. +describe("V2Thread — Scout's starts under zh-CN", () => { + beforeAll(async () => { + await i18nReady; + }); + + afterEach(async () => { + await i18n.changeLanguage('en'); + }); + + test('the button shows the zh-CN label, and a click sends the English label', async () => { + await i18n.changeLanguage('zh-CN'); + const sendSpy = renderThread([welcome]); + + // The group's own label is translated too, so find it by its zh-CN name. + const group = screen.getByRole('group', { name: '开始方式' }); + expect(within(group).getByRole('button', { name: '接入我在用的智能体' })).toBeInTheDocument(); + expect(within(group).queryByRole('button', { name: CONNECT })).not.toBeInTheDocument(); + + fireEvent.click(within(group).getByRole('button', { name: '接入我在用的智能体' })); + + await waitFor(() => expect(sendSpy).toHaveBeenCalledWith(CONNECT, 'text', undefined, undefined)); + }); +}); diff --git a/frontend/src/v2/components/V2Register.tsx b/frontend/src/v2/components/V2Register.tsx index 9a0918b69..907ec9f73 100644 --- a/frontend/src/v2/components/V2Register.tsx +++ b/frontend/src/v2/components/V2Register.tsx @@ -35,7 +35,7 @@ const Brand: React.FC = () => ( const V2Register: React.FC = () => { const { login } = useAuth(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); const [email, setEmail] = useState(''); @@ -95,6 +95,8 @@ const V2Register: React.FC = () => { // Where this signup came from (?ref=x on the first visit), stored once. signupRef: readFirstTouchRef(), signupSource: readFirstTouchSource(), + // The language they are reading, so Scout's welcome is stored in it (TASK-550). + locale: i18n.language === 'zh-CN' ? 'zh-CN' : 'en', }); const data = res.data as { message?: string }; const message = data?.message || t('auth.register.success.ready'); diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx index 31e8d54f8..412ed4ea3 100644 --- a/frontend/src/v2/components/V2ThreadMessages.tsx +++ b/frontend/src/v2/components/V2ThreadMessages.tsx @@ -312,7 +312,7 @@ const V2ThreadMessages: React.FC = ({ aria-busy={starting || undefined} onClick={() => onStart(start, String(message.id))} > - {starting ? t('agentByo.actions.starting') : start.label} + {starting ? t('agentByo.actions.starting') : t(`podChat.starts.labels.${start.id}`, { defaultValue: start.label })} {hostedHere?.status === 'failed' && (

{hostedHere.error}