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
36 changes: 36 additions & 0 deletions backend/__tests__/unit/config/scoutWelcome.test.js
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
26 changes: 24 additions & 2 deletions backend/config/native-agents/scout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
15 changes: 9 additions & 6 deletions backend/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
Expand All @@ -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);
}
Expand Down Expand Up @@ -408,6 +408,7 @@ exports.register = async (req: any, res: any) => {
invitationCode,
signupRef,
signupSource,
locale,
} = req.body;

const normalizedEmail = normalizeEmail(email);
Expand Down Expand Up @@ -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);

Expand Down
33 changes: 33 additions & 0 deletions frontend/src/i18n/__tests__/scoutStartLabels.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,12 @@
},
"starts": {
"label": "开始方式",
"openByo": "去接入自己的智能体"
"openByo": "去接入自己的智能体",
"labels": {
"connect-agent": "接入我在用的智能体",
"start-pod": "新建项目 Pod",
"run-hosted": "在这里运行智能体"
}
},
"dmMark": "私信",
"invite": "邀请成员加入这个 Pod",
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/v2/__tests__/V2ThreadStarts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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));
});
});
4 changes: 3 additions & 1 deletion frontend/src/v2/components/V2Register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/v2/components/V2ThreadMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ const V2ThreadMessages: React.FC<V2ThreadMessagesProps> = ({
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 })}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</button>
{hostedHere?.status === 'failed' && (
<p className="v2-chat__start-note v2-chat__start-note--error" role="alert">{hostedHere.error}</p>
Expand Down
Loading