From b761e8604125e34ecf0e6d21cc7328e9788e9460 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:45:56 -0700 Subject: [PATCH 1/6] fix(i18n): show Scout's start labels in zh-CN by id (TASK-550) The click still sends the English label, which Scout matches on (backend/config/native-agents/scout.ts). Only the displayed text is translated, keyed on the start id, with the English label as fallback. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: swift Agent-Model: claude/claude-haiku-5-5/default Co-authored-by: tern --- .../i18n/__tests__/scoutStartLabels.test.ts | 33 +++++++++++++++++++ frontend/src/i18n/locales/en.json | 7 +++- frontend/src/i18n/locales/zh-CN.json | 7 +++- .../src/v2/components/V2ThreadMessages.tsx | 2 +- 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 frontend/src/i18n/__tests__/scoutStartLabels.test.ts diff --git a/frontend/src/i18n/__tests__/scoutStartLabels.test.ts b/frontend/src/i18n/__tests__/scoutStartLabels.test.ts new file mode 100644 index 0000000000..0d4df717a9 --- /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 3dbd8b52a5..68f5d9eb48 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 4755445bb2..23fb9d7213 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/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx index 31e8d54f8e..412ed4ea37 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}

From 74c94d58f13d1672596e75b46b273741c94509db Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:48:00 -0700 Subject: [PATCH 2/6] fix(onboarding): store Scout's welcome body in the signup language (TASK-550) The register call sends the locale the person is reading (zh-CN or en); the server normalises it and passes it to the welcome seed. The English start labels are unchanged, since Scout matches on them. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: swift Agent-Model: claude/claude-haiku-5-5/default Co-authored-by: tern --- .../unit/config/scoutWelcome.test.js | 36 +++++++++++++++++++ backend/config/native-agents/scout.ts | 26 ++++++++++++-- backend/controllers/authController.ts | 15 ++++---- frontend/src/v2/components/V2Register.tsx | 4 ++- 4 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 backend/__tests__/unit/config/scoutWelcome.test.js diff --git a/backend/__tests__/unit/config/scoutWelcome.test.js b/backend/__tests__/unit/config/scoutWelcome.test.js new file mode 100644 index 0000000000..8f704dfe96 --- /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 14df6e7d1d..f0115ef3e6 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 a809014dd4..019ea8253c 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/v2/components/V2Register.tsx b/frontend/src/v2/components/V2Register.tsx index 9a0918b69f..907ec9f737 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'); From 59f18a5b1c8bf5b0c10390779b89b212922ee061 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:23:16 -0700 Subject: [PATCH 3/6] fix(i18n): capitalise Pod in Scout's zh-CN start label (TASK-550) The zh-CN locale keeps the product noun 'Pod' capitalised everywhere else. The start button read 'pod' in lowercase, so it did not match. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: wisp Agent-Model: claude/claude-haiku-5-5/default Co-authored-by: tern --- frontend/src/i18n/locales/zh-CN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 23fb9d7213..01030d05d0 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -561,7 +561,7 @@ "openByo": "去接入自己的智能体", "labels": { "connect-agent": "连接我已有的智能体", - "start-pod": "为一个项目创建 pod", + "start-pod": "为一个项目创建 Pod", "run-hosted": "在这里运行一个智能体" } }, From 1dd4379dc44d71d791429a0ec2021af3c0b2ba02 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:22:22 -0700 Subject: [PATCH 4/6] fix(i18n): adjudicate Scout's zh-CN start labels against the glossary (TASK-550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice and terminology passes per zh-cn-ui-localization. connect-agent reads as a native phrase now (已在用的). start-pod drops the filler 一个 so the verb-first button stays short. Change table: | key | before | after | reason | | starts.labels.connect-agent | 连接我已有的智能体 | 连接我已在用的智能体 | 翻译腔 'I already have'; 已在用 is the native phrasing | | starts.labels.start-pod | 为一个项目创建 Pod | 为项目创建 Pod | button should be short and verb-first | Co-Authored-By: Claude Haiku 5.5 Co-authored-by: wisp Agent-Model: claude/claude-haiku-5-5/default Co-authored-by: tern --- frontend/src/i18n/locales/zh-CN.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 01030d05d0..8e14fa904e 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -560,8 +560,8 @@ "label": "开始方式", "openByo": "去接入自己的智能体", "labels": { - "connect-agent": "连接我已有的智能体", - "start-pod": "为一个项目创建 Pod", + "connect-agent": "连接我已在用的智能体", + "start-pod": "为项目创建 Pod", "run-hosted": "在这里运行一个智能体" } }, From 8264f1be6aa3ea473280ef6353a200f9a9e69d4c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:10:14 -0700 Subject: [PATCH 5/6] test(i18n): pin zh-CN start labels in the thread render (TASK-550) The starts test only rendered and clicked with English labels, so it would still pass if the renderer went back to start.label. Under zh-CN the button must show the Chinese label, and the click must still send the English label Scout matches on. Reverting the lookup fails the new case. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: tern Agent-Model: claude/claude-haiku-5-5/default --- .../src/v2/__tests__/V2ThreadStarts.test.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx index f338073b94..d36dba5e5a 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)); + }); +}); From dc9f74cfcc6972c4466a7e624c523289fe59fc4b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:25:12 -0700 Subject: [PATCH 6/6] fix(i18n): apply the zh-CN glossary pass to Scout's start labels (TASK-550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Otto's independent glossary pass HOLD on #2737 asked for three changes: - start-pod: 为项目创建 Pod -> 新建项目 Pod (verb first) - run-hosted: 在这里运行一个智能体 -> 在这里运行智能体 (matches agentByo.compact.title) - connect-agent: 连接我已在用的智能体 -> 接入我在用的智能体 (own-agent path uses 接入) The starts test pins the connect label, so it moves with it. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: tern Agent-Model: claude/claude-haiku-5-5/default --- frontend/src/i18n/locales/zh-CN.json | 6 +++--- frontend/src/v2/__tests__/V2ThreadStarts.test.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 8e14fa904e..069377dc98 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -560,9 +560,9 @@ "label": "开始方式", "openByo": "去接入自己的智能体", "labels": { - "connect-agent": "连接我已在用的智能体", - "start-pod": "为项目创建 Pod", - "run-hosted": "在这里运行一个智能体" + "connect-agent": "接入我在用的智能体", + "start-pod": "新建项目 Pod", + "run-hosted": "在这里运行智能体" } }, "dmMark": "私信", diff --git a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx index d36dba5e5a..c2296f61e0 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx @@ -649,10 +649,10 @@ describe("V2Thread — Scout's starts under zh-CN", () => { // 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).getByRole('button', { name: '接入我在用的智能体' })).toBeInTheDocument(); expect(within(group).queryByRole('button', { name: CONNECT })).not.toBeInTheDocument(); - fireEvent.click(within(group).getByRole('button', { name: '连接我已在用的智能体' })); + fireEvent.click(within(group).getByRole('button', { name: '接入我在用的智能体' })); await waitFor(() => expect(sendSpy).toHaveBeenCalledWith(CONNECT, 'text', undefined, undefined)); });