diff --git a/docs/design/evidence/connectors-tools-zh-CN-1200.png b/docs/design/evidence/connectors-tools-zh-CN-1200.png new file mode 100644 index 000000000..c5e7c9968 Binary files /dev/null and b/docs/design/evidence/connectors-tools-zh-CN-1200.png differ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 50bc0e798..7df0d4b34 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1837,6 +1837,37 @@ "notConfigured": "Billing isn't switched on yet. Please contact us and we'll set you up." } }, + "time": { + "justNow": "just now", + "inAMoment": "in a moment", + "minutesAgo": "{{count}}m ago", + "minutesAgo_one": "{{count}}m ago", + "minutesAgo_other": "{{count}}m ago", + "hoursAgo": "{{count}}h ago", + "hoursAgo_one": "{{count}}h ago", + "hoursAgo_other": "{{count}}h ago", + "daysAgo": "{{count}}d ago", + "daysAgo_one": "{{count}}d ago", + "daysAgo_other": "{{count}}d ago", + "minutesFromNow": "{{count}}m from now", + "minutesFromNow_one": "{{count}}m from now", + "minutesFromNow_other": "{{count}}m from now", + "hoursFromNow": "{{count}}h from now", + "hoursFromNow_one": "{{count}}h from now", + "hoursFromNow_other": "{{count}}h from now", + "daysFromNow": "{{count}}d from now", + "daysFromNow_one": "{{count}}d from now", + "daysFromNow_other": "{{count}}d from now", + "minutesDuration": "{{count}}m", + "minutesDuration_one": "{{count}}m", + "minutesDuration_other": "{{count}}m", + "hoursDuration": "{{count}}h", + "hoursDuration_one": "{{count}}h", + "hoursDuration_other": "{{count}}h", + "daysDuration": "{{count}}d", + "daysDuration_one": "{{count}}d", + "daysDuration_other": "{{count}}d" + }, "connectors": { "loading": "Loading connectors…", "loadError": "Could not load connectors.", @@ -1858,7 +1889,201 @@ "createPod": "Create a pod", "creating": "Creating…", "createTelegram": "New Telegram connector", - "footnote": "You get a one-time code to send to the bot. More platforms are on the way." + "footnote": "You get a one-time code to send to the bot. More platforms are on the way.", + "slackCallbackError": "Slack authorization didn’t complete — try again.", + "installInProgressForPod": "Still setting up for {{pod}} — try again in a moment.", + "installInProgress": "Still setting up — try again in a moment.", + "alreadyBound": "Your {{connector}} channel is bound to {{pod}}. Remove it to bind a different pod.", + "pausedRefusal": "An administrator paused this connector. Ask your operator.", + "providerNotConfigured": "{{connector}} is not enabled on this instance. Ask your operator.", + "codeError": "Could not create a new code.", + "slackAuthorizeError": "Could not begin Slack authorization. Try again in a moment.", + "slackBindError": "Could not update the Slack connection. Try again in a moment.", + "removeError": "Could not remove the channel.", + "cancelError": "Could not cancel setup.", + "modeError": "Could not switch mode.", + "newCode": "New code", + "errorReconnect": "reconnect to resume", + "errorLine": "The connection dropped.", + "pickPod": "Pick a pod", + "notLinkedDetail": "your messages have nowhere to go", + "notLinkedLine": "{{title}} · not linked to a pod", + "manage": "Manage", + "rowMirror": "every agent line reaches the channel", + "rowAttention": "attention · escalations reach the channel", + "rowRelayOff": "relay off · messages stay in the pod", + "showCode": "Show code", + "nothingSent": "nothing was sent", + "codeExpired": "The enable code expired.", + "slackConfirm": "Confirm", + "slackConfirmConnection": "Confirm connection", + "slackConfirmDetail": "waiting for you to confirm", + "slackConfirmRow": "{{workspace}} says {{user}} connected — is that you?", + "slackAuthorize": "Authorize in Slack", + "slackAuthorizeDetail": "one click in Slack", + "slackAuthorizeHint": "Authorize Commonly in your Slack workspace to connect your DM.", + "askOperator": "ask your operator", + "notEnabled": "Not enabled on this instance.", + "availableTelegram": "one message", + "availableSlack": "one click in your workspace", + "availableLine": "Connect {{label}} to a pod.", + "notConnected": "not connected", + "cancel": "Cancel", + "serverLetGo": "the server let go of it", + "setupSlow": "Setup is taking longer than it should.", + "waitingServer": "waiting for the server", + "settingUp": "Setting up…", + "retryRemove": "Retry remove", + "removeSlow": "Removal is taking longer than it should.", + "removing": "Removing…", + "retry": "Retry", + "retryOrRemove": "retry, or remove it", + "setupFailed": "Setup didn’t finish.", + "pausedLine": "Paused by an administrator.", + "notRelaying": "not relaying", + "pausedShort": "Paused.", + "retryRebuilds": "retry rebuilds it", + "recordGone": "The channel record is gone.", + "viewChannel": "View {{channel}}", + "slackAuthorizing": "Opening Slack…", + "pickWhere": "Pick where your messages go", + "gatesTitle": "Pods that reach this channel", + "noPods": "Join a pod first.", + "activeTag": "active", + "gateOff": "off", + "gateSwitch": "Relay {{pod}}", + "gateMode": "Mode for {{pod}}", + "modeDefault": "Default", + "modeAttention": "Attention", + "modeMirror": "Mirror", + "gateLead": "Lead agent for {{pod}}", + "leadAny": "Any agent leads", + "makeActive": "Make active", + "removeConfirm": "Really remove — the channel goes quiet", + "remove": "Remove", + "channelDetails": "Channel details", + "pausedEyebrow": "Paused", + "nextStep": "Next step", + "pausedNote": "Only an administrator can resume it. Your relay, mode and pods are kept.", + "copied": "Copied", + "copyCommand": "Copy command", + "slackConfirmHint": "{{workspace}} wants to connect as {{user}}.", + "slackReject": "This is not me", + "channelSees": "What the channel sees", + "mirrorHint": "Every agent message reaches the channel.", + "attentionHint": "Only escalations and the lead agent reach the channel.", + "relayOffHint": "Messages stay in the pod.", + "mode": "Relay mode", + "provider": "Channel provider", + "connect": "Connect", + "title": "Connectors", + "description": "Each channel gets one agent from a pod. It answers there in its own name; the rest of the team stays behind it.", + "channels": "Your channels", + "notYetLine": "Not yet. Tell us which channel you need and we build it next.", + "ask": "Ask", + "connectChannel": "Connect a channel", + "connectChannelHint": "Choose a channel and the pod it should join.", + "startedWhen": "started {{rel}}", + "sinceWhen": "since {{rel}}", + "addedWhen": "added {{rel}}", + "pausedWhen": "paused {{rel}}", + "slackAnsweredWhen": "Slack answered {{rel}}", + "linkedLine": "{{title}} · linked to {{pod}}", + "waitingToConnect": "{{label}} is waiting to connect.", + "untitledPod": "Untitled pod", + "anotherPod": "another pod", + "thisSlackWorkspace": "This Slack workspace", + "yourSlackUser": "your Slack user" + }, + "tools": { + "aPod": "a pod", + "aSeat": "an agent", + "nobody": "no agent", + "asksNothing": "nothing asks first", + "asksEveryWrite": "every write asks first", + "asksList": "{{tools}} ask first", + "outcomeOk": "ok", + "outcomeRefused": "refused", + "awaiting": "awaiting a person", + "outcomeFailed": "failed", + "modeRead": "read", + "modeWriteConfirm": "read and write, ask first", + "modeWrite": "read and write", + "grantError": "Could not grant it.", + "revokeError": "Could not revoke the grant.", + "grantedWhen": "granted {{rel}}", + "revokedByLine": "revoked by {{member}} {{rel}}", + "revokedLine": "revoked {{rel}}", + "expiredLine": "expired {{rel}}", + "mayUse": "may use it", + "viewGrant": "View {{tool}} in {{pod}}", + "grantedTo": "granted to", + "by": "by", + "grantAgain": "Grant again", + "manage": "Manage", + "notEnabled": "not enabled on this instance · ask your operator", + "noConnection": "install the GitHub App first · an admin does this once", + "installGitHubApp": "Install GitHub App", + "adminSetup": "administrator setup", + "githubAppSetupHint": "Connect the GitHub App once so people can grant GitHub tools to their rooms.", + "installationId": "installation ID", + "owner": "owner", + "repository": "repository", + "installingGitHubApp": "Installing…", + "githubAppRequired": "Enter the installation ID, owner, and repository.", + "githubAppError": "Could not install the GitHub App.", + "githubAppOwnedByOther": "This GitHub App is already installed by another administrator.", + "readOrWrite": "read, or read and write", + "notGranted": "not granted", + "ask": "Ask", + "add": "Add", + "addTool": "Add {{tool}}", + "grant": "grant", + "changeAccess": "Change access", + "toRoom": "room", + "connection": "connection", + "mode": "what it may do", + "noTools": "no tools on the allow-list", + "agents": "agents", + "noSeats": "no agent in this room yet", + "ends": "ends", + "days": "in {{count}} days", + "granting": "Granting…", + "grantAct": "Grant", + "cancel": "Cancel", + "grantDetails": "Grant details", + "grantedByOn": "Granted by {{member}} {{rel}}.", + "grantedOn": "Granted {{rel}}.", + "endedRevokedBy": "Revoked by {{member}} {{rel}}.", + "endedRevoked": "Revoked {{rel}}.", + "endedExpired": "Expired {{rel}}.", + "endsRel": "Ends {{rel}}.", + "agentsAllowed": "agents allowed", + "asksFirst": "asks a person first", + "budget": "budget", + "budgetWindow": "{{calls}} calls per {{window}}", + "budgetTotal": "{{calls}} calls", + "staleAfterChange": "The new grant is live. This old one still is too — its revoke did not go through.", + "revokeOld": "Revoke the old grant", + "revokeConfirm": "Yes, revoke it", + "keep": "Keep it", + "revoke": "Revoke", + "trail": "trail", + "counts": "Call counts", + "calls": "calls", + "refused": "refused", + "trailError": "Could not read the trail.", + "trailEmpty": "No calls yet.", + "loading": "Loading tools…", + "title": "Tools", + "grantedCount": "{{count}} granted", + "moreCount": "{{count}} more", + "search": "Search tools", + "filter": "Show", + "segAll": "All", + "segGranted": "Granted", + "segNotYet": "Not yet", + "nothingMatches": "Nothing matches." }, "artifacts": { "title": "Artifacts", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index b68bead77..08d50c4b6 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1829,20 +1829,51 @@ "notConfigured": "付款功能尚未开启,请联系我们为你开通。" } }, + "time": { + "justNow": "刚刚", + "inAMoment": "即将", + "minutesAgo": "{{count}} 分钟前", + "minutesAgo_one": "{{count}} 分钟前", + "minutesAgo_other": "{{count}} 分钟前", + "hoursAgo": "{{count}} 小时前", + "hoursAgo_one": "{{count}} 小时前", + "hoursAgo_other": "{{count}} 小时前", + "daysAgo": "{{count}} 天前", + "daysAgo_one": "{{count}} 天前", + "daysAgo_other": "{{count}} 天前", + "minutesFromNow": "{{count}} 分钟后", + "minutesFromNow_one": "{{count}} 分钟后", + "minutesFromNow_other": "{{count}} 分钟后", + "hoursFromNow": "{{count}} 小时后", + "hoursFromNow_one": "{{count}} 小时后", + "hoursFromNow_other": "{{count}} 小时后", + "daysFromNow": "{{count}} 天后", + "daysFromNow_one": "{{count}} 天后", + "daysFromNow_other": "{{count}} 天后", + "minutesDuration": "{{count}} 分钟", + "minutesDuration_one": "{{count}} 分钟", + "minutesDuration_other": "{{count}} 分钟", + "hoursDuration": "{{count}} 小时", + "hoursDuration_one": "{{count}} 小时", + "hoursDuration_other": "{{count}} 小时", + "daysDuration": "{{count}} 天", + "daysDuration_one": "{{count}} 天", + "daysDuration_other": "{{count}} 天" + }, "connectors": { "loading": "正在加载连接器…", "loadError": "无法加载连接器。", "createError": "无法创建连接器。", - "toggleError": "无法更新实时中继。", + "toggleError": "无法更新实时转发。", "empty": "还没有连接器。在下方绑定一个频道,让你的 Pod 出现在团队已有的聊天工具里。", "connected": "已连接", "pending": "等待频道确认", "enableHint": "打开与 Commonly 机器人的私聊", "enableHintSend": ",然后发送:", "waitingTelegram": "在 Telegram 聊天中发送 /commonly-enable 命令。", - "codeExpires": "代码将在 {{minutes}} 分钟后过期", - "liveRelay": "中继", - "liveRelayHint": "聊天消息会发布到 Pod 并唤醒被提及的智能体;智能体的升级消息会回到频道。", + "codeExpires": "启用码将在 {{minutes}} 分钟后过期", + "liveRelay": "转发", + "liveRelayHint": "聊天消息会发布到 Pod 并唤醒被提及的智能体;需要关注的智能体消息会回到频道。", "newTitle": "连接频道", "choosePod": "选择 Pod", "podPicker": "要桥接的 Pod", @@ -1850,7 +1881,201 @@ "createPod": "创建 Pod", "creating": "创建中…", "createTelegram": "新建 Telegram 连接器", - "footnote": "你会获得一个一次性代码,发送给机器人即可。更多平台即将支持。" + "footnote": "你会获得一个一次性代码,发送给机器人即可。更多平台即将支持。", + "slackCallbackError": "Slack 授权未完成,请重试。", + "installInProgressForPod": "{{pod}} 仍在设置中,请稍后再试。", + "installInProgress": "仍在设置中,请稍后再试。", + "alreadyBound": "你的 {{connector}} 频道已绑定到 {{pod}}。移除后才能绑定其他 Pod。", + "pausedRefusal": "管理员已暂停此连接器,请联系实例管理员。", + "providerNotConfigured": "此实例尚未启用 {{connector}},请联系实例管理员。", + "codeError": "无法生成新的启用码。", + "slackAuthorizeError": "无法开始 Slack 授权,请稍后再试。", + "slackBindError": "无法更新 Slack 连接,请稍后再试。", + "removeError": "无法移除频道。", + "cancelError": "无法取消设置。", + "modeError": "无法切换模式。", + "newCode": "新启用码", + "errorReconnect": "重新连接以恢复", + "errorLine": "连接已断开。", + "pickPod": "选择 Pod", + "notLinkedDetail": "你的消息还没有去处", + "notLinkedLine": "{{title}} · 未连接到 Pod", + "manage": "管理", + "rowMirror": "每条智能体消息都会发送到频道", + "rowAttention": "仅提醒 · 需要关注的消息才会发送到频道", + "rowRelayOff": "转发已关闭 · 消息会留在 Pod 中", + "showCode": "显示启用码", + "nothingSent": "尚未发送任何内容", + "codeExpired": "启用码已过期。", + "slackConfirm": "确认", + "slackConfirmConnection": "确认连接", + "slackConfirmDetail": "等待你确认", + "slackConfirmRow": "{{workspace}} 显示 {{user}} 已连接,是你吗?", + "slackAuthorize": "在 Slack 中授权", + "slackAuthorizeDetail": "在 Slack 中点一下", + "slackAuthorizeHint": "在你的 Slack 工作空间中授权 Commonly,以连接你的私信。", + "askOperator": "联系实例管理员", + "notEnabled": "此实例尚未启用。", + "availableTelegram": "发一条消息", + "availableSlack": "在你的工作空间中点一下", + "availableLine": "将 {{label}} 连接到 Pod。", + "notConnected": "未连接", + "cancel": "取消", + "serverLetGo": "服务器已释放该连接", + "setupSlow": "设置时间超出预期。", + "waitingServer": "等待服务器响应", + "settingUp": "设置中…", + "retryRemove": "重试移除", + "removeSlow": "移除时间超出预期。", + "removing": "移除中…", + "retry": "重试", + "retryOrRemove": "重试,或移除它", + "setupFailed": "设置未完成。", + "pausedLine": "管理员已暂停。", + "notRelaying": "未转发", + "pausedShort": "已暂停。", + "retryRebuilds": "重试即可重建", + "recordGone": "频道记录已不存在。", + "viewChannel": "查看 {{channel}}", + "slackAuthorizing": "正在打开 Slack…", + "pickWhere": "选择消息发往哪里", + "gatesTitle": "接入此频道的 Pod", + "noPods": "请先加入一个 Pod。", + "activeTag": "当前", + "gateOff": "已关闭", + "gateSwitch": "转发 {{pod}}", + "gateMode": "{{pod}} 的模式", + "modeDefault": "默认", + "modeAttention": "提醒", + "modeMirror": "镜像", + "gateLead": "{{pod}} 的主导智能体", + "leadAny": "任一智能体都可主导", + "makeActive": "设为当前", + "removeConfirm": "确定要移除吗?频道将停止接收消息", + "remove": "移除", + "channelDetails": "频道详情", + "pausedEyebrow": "已暂停", + "nextStep": "下一步", + "pausedNote": "只有管理员可以恢复。你的转发、模式和 Pod 都会保留。", + "copied": "已复制", + "copyCommand": "复制命令", + "slackConfirmHint": "{{workspace}} 想以 {{user}} 的身份连接。", + "slackReject": "这不是我", + "channelSees": "频道可见内容", + "mirrorHint": "每条智能体消息都会发送到频道。", + "attentionHint": "只有需要关注的消息和主导智能体的消息会发送到频道。", + "relayOffHint": "消息会留在 Pod 中。", + "mode": "转发模式", + "provider": "频道提供商", + "connect": "连接", + "title": "连接器", + "description": "每个频道从一个 Pod 接入 1 个智能体。它会在那里以自己的名义回复,其余团队成员都在幕后。", + "channels": "你的频道", + "notYetLine": "还没有。告诉我们你需要哪个频道,我们下一个就做。", + "ask": "申请", + "connectChannel": "连接频道", + "connectChannelHint": "选择频道,以及它要加入的 Pod。", + "startedWhen": "{{rel}}开始", + "sinceWhen": "{{rel}}起", + "addedWhen": "{{rel}}添加", + "pausedWhen": "{{rel}}暂停", + "slackAnsweredWhen": "Slack {{rel}}回复", + "linkedLine": "{{title}} · 已连接到 {{pod}}", + "waitingToConnect": "{{label}} 正在等待连接。", + "untitledPod": "未命名 Pod", + "anotherPod": "另一个 Pod", + "thisSlackWorkspace": "此 Slack 工作空间", + "yourSlackUser": "你的 Slack 账号" + }, + "tools": { + "aPod": "一个 Pod", + "aSeat": "一个智能体", + "nobody": "没有智能体", + "asksNothing": "无需确认", + "asksEveryWrite": "每次写入前都需确认", + "asksList": "{{tools}} 调用前需确认", + "outcomeOk": "成功", + "outcomeRefused": "已拒绝", + "awaiting": "等待人工确认", + "outcomeFailed": "失败", + "modeRead": "读取", + "modeWriteConfirm": "读取和写入,写入前需确认", + "modeWrite": "读取和写入", + "grantError": "无法授权。", + "revokeError": "无法撤销授权。", + "grantedWhen": "{{rel}}授权", + "revokedByLine": "{{rel}}由 {{member}} 撤销", + "revokedLine": "{{rel}}撤销", + "expiredLine": "{{rel}}过期", + "mayUse": "可以使用", + "viewGrant": "在 {{pod}} 中查看 {{tool}}", + "grantedTo": "授权给", + "by": "授权者", + "grantAgain": "再次授权", + "manage": "管理", + "notEnabled": "此实例尚未启用 · 联系实例管理员", + "noConnection": "请先安装 GitHub App · 管理员只需安装一次", + "installGitHubApp": "安装 GitHub App", + "adminSetup": "管理员设置", + "githubAppSetupHint": "只需连接一次 GitHub App,成员就能把 GitHub 工具授权给自己的 Pod。", + "installationId": "安装 ID", + "owner": "所有者", + "repository": "仓库", + "installingGitHubApp": "安装中…", + "githubAppRequired": "请填写安装 ID、所有者和仓库。", + "githubAppError": "无法安装 GitHub App。", + "githubAppOwnedByOther": "这个 GitHub App 已由另一位管理员安装。", + "readOrWrite": "读取,或读取和写入", + "notGranted": "未授权", + "ask": "申请", + "add": "添加", + "addTool": "添加 {{tool}}", + "grant": "授权", + "changeAccess": "更改访问权限", + "toRoom": "Pod", + "connection": "连接", + "mode": "可执行的操作", + "noTools": "允许列表中没有工具", + "agents": "智能体", + "noSeats": "此 Pod 中还没有智能体", + "ends": "结束时间", + "days": "{{count}} 天后", + "granting": "授权中…", + "grantAct": "授权", + "cancel": "取消", + "grantDetails": "授权详情", + "grantedByOn": "{{rel}}由 {{member}} 授权。", + "grantedOn": "{{rel}}授权。", + "endedRevokedBy": "{{rel}}由 {{member}} 撤销。", + "endedRevoked": "{{rel}}撤销。", + "endedExpired": "{{rel}}过期。", + "endsRel": "{{rel}}结束。", + "agentsAllowed": "可使用的智能体", + "asksFirst": "调用前需人工确认", + "budget": "额度", + "budgetWindow": "每 {{window}} 可调用 {{calls}} 次", + "budgetTotal": "{{calls}} 次调用", + "staleAfterChange": "新授权已生效。旧授权也仍然有效——撤销未成功。", + "revokeOld": "撤销旧授权", + "revokeConfirm": "确定,撤销", + "keep": "保留", + "revoke": "撤销", + "trail": "调用记录", + "counts": "调用次数", + "calls": "调用", + "refused": "拒绝", + "trailError": "无法读取调用记录。", + "trailEmpty": "还没有调用。", + "loading": "正在加载工具…", + "title": "工具", + "grantedCount": "已授权 {{count}} 个", + "moreCount": "另有 {{count}} 个", + "search": "搜索工具", + "filter": "显示", + "segAll": "全部", + "segGranted": "已授权", + "segNotYet": "尚未授权", + "nothingMatches": "没有匹配项。" }, "artifacts": { "title": "产物", diff --git a/frontend/src/v2/__tests__/localizeRelativeTime.test.ts b/frontend/src/v2/__tests__/localizeRelativeTime.test.ts new file mode 100644 index 000000000..27fa1817a --- /dev/null +++ b/frontend/src/v2/__tests__/localizeRelativeTime.test.ts @@ -0,0 +1,38 @@ +import { localizeRelativeTime, relativeParts, relativeTime } from '../utils/localizeRelativeTime'; + +const translate = jest.fn((key: string, options: { defaultValue: string }) => options.defaultValue) as any; + +const at = (iso: string) => new Date(iso).toISOString(); + +describe('localizeRelativeTime', () => { + beforeEach(() => jest.spyOn(Date, 'now').mockReturnValue(new Date('2026-09-16T08:00:00.000Z').getTime())); + afterEach(() => { + jest.restoreAllMocks(); + translate.mockClear(); + }); + + it('localizes missing connector timestamps and clamps future connector timestamps', () => { + const options = { includeFuture: false, missing: 'just now', rounding: 'floor' } as const; + expect(relativeParts(undefined, options)).toEqual({ kind: 'missing' }); + expect(localizeRelativeTime(undefined, translate, options)).toBe('just now'); + expect(translate).toHaveBeenCalledWith('time.justNow', { defaultValue: 'just now' }); + expect(relativeParts(at('2026-09-16T08:02:00.000Z'), options)).toEqual({ kind: 'now', future: false }); + expect(localizeRelativeTime(at('2026-09-16T08:02:00.000Z'), translate, options)).toBe('just now'); + expect(translate).toHaveBeenLastCalledWith('time.justNow', { defaultValue: 'just now' }); + }); + + it.each([ + ['minutes ago', '2026-09-16T07:58:00.000Z', { kind: 'rel', unit: 'minute', count: 2, future: false }, 'time.minutesAgo', '2m ago'], + ['hours ago', '2026-09-16T06:00:00.000Z', { kind: 'rel', unit: 'hour', count: 2, future: false }, 'time.hoursAgo', '2h ago'], + ['days ago', '2026-09-14T08:00:00.000Z', { kind: 'rel', unit: 'day', count: 2, future: false }, 'time.daysAgo', '2d ago'], + ['minutes from now', '2026-09-16T08:02:00.000Z', { kind: 'rel', unit: 'minute', count: 2, future: true }, 'time.minutesFromNow', '2m from now'], + ['hours from now', '2026-09-16T10:00:00.000Z', { kind: 'rel', unit: 'hour', count: 2, future: true }, 'time.hoursFromNow', '2h from now'], + ['days from now', '2026-09-18T08:00:00.000Z', { kind: 'rel', unit: 'day', count: 2, future: true }, 'time.daysFromNow', '2d from now'], + ])('formats %s from the same relative parts in English and localized output', (_label, date, expectedParts, key, expectedEnglish) => { + const options = { rounding: 'floor' } as const; + expect(relativeParts(at(date as string), options)).toEqual(expectedParts); + expect(relativeTime(at(date as string), options)).toBe(expectedEnglish); + expect(localizeRelativeTime(at(date as string), translate, options)).toBe(expectedEnglish); + expect(translate).toHaveBeenCalledWith(key, { count: 2, defaultValue: expectedEnglish }); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-i18n-invariants.test.ts b/frontend/src/v2/__tests__/v2-i18n-invariants.test.ts new file mode 100644 index 000000000..7f81a8492 --- /dev/null +++ b/frontend/src/v2/__tests__/v2-i18n-invariants.test.ts @@ -0,0 +1,73 @@ +import en from '../../i18n/locales/en.json'; +import zhCN from '../../i18n/locales/zh-CN.json'; +import fs from 'fs'; +import path from 'path'; + +const SOURCES = [ + path.join(__dirname, '../components/V2ConnectorsPage.tsx'), + path.join(__dirname, '../components/V2ConnectorTools.tsx'), + path.join(__dirname, '../utils/localizeRelativeTime.ts'), +]; + +const source = SOURCES + .map((filePath) => fs.readFileSync(filePath, 'utf8')) + .join('\n'); + +const USED_KEYS = [...new Set( + [...source.matchAll(/t\(\s*['"]((?:connectors|tools)\.[^'"]+)/g)].map((match) => match[1]), +)].sort(); + +const SHARED_TIME_KEYS = [...new Set( + [...source.matchAll(/t\(\s*['"](time\.[^'"]+)/g)].map((match) => match[1]), +)].sort(); + +const lookup = (bundle: Record, key: string): unknown => ( + key.split('.').reduce((value, part) => ( + value && typeof value === 'object' ? (value as Record)[part] : undefined + ), bundle) +); + +describe('Connectors and Tools locale coverage', () => { + test('every component translation key exists in both shipped locales', () => { + expect(USED_KEYS.length).toBeGreaterThan(150); + for (const key of USED_KEYS) { + expect(lookup(en, key)).toEqual(expect.any(String)); + expect(lookup(zhCN, key)).toEqual(expect.any(String)); + } + }); + + test('both pages use one shared time family', () => { + expect(SHARED_TIME_KEYS.length).toBeGreaterThan(0); + for (const key of SHARED_TIME_KEYS) { + expect(lookup(en, key)).toEqual(expect.any(String)); + expect(lookup(zhCN, key)).toEqual(expect.any(String)); + } + const enTime = en.time as Record; + const zhTime = zhCN.time as Record; + expect(Object.keys(enTime).sort()).toEqual(Object.keys(zhTime).sort()); + for (const key of Object.keys(enTime)) { + expect(enTime[key]).toEqual(expect.any(String)); + expect(zhTime[key]).toEqual(expect.any(String)); + } + expect(lookup(en, 'connectors.time')).toBeUndefined(); + expect(lookup(en, 'tools.time')).toBeUndefined(); + expect(lookup(zhCN, 'connectors.time')).toBeUndefined(); + expect(lookup(zhCN, 'tools.time')).toBeUndefined(); + }); + + test('rendered time and fallback copy does not bypass translation keys', () => { + const rawEnglishTemplates = [ + /`started \$\{/, + /`since \$\{/, + /`added \$\{/, + /`paused \$\{/, + /`Slack answered \$\{/, + /`\$\{title\} · linked to/, + /\|\| 'Untitled pod'/, + /\|\| 'another pod'/, + /\|\| 'This Slack workspace'/, + /\? `@\$\{[^}]+\}` : 'your Slack user'/, + ]; + for (const pattern of rawEnglishTemplates) expect(source).not.toMatch(pattern); + }); +}); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index b3605de75..9b7cfaa4f 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -12,10 +12,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { useV2Api } from '../hooks/useV2Api'; import { useAuth } from '../../context/AuthContext'; import { V2Pod } from '../hooks/useV2Pods'; import { PlatformGlyph } from '../icons/platforms'; +import { localizeRelativeTime, relativeTime } from '../utils/localizeRelativeTime'; + +export { localizeRelativeTime, relativeTime } from '../utils/localizeRelativeTime'; export type GrantWriteMode = 'read' | 'write' | 'write-with-confirm'; @@ -102,19 +106,12 @@ const USED_RECENTLY_MS = 10 * 60 * 1000; const MAX_PODS = 20; const MODE_RANK: Record = { read: 0, 'write-with-confirm': 1, write: 2 }; -export const relativeTime = (date?: string | null): string => { - if (!date) return '—'; - const ms = Date.now() - new Date(date).getTime(); - if (!Number.isFinite(ms)) return '—'; - const abs = Math.abs(ms); - const suffix = ms >= 0 ? 'ago' : 'from now'; - const minutes = Math.round(abs / 60_000); - if (minutes < 1) return ms >= 0 ? 'just now' : 'in a moment'; - if (minutes < 60) return `${minutes}m ${suffix}`; +const localizeWindow = (windowMs: number, t: TFunction): string => { + const minutes = Math.max(1, Math.round(windowMs / 60_000)); + if (minutes < 60) return t('time.minutesDuration', { count: minutes, defaultValue: `${minutes}m` }); const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours}h ${suffix}`; - const days = Math.round(hours / 24); - return `${days}d ${suffix}`; + if (hours < 24) return t('time.hoursDuration', { count: hours, defaultValue: `${hours}h` }); + return t('time.daysDuration', { count: Math.round(hours / 24), defaultValue: `${Math.round(hours / 24)}d` }); }; const isExpired = (grant: ToolGrant, now = Date.now()): boolean => new Date(grant.expiresAt).getTime() <= now; @@ -423,14 +420,14 @@ const V2ConnectorTools: React.FC = ({ pods }) => { const isSelected = selectedId === grant.grantId; const entry = entryFor(grant); const label = toolLabel(grant); - const when = t('tools.grantedWhen', { defaultValue: 'granted {{rel}}', rel: relativeTime(grant.createdAt) }); + const when = t('tools.grantedWhen', { defaultValue: 'granted {{rel}}', rel: localizeRelativeTime(grant.createdAt, t) }); const revokedBy = grant.revokedBy ? memberName(grant.revokedBy) : null; const line2 = dead ? (grant.revokedAt ? (revokedBy - ? t('tools.revokedByLine', { defaultValue: 'revoked by {{member}} {{rel}}', member: revokedBy, rel: relativeTime(grant.revokedAt) }) - : t('tools.revokedLine', { defaultValue: 'revoked {{rel}}', rel: relativeTime(grant.revokedAt) })) - : t('tools.expiredLine', { defaultValue: 'expired {{rel}}', rel: relativeTime(grant.expiresAt) })) + ? t('tools.revokedByLine', { defaultValue: 'revoked by {{member}} {{rel}}', member: revokedBy, rel: localizeRelativeTime(grant.revokedAt, t) }) + : t('tools.revokedLine', { defaultValue: 'revoked {{rel}}', rel: localizeRelativeTime(grant.revokedAt, t) })) + : t('tools.expiredLine', { defaultValue: 'expired {{rel}}', rel: localizeRelativeTime(grant.expiresAt, t) })) : `${audienceLabels(grant)} ${t('tools.mayUse', { defaultValue: 'may use it' })} · ${asksFirst(grant)}`; return (
@@ -661,16 +658,16 @@ const V2ConnectorTools: React.FC = ({ pods }) => {

{toolLabel(grant)} · {grant.target.kind === 'pod' ? podName(grant.target.id) : seatLabel(podId, grant.target.id)}

{granter - ? t('tools.grantedByOn', { defaultValue: 'Granted by {{member}} {{rel}}.', member: granter, rel: relativeTime(grant.createdAt) }) - : t('tools.grantedOn', { defaultValue: 'Granted {{rel}}.', rel: relativeTime(grant.createdAt) })} + ? t('tools.grantedByOn', { defaultValue: 'Granted by {{member}} {{rel}}.', member: granter, rel: localizeRelativeTime(grant.createdAt, t) }) + : t('tools.grantedOn', { defaultValue: 'Granted {{rel}}.', rel: localizeRelativeTime(grant.createdAt, t) })} {' '} {grant.revokedAt ? (revokedBy - ? t('tools.endedRevokedBy', { defaultValue: 'Revoked by {{member}} {{rel}}.', member: revokedBy, rel: relativeTime(grant.revokedAt) }) - : t('tools.endedRevoked', { defaultValue: 'Revoked {{rel}}.', rel: relativeTime(grant.revokedAt) })) + ? t('tools.endedRevokedBy', { defaultValue: 'Revoked by {{member}} {{rel}}.', member: revokedBy, rel: localizeRelativeTime(grant.revokedAt, t) }) + : t('tools.endedRevoked', { defaultValue: 'Revoked {{rel}}.', rel: localizeRelativeTime(grant.revokedAt, t) })) : (isExpired(grant) - ? t('tools.endedExpired', { defaultValue: 'Expired {{rel}}.', rel: relativeTime(grant.expiresAt) }) - : t('tools.endsRel', { defaultValue: 'Ends {{rel}}.', rel: relativeTime(grant.expiresAt) }))} + ? t('tools.endedExpired', { defaultValue: 'Expired {{rel}}.', rel: localizeRelativeTime(grant.expiresAt, t) }) + : t('tools.endsRel', { defaultValue: 'Ends {{rel}}.', rel: localizeRelativeTime(grant.expiresAt, t) }))}

{t('tools.agentsAllowed', { defaultValue: 'agents allowed' })}
@@ -683,7 +680,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => { <>
{t('tools.budget', { defaultValue: 'budget' })}
{grant.budget.windowMs - ? t('tools.budgetWindow', { defaultValue: '{{calls}} calls per {{window}}', calls: grant.budget.calls, window: relativeTime(new Date(Date.now() - grant.budget.windowMs).toISOString()).replace(' ago', '') }) + ? t('tools.budgetWindow', { defaultValue: '{{calls}} calls per {{window}}', calls: grant.budget.calls, window: localizeWindow(grant.budget.windowMs, t) }) : t('tools.budgetTotal', { defaultValue: '{{calls}} calls', calls: grant.budget.calls })}
)} @@ -733,7 +730,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => { {trail.calls.map((line) => (
  • {seatLabel(podId, line.agentUserId)} · {line.tool} · {outcomeLabel(line.outcome)} - {relativeTime(line.at)} + {localizeRelativeTime(line.at, t)}
  • ))} diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index ee6e1f5a7..2af3afeff 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'; import { useV2Api } from '../hooks/useV2Api'; import { V2Pod, V2PodMember } from '../hooks/useV2Pods'; import { PlatformGlyph } from '../icons/platforms'; -import V2ConnectorTools from './V2ConnectorTools'; +import V2ConnectorTools, { localizeRelativeTime } from './V2ConnectorTools'; interface ConnectorGate { enabled?: boolean; @@ -167,16 +167,7 @@ const codeExpiresInMinutes = (connector: Connector): number => { return Math.max(1, Math.ceil((expiry - Date.now()) / 60_000)); }; -const relativeTime = (date?: string): string => { - const timestamp = date ? new Date(date).getTime() : NaN; - if (!Number.isFinite(timestamp)) return 'just now'; - const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60_000)); - if (minutes < 1) return 'just now'; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -}; +const connectorRelativeTimeOptions = { includeFuture: false, missing: 'just now', rounding: 'floor' } as const; const claimIsStale = (installation: CatalogInstallation): boolean => { const claimedAt = installation.claimedAt ? new Date(installation.claimedAt).getTime() : NaN; @@ -308,15 +299,15 @@ const V2ConnectorsPage: React.FC = () => { const podNameById = (podId: string | null | undefined, fallback?: Connector | null): string => { const pod = podList.find((candidate) => String(candidate._id) === String(podId || '')); - if (pod) return pod.name || 'Untitled pod'; + if (pod) return pod.name || t('connectors.untitledPod', { defaultValue: 'Untitled pod' }); if (fallback && typeof fallback.podId === 'object' && fallback.podId?.name) return fallback.podId.name; - return 'Untitled pod'; + return t('connectors.untitledPod', { defaultValue: 'Untitled pod' }); }; const boundPodName = (boundPodId: string | undefined): string => { const existing = connectors.find((connector) => connectorPodId(connector) === String(boundPodId)); const boundPod = podList.find((pod) => String(pod._id) === String(boundPodId)); - return existing ? podNameById(connectorPodId(existing), existing) : (boundPod?.name || 'another pod'); + return existing ? podNameById(connectorPodId(existing), existing) : (boundPod?.name || t('connectors.anotherPod', { defaultValue: 'another pod' })); }; const installInProgressMessage = (boundPodId?: string): string => ( @@ -563,7 +554,10 @@ const V2ConnectorsPage: React.FC = () => { }; const rowFor = (connector: Connector): ConnectorRow => { - const started = `started ${relativeTime(connector.createdAt)}`; + const started = t('connectors.startedWhen', { + defaultValue: 'started {{rel}}', + rel: localizeRelativeTime(connector.createdAt, t, connectorRelativeTimeOptions), + }); const isTelegram = connector.type === 'telegram'; const isSlack = connector.type === 'slack'; const title = connector.config?.chatTitle || TYPE_LABELS[connector.type] || connector.type; @@ -576,7 +570,10 @@ const V2ConnectorsPage: React.FC = () => { dot: 'empty', line: t('connectors.errorLine', { defaultValue: 'The connection dropped.' }), pulse: false, - when: `since ${relativeTime(connector.updatedAt || connector.createdAt)}`, + when: t('connectors.sinceWhen', { + defaultValue: 'since {{rel}}', + rel: localizeRelativeTime(connector.updatedAt || connector.createdAt, t, connectorRelativeTimeOptions), + }), }; } @@ -592,7 +589,10 @@ const V2ConnectorsPage: React.FC = () => { dot: 'idle', line: t('connectors.notLinkedLine', { defaultValue: '{{title}} · not linked to a pod', title }), pulse: false, - when: `added ${relativeTime(connector.createdAt)}`, + when: t('connectors.addedWhen', { + defaultValue: 'added {{rel}}', + rel: localizeRelativeTime(connector.createdAt, t, connectorRelativeTimeOptions), + }), }; } const relay = Boolean(connector.config?.liveRelay); @@ -607,10 +607,17 @@ const V2ConnectorsPage: React.FC = () => { : t('connectors.rowAttention', { defaultValue: 'attention · escalations reach the channel' })) : t('connectors.rowRelayOff', { defaultValue: 'relay off · messages stay in the pod' }), dot: relay ? 'live' : 'idle', - line: `${title} · linked to ${podNameById(activePodId, connector)}`, + line: t('connectors.linkedLine', { + defaultValue: '{{title}} · linked to {{pod}}', + title, + pod: podNameById(activePodId, connector), + }), pulse: relay && Boolean(recent), secondary: true, - when: `added ${relativeTime(connector.createdAt)}`, + when: t('connectors.addedWhen', { + defaultValue: 'added {{rel}}', + rel: localizeRelativeTime(connector.createdAt, t, connectorRelativeTimeOptions), + }), }; } @@ -644,8 +651,8 @@ const V2ConnectorsPage: React.FC = () => { } if (isSlack && connector.config?.pendingBind) { - const workspace = connector.config.pendingBind.teamName || 'This Slack workspace'; - const user = connector.config.pendingBind.slackUserName ? `@${connector.config.pendingBind.slackUserName}` : 'your Slack user'; + const workspace = connector.config.pendingBind.teamName || t('connectors.thisSlackWorkspace', { defaultValue: 'This Slack workspace' }); + const user = connector.config.pendingBind.slackUserName ? `@${connector.config.pendingBind.slackUserName}` : t('connectors.yourSlackUser', { defaultValue: 'your Slack user' }); return { action: 'confirm', actionLabel: t('connectors.slackConfirm', { defaultValue: 'Confirm' }), @@ -653,7 +660,10 @@ const V2ConnectorsPage: React.FC = () => { dot: 'pending', line: t('connectors.slackConfirmRow', { defaultValue: '{{workspace}} says {{user}} connected — is that you?', workspace, user }), pulse: true, - when: `Slack answered ${relativeTime(connector.updatedAt || connector.createdAt)}`, + when: t('connectors.slackAnsweredWhen', { + defaultValue: 'Slack answered {{rel}}', + rel: localizeRelativeTime(connector.updatedAt || connector.createdAt, t, connectorRelativeTimeOptions), + }), }; } @@ -673,7 +683,10 @@ const V2ConnectorsPage: React.FC = () => { action: null, detail: t('connectors.pending', { defaultValue: 'Waiting for the channel' }), dot: 'empty', - line: `${TYPE_LABELS[connector.type] || connector.type} is waiting to connect.`, + line: t('connectors.waitingToConnect', { + defaultValue: '{{label}} is waiting to connect.', + label: TYPE_LABELS[connector.type] || connector.type, + }), pulse: false, when: started, }; @@ -712,8 +725,14 @@ const V2ConnectorsPage: React.FC = () => { when: t('connectors.notConnected', { defaultValue: 'not connected' }), }; } - const since = `since ${relativeTime(installation.updatedAt)}`; - const started = `started ${relativeTime(installation.claimedAt || installation.updatedAt)}`; + const since = t('connectors.sinceWhen', { + defaultValue: 'since {{rel}}', + rel: localizeRelativeTime(installation.updatedAt, t, connectorRelativeTimeOptions), + }); + const started = t('connectors.startedWhen', { + defaultValue: 'started {{rel}}', + rel: localizeRelativeTime(installation.claimedAt || installation.updatedAt, t, connectorRelativeTimeOptions), + }); const stale = claimIsStale(installation); if (installation.status === 'installing' || installation.status === 'activating') { return stale @@ -777,7 +796,10 @@ const V2ConnectorsPage: React.FC = () => { dot: 'empty', line: `${t('connectors.pausedLine', { defaultValue: 'Paused by an administrator.' })}${reason}`, pulse: false, - when: `paused ${relativeTime(pause?.at || installation.updatedAt)}`, + when: t('connectors.pausedWhen', { + defaultValue: 'paused {{rel}}', + rel: localizeRelativeTime(pause?.at || installation.updatedAt, t, connectorRelativeTimeOptions), + }), }; } if (installation.status === 'stale') { @@ -964,7 +986,10 @@ const V2ConnectorsPage: React.FC = () => { {active && {t('connectors.activeTag', { defaultValue: 'active' })}} - {enabled ? `since ${relativeTime(gate?.since)}` : t('connectors.gateOff', { defaultValue: 'off' })} + {enabled ? t('connectors.sinceWhen', { + defaultValue: 'since {{rel}}', + rel: localizeRelativeTime(gate?.since, t, connectorRelativeTimeOptions), + }) : t('connectors.gateOff', { defaultValue: 'off' })} { <>

    {t('connectors.slackConfirmHint', { defaultValue: '{{workspace}} wants to connect as {{user}}.', - workspace: pendingBind.teamName || 'This Slack workspace', - user: pendingBind.slackUserName ? `@${pendingBind.slackUserName}` : 'your Slack user', + workspace: pendingBind.teamName || t('connectors.thisSlackWorkspace', { defaultValue: 'This Slack workspace' }), + user: pendingBind.slackUserName ? `@${pendingBind.slackUserName}` : t('connectors.yourSlackUser', { defaultValue: 'your Slack user' }), })}