From 5b4a3074d65560dfb2087881dd643d11d08f2e00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 07:16:52 +0000 Subject: [PATCH 1/2] fix(plugin-chatbot): translate the whole AI pending-actions inbox (#7173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AiPendingActionsInbox` held its own relative-time helper returning hardcoded English, and had no translation wiring of its own — the unwired-component shape. Swept whole rather than converting only its timestamps: #7142 wired one string into an otherwise untranslated component and shipped something visibly half-done, and the triage ruling on this card carried that forward. The four relative-time branches borrow `detail.justNow` / `minutesAgo` / `hoursAgo` / `daysAgo`, already translated in all ten packs; cross-package `detail.*` borrowing is settled convention here (ObjectGrid, ObjectKanban, ObjectTree, ListView, ObjectView, NavigationOverlay, RecordDetailView). The rest of the sweep needed copy no pack had, so `@object-ui/i18n` gains an `aiApprovals` namespace — 38 keys, translated in all ten packs, deliberately separate from `approvalsInbox` (the human approval-process inbox). The five relative-time helpers are NOT unified: they differ in real behaviour (`Math.round` vs `Math.floor`, 45s/30d vs 60s/7d, different tails). This copy's arithmetic is untouched and three test rows exist only to pin it. Assertions are in zh and ar — each key's `en` value is byte-identical to the literal it replaced, so an `en`-only test is green before the fix too. The provider-less leg lives in its own file because `createI18n` installs itself as react-i18next's module-level global. No inline `defaultValue` (#3517). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho --- .../7173-ai-pending-actions-inbox-i18n.md | 58 +++ .../__tests__/de-quote-pairing-3876.test.ts | 14 +- packages/i18n/src/locales/ar.ts | 40 ++ packages/i18n/src/locales/de.ts | 40 ++ packages/i18n/src/locales/en.ts | 47 +++ packages/i18n/src/locales/es.ts | 40 ++ packages/i18n/src/locales/fr.ts | 40 ++ packages/i18n/src/locales/ja.ts | 40 ++ packages/i18n/src/locales/ko.ts | 40 ++ packages/i18n/src/locales/pt.ts | 40 ++ packages/i18n/src/locales/ru.ts | 40 ++ packages/i18n/src/locales/zh.ts | 40 ++ .../src/AiPendingActionsInbox.tsx | 236 ++++++++---- .../AiPendingActionsInbox.i18n.test.tsx | 358 ++++++++++++++++++ ...ngActionsInbox.noProviderFallback.test.tsx | 158 ++++++++ .../src/useAiApprovalsTranslation.ts | 131 +++++++ 16 files changed, 1282 insertions(+), 80 deletions(-) create mode 100644 .changeset/7173-ai-pending-actions-inbox-i18n.md create mode 100644 packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.i18n.test.tsx create mode 100644 packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.noProviderFallback.test.tsx create mode 100644 packages/plugin-chatbot/src/useAiApprovalsTranslation.ts diff --git a/.changeset/7173-ai-pending-actions-inbox-i18n.md b/.changeset/7173-ai-pending-actions-inbox-i18n.md new file mode 100644 index 0000000000..8929cf48be --- /dev/null +++ b/.changeset/7173-ai-pending-actions-inbox-i18n.md @@ -0,0 +1,58 @@ +--- +'@object-ui/plugin-chatbot': patch +'@object-ui/i18n': patch +--- + +`AiPendingActionsInbox` speaks the session locale — every string in it, not only its timestamps (objectui#7173). + +The AI HITL approval inbox held its own relative-time helper returning hardcoded +English (`'just now'`, `` `${min}m ago` ``), so a zh / ja / ar session read English +relative times on every row. It is the fifth spelling of that helper in the repo, +and the file had **no translation wiring at all** — the unwired-component shape, +not the lookup-swap shape. + +It is therefore swept whole. objectui#7142 wired one string into an otherwise +untranslated component and shipped something visibly half-done, and objectui#7149 +is what finishing that afterwards cost; the triage ruling on this card (2026-09-01) +carried that forward as *sweep the file whole or leave it*. Everything the user can +read now resolves from the locale packs: the card heading and description, the three +tabs, the refresh button, all five status badges, the six column headings, the empty +state, the row and drawer buttons, all nine drawer field labels, the outcome banner +and the whole reject-reason dialog. + +**No new rows for the four relative-time branches.** `detail.justNow`, +`detail.minutesAgo`, `detail.hoursAgo` and `detail.daysAgo` already existed, +translated, in all ten packs, and cross-package key borrowing is this repo's settled +convention rather than an open question — `ObjectGrid`, `ObjectKanban`, `ObjectTree`, +`ListView`, `ObjectView`, `NavigationOverlay`, `RecordAttachmentsPanel`, +`RecordDetailView` and `apps/console` all resolve `detail.*` from outside +`plugin-detail`. One phrase on one kind of control should not get a second +translation that can drift from the first. + +The rest of the sweep needed copy no pack had, so `@object-ui/i18n` gains an +`aiApprovals` namespace: 38 keys, translated in all ten packs. It is deliberately +separate from `approvalsInbox`, which is the human approval-**process** inbox — a +different surface and a different feature, so no rows are shared with it. Four +generic verbs are reused rather than forked (`common.refresh`, `common.cancel`, +`common.loading`, `common.ok`). + +**⛔ The five relative-time helpers are not unified.** They differ in real behaviour +— `Math.round` here against `Math.floor` in `plugin-detail`, thresholds 45s/30d +against 60s/7d, different tails — so normalising them is a behaviour change wearing +a refactor's clothes and needs its own card. This inbox's arithmetic is untouched, +and three rows in the new suite exist only to pin it: 50s renders `1m ago` (a 60s +threshold would still say "just now"), 90s renders `2m ago` (`Math.floor` gives +`1m ago`), and 20d renders `20d ago` (a 7d threshold would already show a date). + +Two assembled English sentences became single interpolated keys — the outcome banner +(`Approve for {{id}}: {{message}}`) and the drawer subtitle +(`Tool {{tool}} on {{object}}`). Their word order differs per locale, which fragments +around a `` element cannot express, so the two identifiers lose their monospace +styling. That is the deliberate cost of making those sentences translatable. + +Evidence: an `en`-only assertion cannot discriminate here, because each key's `en` +value is byte-identical to the literal it replaced. The suite asserts in **zh and +ar**, and the provider-less path separately, in its own file (`createI18n` installs +itself as react-i18next's module-level global, so a provider-less render in a file +that has already mounted a provider silently reads that pack instead of the defaults +map). No inline `defaultValue` anywhere (objectui#3517). diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 1b3bb8fa6c..5b526b346c 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -273,8 +273,13 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // keys: `detail.activityFieldChanged` quotes the OLD and NEW field values // („{{old}}“ / „{{new}}“, two spans in one value, like `navigationSync. // renamedPage` above) and `detail.activityStatusChanged` the new status - // („{{value}}“) — three interpolated spans, all runtime data. - expect(okSpans, 'correctly paired spans').toBe(58); + // („{{value}}“) — three interpolated spans, all runtime data, + // 59 once objectui#7173 gave `AiPendingActionsInbox` pack keys: + // `aiApprovals.rejectPlaceholder` quotes the EXAMPLE rejection reason the + // placeholder suggests („Falsche Datensatz-ID — …“) — one LITERAL span, like + // `timeline.unsupported.objectBoundGantt` above rather than the interpolated + // ones, because the quoted thing is sample prose this pack authored. + expect(okSpans, 'correctly paired spans').toBe(59); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -298,8 +303,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // after objectui#7149 added the two quoted `ActivityTimeline` sentences // (three spans between them). `rdq` staying at 0 is the load-bearing half: // each new value added a MATCHED „…“ pair, not a stray closer that would - // have made `close === open` true for the wrong reason. - expect({ open, close, rdq }).toEqual({ open: 58, close: 58, rdq: 0 }); + // have made `close === open` true for the wrong reason. 59 / 59 / 0 after + // objectui#7173 added `aiApprovals.rejectPlaceholder`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 59, close: 59, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 8ae5efd7b1..065d3e0258 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2796,6 +2796,46 @@ const ar = { openProduction: "فتح بيئة الإنتاج", manageEnvironments: "إدارة البيئات", }, + aiApprovals: { + title: "موافقات الذكاء الاصطناعي", + description: "إجراءات اقترحها وكيل ذكاء اصطناعي وتحتاج إلى مراجعة بشرية قبل تنفيذها.", + tabPending: "قيد الانتظار", + tabDecided: "تم البت فيها", + tabAll: "الكل", + statusPending: "قيد الانتظار", + statusApproved: "تمت الموافقة", + statusExecuted: "تم التنفيذ", + statusFailed: "فشل", + statusRejected: "مرفوض", + colTool: "الأداة", + colAction: "الإجراء", + colObject: "الكائن", + colStatus: "الحالة", + colProposed: "وقت الاقتراح", + colDecision: "القرار", + emptyTitle: "لا توجد إجراءات في الانتظار", + emptyDescription: "عندما يقترح الذكاء الاصطناعي إجراءً حساسًا سيظهر هنا للمراجعة.", + view: "عرض", + approve: "موافقة", + reject: "رفض", + working: "جارٍ التنفيذ…", + approveAndExecute: "الموافقة والتنفيذ", + outcomeApprove: "موافقة على {{id}}: {{message}}", + outcomeReject: "رفض {{id}}: {{message}}", + outcomeExecuteFailed: "فشل الإجراء أثناء التنفيذ", + drawerFallbackTitle: "إجراء قيد الانتظار", + drawerSubtitle: "الأداة {{tool}} على {{object}}", + fieldProposedBy: "اقترحه", + fieldDecidedBy: "قرّره", + fieldConversation: "المحادثة", + fieldToolInput: "مدخلات الأداة", + fieldResult: "النتيجة", + fieldError: "خطأ", + fieldRejectionReason: "سبب الرفض", + rejectTitle: "هل تريد رفض هذا الإجراء؟", + rejectBody: "يُعاد السبب إلى الذكاء الاصطناعي ليعدّل ردّه التالي.", + rejectPlaceholder: "سبب اختياري (مثال: «معرّف السجل غير صحيح — يرجى التأكيد مع المستخدم أولاً.»)", + }, aiModelStatus: { summary: "يستخدم الإنشاء / السؤال {{conversational}} ({{conversationalSource}})؛ ويستخدم الإخراج المهيكل {{structured}} ({{structuredSource}}).", summaryRouting: "سياسة التوجيه: الخطط المجانية ← {{free}}، الخطط المدفوعة ← {{paid}}.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 83fb2c51d7..d41b46744e 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2789,6 +2789,46 @@ const de = { openProduction: "Produktion öffnen", manageEnvironments: "Umgebungen verwalten", }, + aiApprovals: { + title: "KI-Genehmigungen", + description: "Von einem KI-Agenten vorgeschlagene Aktionen, die vor der Ausführung eine menschliche Prüfung benötigen.", + tabPending: "Ausstehend", + tabDecided: "Entschieden", + tabAll: "Alle", + statusPending: "Ausstehend", + statusApproved: "Genehmigt", + statusExecuted: "Ausgeführt", + statusFailed: "Fehlgeschlagen", + statusRejected: "Abgelehnt", + colTool: "Werkzeug", + colAction: "Aktion", + colObject: "Objekt", + colStatus: "Status", + colProposed: "Vorgeschlagen", + colDecision: "Entscheidung", + emptyTitle: "Keine wartenden Aktionen", + emptyDescription: "Wenn die KI eine sensible Aktion vorschlägt, erscheint sie hier zur Prüfung.", + view: "Ansehen", + approve: "Genehmigen", + reject: "Ablehnen", + working: "Wird bearbeitet…", + approveAndExecute: "Genehmigen & ausführen", + outcomeApprove: "Genehmigung für {{id}}: {{message}}", + outcomeReject: "Ablehnung für {{id}}: {{message}}", + outcomeExecuteFailed: "Die Aktion ist bei der Ausführung fehlgeschlagen", + drawerFallbackTitle: "Ausstehende Aktion", + drawerSubtitle: "Werkzeug {{tool}} auf {{object}}", + fieldProposedBy: "Vorgeschlagen von", + fieldDecidedBy: "Entschieden von", + fieldConversation: "Konversation", + fieldToolInput: "Werkzeugeingabe", + fieldResult: "Ergebnis", + fieldError: "Fehler", + fieldRejectionReason: "Ablehnungsgrund", + rejectTitle: "Diese Aktion ablehnen?", + rejectBody: "Der Grund wird an die KI zurückgemeldet, damit sie ihre nächste Antwort anpassen kann.", + rejectPlaceholder: "Optionaler Grund (z. B. „Falsche Datensatz-ID — bitte zuerst mit der Nutzerin oder dem Nutzer klären.“)", + }, aiModelStatus: { summary: "Erstellen / Fragen nutzt {{conversational}} ({{conversationalSource}}); strukturiert nutzt {{structured}} ({{structuredSource}}).", summaryRouting: "Routing-Richtlinie: kostenlose Tarife → {{free}}, kostenpflichtige Tarife → {{paid}}.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 18e2b72154..c23633530d 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3135,6 +3135,53 @@ const en = { openProduction: 'Open Production', manageEnvironments: 'Manage environments', }, + // The AI HITL approval inbox (`@object-ui/plugin-chatbot`'s + // `AiPendingActionsInbox`) — objectui#7173. Its four relative-time phrases + // are NOT here: it borrows `detail.justNow` / `minutesAgo` / `hoursAgo` / + // `daysAgo`, already translated in all ten packs, the way `ObjectGrid` and + // `ObjectKanban` borrow `detail.recordDetail`. Distinct from + // `approvalsInbox` above, which is the human approval-PROCESS inbox: + // different surface, different feature, so no rows are shared with it. + aiApprovals: { + title: 'AI Approvals', + description: 'Actions an AI agent proposed that need a human review before execution.', + tabPending: 'Pending', + tabDecided: 'Decided', + tabAll: 'All', + statusPending: 'Pending', + statusApproved: 'Approved', + statusExecuted: 'Executed', + statusFailed: 'Failed', + statusRejected: 'Rejected', + colTool: 'Tool', + colAction: 'Action', + colObject: 'Object', + colStatus: 'Status', + colProposed: 'Proposed', + colDecision: 'Decision', + emptyTitle: 'No actions waiting', + emptyDescription: 'When the AI proposes a sensitive action it will appear here for review.', + view: 'View', + approve: 'Approve', + reject: 'Reject', + working: 'Working…', + approveAndExecute: 'Approve & Execute', + outcomeApprove: 'Approve for {{id}}: {{message}}', + outcomeReject: 'Reject for {{id}}: {{message}}', + outcomeExecuteFailed: 'Action failed during execution', + drawerFallbackTitle: 'Pending action', + drawerSubtitle: 'Tool {{tool}} on {{object}}', + fieldProposedBy: 'Proposed by', + fieldDecidedBy: 'Decided by', + fieldConversation: 'Conversation', + fieldToolInput: 'Tool input', + fieldResult: 'Result', + fieldError: 'Error', + fieldRejectionReason: 'Rejection reason', + rejectTitle: 'Reject this action?', + rejectBody: 'The reason is shown back to the AI so it can adjust its next response.', + rejectPlaceholder: "Optional reason (e.g. 'Wrong record id — please confirm with the user first.')", + }, aiModelStatus: { summary: 'Build / Ask uses {{conversational}} ({{conversationalSource}}); structured uses {{structured}} ({{structuredSource}}).', summaryRouting: 'Routing policy: free plans → {{free}}, paid plans → {{paid}}.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 74a4d3c0d5..5fdcd3fb35 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2793,6 +2793,46 @@ const es = { openProduction: "Abrir producción", manageEnvironments: "Gestionar entornos", }, + aiApprovals: { + title: "Aprobaciones de IA", + description: "Acciones propuestas por un agente de IA que necesitan revisión humana antes de ejecutarse.", + tabPending: "Pendientes", + tabDecided: "Decididas", + tabAll: "Todas", + statusPending: "Pendiente", + statusApproved: "Aprobada", + statusExecuted: "Ejecutada", + statusFailed: "Fallida", + statusRejected: "Rechazada", + colTool: "Herramienta", + colAction: "Acción", + colObject: "Objeto", + colStatus: "Estado", + colProposed: "Propuesta", + colDecision: "Decisión", + emptyTitle: "No hay acciones en espera", + emptyDescription: "Cuando la IA proponga una acción sensible, aparecerá aquí para su revisión.", + view: "Ver", + approve: "Aprobar", + reject: "Rechazar", + working: "Procesando…", + approveAndExecute: "Aprobar y ejecutar", + outcomeApprove: "Aprobación de {{id}}: {{message}}", + outcomeReject: "Rechazo de {{id}}: {{message}}", + outcomeExecuteFailed: "La acción falló durante su ejecución", + drawerFallbackTitle: "Acción pendiente", + drawerSubtitle: "Herramienta {{tool}} sobre {{object}}", + fieldProposedBy: "Propuesta por", + fieldDecidedBy: "Decidida por", + fieldConversation: "Conversación", + fieldToolInput: "Entrada de la herramienta", + fieldResult: "Resultado", + fieldError: "Error", + fieldRejectionReason: "Motivo del rechazo", + rejectTitle: "¿Rechazar esta acción?", + rejectBody: "El motivo se devuelve a la IA para que ajuste su siguiente respuesta.", + rejectPlaceholder: "Motivo opcional (p. ej.: «ID de registro incorrecto — confírmalo antes con la persona usuaria.»)", + }, aiModelStatus: { summary: "Crear / Preguntar usa {{conversational}} ({{conversationalSource}}); estructurado usa {{structured}} ({{structuredSource}}).", summaryRouting: "Política de enrutamiento: planes gratuitos → {{free}}, planes de pago → {{paid}}.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index ba0eabf722..ac605a1512 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2791,6 +2791,46 @@ const fr = { openProduction: "Ouvrir la production", manageEnvironments: "Gérer les environnements", }, + aiApprovals: { + title: "Approbations IA", + description: "Actions proposées par un agent IA qui nécessitent une validation humaine avant exécution.", + tabPending: "En attente", + tabDecided: "Traitées", + tabAll: "Toutes", + statusPending: "En attente", + statusApproved: "Approuvée", + statusExecuted: "Exécutée", + statusFailed: "Échouée", + statusRejected: "Rejetée", + colTool: "Outil", + colAction: "Action", + colObject: "Objet", + colStatus: "Statut", + colProposed: "Proposée", + colDecision: "Décision", + emptyTitle: "Aucune action en attente", + emptyDescription: "Lorsque l'IA propose une action sensible, elle apparaît ici pour validation.", + view: "Voir", + approve: "Approuver", + reject: "Rejeter", + working: "En cours…", + approveAndExecute: "Approuver et exécuter", + outcomeApprove: "Approbation de {{id}} : {{message}}", + outcomeReject: "Rejet de {{id}} : {{message}}", + outcomeExecuteFailed: "L'action a échoué pendant son exécution", + drawerFallbackTitle: "Action en attente", + drawerSubtitle: "Outil {{tool}} sur {{object}}", + fieldProposedBy: "Proposée par", + fieldDecidedBy: "Décidée par", + fieldConversation: "Conversation", + fieldToolInput: "Entrée de l'outil", + fieldResult: "Résultat", + fieldError: "Erreur", + fieldRejectionReason: "Motif du rejet", + rejectTitle: "Rejeter cette action ?", + rejectBody: "Le motif est renvoyé à l'IA pour qu'elle ajuste sa prochaine réponse.", + rejectPlaceholder: "Motif facultatif (par ex. « Identifiant d'enregistrement erroné — merci de confirmer d'abord avec l'utilisateur. »)", + }, aiModelStatus: { summary: "Créer / Demander utilise {{conversational}} ({{conversationalSource}}) ; structuré utilise {{structured}} ({{structuredSource}}).", summaryRouting: "Politique de routage : offres gratuites → {{free}}, offres payantes → {{paid}}.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 9d2661b3c3..4749bda9ca 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2791,6 +2791,46 @@ const ja = { openProduction: "本番環境を開く", manageEnvironments: "環境を管理", }, + aiApprovals: { + title: "AI 承認", + description: "AI エージェントが提案した操作です。実行前に人による確認が必要です。", + tabPending: "承認待ち", + tabDecided: "処理済み", + tabAll: "すべて", + statusPending: "承認待ち", + statusApproved: "承認済み", + statusExecuted: "実行済み", + statusFailed: "失敗", + statusRejected: "却下", + colTool: "ツール", + colAction: "アクション", + colObject: "オブジェクト", + colStatus: "ステータス", + colProposed: "提案日時", + colDecision: "判断", + emptyTitle: "待機中の操作はありません", + emptyDescription: "AI が重要な操作を提案すると、確認のためここに表示されます。", + view: "表示", + approve: "承認", + reject: "却下", + working: "処理中…", + approveAndExecute: "承認して実行", + outcomeApprove: "{{id}} の承認: {{message}}", + outcomeReject: "{{id}} の却下: {{message}}", + outcomeExecuteFailed: "実行中に操作が失敗しました", + drawerFallbackTitle: "保留中の操作", + drawerSubtitle: "{{object}} に対するツール {{tool}}", + fieldProposedBy: "提案者", + fieldDecidedBy: "判断者", + fieldConversation: "会話", + fieldToolInput: "ツール入力", + fieldResult: "結果", + fieldError: "エラー", + fieldRejectionReason: "却下理由", + rejectTitle: "この操作を却下しますか?", + rejectBody: "理由は AI に返され、次の応答の調整に使われます。", + rejectPlaceholder: "理由(任意。例:「レコード ID が誤っています — 先にユーザーに確認してください。」)", + }, aiModelStatus: { summary: "ビルド / 質問は {{conversational}}({{conversationalSource}})、構造化出力は {{structured}}({{structuredSource}})を使用します。", summaryRouting: "ルーティングポリシー: 無料プラン → {{free}}、有料プラン → {{paid}}。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index fdacc7fec5..4a7a6b0516 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2788,6 +2788,46 @@ const ko = { openProduction: "프로덕션 열기", manageEnvironments: "환경 관리", }, + aiApprovals: { + title: "AI 승인", + description: "AI 에이전트가 제안한 작업으로, 실행 전에 사람의 검토가 필요합니다.", + tabPending: "대기 중", + tabDecided: "처리됨", + tabAll: "전체", + statusPending: "대기 중", + statusApproved: "승인됨", + statusExecuted: "실행됨", + statusFailed: "실패", + statusRejected: "거부됨", + colTool: "도구", + colAction: "작업", + colObject: "객체", + colStatus: "상태", + colProposed: "제안 시각", + colDecision: "결정", + emptyTitle: "대기 중인 작업 없음", + emptyDescription: "AI가 민감한 작업을 제안하면 검토를 위해 여기에 표시됩니다.", + view: "보기", + approve: "승인", + reject: "거부", + working: "처리 중…", + approveAndExecute: "승인 후 실행", + outcomeApprove: "{{id}} 승인: {{message}}", + outcomeReject: "{{id}} 거부: {{message}}", + outcomeExecuteFailed: "실행 중 작업이 실패했습니다", + drawerFallbackTitle: "대기 중인 작업", + drawerSubtitle: "{{object}}에 대한 도구 {{tool}}", + fieldProposedBy: "제안자", + fieldDecidedBy: "결정자", + fieldConversation: "대화", + fieldToolInput: "도구 입력", + fieldResult: "결과", + fieldError: "오류", + fieldRejectionReason: "거부 사유", + rejectTitle: "이 작업을 거부할까요?", + rejectBody: "사유는 AI에 전달되어 다음 응답을 조정하는 데 사용됩니다.", + rejectPlaceholder: "사유(선택). 예: ‘레코드 ID가 잘못되었습니다 — 사용자에게 먼저 확인하세요.’", + }, aiModelStatus: { summary: "빌드 / 질문은 {{conversational}}({{conversationalSource}}), 구조화 출력은 {{structured}}({{structuredSource}})을(를) 사용합니다.", summaryRouting: "라우팅 정책: 무료 요금제 → {{free}}, 유료 요금제 → {{paid}}.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 571ec69c8a..2ca9f45a7b 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2788,6 +2788,46 @@ const pt = { openProduction: "Abrir produção", manageEnvironments: "Gerenciar ambientes", }, + aiApprovals: { + title: "Aprovações de IA", + description: "Ações propostas por um agente de IA que precisam de revisão humana antes da execução.", + tabPending: "Pendentes", + tabDecided: "Decididas", + tabAll: "Todas", + statusPending: "Pendente", + statusApproved: "Aprovada", + statusExecuted: "Executada", + statusFailed: "Falhou", + statusRejected: "Rejeitada", + colTool: "Ferramenta", + colAction: "Ação", + colObject: "Objeto", + colStatus: "Status", + colProposed: "Proposta", + colDecision: "Decisão", + emptyTitle: "Nenhuma ação aguardando", + emptyDescription: "Quando a IA propuser uma ação sensível, ela aparecerá aqui para revisão.", + view: "Ver", + approve: "Aprovar", + reject: "Rejeitar", + working: "Processando…", + approveAndExecute: "Aprovar e executar", + outcomeApprove: "Aprovação de {{id}}: {{message}}", + outcomeReject: "Rejeição de {{id}}: {{message}}", + outcomeExecuteFailed: "A ação falhou durante a execução", + drawerFallbackTitle: "Ação pendente", + drawerSubtitle: "Ferramenta {{tool}} em {{object}}", + fieldProposedBy: "Proposta por", + fieldDecidedBy: "Decidida por", + fieldConversation: "Conversa", + fieldToolInput: "Entrada da ferramenta", + fieldResult: "Resultado", + fieldError: "Erro", + fieldRejectionReason: "Motivo da rejeição", + rejectTitle: "Rejeitar esta ação?", + rejectBody: "O motivo é devolvido à IA para que ela ajuste a próxima resposta.", + rejectPlaceholder: "Motivo opcional (ex.: «ID de registro incorreto — confirme antes com a pessoa usuária.»)", + }, aiModelStatus: { summary: "Criar / Perguntar usa {{conversational}} ({{conversationalSource}}); estruturado usa {{structured}} ({{structuredSource}}).", summaryRouting: "Política de roteamento: planos gratuitos → {{free}}, planos pagos → {{paid}}.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index cb69d2dd1b..96da511b72 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2802,6 +2802,46 @@ const ru = { openProduction: "Открыть продакшн", manageEnvironments: "Управление окружениями", }, + aiApprovals: { + title: "Согласования ИИ", + description: "Действия, предложенные ИИ-агентом: перед выполнением их должен проверить человек.", + tabPending: "Ожидают", + tabDecided: "Обработанные", + tabAll: "Все", + statusPending: "Ожидает", + statusApproved: "Согласовано", + statusExecuted: "Выполнено", + statusFailed: "Не выполнено", + statusRejected: "Отклонено", + colTool: "Инструмент", + colAction: "Действие", + colObject: "Объект", + colStatus: "Статус", + colProposed: "Предложено", + colDecision: "Решение", + emptyTitle: "Нет действий в ожидании", + emptyDescription: "Когда ИИ предложит важное действие, оно появится здесь на проверку.", + view: "Открыть", + approve: "Согласовать", + reject: "Отклонить", + working: "Выполняется…", + approveAndExecute: "Согласовать и выполнить", + outcomeApprove: "Согласование {{id}}: {{message}}", + outcomeReject: "Отклонение {{id}}: {{message}}", + outcomeExecuteFailed: "Действие завершилось ошибкой при выполнении", + drawerFallbackTitle: "Действие в ожидании", + drawerSubtitle: "Инструмент {{tool}} для объекта {{object}}", + fieldProposedBy: "Кто предложил", + fieldDecidedBy: "Кто решил", + fieldConversation: "Диалог", + fieldToolInput: "Входные данные инструмента", + fieldResult: "Результат", + fieldError: "Ошибка", + fieldRejectionReason: "Причина отклонения", + rejectTitle: "Отклонить это действие?", + rejectBody: "Причина возвращается ИИ, чтобы он скорректировал следующий ответ.", + rejectPlaceholder: "Причина (необязательно), например: «Неверный идентификатор записи — сначала уточните у пользователя.»", + }, aiModelStatus: { summary: "Сборка / вопросы используют {{conversational}} ({{conversationalSource}}); структурированный вывод — {{structured}} ({{structuredSource}}).", summaryRouting: "Политика маршрутизации: бесплатные тарифы → {{free}}, платные тарифы → {{paid}}.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 9c199838d6..5226bd3178 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2926,6 +2926,46 @@ const zh = { openProduction: '打开生产环境', manageEnvironments: '管理环境', }, + aiApprovals: { + title: 'AI 审批', + description: 'AI 智能体提出的操作,需要人工审核后才会执行。', + tabPending: '待审批', + tabDecided: '已处理', + tabAll: '全部', + statusPending: '待审批', + statusApproved: '已通过', + statusExecuted: '已执行', + statusFailed: '失败', + statusRejected: '已拒绝', + colTool: '工具', + colAction: '动作', + colObject: '对象', + colStatus: '状态', + colProposed: '提出时间', + colDecision: '决定', + emptyTitle: '暂无待处理操作', + emptyDescription: '当 AI 提出敏感操作时,会显示在这里等待审核。', + view: '查看', + approve: '通过', + reject: '拒绝', + working: '处理中…', + approveAndExecute: '通过并执行', + outcomeApprove: '对 {{id}} 的通过操作:{{message}}', + outcomeReject: '对 {{id}} 的拒绝操作:{{message}}', + outcomeExecuteFailed: '操作执行失败', + drawerFallbackTitle: '待处理操作', + drawerSubtitle: '在 {{object}} 上执行工具 {{tool}}', + fieldProposedBy: '提出者', + fieldDecidedBy: '处理人', + fieldConversation: '会话', + fieldToolInput: '工具输入', + fieldResult: '结果', + fieldError: '错误', + fieldRejectionReason: '拒绝原因', + rejectTitle: '要拒绝此操作吗?', + rejectBody: '该原因会回传给 AI,便于它调整下一步回复。', + rejectPlaceholder: '可选原因(例如:“记录 ID 有误 — 请先与用户确认。”)', + }, aiModelStatus: { summary: '构建 / 提问使用 {{conversational}}({{conversationalSource}});结构化使用 {{structured}}({{structuredSource}})。', summaryRouting: '路由策略:免费方案 → {{free}},付费方案 → {{paid}}。', diff --git a/packages/plugin-chatbot/src/AiPendingActionsInbox.tsx b/packages/plugin-chatbot/src/AiPendingActionsInbox.tsx index 755672e008..cc690304a5 100644 --- a/packages/plugin-chatbot/src/AiPendingActionsInbox.tsx +++ b/packages/plugin-chatbot/src/AiPendingActionsInbox.tsx @@ -67,6 +67,10 @@ import { Clock, Bot, } from 'lucide-react'; +import { + useAiApprovalsTranslation, + type InboxTranslate, +} from './useAiApprovalsTranslation'; import { usePendingActions, type PendingActionRow, @@ -91,9 +95,15 @@ export interface AiPendingActionsInboxProps { conversationId?: string; /** Visual style. `card` (default) wraps in a Card; `bare` renders without. */ variant?: 'card' | 'bare'; - /** Optional title shown in the card header. */ + /** + * Optional title shown in the card header. Defaults to the locale pack's + * `aiApprovals.title` — a lookup, so it cannot be a default parameter value. + */ title?: string; - /** Optional description shown in the card header. */ + /** + * Optional description shown in the card header. Defaults to the locale + * pack's `aiApprovals.description`; pass `''` to hide it. + */ description?: string; /** Class name applied to the outer wrapper. */ className?: string; @@ -101,32 +111,75 @@ export interface AiPendingActionsInboxProps { type TabKey = 'pending' | 'decided' | 'all'; -const STATUS_BADGE: Record = { - pending: { variant: 'secondary', label: 'Pending' }, - approved: { variant: 'default', label: 'Approved' }, - executed: { variant: 'default', label: 'Executed' }, - failed: { variant: 'destructive', label: 'Failed' }, - rejected: { variant: 'outline', label: 'Rejected' }, + +const STATUS_VARIANT: Record = { + pending: 'secondary', + approved: 'default', + executed: 'default', + failed: 'destructive', + rejected: 'outline', }; -function StatusBadge({ status }: { status: string }) { - const cfg = STATUS_BADGE[status] ?? { variant: 'outline' as const, label: status }; - return {cfg.label}; +/** + * The badge label for a lifecycle status. + * + * A `switch` over LITERAL keys, not `t(STATUS_BADGE[status].labelKey)` — the + * lesson objectui#7149 paid for: a key visible only as a map VALUE has no call + * site the i18n scanners can resolve, so it reads as unreferenced to + * `check:i18n-keys` and `check:i18n-dead-keys` alike. Measured on this very + * change: with the keys held in a map, the dead-key sweep listed + * `aiApprovals.statusPending` and `aiApprovals.statusApproved` as + * needs-review — the two whose only appearance was that map — while the three + * that also had a literal call site elsewhere were seen. The variant table + * above stays a map because a Badge variant is not copy. + * + * `null` for an unrecognised status: that is DATA, whatever the service sent, + * and the caller renders it verbatim rather than through `t`, which would hand + * the same string back as a missing key. + */ +function statusLabel(status: string, t: InboxTranslate): string | null { + switch (status) { + case 'pending': return t('aiApprovals.statusPending'); + case 'approved': return t('aiApprovals.statusApproved'); + case 'executed': return t('aiApprovals.statusExecuted'); + case 'failed': return t('aiApprovals.statusFailed'); + case 'rejected': return t('aiApprovals.statusRejected'); + default: return null; + } +} + +function StatusBadge({ status, t }: { status: string; t: InboxTranslate }) { + const label = statusLabel(status, t); + if (label === null) return {status}; + return {label}; } -function formatRelative(s: string | null | undefined): string { +/** + * Relative "time since proposed" for a row. + * + * ⛔ The arithmetic is deliberately UNCHANGED (objectui#7173): `Math.round` + * (not floor), thresholds 45s / 60min / 24h / 30d (not 60s / 7d), and a + * `toLocaleDateString()` tail. Four sibling helpers in `plugin-detail` round and + * break differently; unifying them is a behaviour change that needs its own + * card, so this translates the OUTPUT where it stands. + * + * Past 30 days the value is a DATE, not a relative phrase, and + * `toLocaleDateString()` already localizes it — there is no literal there to + * key. Same reasoning, byte for byte, as the sibling helpers' own tails. + */ +function formatRelative(s: string | null | undefined, t: InboxTranslate): string { if (!s) return '—'; - const t = Date.parse(s); - if (Number.isNaN(t)) return s; - const diffMs = Date.now() - t; + const parsed = Date.parse(s); + if (Number.isNaN(parsed)) return s; + const diffMs = Date.now() - parsed; const sec = Math.round(diffMs / 1000); - if (sec < 45) return 'just now'; + if (sec < 45) return t('detail.justNow'); const min = Math.round(sec / 60); - if (min < 60) return `${min}m ago`; + if (min < 60) return t('detail.minutesAgo', { count: min }); const hr = Math.round(min / 60); - if (hr < 24) return `${hr}h ago`; + if (hr < 24) return t('detail.hoursAgo', { count: hr }); const day = Math.round(hr / 24); - if (day < 30) return `${day}d ago`; + if (day < 30) return t('detail.daysAgo', { count: day }); try { return new Date(s).toLocaleDateString(); } catch { return s; } } @@ -165,10 +218,15 @@ export function AiPendingActionsInbox({ pollInterval = 5000, conversationId, variant = 'card', - title = 'AI Approvals', - description = 'Actions an AI agent proposed that need a human review before execution.', + title, + description, className, }: AiPendingActionsInboxProps) { + const { t } = useAiApprovalsTranslation(); + // `??`, not `||`: an explicit `description=''` still hides the line, which is + // what the literal default used to allow. + const resolvedTitle = title ?? t('aiApprovals.title'); + const resolvedDescription = description ?? t('aiApprovals.description'); const [tab, setTab] = React.useState('pending'); const [openId, setOpenId] = React.useState(null); const [rejectFor, setRejectFor] = React.useState(null); @@ -203,7 +261,15 @@ export function AiPendingActionsInbox({ try { const out = await approve(id); const ok = out.status === 'executed'; - setLastOutcome({ id, kind: 'approve', ok, message: ok ? 'Executed' : (out.error ?? 'Action failed during execution') }); + setLastOutcome({ + id, + kind: 'approve', + ok, + // `out.error` is a server-authored string — data, passed through as-is. + message: ok + ? t('aiApprovals.statusExecuted') + : (out.error ?? t('aiApprovals.outcomeExecuteFailed')), + }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setMutationError(msg); @@ -211,14 +277,14 @@ export function AiPendingActionsInbox({ } finally { setBusyId(null); } - }, [approve]); + }, [approve, t]); const handleReject = React.useCallback(async (id: string, reason: string) => { setBusyId(id); setMutationError(null); try { await reject(id, reason.trim() || undefined); - setLastOutcome({ id, kind: 'reject', ok: true, message: 'Rejected' }); + setLastOutcome({ id, kind: 'reject', ok: true, message: t('aiApprovals.statusRejected') }); setRejectFor(null); setRejectReason(''); } catch (err) { @@ -228,16 +294,16 @@ export function AiPendingActionsInbox({ } finally { setBusyId(null); } - }, [reject]); + }, [reject, t]); const body = (
setTab(v as TabKey)}> - Pending - Decided - All + {t('aiApprovals.tabPending')} + {t('aiApprovals.tabDecided')} + {t('aiApprovals.tabAll')}
@@ -270,9 +336,24 @@ export function AiPendingActionsInbox({ {lastOutcome.ok ? : } - {lastOutcome.kind === 'approve' ? 'Approve' : 'Reject'} for{' '} - {lastOutcome.id.slice(0, 8)}…:{' '} - {lastOutcome.message ?? (lastOutcome.ok ? 'OK' : 'Failed')} + {/* + One key with `{{id}}` and `{{message}}` holes, not an English + sentence assembled from fragments around a `` element: word + order differs per locale, so the fragments could not be reordered. + The id keeps its truncation; it loses the monospace styling, which + is the deliberate cost of making the sentence translatable. + */} + {t( + lastOutcome.kind === 'approve' + ? 'aiApprovals.outcomeApprove' + : 'aiApprovals.outcomeReject', + { + id: `${lastOutcome.id.slice(0, 8)}…`, + message: + lastOutcome.message ?? + (lastOutcome.ok ? t('common.ok') : t('aiApprovals.statusFailed')), + }, + )} ) : null} @@ -289,10 +370,8 @@ export function AiPendingActionsInbox({ - No actions waiting - - When the AI proposes a sensitive action it will appear here for review. - + {t('aiApprovals.emptyTitle')} + {t('aiApprovals.emptyDescription')} ) : ( @@ -300,12 +379,12 @@ export function AiPendingActionsInbox({ - Tool - Action - Object - Status - Proposed - Decision + {t('aiApprovals.colTool')} + {t('aiApprovals.colAction')} + {t('aiApprovals.colObject')} + {t('aiApprovals.colStatus')} + {t('aiApprovals.colProposed')} + {t('aiApprovals.colDecision')} @@ -317,10 +396,10 @@ export function AiPendingActionsInbox({ {row.tool_name} {row.action_name} {row.object_name} - + - {formatRelative(row.proposed_at)} + {formatRelative(row.proposed_at, t)} @@ -331,7 +410,7 @@ export function AiPendingActionsInbox({ onClick={() => setOpenId(row.id)} data-testid={`ai-inbox-view-${row.id}`} > - View + {t('aiApprovals.view')} {isPending ? ( <> @@ -343,7 +422,7 @@ export function AiPendingActionsInbox({ data-testid={`ai-inbox-approve-${row.id}`} > - {isBusy ? 'Working…' : 'Approve'} + {isBusy ? t('aiApprovals.working') : t('aiApprovals.approve')} ) : null} @@ -373,9 +452,9 @@ export function AiPendingActionsInbox({ - {title} + {resolvedTitle} - {description ? {description} : null} + {resolvedDescription ? {resolvedDescription} : null} {body} @@ -393,15 +472,22 @@ export function AiPendingActionsInbox({ - {selected ? selected.action_name : 'Pending action'} + {selected ? selected.action_name : t('aiApprovals.drawerFallbackTitle')} - {selected ? ( - <> - Tool {selected.tool_name} on{' '} - {selected.object_name} - - ) : 'Loading…'} + {/* + One key with `{{tool}}` and `{{object}}` holes rather than an + English sentence assembled around two `` elements — the + two nouns swap order in several locales, which fragments cannot + express. The identifiers lose their monospace styling; that is + the deliberate cost of making the sentence translatable. + */} + {selected + ? t('aiApprovals.drawerSubtitle', { + tool: selected.tool_name, + object: selected.object_name, + }) + : t('common.loading')} @@ -409,24 +495,24 @@ export function AiPendingActionsInbox({
- -
+ +
- -
{formatRelative(selected.proposed_at)}
+ +
{formatRelative(selected.proposed_at, t)}
- +
{selected.proposed_by ?? '—'}
- +
{selected.decided_by ?? '—'}
{selected.conversation_id ? (
- +
{selected.conversation_id}
) : null} @@ -435,27 +521,27 @@ export function AiPendingActionsInbox({
- +
{selected.result ? (
- +
) : null} {selected.error ? (
- +
) : null} {selected.rejection_reason ? (
- +
{selected.rejection_reason}
) : null} @@ -468,7 +554,7 @@ export function AiPendingActionsInbox({ disabled={busyId === selected.id} > - Approve & Execute + {t('aiApprovals.approveAndExecute')}
) : null} @@ -490,22 +576,20 @@ export function AiPendingActionsInbox({ { if (!o) { setRejectFor(null); setRejectReason(''); } }}> - Reject this action? - - The reason is shown back to the AI so it can adjust its next response. - + {t('aiApprovals.rejectTitle')} + {t('aiApprovals.rejectBody')}