diff --git a/.changeset/popup-copy-english-i18n.md b/.changeset/popup-copy-english-i18n.md
new file mode 100644
index 000000000..3f98bf4eb
--- /dev/null
+++ b/.changeset/popup-copy-english-i18n.md
@@ -0,0 +1,19 @@
+---
+"@object-ui/app-shell": patch
+"@object-ui/i18n": patch
+---
+
+The console server-action wrapper's `opensInNewTab` choreography no longer
+ships hard-coded bilingual Chinese/English copy (objectui#3321, AGENTS.md
+Commandment #-1): the pre-opened SSO spinner tab (title + body) and the
+popup-blocked toast (title, description, action label) are now localized
+through new `console.serverAction.*` keys in `@object-ui/i18n`, added at full
+parity across all eleven locale packs.
+
+`createConsoleServerActionHandler` gains an optional i18next-style `t` option
+(`t(key, englishDefault)`) — the wrapper is a plain function, so the translate
+function is injected from the two hook-context call sites
+(`useConsoleActionRuntime`, `RecordDetailView`) via `useObjectTranslation`.
+When omitted (tests / standalone), every string falls back to its English
+default; no non-English copy remains in code. Locale strings are HTML-escaped
+before being written into the spinner document.
diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
index 582901da1..1a1441e58 100644
--- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
+++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
@@ -507,14 +507,18 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
// memoized once) while the config thunks read the latest object scope and
// refresh callback — the factory's in-flight guard only spans invocations of
// the same instance.
- const serverActionEnvRef = useRef({ objApiName, refresh });
- serverActionEnvRef.current = { objApiName, refresh };
+ const serverActionEnvRef = useRef({ objApiName, refresh, t });
+ serverActionEnvRef.current = { objApiName, refresh, t };
const serverActionHandler = useMemo(
() => createConsoleServerActionHandler({
fetch: authFetch,
baseUrl: () => import.meta.env.VITE_SERVER_URL || '',
resolveObject: () => serverActionEnvRef.current.objApiName,
onRefresh: () => serverActionEnvRef.current.refresh(),
+ // Read through the env ref so the spinner-tab / popup-blocked copy
+ // follows a language switch without invalidating the handler instance
+ // (objectui#3321).
+ t: (key, englishDefault) => String(serverActionEnvRef.current.t(key, englishDefault)),
}),
[authFetch],
);
diff --git a/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx b/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx
index 9a1aabae0..bd8e0fe87 100644
--- a/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx
+++ b/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx
@@ -160,6 +160,11 @@ describe('redirectUrl convention', () => {
expect(openSpy).toHaveBeenCalledWith('https://example.test/sso', '_blank');
expect(toast).toHaveBeenCalledTimes(1); // popup blocked → one-click fallback
+ // Default (no injected `t`) copy is the English fallback (objectui#3321).
+ expect(toast).toHaveBeenCalledWith('Popup blocked', expect.objectContaining({
+ description: 'Your browser blocked the new tab from opening.',
+ action: expect.objectContaining({ label: 'Open in new tab' }),
+ }));
});
it('closes the optimistically pre-opened tab when the handler returns no redirectUrl', async () => {
@@ -174,6 +179,75 @@ describe('redirectUrl convention', () => {
});
});
+describe('user-facing copy (objectui#3321)', () => {
+ it('the spinner tab defaults to English-only copy — no CJK in code (Commandment #-1)', async () => {
+ const tab = makeTab();
+ vi.spyOn(window, 'open').mockReturnValue(tab as any);
+ const { handler } = makeHandler();
+
+ await handler({
+ type: 'script', name: 'sso_as_owner', opensInNewTab: true,
+ newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' },
+ } as any);
+
+ expect(tab.document.write).toHaveBeenCalledTimes(1);
+ const html = (tab.document.write as any).mock.calls[0][0] as string;
+ expect(html).toContain('
Opening…');
+ expect(html).toContain('Opening… this may take a moment.');
+ // The commandment pin: the copy shipped from CODE carries no CJK. Chinese
+ // lives in `@object-ui/i18n`'s zh locale pack and arrives via `t`.
+ expect(html).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/);
+ });
+
+ it('an injected `t` localizes the spinner tab via console.serverAction.* keys', async () => {
+ const tab = makeTab();
+ vi.spyOn(window, 'open').mockReturnValue(tab as any);
+ const { handler } = makeHandler({ t: (key: string) => `x:${key}` });
+
+ await handler({
+ type: 'script', name: 'sso_as_owner', opensInNewTab: true,
+ newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' },
+ } as any);
+
+ const html = (tab.document.write as any).mock.calls[0][0] as string;
+ expect(html).toContain('x:console.serverAction.openingTitle');
+ expect(html).toContain('x:console.serverAction.openingBody');
+ });
+
+ it('an injected `t` localizes the popup-blocked toast via console.serverAction.* keys', async () => {
+ vi.spyOn(window, 'open').mockReturnValue(null);
+ const { handler } = makeHandler({
+ t: (key: string) => `x:${key}`,
+ fetch: okFetch({
+ success: true,
+ data: { success: true, data: { redirectUrl: 'https://example.test/sso' } },
+ }) as any,
+ });
+
+ await handler({ type: 'script', name: 'open_env' } as any);
+
+ expect(toast).toHaveBeenCalledWith('x:console.serverAction.popupBlockedTitle', expect.objectContaining({
+ description: 'x:console.serverAction.popupBlockedDescription',
+ action: expect.objectContaining({ label: 'x:console.serverAction.popupBlockedAction' }),
+ }));
+ });
+
+ it('locale strings are HTML-escaped before entering the spinner document', async () => {
+ const tab = makeTab();
+ vi.spyOn(window, 'open').mockReturnValue(tab as any);
+ const { handler } = makeHandler({ t: (_key: string, englishDefault: string) => `&${englishDefault}` });
+
+ await handler({
+ type: 'script', name: 'sso_as_owner', opensInNewTab: true,
+ newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' },
+ } as any);
+
+ const html = (tab.document.write as any).mock.calls[0][0] as string;
+ expect(html).toContain('<b>&Opening…');
+ expect(html).not.toContain('&Opening…');
+ });
+});
+
describe('failure paths close the pre-opened tab', () => {
it('on a failed dispatch', async () => {
const tab = makeTab();
diff --git a/packages/app-shell/src/utils/consoleServerAction.ts b/packages/app-shell/src/utils/consoleServerAction.ts
index ab5ba5af7..d437900d2 100644
--- a/packages/app-shell/src/utils/consoleServerAction.ts
+++ b/packages/app-shell/src/utils/consoleServerAction.ts
@@ -48,6 +48,17 @@ import {
type ServerActionRecordIdResolver,
} from '@object-ui/core';
+/**
+ * i18next-style translate function: `t(key, englishDefault)` returns the
+ * translation for `key` in the active locale, or `englishDefault` when the
+ * key is missing. Injected from a hook context (`useObjectTranslation().t`)
+ * because this wrapper is a plain function and cannot call hooks itself.
+ */
+export type ConsoleServerActionTranslate = (key: string, englishDefault: string) => string;
+
+/** No-`t` fallback (tests / standalone): every string is its English default. */
+const defaultTranslate: ConsoleServerActionTranslate = (_key, englishDefault) => englishDefault;
+
export interface ConsoleServerActionOptions {
/** Authenticated fetch wrapper (Bearer + tenant + cookies). */
fetch: ServerActionFetch;
@@ -63,6 +74,19 @@ export interface ConsoleServerActionOptions {
resolveRecordId?: ServerActionRecordIdResolver;
/** Data invalidation, invoked per the action's `refreshAfter` semantics. */
onRefresh: () => void;
+ /**
+ * Localizes the user-facing copy this wrapper owns (the SSO spinner tab,
+ * the popup-blocked toast) via the `console.serverAction.*` keys in
+ * `@object-ui/i18n`. Optional: when omitted the English defaults apply —
+ * per AGENTS.md Commandment #-1, no non-English copy lives in code
+ * (objectui#3321).
+ */
+ t?: ConsoleServerActionTranslate;
+}
+
+/** Minimal HTML escape for locale strings interpolated into the spinner document. */
+function escapeHtml(s: string): string {
+ return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
/**
@@ -75,11 +99,13 @@ export interface ConsoleServerActionOptions {
* fallback would fire, and the CURRENT tab would navigate to the now-consumed
* SSO URL (the double-navigation bug).
*/
-function preOpenSpinnerTab(): Window | null {
+function preOpenSpinnerTab(t: ConsoleServerActionTranslate): Window | null {
try {
const tab = window.open('about:blank', '_blank');
if (tab) {
- tab.document.write('正在打开… Opening…正在为你打开环境…
');
+ const title = escapeHtml(t('console.serverAction.openingTitle', 'Opening…'));
+ const body = escapeHtml(t('console.serverAction.openingBody', 'Opening… this may take a moment.'));
+ tab.document.write(`${title}${body}
`);
tab.document.close();
}
return tab;
@@ -99,7 +125,7 @@ function closeTab(tab: Window | null): void {
* and, when the popup blocker eats it, offer a one-click toast instead of
* silently hijacking the current tab.
*/
-function openInTab(preOpenedTab: Window | null, url: string): void {
+function openInTab(preOpenedTab: Window | null, url: string, t: ConsoleServerActionTranslate): void {
if (preOpenedTab) {
try {
preOpenedTab.location.href = url;
@@ -114,9 +140,9 @@ function openInTab(preOpenedTab: Window | null, url: string): void {
// null return would always trip the toast fallback.
try { popup = window.open(url, '_blank'); } catch { popup = null; }
if (!popup) {
- toast('浏览器拦截了弹窗 / Popup blocked', {
- description: '点击在新标签页打开环境',
- action: { label: '打开环境', onClick: () => { try { window.open(url, '_blank'); } catch { window.location.href = url; } } },
+ toast(t('console.serverAction.popupBlockedTitle', 'Popup blocked'), {
+ description: t('console.serverAction.popupBlockedDescription', 'Your browser blocked the new tab from opening.'),
+ action: { label: t('console.serverAction.popupBlockedAction', 'Open in new tab'), onClick: () => { try { window.open(url, '_blank'); } catch { window.location.href = url; } } },
duration: 10000,
});
}
@@ -131,6 +157,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
onRefresh: opts.onRefresh,
});
const resolveRecordId = opts.resolveRecordId ?? resolveServerActionRecordId;
+ const t = opts.t ?? defaultTranslate;
return async (action: ActionDef, context?: ActionContext) => {
// ── Zero-roundtrip fast path ────────────────────────────────────────
@@ -151,18 +178,18 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
if (recordId == null) {
return { success: false, error: 'This action runs on a single record — no record id available.' };
}
- const preOpenedTab = preOpenSpinnerTab();
+ const preOpenedTab = preOpenSpinnerTab(t);
// Absolute URL required: the pre-opened tab is an about:blank document,
// so a bare-relative href has no reliable resolution base.
const directUrl = `${opts.baseUrl() || window.location.origin}${newTabUrl.replace('{recordId}', encodeURIComponent(String(recordId)))}`;
- openInTab(preOpenedTab, directUrl);
+ openInTab(preOpenedTab, directUrl, t);
if (action.refreshAfter === true) opts.onRefresh();
return { success: true };
}
// Popup-blocker workaround: pre-open about:blank synchronously before the
// await so the user-gesture context is preserved.
- const preOpenedTab = action.opensInNewTab ? preOpenSpinnerTab() : null;
+ const preOpenedTab = action.opensInNewTab ? preOpenSpinnerTab(t) : null;
try {
const result = await dispatch(action, context);
if (!result.success) {
@@ -182,7 +209,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
? (payload as { redirectUrl: string }).redirectUrl
: null;
if (redirectUrl) {
- openInTab(preOpenedTab, redirectUrl);
+ openInTab(preOpenedTab, redirectUrl, t);
} else {
// Handler didn't return a redirectUrl — close the empty tab we
// optimistically pre-opened so the user isn't left with about:blank.
diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx
index fa21e281c..5eb52954b 100644
--- a/packages/app-shell/src/views/RecordDetailView.tsx
+++ b/packages/app-shell/src/views/RecordDetailView.tsx
@@ -814,8 +814,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
// `action.recordId`; header/more actions carry none and use this page's id.
// The env ref keeps the handler instance stable across renders (authFetch is
// memoized once) while the thunks read the live record/object.
- const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged });
- serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged };
+ const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged, t });
+ serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged, t };
const serverActionHandler = useMemo(
() => createConsoleServerActionHandler({
fetch: authFetch,
@@ -825,6 +825,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
recordId: (action as { recordId?: unknown }).recordId ?? serverActionEnvRef.current.pureRecordId ?? undefined,
}),
onRefresh: () => serverActionEnvRef.current.notifyRecordChanged(),
+ // Read through the env ref so the spinner-tab / popup-blocked copy
+ // follows a language switch without invalidating the handler instance
+ // (objectui#3321).
+ t: (key, englishDefault) => String(serverActionEnvRef.current.t(key, englishDefault)),
}),
[authFetch],
);
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index fc16ce393..bd2676b1f 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -1419,6 +1419,13 @@ const ar = {
retry: "إعادة المحاولة",
retrying: "جاري إعادة المحاولة…",
},
+ serverAction: {
+ openingTitle: "جارٍ الفتح…",
+ openingBody: "جارٍ الفتح… قد يستغرق ذلك لحظة.",
+ popupBlockedTitle: "تم حظر النافذة المنبثقة",
+ popupBlockedDescription: "حظر المتصفح فتح علامة التبويب الجديدة.",
+ popupBlockedAction: "فتح في علامة تبويب جديدة",
+ },
shortcuts: {
title: "اختصارات لوحة المفاتيح",
description: "مرجع سريع لجميع اختصارات لوحة المفاتيح المتاحة.",
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index cd0f2b1ad..a5ad98059 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -1419,6 +1419,13 @@ const de = {
retry: "Erneut versuchen",
retrying: "Wird wiederholt…",
},
+ serverAction: {
+ openingTitle: "Wird geöffnet…",
+ openingBody: "Wird geöffnet… das kann einen Moment dauern.",
+ popupBlockedTitle: "Pop-up blockiert",
+ popupBlockedDescription: "Der Browser hat das Öffnen des neuen Tabs blockiert.",
+ popupBlockedAction: "In neuem Tab öffnen",
+ },
shortcuts: {
title: "Tastenkürzel",
description: "Kurzreferenz für alle verfügbaren Tastenkürzel.",
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 006506be3..47c8ca9db 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -1414,6 +1414,15 @@ const en = {
retry: 'Retry',
retrying: 'Retrying…',
},
+ // Copy owned by the console server-action wrapper (`consoleServerAction.ts`,
+ // objectui#3321): the pre-opened SSO spinner tab and the popup-blocked toast.
+ serverAction: {
+ openingTitle: 'Opening…',
+ openingBody: 'Opening… this may take a moment.',
+ popupBlockedTitle: 'Popup blocked',
+ popupBlockedDescription: 'Your browser blocked the new tab from opening.',
+ popupBlockedAction: 'Open in new tab',
+ },
shortcuts: {
title: 'Keyboard Shortcuts',
description: 'Quick reference for all available keyboard shortcuts.',
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index 2ca12106d..04ad5c6cb 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -1419,6 +1419,13 @@ const es = {
retry: "Reintentar",
retrying: "Reintentando…",
},
+ serverAction: {
+ openingTitle: "Abriendo…",
+ openingBody: "Abriendo… esto puede tardar un momento.",
+ popupBlockedTitle: "Ventana emergente bloqueada",
+ popupBlockedDescription: "El navegador bloqueó la apertura de la nueva pestaña.",
+ popupBlockedAction: "Abrir en una pestaña nueva",
+ },
shortcuts: {
title: "Atajos de teclado",
description: "Referencia rápida de todos los atajos de teclado disponibles.",
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index d89034537..f6ded0857 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -1419,6 +1419,13 @@ const fr = {
retry: "Réessayer",
retrying: "Nouvelle tentative…",
},
+ serverAction: {
+ openingTitle: "Ouverture…",
+ openingBody: "Ouverture… cela peut prendre un moment.",
+ popupBlockedTitle: "Fenêtre contextuelle bloquée",
+ popupBlockedDescription: "Le navigateur a bloqué l'ouverture du nouvel onglet.",
+ popupBlockedAction: "Ouvrir dans un nouvel onglet",
+ },
shortcuts: {
title: "Raccourcis clavier",
description: "Référence rapide de tous les raccourcis clavier disponibles.",
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 3d6dda287..acd0bfbce 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -1419,6 +1419,13 @@ const ja = {
retry: "再試行",
retrying: "再試行中…",
},
+ serverAction: {
+ openingTitle: "開いています…",
+ openingBody: "開いています…しばらくお待ちください。",
+ popupBlockedTitle: "ポップアップがブロックされました",
+ popupBlockedDescription: "ブラウザが新しいタブを開くのをブロックしました。",
+ popupBlockedAction: "新しいタブで開く",
+ },
shortcuts: {
title: "キーボードショートカット",
description: "利用可能なすべてのキーボードショートカットのクイックリファレンス。",
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index 2bcdddc1a..b436817ba 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -1419,6 +1419,13 @@ const ko = {
retry: "재시도",
retrying: "재시도 중…",
},
+ serverAction: {
+ openingTitle: "여는 중…",
+ openingBody: "여는 중… 잠시 기다려 주세요.",
+ popupBlockedTitle: "팝업이 차단되었습니다",
+ popupBlockedDescription: "브라우저가 새 탭 열기를 차단했습니다.",
+ popupBlockedAction: "새 탭에서 열기",
+ },
shortcuts: {
title: "키보드 단축키",
description: "사용 가능한 모든 키보드 단축키 빠른 참조.",
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index e6e2d6c0b..8b6cac503 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -1419,6 +1419,13 @@ const pt = {
retry: "Tentar novamente",
retrying: "Tentando novamente…",
},
+ serverAction: {
+ openingTitle: "Abrindo…",
+ openingBody: "Abrindo… isso pode levar um momento.",
+ popupBlockedTitle: "Pop-up bloqueado",
+ popupBlockedDescription: "O navegador bloqueou a abertura da nova aba.",
+ popupBlockedAction: "Abrir em nova aba",
+ },
shortcuts: {
title: "Atalhos de teclado",
description: "Referência rápida de todos os atalhos de teclado disponíveis.",
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index cb65afaaf..49c913dec 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -1419,6 +1419,13 @@ const ru = {
retry: "Повторить",
retrying: "Повтор…",
},
+ serverAction: {
+ openingTitle: "Открытие…",
+ openingBody: "Открытие… это может занять некоторое время.",
+ popupBlockedTitle: "Всплывающее окно заблокировано",
+ popupBlockedDescription: "Браузер заблокировал открытие новой вкладки.",
+ popupBlockedAction: "Открыть в новой вкладке",
+ },
shortcuts: {
title: "Горячие клавиши",
description: "Краткий справочник по всем доступным горячим клавишам.",
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index a18d651a3..e944f4105 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -1417,6 +1417,13 @@ const zh = {
retry: '重试',
retrying: '正在重试…',
},
+ serverAction: {
+ openingTitle: '正在打开…',
+ openingBody: '正在打开…可能需要一点时间。',
+ popupBlockedTitle: '浏览器拦截了弹窗',
+ popupBlockedDescription: '浏览器阻止了新标签页的打开。',
+ popupBlockedAction: '在新标签页打开',
+ },
shortcuts: {
title: '键盘快捷键',
description: '所有可用键盘快捷键的快速参考。',